Compare commits

...

95 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
mAi
c681b01aff feat(ui): Solve flow + setup-templates apply + cable rendering
Header gains a Solve button (keyboard S) + Apply template button.

Canvas:
- Cables render as straight lines port→port (or device-centre when the
  endpoint is a whole device, or io-marker centre). Auto-cables get a
  dashed stroke; manual cables (auto=0) solid. Stroke colour = cable_type.
- Click a cable to select it → inspector pane updates.

Solve preview-diff modal:
- Calls POST .../solve?preview=1 on open.
- Renders cables_added, cables_removed, bundles_added in colour-coded
  lists. Unsatisfied entries get a class="unmet" badge + one-click
  quick-fix:
  * "no free <type> port" → "+ Add <type> port to <device> and re-solve"
    fires POST .../devices/:id/ports-and-resolve in one round-trip and
    re-renders the preview.
  * "ambiguous cable type" → "Specify cable type…" re-opens the
    requirement modal.
  * "no compatible cable type" with a preferred type → "+ Add port…"
    quick-fix on the from-side device.
- Apply → POST .../solve (no preview) → re-snapshot to pick up new
  cable ids + bundle assignments.

Cable inspector (kind=cable):
- Shows type, from-endpoint, to-endpoint labels.
- For solver-owned cables, shows the driving requirement (best-effort
  match by unordered device pair + type) and a "Promote to manual"
  button (PATCH with `promote: true` flips auto→0).
- Delete button on both auto and manual cables.

Apply-template flow:
- "Apply template…" header button opens a wide modal with a template
  dropdown (Living Room / Home Office / Server Rack) + a preview panel
  showing each device row (skip checkbox + editable name input) and
  the template's requirements.
- Submit → POST .../apply-template with name_overrides + skip_devices,
  then re-snapshot.

State + snapshot:
- state.cables, state.bundles, state.setupTemplates added.
- activateProject pulls them from the snapshot; teardown on switch.
2026-05-16 01:07:20 +02:00
mAi
c8bda7a222 feat(http): solver + cables + bundles + templates endpoints 2026-05-16 01:02:31 +02:00
mAi
b93c42a6e0 feat(db): solver + setup templates + cables/bundles store
Migration 004:
- setup_templates + setup_template_devices + setup_template_requirements
- 3 built-in templates seeded: Living Room (TV+Soundbar+ChromeCast,
  2× HDMI), Home Office (PC+Screen+Keyboard+Mouse, 1× HDMI + 2× USB),
  Server Rack (NAS+Switch+fritz, 2× RJ45).

Cables store (cables.go):
- CRUD with endpoint validation (port|device|io exactly-one, project-
  scoped). Tx-aware: validateEndpointEx + assertCableTypeEx avoid
  deadlocks when the solver Apply tx holds the MaxOpenConns(1) connection.

Bundles store (bundles.go):
- CRUD with cable_ids replacement on PATCH. createBundle(ex, …, ownTx)
  inherits the caller's tx for solver-internal use; returns a locally-
  constructed Bundle when ownTx=false (re-fetching via s.db would
  deadlock).

Solver (solver.go) implements design v4.1 §5b.2 exactly:
- Pre-fetch devices/ports/cables/requirements/bundles.
- Reserve ports used by manual cables (auto=0) so the solver can't
  reuse them.
- For each requirement (must_connect DESC, id ASC):
    * Resolve cable type: preferred, or T = port-types(from) ∩
      port-types(to). |T|==0 → unsatisfied "no compat type"; |T|>1 →
      "ambiguous"; |T|==1 → that one.
    * Pick lowest-id free port on each side. None → unsatisfied with
      WhichSide hint + cable-type name.
- Endpoint-pair bundle: ≥2 staged cables between the same device pair
  → auto bundle.
- Diff against existing auto cables by (type_id, MIN(from,to), MAX(from,to))
  signature. Matched = kept; new = added; orphans = removed.
- Preview returns the diff without writing; Apply runs in a single tx
  that wipes auto bundles, deletes orphan auto cables, inserts new
  ones, and rebuilds bundles.
- PortsAndResolve: combo helper for the inspector quick-fix —
  inserts a port + re-runs Solve.

Setup-templates store (setup_templates.go):
- List/Get with hydrated devices + requirements.
- ApplyTemplate(projectID, templateID, opts) seeds devices + requirements
  in one tx. Per-device name overrides + opt-out. Name collisions skip
  the device (skipped_devices); requirements whose endpoints both fail
  are also skipped (requirements_skipped). UNIQUE-collision on an
  existing requirement is non-fatal; logged in requirements_skipped.

Snapshot: cables + bundles fields tightened to []Cable / []Bundle and
populated from the store.

11 new tests (solver_test.go), all green with -race:
- Basic NAS↔Switch (RJ45) → 1 cable, auto=true
- Ambiguous cable type → unsatisfied
- No free port → unsatisfied with side hint
- Preview doesn't write
- Apply then re-apply → idempotent (kept=N, added=0)
- Manual cable reserves its port → solver can't claim it
- ApplyTemplate Living Room → 3 devices + 2 requirements + 7 ports
  (from the device-type port seeder)
- Home Office template then Solve → 3 cables, 0 unsatisfied
- Name-collision pre-existing device → skipped + req-pair skipped
2026-05-16 01:02:31 +02:00
mAi
75b826c583 merge: slice 5 — connection requirements CRUD + UI
picasso shipped (3 commits @ 6b830a5):
- migration 003: connection_requirements (pair_lo/pair_hi normalisation,
  UNIQUE on the unordered pair + cable_type), plus cables.auto column
  for the slice-6 solver
- store + handlers: full CRUD under /api/projects/:pid/connection-requirements
- frontend: Requirements sidebar section, +Requirement modal (device-pair
  autocomplete + cable-type picker + must/nice toggle), drag-A-to-B
  gesture pre-fills the modal, inspector for selected device lists its
  requirements
2026-05-16 00:43:45 +02:00
mAi
6b830a54b9 feat(ui): connection requirements — sidebar + modal + drag-A-to-B + inspector
Snapshot now carries connection_requirements; state.requirements is
populated on project switch.

Sidebar:
- New "Requirements" section between Cable types and Tools.
- Each row shows "A ↔ B · cable-type" plus a must/nice badge. Clicking
  a row selects the requirement (inspector pane updates).

+ Requirement modal:
- Device-pair pickers (autocompletes from the project's current devices).
- Cable-type picker with "— solver picks —" as the first option (saves
  preferred_cable_type_id as null on the wire).
- "Must connect" checkbox (default on); notes textarea.
- POSTs to /api/projects/:pid/connection-requirements. 409 collisions
  (reversed-pair duplicates) surface as inline form errors.

Drag-from-A-to-B gesture:
- New tool `req` (keyboard R + "Drag req A→B" button). Arming the tool
  + pointerdown on a device starts a dashed-line preview. Pointerup on
  another device opens the modal with from/to pre-filled. Anywhere
  else cancels. Crosshair cursor while armed.

Inspector:
- Device pane gains a "Requirements" section listing every requirement
  involving the selected device, sorted by the other device's name.
  Each row is clickable → inspector jumps to that requirement.
- New `requirement` selection kind with its own inspector renderer
  showing from/to, cable type, must/nice toggle button, debounced
  notes textarea, "Edit" (re-opens modal), and Delete.

Delete of a device cleans up its requirements in local state (server
already CASCADEs the rows).
2026-05-16 00:42:26 +02:00
mAi
9af4b6caa3 feat(http): /api/projects/:pid/connection-requirements full CRUD 2026-05-16 00:37:34 +02:00
mAi
d8637de4a0 feat(db): connection_requirements + cables.auto
Migration 003 adds the solver's per-project input table + the auto flag
that slice 6 will use to distinguish solver-owned cables from m's
hand-drawn ones.

connection_requirements:
- (from_device_id, to_device_id, preferred_cable_type_id) with
  preferred_cable_type_id nullable ("solver picks if exactly one type
  matches both ends").
- (pair_lo, pair_hi) is the order-normalised MIN/MAX of (from, to),
  stored alongside the m-facing from/to so the UI doesn't have to
  denormalise.
- UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id) →
  (A,B,T) and (B,A,T) collide; (A,B,Power) + (A,B,RJ45) coexist.
- CHECK (from != to). FK CASCADE from devices → requirement vanishes
  if either endpoint device is deleted.

Store + 11 new tests:
- pair normalisation rejects the reversed-direction duplicate
- different cable types on the same pair coexist
- self-loop rejected (ErrInvalidInput)
- cross-project device reference rejected
- two null-cable-type reqs on the same pair both succeed (SQLite NULL
  != NULL in UNIQUE — semantically "solver picks both times", second
  wins)
- partial PATCH: preferred_cable_type_id tri-state (leave/set/clear),
  must_connect bool, notes string
- device delete cascades to its requirements
- snapshot.connection_requirements is non-nil and populated

cables.auto:
- ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0 CHECK
  (auto IN (0,1)). Slice 6 sets 1 from the solver; slice 7's manual
  cable POST keeps the default 0.
2026-05-16 00:37:34 +02:00
mAi
88821c0f21 merge: slice 4 — device-type catalog + type-aware device create
picasso shipped (4 commits @ 7f0b6e4):
- migration 002: device_types + device_type_ports + devices.type_id,
  seeded with 16 built-ins (NAS PC Mac Notebook TV Soundbar Switch
  fritz ChromeCast SteamLink IOx-3/6/8 Screen Keyboard Mouse)
- store: type-aware device POST seeds ports transactionally with
  even-spread layout along the configured edge
- handlers: /api/device-types (built-ins) + /api/projects/:pid/device-types
  (merged with project-custom), 403 on built-in mutations
- frontend: +Dev becomes a type-dropdown grouped by kind + name input
  pre-fill, port rendering as SVG circles colour-stroked by cable type
2026-05-16 00:33:53 +02:00
mAi
7f0b6e4fab feat(ui): type-aware device creation + port rendering
Modal-driven +Dev (replaces the v3 inline namer):
- Tool armed → click on canvas captures the click position + frame_id
  from frameAt(p), then opens a #modal-new-device dialog.
- Dialog has a <select> grouped by `kind` for built-ins, then
  project-custom rows, then "Custom (no type)" at the bottom.
- Default selection is the first built-in (NAS). Name input is
  auto-pre-filled to <type-name>, bumping to <type-name>-N if a name
  collision is detected in the current device list.
- Submit POSTs name + type_id + x/y/w/h + frame_id. Server seeds the
  ports in the same transaction; we re-snapshot to pick them up.

Canvas:
- After each device's <rect> + label, render the device's ports as
  white-filled <circle>s with stroke = the port's cable_type colour.
- Position: (device.x + port.x_offset, device.y + port.y_offset). The
  seeder's "evenly along the edge" layout means ports already sit on
  the device's bottom edge by default and follow the device on drag
  (because they re-render from the same x/y on every renderCanvas).
- Ports themselves are `pointer-events: none` for slice 4 — selection
  remains device-level. Per-port click semantics ship in slice 7
  (manual cable draw).

Inspector device pane:
- New "type" row showing the type name + a "(custom)" badge for
  project-custom types, or "Custom (no type)" for freeform.
- New "Ports" section with one row per seeded port: cable-type-colour
  swatch, label, "unconnected" placeholder. Label falls back to the
  cable type's name when the seeded label_prefix was blank.

State + snapshot:
- state.ports populated from snap.ports; cleared on project switch /
  404.
- state.deviceTypes hydrated from GET /api/projects/:pid/device-types
  after the snapshot loads. Failure of that fetch is non-fatal — the
  +Dev modal just shows "Custom (no type)" only.
- Delete-device cleans up its ports from state.ports too (server-side
  CASCADE already handles persistence).
2026-05-16 00:31:55 +02:00
mAi
0a34dce398 feat(http): device-type endpoints + type_id on device create/patch
- GET /api/device-types — built-ins only (read-only).
- GET /api/projects/:pid/device-types — built-ins + project-custom merged.
- POST/PATCH/DELETE /api/projects/:pid/device-types — project-custom only.
  Mutating a built-in row returns 403 via the new ErrForbidden → 403 map
  in writeError.
- devicePatch / deviceCreate JSON shapes accept type_id (tri-state for
  PATCH via the existing parseFrameRef helper applied to type_id too).
- POST /api/projects/:pid/devices with type_id seeds ports in one tx
  server-side; response carries the device row + the snapshot will then
  carry the new ports.
2026-05-16 00:27:49 +02:00
mAi
8cb237fe8e feat(db): device_types store + port seeding on device create
Catalog: 11 built-ins from §2.2 + the v4.1 trio (Screen, Keyboard, Mouse)
seeded in migration 002, totalling 16 built-in types.

Store layer:
- internal/db/device_types.go — CRUD for device_types. Built-ins
  (project_id NULL) reject PATCH/DELETE with new ErrForbidden sentinel
  (handler maps to HTTP 403). Project-custom types accept full CRUD;
  cross-project access returns ErrNotFound. Replacing the port profile
  on UPDATE is one transaction.
- internal/db/ports.go — ListPortsForProject for the snapshot loader +
  seedPortsFromType(tx, …) used by CreateDevice. Layout is "evenly spaced
  along the configured edge", per-edge group ordering by sort_order +
  id. Labels are "<prefix>" for count==1 and "<prefix> N" 1-indexed for
  count>1.
- Device gains a nullable TypeID + tri-state on UpdateDevice. CreateDevice
  validates the type is built-in or a project-custom row of the same
  project, then seeds the device's ports in the same transaction.

Snapshot now populates Ports from the store; field type tightened to
[]Port.

Tests (15 new, all green with -race):
- 16 built-ins seeded with correct names + project_id=NULL + built_in=1
- Port-profile totals match the §2.2 table for every built-in type
- Project-custom create + name-collision-with-built-in → 409 (ErrConflict)
- Per-project name UNIQUE — same custom name across projects is fine
- PATCH/DELETE built-in → ErrForbidden
- Cross-project custom PATCH → ErrNotFound
- CreateDevice with NAS type → 2 ports along bottom edge, evenly spaced,
  labels set
- CreateDevice with PC type → 5 ports incl. "USB 1" + "USB 2"
- CreateDevice without type_id → 0 ports (freeform fallback)
- Cross-project custom type on CreateDevice → ErrInvalidInput
- Snapshot includes the seeded ports
2026-05-16 00:27:49 +02:00
mAi
2b26f63c86 feat(db): migration 002 — device_types + device_type_ports + devices.type_id + 16 built-ins seeded 2026-05-16 00:27:49 +02:00
mAi
08385b0d9f merge: slice 3 — IO markers + cable-type editing UI
picasso shipped (3 commits @ a3f0586):
- internal/db/io_markers.go: project-scoped CRUD, cross-project FK rejection
- internal/server/io_markers.go: handlers under /api/projects/:pid/io-markers
- web/static: +IO tool with click-place, diamond rendering (SVG polygon),
  drag, inspector for IO + cable-type, interactive legend with native
  colour-picker + delete-blocked-on-use, '+ Type' modal, 'used by N
  cables' counter

37 store tests green with -race.
2026-05-16 00:13:53 +02:00
mAi
a3f0586296 feat: frontend — IO markers + cable-type inspector
Slice 3 frontend.

+ IO tool (keyboard `I`):
- Single-click on canvas places a 30x30 diamond (rotated <rect>) at the
  point, with the Power-cable_type colour fill (red-ish).
- Inline namer prompts for a label; empty → server defaults to "IO".
- Drop-point determines initial frame_id via the existing frameAt()
  point-in-rect logic, same as devices.

Render:
- io_markers come from snap.io_markers in the snapshot loader. Each
  renders as a <rect> with rotate(45) around its centre + a small text
  label below the diamond. Selection halo on stroke-width.
- Drag is the same pointer-event flow as devices; on pointerup, PATCH
  x,y + recompute frame_id from the new centre. Cross-frame moves
  update frame_id with explicit null on the wire when leaving all frames.
- Frame-drag now also relocates contained IO markers (mirrors the
  device-cascade pattern). Single PATCH per IO marker on release.

Cable-type inspector:
- Clicking a legend row now sets state.selection = {kind:"cable_type", id}
  in addition to toggling activeTypeId. The inspector renders the cable
  type's details (name + colour, both editable, with the
  "shared across projects" banner from v3 §7), a used-by counter (0
  until slice 7 ships cables), and a Delete button that surfaces the
  RESTRICT in_use_by_cables count from the server.
- Debounced rename via the existing bindDebouncedRename helper.

Inspector frame view picks up an "IO" count alongside the device count.
Background click + Esc clear the selection (existing behaviour, now
covers cable_type too).

Hand-tested via the API equivalents: 3 IO markers created (free, in
frame, default-label), PATCH x,y + frame_id-to-null all work, cross-
project frame_id rejected with 400, DELETE 9999 returns 404. Snapshot
shape post-slice-3: {frames, devices, io_markers, cable_types} all
populated, ports/cables/bundles still [].
2026-05-16 00:12:24 +02:00
mAi
d114bfb547 feat: http handlers — IO markers CRUD under /api/projects/:pid/io-markers 2026-05-16 00:06:16 +02:00
mAi
1ea6082948 feat: db store — IO markers CRUD, snapshot wiring
Schema already in 001_init.sql; this is just the Go store layer.

IO markers are project-scoped wall-outlet terminators (a cable's
"this end plugs into a wall socket outside the diagram" endpoint).
Power-by-convention; no schema-level type enforcement.

- CreateIOMarker validates frame_id is in the same project (cross-project
  ref → ErrInvalidInput), defaults label to "IO" when blank.
- GetIOMarker is project-scoped — wrong-project read returns ErrNotFound.
- UpdateIOMarker uses the FrameRef tri-state for frame_id (same as
  DeviceUpdate) so callers can clear it explicitly.
- DeleteIOMarker is direct delete — ON DELETE SET NULL from the schema
  drops the io_markers.frame_id ref cleanly when the frame is deleted
  (verified by TestDeleteFrame_SetsIOMarkerFrameIDToNull).

Snapshot now populates IOMarkers from the store; field type tightened
from []any to []IOMarker.

7 new table-driven tests, all green with -race.
2026-05-16 00:05:40 +02:00
mAi
376ffd8197 merge: design v4.1 — schematic-only, templates folded in
m's review of v4 locked 6 answers. Tight doc pass:
- Schematic-only bundling: dropped trunk-segment/frame-edge/cable-tray
  language. v3 endpoint-pair rule is the only bundle rule.
- Setup templates folded in (not post-MVP): migration 004 with 3
  built-ins (Living Room, Home Office, Server Rack) + 3 new device
  types (Screen, Keyboard, Mouse) + apply-template API in slice 6.
- Unmet-requirement quick-fix: combo endpoint adds a missing port and
  re-solves in one server roundtrip.
- Solver still button-only, catalog still SQL-seeded, promote still
  explicit on cable inspector.

All 9 §9 questions resolved.
2026-05-16 00:03:51 +02:00
mAi
e42b351280 docs: design v4.1 — schematic-only bundling, setup templates folded in
Tight pass on m's review of v4 (single commit per head's instruction).

Six locked answers integrated:

1. mCables is a schematic, not a physical-routing tool. Stripped
   'trunk', 'frame-edge corridor', 'cable tray', 'path optimisation'
   from §5b.1, §5b.2, §7, §8, §9. Bundling reduces to the v3 endpoint-
   pair rule: ≥2 cables between the same A↔B endpoint pair → group as
   one bundle. Anything path-shaped is "out of scope, period" (§8).
2. Solver button-only for v0 (no change). Live-solve parked at 9+.
3. Unmet-requirement quick-fix: red badge on the affected device in the
   inspector with a single "+ Add <type> port to <device> and re-solve"
   button per §5b.4. New endpoint
   POST /api/projects/:pid/devices/:id/ports-and-resolve chains the
   port insert + the solve re-run in one transaction.
4. Setup templates fold INTO v4.1. New §2.4 with the schema for
   setup_templates + setup_template_devices + setup_template_requirements
   (migration 004), 3 built-in templates seeded (Living Room, Home
   Office, Server Rack). New API: GET /api/setup-templates,
   POST /api/projects/:pid/apply-template. New UI flow: "or start from
   a template" section in the New Project modal + an "Apply template"
   action on empty projects. Built-in catalog grows to 14 types
   (adds Screen, Keyboard, Mouse).
5. Catalog SQL seed in migration 002 (no change).
6. Promote-to-manual: explicit button on cable inspector (no change).

§8 slice 6 absorbs the templates work alongside the solver MVP.
§9 closes all six v4 questions; no open design questions remain.
Trailer changes to "DESIGN v4.1 READY FOR REVIEW".

CLAUDE.md mirrors: schematic-only framing, 14-type catalog, setup
templates as a first-class feature, quick-fix UX note.
2026-05-16 00:03:19 +02:00
mAi
e862a06e9d docs: design v4 — solver-as-core, hybrid device-type catalog, requirements
Big rescope driven by m's product-vision clarification: mCables is a
cable-management framework with a solver as its core value prop, not a
manual draw-and-click editor. m declares devices + required connections
between them; the solver emits the cable plan + bundle recommendations,
optimising for maximum bundling.

Schema additions (migrations 002 + 003):
- device_types (catalog) — built-ins (project_id NULL) + project-custom
  (project_id non-null). 11 built-in types seeded with default port
  profiles (NAS, PC, Mac, TV, Soundbar, Switch, fritz, ChromeCast,
  SteamLink, IOx-3/6/8, Notebook).
- device_type_ports (profile rows: cable_type × count × edge).
- devices.type_id (nullable). Picking a type seeds ports once;
  instance-owned thereafter (no retroactive re-seed).
- connection_requirements (per-project, from/to device + preferred type
  + must_connect flag, with order-normalised pair_lo/pair_hi for
  duplicate prevention).
- cables.auto (slice 5.5 migration) — distinguishes solver-owned cables
  from user-drawn ones.

API additions:
- GET /api/device-types (built-ins only, read-only) and
  GET /api/projects/:pid/device-types (built-ins + project-custom merged)
- POST/PATCH/DELETE under /api/projects/:pid/device-types (project-custom
  only; built-ins are 403)
- /api/projects/:pid/connection-requirements full CRUD
- POST /api/projects/:pid/solve with ?preview=1 — pure-function solver
  (greedy port allocation, endpoint-pair bundling for v0); returns
  add[], remove[], bundles_added[], unsatisfied[], warnings[]

Solver algorithm (§5b):
- Read project devices + ports + connection_requirements + manual cables
- Assign each requirement a (port_a, port_b) using the preferred cable
  type (or auto-pick if exactly one type matches both ends)
- Bundle by endpoint-pair (v3 rule, applied to auto cables only)
- Surface unsatisfied requirements per class (no compat type / ambiguous
  type / no free port) — does NOT auto-add ports; UI quick-fix instead
- ?preview=1 returns the diff without writing; default applies in a tx

UI additions:
- Device-create modal: type dropdown (built-ins grouped by kind, then
  project-custom, then "Custom (no type)" for the v3 freeform fallback)
- Left-sidebar Requirements section with + Requirement button
- Header Solve button (S keybinding) → preview modal → Apply
- Inspector for selected device: type, ports grid, unmet requirements
  with red badges + quick-fix actions
- Inspector for selected auto cable: driving requirement, parent bundle,
  Promote-to-manual button

Slice reshape (§8):
- Slices 1, 2 shipped. v4 inserts: 4 = catalog + type-aware device create,
  4.5 = catalog management, 5 = requirements CRUD + UI, 6 = solver MVP +
  Solve button. Old "manual port + manual cable draw" slides to slice 7
  as a tweak path on solver output. Export becomes slice 8.

Six new open questions (§9) for m to gate before slice 4:
1. Path source (auto-route through frame edges / user cable-trays /
   Steiner-tree)?
2. Live-solve vs. button-only?
3. UX when solver has no compatible port pair?
4. Setup templates in v4 or post-MVP?
5. Catalog as code seed or JSON file?
6. Auto-promote vs. explicit Promote-to-manual on solver cable edits?

CLAUDE.md updated to reflect the solver-core framing, hybrid catalog,
connection-requirements model, and auto/manual cable distinction.

Trailer changes to "DESIGN v4 READY FOR REVIEW".
2026-05-15 23:57:22 +02:00
mAi
4f862e741a merge: fix inspector not updating on device/frame selection
startDrag set state.selection but never re-rendered. One render() call
after the assignment fixes it. Now selecting a device or frame
populates the inspector with name/dims/delete-button as designed.
2026-05-15 23:39:15 +02:00
mAi
29e221e080 fix(ui): inspector now updates on device/frame selection
startDrag set state.selection but didn't render until pointerup's onUp
ran — and onUp can throw on `e.currentTarget.classList.remove` if the
event reference is stale after pointer capture release, which leaves
the inspector stuck on 'Nothing selected.'

One-line fix: call render() right after state.selection assignment so
the inspector + halo update from pointerdown, independent of whether
onUp completes cleanly. The drag-completion render at the end of onUp
stays — when both fire it's idempotent (renders are pure functions of
state).
2026-05-15 23:38:12 +02:00
mAi
c7dfbe010c merge: fix +Dev inline-namer blur (sherlock's preventDefault diagnosis)
Primary fix: e.preventDefault() on the pointerdown for both armed-tool
branches in onCanvasPointerDown. Without it, the browser's default
mousedown action blurs the freshly-focused input in promptInline
(the SVG click target isn't focusable), and the blur handler calls
done(null) before m can type.

Secondary fix: clear activeNamer before fo.remove() in done(), to
prevent a re-entrant pageerror when Enter triggers blur synchronously.
2026-05-15 23:18:57 +02:00
mAi
12804619b2 fix(ui): +Dev inline-namer kept getting blurred by default mousedown
Root cause traced by sherlock with Playwright (docs/sherlock-+dev-bug.md
on the sherlock branch). The previous routing fix at 94869f3 was
necessary but not sufficient: placeDeviceAt() now reaches promptInline()
correctly, but the synchronous input.focus() is undone ~6ms later by
the browser's compatibility-mousedown default — which blurs the active
element when the mousedown landed on a non-focusable target (SVG rect /
SVG root). The blur listener then ran done(null) and ripped the
<foreignObject> out before m could type a name.

Primary fix: e.preventDefault() at the top of both armed-tool branches
in onCanvasPointerDown. Suppresses the focus-shifting default so the
input keeps focus.

+Frame is also wrapped for symmetry. It wasn't strictly affected (its
namer runs from pointerup, not pointerdown) but preventDefault avoids
a subtle text-selection side effect during rubber-band drag.

Secondary fix in promptInline.done(): clear activeNamer *before*
fo.remove(). Enter-key triggers a synchronous blur listener which
re-enters done() — if remove() ran first, the re-entry hit a
"node no longer a child" pageerror. Reordering makes the re-entry a
no-op (activeNamer is already null).

Verified locally: served /main.js shows e.preventDefault() inside both
tool branches and the reordered done() body. go test -race ./... still
green.
2026-05-15 23:17:44 +02:00
mAi
e12b449169 merge: cursor + cache fixes
- CSS: .canvas-wrap.tool-{frame,device} #canvas, #canvas * { cursor:
  crosshair !important } so frame/device rects don't display grab while
  a tool is armed
- Server: Cache-Control: no-cache on embedded static handler so browsers
  revalidate via ETag instead of serving stale main.js after redeploy
2026-05-15 20:40:07 +02:00
mAi
28a376a7f3 fix(ui+server): tool cursor wins on canvas children; no-cache static assets
Issue 1 — cursor lies about armed tool. .svg-draggable { cursor: grab }
on frame/device rects beat the .canvas-wrap.tool-device #canvas {
cursor: crosshair } rule because element-level wins over descendant.
m saw "grab" hovering a frame with +Dev armed and thought the tool was
broken even though clicks routed correctly after the previous fix. Add
a descendant rule with !important so tool-armed wraps any child cursor:
  .canvas-wrap.tool-frame  #canvas *,
  .canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }

Issue 2 — stale browser cache after each redeploy. http.FileServerFS
served embedded assets with no Cache-Control header, so browsers held
on to the previous main.js/style.css until hard-reload. New noCache
middleware on the static handler emits Cache-Control: no-cache. Note:
embedded FS files have zero ModTime, so http.FileServer suppresses
Last-Modified — every fetch is a fresh 200 rather than a 304. Fine at
~30KB of JS+CSS, and fixes the staleness problem completely.

Middleware is wrapped only around the static handler. /api/* responses
write their own headers and aren't touched.

Verified locally:
  curl -I /main.js   → Cache-Control: no-cache
  curl -I /style.css → Cache-Control: no-cache + contains the new rule
  curl -I /api/healthz → unaffected (no Cache-Control from us)
go test -race ./... still green.
2026-05-15 20:38:48 +02:00
mAi
6d637e1fac merge: fix +Dev inside frame silently dropped
Move the [data-frame-id]/[data-device-id] early-return below the
tool-armed branches in onCanvasPointerDown. With a tool armed,
the canvas-level handler always wins; without a tool, the original
behaviour (frame/device pointerdown handlers capture for drag/select)
is restored.
2026-05-15 20:34:39 +02:00
mAi
94869f342e fix(ui): +Dev inside a frame was silently dropped
onCanvasPointerDown returned early whenever the click landed on a
[data-device-id] or [data-frame-id] element so the per-element drag
handlers wouldn't get hijacked. Problem: this early-return fired BEFORE
the tool check, so clicking +Dev inside an existing frame never reached
placeDeviceAt().

Reordered: tool-armed branches run first and short-circuit. Only when
no tool is armed does the "click started on a child element — leave it
alone" guard kick in. End behaviour:
- +Dev anywhere (incl. inside a frame) drops a device. frame_id
  auto-resolves via the existing frameAt() point-in-rect.
- +Frm anywhere (incl. inside an existing frame) starts a rubber-band;
  rare but not harmful.
- No tool armed: clicking a device/frame still goes to its own handler
  (drag / select). Clicking empty canvas still clears selection.

Hand-tested via the served /main.js + the equivalent backend POST/PATCH
sequence: device-in-frame, device-outside, device-drag, frame-drag with
cascaded device patches — all work.
2026-05-15 20:33:17 +02:00
mAi
a9e6d7aa62 merge: slice 2 — frames + devices + drag-to-position
picasso shipped (3 commits @ b159131):
- internal/db/frames_devices.go: project-scoped CRUD, cross-project FK
  rejection, sentinel errors (duplicate name -> 409, invalid input -> 400)
- internal/server/frames_devices.go: handlers under /api/projects/:pid/
  {frames,devices}, full CRUD
- web/static: SVG rendering + tools (+ Frm rubber-band, + Dev click-place),
  drag with frame-children-follow, inspector with debounced edits

30 store tests green with -race. Hand-test: cross-frame device drag,
frame-drag-with-children, server restart all preserve state.
2026-05-15 18:23:37 +02:00
mAi
b15913124a feat: frontend — frames + devices on SVG, tools, drag, inspector
Renders the slice-2 backend on the empty canvas from slice 1.

Canvas:
- Frames render as dashed-stroke rects with top-left label, slightly
  tinted fill. Devices render as solid-stroke rects with centred label
  in device.color.
- Selection halo via .selected class (stroke-width bump).
- Empty-state hint disappears once any geometry exists.

Tools (left sidebar + keyboard):
- F / + Frame  — rubber-band rect on the canvas. <80×60 cancels. On
  release, inline foreignObject namer → POST /api/projects/:pid/frames.
- D / + Device — single click places a 100×35 device centred at the
  click. Inline namer → POST devices. Drop-point determines initial
  frame_id via point-in-rect against all frames (smallest bbox wins).
- Esc cancels active tool / inline namer / clears selection.

Drag (pointer events + svg getScreenCTM):
- Devices: drag updates x/y live via transform, persists via
  PATCH .../devices/:id on pointerup. Also recomputes frame_id from
  drop point and includes "frame_id": null|<id> if it changed.
- Frames: dragging a frame moves its contained devices visually too;
  on pointerup, single PATCH for the frame + one PATCH per moved device.
  Children-batch is computed at pointerdown and only sent on release —
  no per-pointermove network traffic.

Inspector:
- Frame selection: name (debounced rename), x/y/w/h, device count,
  Delete button (confirm prompt — devices keep existing, frame_id → NULL
  via the schema's ON DELETE SET NULL).
- Device selection: name (debounced rename), colour picker
  (change-event PATCH, no debounce), x/y/w/h, current frame, Delete.
- Background click clears selection.

devicePatch wire format uses tri-state frame_id: key absent = leave,
key:null = clear, key:<int> = move. Frontend uses `null` explicitly
when a device drops outside all frames.
2026-05-15 18:22:49 +02:00
mAi
21bf00566c feat: http handlers — frames + devices CRUD under /api/projects/:pid/
All 8 endpoints (list, create, patch, delete) for both resources. Path
params parsed via Go 1.22 ServeMux PathValue.

devicePatch uses json.RawMessage for frame_id so the wire format
distinguishes:
  - key absent       → leave as-is
  - "frame_id": null → clear (device leaves all frames)
  - "frame_id": 42   → move to that frame
parseFrameRef translates that into the store's db.FrameRef tri-state.

Sentinel-error mapping unchanged (writeError covers ErrInvalidInput,
ErrConflict, ErrNotFound, etc.). Cross-project frame_id refs surface as
400.
2026-05-15 18:17:43 +02:00
mAi
cf1671e8c1 feat: db store — frames + devices CRUD, project-scoped
Snapshot now populates frames + devices from the DB (slice 1 left them as
empty arrays).

Frame store:
- CreateFrame requires positive width/height; rejects empty name; UNIQUE
  (project_id, name) collisions surface as ErrConflict via mapWriteErr.
- GetFrame is project-scoped — wrong-project read returns ErrNotFound.
- UpdateFrame applies a partial; project_id is not exposed (moving a
  frame across projects would orphan its devices).
- DeleteFrame relies on the schema's ON DELETE SET NULL to drop
  devices' frame_id refs cleanly; verified by test.

Device store:
- CreateDevice defaults color to #1e1e1e if blank; rejects empty name,
  non-positive size; validates frame_id is in the same project (returns
  ErrInvalidInput on cross-project ref).
- UpdateDevice uses a FrameRef tri-state for frame_id so callers can
  distinguish "leave alone" from "clear to NULL" from "move to frame X".
- Cross-project frame_id on PATCH is rejected with ErrInvalidInput.
- ListDevices supports an optional frame_id filter.

13 new table-driven tests, all green with -race.
2026-05-15 18:16:33 +02:00
mAi
d3b660d140 merge: image moved to m/mcables namespace
mAi got admin on m/mCables but Gitea container packages are
user-namespace-scoped — repo-collab perm is insufficient. Pushed
once using m's ~/.netrc token, deleted the mAi/mcables stub.

Compose now references mgit.msbls.de/m/mcables:latest.
2026-05-15 18:12:37 +02:00
mAi
dc5fafeaa8 deploy: image now under m/ namespace on mgit.msbls.de
m granted mAi admin on m/mCables, but Gitea's container registry is
user-namespace-scoped (not repo-collab-scoped) so the push had to go
through m's own credentials for this one administrative move:

    docker login mgit.msbls.de -u m -p <m's token>
    docker push mgit.msbls.de/m/mcables:latest

Image digest sha256:76624f17… is identical to the one previously living
at mgit.msbls.de/mai/mcables:latest — same build, just retagged.

Drops the workaround comment from the compose file. The mai/mcables
package will be deleted via API after the deploy verifies.
2026-05-15 18:11:31 +02:00
mAi
017a77e187 merge: deploy infra to mDock (pulled forward from §10)
picasso shipped (commit 8a31f0a on mai/picasso/deploy-mdock):
- Dockerfile: multi-stage golang:1.23-alpine -> distroless/static
- docker-compose.yml at repo root (raw-docker pattern, not Dokploy)
- .dockerignore
- README deploy section

Live: http://mdock:7777 (image sha256:76624f17, 12.2MB).
Persistence verified across compose restart.

Note: mAi lacks write on m/ in Gitea, so image lives at
mgit.msbls.de/mAi/mcables:latest. m can retag once mAi gets write
on m/mCables (see docker-compose.yml comment).
2026-05-15 18:02:09 +02:00
mAi
8a31f0af60 deploy: Dockerfile + docker-compose.yml for mDock, manual first roll
Pulls the deploy infra forward from §10 so m can see slice 1 on his LAN.

- Dockerfile: multi-stage golang:1.25-alpine → distroless/static-debian12.
  CGO_ENABLED=0 (modernc.org/sqlite is pure Go). USER 1000:1000 so the
  bind-mount on mDock (owned by m:m) is writable without chowning the
  host dir. -trimpath + -s -w; 12.2MB final image.
- docker-compose.yml: matches the mDock convention surveyed earlier
  (container_name explicit, restart: unless-stopped, env_file in
  /home/m/secrets/mcables/.env, bind-mount /home/m/stacks/mcables/data,
  port 7777 exposed on LAN). Image temporarily under the mai/ namespace
  on mgit.msbls.de because mAi doesn't have write access to m/* today —
  documented in a comment so retagging is one line when permissions land.
- .dockerignore: keeps .git, .worktrees, .m, data/, docs/, *.md,
  editor cruft out of the build context.

Manual deploy verified end-to-end:
- docker build → image sha256:76624f17 (12.2MB)
- mAi-authenticated push to mgit.msbls.de/mai/mcables:latest
- ssh mdock anonymous pull works (registry allows public reads on this
  namespace)
- POST /api/projects {"name":"LOFT"} returns the row, GET /api/projects
  shows it; docker compose restart preserves it on disk; second GET
  still shows LOFT.

Gitea Actions auto-deploy left for a follow-up task per the head's
instruction — gets us the moving parts right first.
2026-05-15 18:01:30 +02:00
mAi
98f30306a1 merge: slice 1 — bootstrap + project CRUD + global cable_types
picasso shipped (7 commits @ 905c75c):
- Go module + cmd/mcables binary
- internal/db: migrations runner + 001_init.sql (full v3 schema, 5 cable_types seeded)
- internal/db/store.go: projects + cable_types CRUD with sentinel errors
- internal/server: net/http handlers (Go 1.22 ServeMux)
- web/static: project picker, legend, modals (new project / cable type / delete), ?project= URL state
- 17 store tests green, end-to-end smoke verified

Endpoints live: /api/healthz, /api/projects {GET POST}, /api/projects/:id
{GET PATCH DELETE?confirm=<name>}, /api/cable-types {GET POST}, /api/cable-types/:id {PATCH DELETE}.

Next: slice 2 (frames + devices + drag-to-position) on m's go.
2026-05-15 16:50:02 +02:00
54 changed files with 13584 additions and 213 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Source-control + worktree noise
.git
.gitignore
.gitea
.worktrees
# mai worker-local logs
.m
# Local runtime state (mounted as a volume in production)
data
*.db
*.db-wal
*.db-shm
# Build artefacts
bin
/cablegui
# Editor cruft
.vscode
.idea
*.swp
# Documentation (lives in git, not in the image)
docs
CLAUDE.md
README.md
# Test files (build still respects them via go.mod, this only strips
# the test fixtures we might check in later)
**/testdata

2
.gitignore vendored
View File

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

114
CLAUDE.md
View File

@@ -1,37 +1,60 @@
# mCables — Project Instructions
# CableGUI — Project Instructions
## Project Overview
Cable-management **framework** for m's setup. Each cable-managed environment
(LOFT, OFFICE, …) is a separate **mCables project**, and each project is
backed by exactly one Excalidraw drawing. The framework provides a visual
web interface backed by a Go HTTP API and SQLite, plus an export pipeline
that writes `.excalidraw` files via mExDraw.
Cable-management **framework + solver** for m's setup. m declares his
**devices** and the **connection requirements** between them ("NAS must
connect to Switch via RJ45"). CableGUI runs a solver that emits the cable
plan + bundle recommendations. CableGUI is a **schematic**, not a
physical-routing tool — cables are straight lines between endpoints; the
"maximum bundling" objective is satisfied by the endpoint-pair rule
(when two or more cables share the same A↔B endpoint pair, group them
into one bundle). The visual editor is still there for tweaking the
plan, but the solver is the headline.
**Memory group_id:** `mcables`
Each cable-managed environment (LOFT, OFFICE, …) is a separate
**CableGUI project**, and each project is backed by exactly one Excalidraw
drawing. The framework provides a visual web interface backed by a Go
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
files via mExDraw.
**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
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
- A reusable framework for tracking devices, ports, cables, cable types,
bundles, frames — **scoped per project** (LOFT and OFFICE are separate
projects, each a separate drawing).
- A visual editor in the browser: switch projects, add frames/devices/ports,
click ports to wire up cables, pick cable types from a per-project legend.
- A one-way export from the DB to the corresponding `.excalidraw` drawing
on `mxdrw.msbls.de` whenever m clicks Export — DB is authoritative,
Excalidraw is the projection.
- Bundle detection: parallel cables along the same path within a project
get grouped + colour-bundled in the diagram.
- A **solver** that, given the project's devices + connection
requirements, emits the cable plan + bundle recommendations.
Objective: maximum bundling via the endpoint-pair rule (schematic
only — no path/trunk/cable-tray modelling).
- A **hybrid device-type catalog**: 14 built-in types (NAS, PC, Mac,
Notebook, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink,
IOx-3/6/8, Screen, Keyboard, Mouse) with default port profiles,
extensible per project. Picking a type on device-create seeds the
device's ports automatically; m overrides per instance.
- **Setup templates** for bootstrapping a project from blank to
solver-ready: built-ins 'Living Room', 'Home Office', 'Server Rack'
stamp their device-types + connection requirements in one transaction.
- A visual editor for switching projects, adding frames/devices,
declaring requirements, running the solver, and tweaking the
resulting plan. Unmet requirements get a one-click quick-fix
("+ Add &lt;type&gt; port to &lt;device&gt; and re-solve").
- A one-way export from the DB to the corresponding `.excalidraw`
drawing on `mxdrw.msbls.de` whenever m clicks Export — DB is
authoritative, Excalidraw is the projection.
## Architecture
| 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. |
| 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.) |
@@ -45,16 +68,31 @@ interface. The backend serves the UI and the API; there is no
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
OFFICE has `desk`, `server`). Frames are not projects — they're zones
within one drawing.
- Every device, port, cable, IO marker, and bundle is **project-scoped**
(`project_id` denormalised onto every row, with `ON DELETE CASCADE` from
`projects`). `UNIQUE (project_id, devices.name)` — no two devices in
one project share a name.
- Every device, port, cable, IO marker, bundle, and **connection
requirement** is **project-scoped** (`project_id` denormalised onto
every row, with `ON DELETE CASCADE` from `projects`).
`UNIQUE (project_id, devices.name)` — no two devices in one project
share a name.
- **Cable types are global.** A single shared `cable_types` table —
no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded
by migration 001 once, not per project. Renaming or recolouring a type
affects every project's legend immediately.
- **Device types are hybrid.** `device_types` is one global table with
`project_id` NULL for the 11 built-in catalog rows (seeded by
migration 002) and `project_id = current` for project-custom types.
Each `device_type` carries a `device_type_ports` profile that seeds
`ports` rows when a device of that type is created. m can extend the
catalog per project; built-ins are read-only from the API.
- **Connection requirements** (`connection_requirements` table) are the
solver's input. m declares "from_device ↔ to_device, preferred cable
type, must_connect"; the solver assigns ports and emits cables.
- **Project deletion guardrail.** `DELETE /api/projects/:pid` requires
`?confirm=<name>` matching the project's current name. 400 otherwise.
- **Solver-owned vs. user-owned cables.** `cables.auto = 1` = created by
the solver and replaceable on re-solve. `auto = 0` = hand-drawn by m,
left alone by the solver. PATCHing endpoint or type of an auto cable
promotes it to manual (explicit "Promote to manual" button in the
inspector, per design v4 §5b.3).
## Branch Strategy
@@ -75,14 +113,14 @@ interface. The backend serves the UI and the API; there is no
## 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
stuff; mDock uses raw `docker compose` per the conventions of the existing
mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`.
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea
- Repo layout on mDock: `/home/m/stacks/cablegui/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/cablegui/.env`.
- 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
mDock with label `self-hosted:host`).
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
@@ -90,12 +128,12 @@ mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- LAN URL: `http://mdock:7777`.
- 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
`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
its visual grammar:
@@ -126,23 +164,25 @@ Legend colours (global, seeded once by migration 001):
## Out of scope (v0)
- Multi-user. mCables is m-only.
- Multi-user. CableGUI is m-only.
- Auth / sharing — LAN-trusted on mDock.
- Mobile / responsive — desktop browser only.
- Cable inventory beyond visual structure (no length, no purchase history,
no SKU). Strictly visual structure for v0.
- 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.
## Worker Preferences
- **First shift = inventor** (design pass): conventions, schema, API,
export pipeline, mDock deploy plan, UI flows, slices. Output:
`docs/design.md` + open questions for m.
- **Second shift = coder** (after m's go on the design): bootstrap repo
skeleton (Go module, SQLite migrations, server, exporter, frontend
scaffold). Take slices 14 first (project CRUD, frames/devices, ports
and cables, IO + cable-type editing); slice 5 (Excalidraw export) closes
the round-trip.
- Use **Sonnet** for both — greenfield, structure matters more than depth.
- **Inventor shifts** (design passes): conventions, schema, API, export
pipeline, mDock deploy plan, UI flows, slices. Output: `docs/design.md`
+ open questions for m. v1v4 are versioned in the doc's header callout.
- **Coder shifts** (after m's go on a design version): build to the
current design.md. Current state: slice 1 (project CRUD + global
cable_types) and slice 2 (frames + devices + drag) are merged; design
v4 reshapes slices 3+ (IO + cable-type editing → device-type catalog →
device-type manage → connection-requirements UI → solver → manual
port/cable draw → export). See `docs/design.md` §8 for the current
sequence.
- Use **Sonnet** for both — structure matters more than depth.

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
#
# CableGUI — single-stage build → distroless runtime image.
# 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.
FROM golang:1.25-alpine AS build
WORKDIR /src
# Cache deps before copying the rest of the source.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# -trimpath strips local paths from the binary; -s -w drops debug info.
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/cablegui \
./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/cablegui /app/cablegui
ENV CABLEGUI_ADDR=0.0.0.0:7777 \
CABLEGUI_DB=/app/data/cablegui.db
EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /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
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
USER 1000:1000
ENTRYPOINT ["/app/cablegui"]

View File

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

View File

@@ -1,9 +1,9 @@
# mCables
# CableGUI
Cable-management **framework** for m's setup — visual web editor backed by
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
mxdrw.msbls.de.
@@ -23,7 +23,7 @@ end-to-end; the SVG canvas is intentionally empty until slice 2.
## Run it
```sh
go run ./cmd/mcables
go run ./cmd/cablegui
# open http://localhost:7777
```
@@ -31,20 +31,21 @@ Or built:
```sh
make build
./bin/mcables
./bin/cablegui
```
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
| Var | Default | Notes |
|---|---|---|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
| `CABLEGUI_ADDR` | `0.0.0.0:7777` | Listen address. |
| `CABLEGUI_DB` | `./data/cablegui.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
| `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
@@ -75,6 +76,66 @@ PATCH /api/cable-types/:id ← partial — affects every project
DELETE /api/cable-types/:id ← 409 in_use if any cable references it
```
## Deploy to mDock
CableGUI runs on **mDock** at `http://mdock:7777` as a docker-compose
service under `/home/m/stacks/cablegui/`. Pattern matches the other
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
no reverse proxy, LAN-trusted.
### Manual deploy
1. **Build + push the image** (image now lives under `m/` in Gitea):
```sh
docker build -t mgit.msbls.de/m/cablegui:latest .
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc \
| docker login mgit.msbls.de -u m --password-stdin
docker push mgit.msbls.de/m/cablegui:latest
```
2. **Prepare directories on mDock** (one-time):
```sh
ssh mdock 'mkdir -p /home/m/stacks/cablegui/data /home/m/secrets/cablegui \
&& touch /home/m/secrets/cablegui/.env \
&& chmod 0600 /home/m/secrets/cablegui/.env'
scp docker-compose.yml mdock:/home/m/stacks/cablegui/docker-compose.yml
```
3. **Pull + start**:
```sh
ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'
```
4. **Verify** from any LAN host:
```sh
curl http://mdock:7777/api/healthz # → {"status":"ok"}
curl http://mdock:7777/api/cable-types # → the 5 seeded types
```
To **update** to a new build: rebuild + push the image, then
`ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'`.
### Persistence
SQLite lives at `/home/m/stacks/cablegui/data/cablegui.db` on the host
(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
up owned by `m`, the host user.
`docker compose restart` keeps the data intact (tested 2026-05-15).
### Automation — follow-up task
This first roll is **manual**. A Gitea Actions workflow on the
self-hosted runner already on mDock (`/home/m/act-runner/`, label
`self-hosted:host`) — build → push → `docker compose up -d` on every
push to `main` — is a separate task per the design's §10. Tracking
spawned by the head if/when wanted.
## Design + project conventions
- `docs/design.md` — full v3 design (schema, API, importer/export

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
}

20
docker-compose.yml Normal file
View File

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

File diff suppressed because it is too large Load Diff

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

222
internal/db/bundles.go Normal file
View File

@@ -0,0 +1,222 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// BundleCreate is the create-shape: a name + the cable IDs to include.
// Auto=true means the solver created the bundle; user-created bundles
// stay auto=0 and survive a re-solve.
type BundleCreate struct {
Name string
CableIDs []int64
Auto bool
}
type BundleUpdate struct {
Name *string
CableIDs *[]int64
}
// CreateBundle inserts a bundle + its cable_bundle rows in one tx.
func (s *Store) CreateBundle(projectID int64, b BundleCreate) (*Bundle, error) {
return s.createBundle(s.db, projectID, b, true)
}
func (s *Store) createBundle(ex execer, projectID int64, b BundleCreate, ownTx bool) (*Bundle, error) {
name := strings.TrimSpace(b.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
// When the caller already holds a tx (ownTx=false), do all validation
// against `ex` (the tx executor) — calling Store methods that hit
// s.db would deadlock against the connection the tx is holding under
// MaxOpenConns(1).
for _, cid := range b.CableIDs {
if _, err := s.getCableTx(ex, projectID, cid); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable_id %d not in project", ErrInvalidInput, cid)
}
return nil, err
}
}
autoInt := 0
if b.Auto {
autoInt = 1
}
var tx *sql.Tx
var err error
useEx := ex
if ownTx {
tx, err = s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
useEx = tx
}
res, err := useEx.Exec(
`INSERT INTO bundles (project_id, name, auto) VALUES (?, ?, ?)`,
projectID, name, autoInt,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
for _, cid := range b.CableIDs {
if _, err := useEx.Exec(
`INSERT INTO bundle_cables (bundle_id, cable_id) VALUES (?, ?)`, id, cid,
); err != nil {
return nil, mapWriteErr(err)
}
}
if ownTx {
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetBundle(projectID, id)
}
// In tx-inheriting mode, build the response struct locally — the
// caller will re-fetch via GetBundle after commit if it needs more.
out := &Bundle{
ID: id, ProjectID: projectID, Name: name, Auto: b.Auto, CableIDs: append([]int64(nil), b.CableIDs...),
}
return out, nil
}
func (s *Store) GetBundle(projectID, id int64) (*Bundle, error) {
var b Bundle
var autoInt int
err := s.db.QueryRow(
`SELECT id, project_id, name, auto, created_at, updated_at
FROM bundles WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&b.ID, &b.ProjectID, &b.Name, &autoInt, &b.CreatedAt, &b.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
b.Auto = autoInt != 0
ids, err := s.bundleCableIDs(id)
if err != nil {
return nil, err
}
b.CableIDs = ids
return &b, nil
}
func (s *Store) bundleCableIDs(bundleID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT cable_id FROM bundle_cables WHERE bundle_id = ? ORDER BY cable_id`, bundleID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []int64{}
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// ListBundles returns every bundle in a project, ordered by id.
func (s *Store) ListBundles(projectID int64) ([]Bundle, error) {
rows, err := s.db.Query(
`SELECT id, project_id, name, auto, created_at, updated_at
FROM bundles WHERE project_id = ? ORDER BY id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Bundle{}
for rows.Next() {
var b Bundle
var autoInt int
if err := rows.Scan(&b.ID, &b.ProjectID, &b.Name, &autoInt,
&b.CreatedAt, &b.UpdatedAt); err != nil {
return nil, err
}
b.Auto = autoInt != 0
out = append(out, b)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
ids, err := s.bundleCableIDs(out[i].ID)
if err != nil {
return nil, err
}
out[i].CableIDs = ids
}
return out, nil
}
// UpdateBundle: name + cable set are mutable. Replacing cables wipes
// bundle_cables and re-inserts in one tx.
func (s *Store) UpdateBundle(projectID, id int64, u BundleUpdate) (*Bundle, error) {
cur, err := s.GetBundle(projectID, id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE bundles SET name = ?, updated_at = datetime('now') WHERE id = ?`,
cur.Name, id,
); err != nil {
return nil, mapWriteErr(err)
}
if u.CableIDs != nil {
if _, err := tx.Exec(`DELETE FROM bundle_cables WHERE bundle_id = ?`, id); err != nil {
return nil, err
}
for _, cid := range *u.CableIDs {
if _, err := s.getCableTx(tx, projectID, cid); err != nil {
return nil, fmt.Errorf("%w: cable_id %d not in project", ErrInvalidInput, cid)
}
if _, err := tx.Exec(
`INSERT INTO bundle_cables (bundle_id, cable_id) VALUES (?, ?)`, id, cid,
); err != nil {
return nil, mapWriteErr(err)
}
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetBundle(projectID, id)
}
func (s *Store) DeleteBundle(projectID, id int64) error {
if _, err := s.GetBundle(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM bundles WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}

371
internal/db/cables.go Normal file
View File

@@ -0,0 +1,371 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// CableEndpoint identifies one side of a cable. Exactly one of PortID /
// DeviceID / IOID must be non-nil; the store enforces this.
type CableEndpoint struct {
PortID *int64
DeviceID *int64
IOID *int64
}
// CableCreate is the create-shape for /api/projects/:pid/cables.
// auto=false (default) marks the cable as m-drawn; the solver writes
// auto=true when it places its rows.
type CableCreate struct {
TypeID int64
Label string
From CableEndpoint
To CableEndpoint
Auto bool
}
// CableUpdate is a partial update. PATCHing endpoint or type on an
// auto=1 cable should promote it to manual; handler logic does that
// (see slice 6 §5b.3).
type CableUpdate struct {
TypeID *int64
Label *string
From *CableEndpoint
To *CableEndpoint
Auto *bool
}
// CreateCable inserts a cable. Validates that the endpoints exist in
// the same project, that exactly one of (port/device/io) is set per side,
// and that the cable type is real.
func (s *Store) CreateCable(projectID int64, c CableCreate) (*Cable, error) {
return s.createCable(s.db, projectID, c)
}
// createCable on a TX-or-DB executor; solver uses the tx form.
func (s *Store) createCable(ex execer, projectID int64, c CableCreate) (*Cable, error) {
if err := s.validateEndpointEx(ex, projectID, "from", c.From); err != nil {
return nil, err
}
if err := s.validateEndpointEx(ex, projectID, "to", c.To); err != nil {
return nil, err
}
if err := s.assertCableTypeEx(ex, c.TypeID); err != nil {
return nil, err
}
autoInt := 0
if c.Auto {
autoInt = 1
}
res, err := ex.Exec(
`INSERT INTO cables
(project_id, type_id, label,
from_port_id, from_device_id, from_io_id,
to_port_id, to_device_id, to_io_id,
auto)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, c.TypeID, nullableString(c.Label),
nullableInt64(c.From.PortID), nullableInt64(c.From.DeviceID), nullableInt64(c.From.IOID),
nullableInt64(c.To.PortID), nullableInt64(c.To.DeviceID), nullableInt64(c.To.IOID),
autoInt,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.getCableTx(ex, projectID, id)
}
// validateEndpoint is the s.db variant for public CRUD callers.
func (s *Store) validateEndpoint(projectID int64, label string, e CableEndpoint) error {
return s.validateEndpointEx(s.db, projectID, label, e)
}
// validateEndpointEx runs the same checks against any executor so the
// solver can call createCable inside its tx without deadlocking on the
// MaxOpenConns(1) connection that the tx holds.
func (s *Store) validateEndpointEx(ex execer, projectID int64, label string, e CableEndpoint) error {
count := 0
if e.PortID != nil {
count++
}
if e.DeviceID != nil {
count++
}
if e.IOID != nil {
count++
}
if count != 1 {
return fmt.Errorf("%w: %s must specify exactly one of port/device/io", ErrInvalidInput, label)
}
if e.PortID != nil {
var pid int64
err := ex.QueryRow(`SELECT project_id FROM ports WHERE id = ?`, *e.PortID).Scan(&pid)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: %s port_id %d not found", ErrInvalidInput, label, *e.PortID)
}
if err != nil {
return err
}
if pid != projectID {
return fmt.Errorf("%w: %s port_id %d is in another project", ErrInvalidInput, label, *e.PortID)
}
}
if e.DeviceID != nil {
var pid int64
err := ex.QueryRow(`SELECT project_id FROM devices WHERE id = ?`, *e.DeviceID).Scan(&pid)
if errors.Is(err, sql.ErrNoRows) || (err == nil && pid != projectID) {
return fmt.Errorf("%w: %s device_id %d not in project", ErrInvalidInput, label, *e.DeviceID)
}
if err != nil {
return err
}
}
if e.IOID != nil {
var pid int64
err := ex.QueryRow(`SELECT project_id FROM io_markers WHERE id = ?`, *e.IOID).Scan(&pid)
if errors.Is(err, sql.ErrNoRows) || (err == nil && pid != projectID) {
return fmt.Errorf("%w: %s io_id %d not in project", ErrInvalidInput, label, *e.IOID)
}
if err != nil {
return err
}
}
return nil
}
// assertCableTypeEx is a lightweight existence check against any executor.
func (s *Store) assertCableTypeEx(ex execer, id int64) error {
var dummy int64
err := ex.QueryRow(`SELECT id FROM cable_types WHERE id = ?`, id).Scan(&dummy)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, id)
}
return err
}
func (s *Store) GetCable(projectID, id int64) (*Cable, error) {
return s.getCableTx(s.db, projectID, id)
}
func (s *Store) getCableTx(ex execer, projectID, id int64) (*Cable, error) {
var c Cable
var fp, fd, fio, tp, td, tio sql.NullInt64
var label, ex2 sql.NullString
var autoInt int
err := ex.QueryRow(
`SELECT id, project_id, type_id, label,
from_port_id, from_device_id, from_io_id,
to_port_id, to_device_id, to_io_id,
auto, excalidraw_id, created_at, updated_at
FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&c.ID, &c.ProjectID, &c.TypeID, &label,
&fp, &fd, &fio, &tp, &td, &tio,
&autoInt, &ex2, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if label.Valid {
v := label.String
c.Label = &v
}
if fp.Valid {
v := fp.Int64
c.FromPortID = &v
}
if fd.Valid {
v := fd.Int64
c.FromDeviceID = &v
}
if fio.Valid {
v := fio.Int64
c.FromIOID = &v
}
if tp.Valid {
v := tp.Int64
c.ToPortID = &v
}
if td.Valid {
v := td.Int64
c.ToDeviceID = &v
}
if tio.Valid {
v := tio.Int64
c.ToIOID = &v
}
c.Auto = autoInt != 0
if ex2.Valid {
c.ExcalidrawID = &ex2.String
}
return &c, nil
}
// ListCables returns every cable in a project.
func (s *Store) ListCables(projectID int64) ([]Cable, error) {
return s.listCablesTx(s.db, projectID)
}
func (s *Store) listCablesTx(ex execer, projectID int64) ([]Cable, error) {
rows, err := ex.Query(
`SELECT id, project_id, type_id, label,
from_port_id, from_device_id, from_io_id,
to_port_id, to_device_id, to_io_id,
auto, excalidraw_id, created_at, updated_at
FROM cables WHERE project_id = ? ORDER BY id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Cable{}
for rows.Next() {
var c Cable
var fp, fd, fio, tp, td, tio sql.NullInt64
var label, ex2 sql.NullString
var autoInt int
if err := rows.Scan(&c.ID, &c.ProjectID, &c.TypeID, &label,
&fp, &fd, &fio, &tp, &td, &tio,
&autoInt, &ex2, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
if label.Valid {
v := label.String
c.Label = &v
}
if fp.Valid {
v := fp.Int64
c.FromPortID = &v
}
if fd.Valid {
v := fd.Int64
c.FromDeviceID = &v
}
if fio.Valid {
v := fio.Int64
c.FromIOID = &v
}
if tp.Valid {
v := tp.Int64
c.ToPortID = &v
}
if td.Valid {
v := td.Int64
c.ToDeviceID = &v
}
if tio.Valid {
v := tio.Int64
c.ToIOID = &v
}
c.Auto = autoInt != 0
if ex2.Valid {
c.ExcalidrawID = &ex2.String
}
out = append(out, c)
}
return out, rows.Err()
}
// UpdateCable applies a partial update. Caller-controlled — promote-to-
// manual semantics live at the handler level (§5b.3: any PATCH touching
// type/endpoint promotes auto→0).
func (s *Store) UpdateCable(projectID, id int64, u CableUpdate) (*Cable, error) {
cur, err := s.GetCable(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.From != nil {
if err := s.validateEndpoint(projectID, "from", *u.From); err != nil {
return nil, err
}
cur.FromPortID = u.From.PortID
cur.FromDeviceID = u.From.DeviceID
cur.FromIOID = u.From.IOID
}
if u.To != nil {
if err := s.validateEndpoint(projectID, "to", *u.To); err != nil {
return nil, err
}
cur.ToPortID = u.To.PortID
cur.ToDeviceID = u.To.DeviceID
cur.ToIOID = u.To.IOID
}
if u.Auto != nil {
cur.Auto = *u.Auto
}
autoInt := 0
if cur.Auto {
autoInt = 1
}
if _, err := s.db.Exec(
`UPDATE cables
SET type_id = ?, label = ?,
from_port_id = ?, from_device_id = ?, from_io_id = ?,
to_port_id = ?, to_device_id = ?, to_io_id = ?,
auto = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.TypeID, nullableStringPtr(cur.Label),
nullableInt64(cur.FromPortID), nullableInt64(cur.FromDeviceID), nullableInt64(cur.FromIOID),
nullableInt64(cur.ToPortID), nullableInt64(cur.ToDeviceID), nullableInt64(cur.ToIOID),
autoInt, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetCable(projectID, id)
}
// DeleteCable removes a cable from a project.
func (s *Store) DeleteCable(projectID, id int64) error {
if _, err := s.GetCable(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}
// nullableString → for label-style strings: "" → SQL NULL.
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
func nullableStringPtr(p *string) any {
if p == nil {
return nil
}
return *p
}
// execer abstracts *sql.DB and *sql.Tx for store helpers used by both
// the public API and inside transactions (e.g. the solver).
type execer interface {
Exec(query string, args ...any) (sql.Result, error)
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
}

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

@@ -0,0 +1,192 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// ConnectionRequirementCreate is the create-shape. Server normalises
// from/to into (pair_lo, pair_hi) so (A,B,T) and (B,A,T) collide.
type ConnectionRequirementCreate struct {
FromDeviceID int64
ToDeviceID int64
PreferredCableTypeID *int64
MustConnect *bool // pointer so "absent" defaults to true
Notes string
}
// ConnectionRequirementUpdate is the partial-update shape. project_id +
// the device pair are immutable post-create (changing either is best
// modelled as delete-then-create — keeps pair_lo/pair_hi semantics simple).
type ConnectionRequirementUpdate struct {
PreferredCableTypeID FrameRef // tri-state: leave / set / clear
MustConnect *bool
Notes *string
}
// CreateConnectionRequirement inserts a new requirement. Validates that
// both devices live in projectID, that from != to, and that the
// (project, pair_lo, pair_hi, preferred_cable_type_id) tuple is unique.
func (s *Store) CreateConnectionRequirement(projectID int64, r ConnectionRequirementCreate) (*ConnectionRequirement, error) {
if r.FromDeviceID == r.ToDeviceID {
return nil, fmt.Errorf("%w: from_device_id and to_device_id must differ", ErrInvalidInput)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if _, err := s.GetDevice(projectID, r.FromDeviceID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: from_device_id %d not in project %d", ErrInvalidInput, r.FromDeviceID, projectID)
}
return nil, err
}
if _, err := s.GetDevice(projectID, r.ToDeviceID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: to_device_id %d not in project %d", ErrInvalidInput, r.ToDeviceID, projectID)
}
return nil, err
}
if r.PreferredCableTypeID != nil {
if _, err := s.GetCableType(*r.PreferredCableTypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *r.PreferredCableTypeID)
}
return nil, err
}
}
must := true
if r.MustConnect != nil {
must = *r.MustConnect
}
mustInt := 0
if must {
mustInt = 1
}
lo, hi := r.FromDeviceID, r.ToDeviceID
if lo > hi {
lo, hi = hi, lo
}
res, err := s.db.Exec(
`INSERT INTO connection_requirements
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, pair_lo, pair_hi)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, r.FromDeviceID, r.ToDeviceID, nullableInt64(r.PreferredCableTypeID),
mustInt, r.Notes, lo, hi,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetConnectionRequirement(projectID, id)
}
// GetConnectionRequirement loads one by id, project-scoped.
func (s *Store) GetConnectionRequirement(projectID, id int64) (*ConnectionRequirement, error) {
var r ConnectionRequirement
var ct sql.NullInt64
var must int
err := s.db.QueryRow(
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, created_at, updated_at
FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if ct.Valid {
v := ct.Int64
r.PreferredCableTypeID = &v
}
r.MustConnect = must != 0
return &r, nil
}
// ListConnectionRequirements returns every requirement in a project,
// ordered by id (insertion order).
func (s *Store) ListConnectionRequirements(projectID int64) ([]ConnectionRequirement, error) {
rows, err := s.db.Query(
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, created_at, updated_at
FROM connection_requirements WHERE project_id = ? ORDER BY id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ConnectionRequirement{}
for rows.Next() {
var r ConnectionRequirement
var ct sql.NullInt64
var must int
if err := rows.Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
if ct.Valid {
v := ct.Int64
r.PreferredCableTypeID = &v
}
r.MustConnect = must != 0
out = append(out, r)
}
return out, rows.Err()
}
// UpdateConnectionRequirement applies a partial update. preferred_cable_type_id
// uses the FrameRef tri-state; must_connect + notes are plain pointers.
// The (from, to) pair is immutable on PATCH — delete + recreate to change.
func (s *Store) UpdateConnectionRequirement(projectID, id int64, u ConnectionRequirementUpdate) (*ConnectionRequirement, error) {
cur, err := s.GetConnectionRequirement(projectID, id)
if err != nil {
return nil, err
}
if u.PreferredCableTypeID.Set {
if u.PreferredCableTypeID.ID != nil {
if _, err := s.GetCableType(*u.PreferredCableTypeID.ID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *u.PreferredCableTypeID.ID)
}
return nil, err
}
}
cur.PreferredCableTypeID = u.PreferredCableTypeID.ID
}
if u.MustConnect != nil {
cur.MustConnect = *u.MustConnect
}
if u.Notes != nil {
cur.Notes = *u.Notes
}
mustInt := 0
if cur.MustConnect {
mustInt = 1
}
if _, err := s.db.Exec(
`UPDATE connection_requirements
SET preferred_cable_type_id = ?, must_connect = ?, notes = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.PreferredCableTypeID), mustInt, cur.Notes, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetConnectionRequirement(projectID, id)
}
// DeleteConnectionRequirement removes a requirement by id, project-scoped.
func (s *Store) DeleteConnectionRequirement(projectID, id int64) error {
if _, err := s.GetConnectionRequirement(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,181 @@
package db
import (
"errors"
"testing"
)
func setupTwoDevices(t *testing.T, s *Store) (int64, int64, int64) {
t.Helper()
p, _ := s.CreateProject("LOFT", "", "")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", X: 200, Y: 0, Width: 100, Height: 35})
return p.ID, a.ID, b.ID
}
func TestCreateConnReq_Basic(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45 := int64(5)
r, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if !r.MustConnect {
t.Errorf("must_connect default should be true")
}
if r.PreferredCableTypeID == nil || *r.PreferredCableTypeID != rj45 {
t.Errorf("preferred_cable_type_id wrong: %+v", r.PreferredCableTypeID)
}
}
func TestCreateConnReq_PairNormalisationRejectsReverse(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45 := int64(5)
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
}); err != nil {
t.Fatalf("first: %v", err)
}
// (B, A, RJ45) should collide on UNIQUE (pair_lo, pair_hi, type).
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: b, ToDeviceID: a, PreferredCableTypeID: &rj45,
})
if !errors.Is(err, ErrConflict) {
t.Errorf("reverse pair err = %v, want ErrConflict", err)
}
}
func TestCreateConnReq_DifferentCableTypesCoexist(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45, power := int64(5), int64(1)
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
}); err != nil {
t.Fatalf("rj45: %v", err)
}
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &power,
}); err != nil {
t.Errorf("power on same pair should be allowed: %v", err)
}
}
func TestCreateConnReq_SelfLoopRejected(t *testing.T) {
s := newTestStore(t)
pid, a, _ := setupTwoDevices(t, s)
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: a,
})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("self-loop err = %v, want ErrInvalidInput", err)
}
}
func TestCreateConnReq_CrossProjectDeviceRejected(t *testing.T) {
s := newTestStore(t)
pid, a, _ := setupTwoDevices(t, s)
p2, _ := s.CreateProject("OFFICE", "", "")
b2, _ := s.CreateDevice(p2.ID, DeviceCreate{Name: "X", X: 0, Y: 0, Width: 100, Height: 35})
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b2.ID,
})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project to-device err = %v, want ErrInvalidInput", err)
}
}
func TestCreateConnReq_NullCableTypeUniqueByPair(t *testing.T) {
// Two NULL-cable-type reqs on the same pair are NOT a conflict in
// SQLite (NULL != NULL in UNIQUE comparisons). This is fine — they
// represent "solver picks" both times; the second wins when solving.
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
}); err != nil {
t.Errorf("second NULL-type req should be allowed (SQLite NULL != NULL): %v", err)
}
}
func TestUpdateConnReq_PartialFields(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
rj45, power := int64(5), int64(1)
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
})
notes := "important"
must := false
updated, err := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
PreferredCableTypeID: FrameRef{Set: true, ID: &power},
MustConnect: &must,
Notes: &notes,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.PreferredCableTypeID == nil || *updated.PreferredCableTypeID != power {
t.Errorf("cable type not switched: %+v", updated.PreferredCableTypeID)
}
if updated.MustConnect {
t.Errorf("must_connect should be false")
}
if updated.Notes != "important" {
t.Errorf("notes = %q", updated.Notes)
}
// Clear the cable type.
cleared, _ := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
PreferredCableTypeID: FrameRef{Set: true, ID: nil},
})
if cleared.PreferredCableTypeID != nil {
t.Errorf("preferred_cable_type_id should be nil after clear; got %v", *cleared.PreferredCableTypeID)
}
}
func TestDeleteConnReq_CascadesOnDeviceDelete(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
})
if err := s.DeleteDevice(pid, a); err != nil {
t.Fatalf("delete device a: %v", err)
}
if _, err := s.GetConnectionRequirement(pid, r.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("requirement should be gone after device delete; got %v", err)
}
}
func TestSnapshot_IncludesConnectionRequirements(t *testing.T) {
s := newTestStore(t)
pid, a, b := setupTwoDevices(t, s)
_, _ = s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
FromDeviceID: a, ToDeviceID: b,
})
snap, err := s.Snapshot(pid)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.ConnectionRequirements) != 1 {
t.Errorf("snapshot.connection_requirements = %d, want 1", len(snap.ConnectionRequirements))
}
}
func TestDeleteConnReq_NotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteConnectionRequirement(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}

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
// and the HTTP layer take a *Store, never a raw *sql.DB.
package db

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

@@ -0,0 +1,351 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// ErrForbidden is the sentinel for "you can't mutate this row" — used by
// PATCH/DELETE on built-in device_types.
var ErrForbidden = errors.New("forbidden")
// -----------------------------------------------------------------------------
// device_types
// -----------------------------------------------------------------------------
// DeviceTypeCreate is the shape POSTed under /api/projects/:pid/device-types.
// project_id is the URL :pid; the caller never passes it in the body.
type DeviceTypeCreate struct {
Name string
Kind string
Icon string
Description string
Ports []DeviceTypePortCreate
}
// DeviceTypePortCreate is one row in the type's port profile.
type DeviceTypePortCreate struct {
CableTypeID int64
LabelPrefix string
Count int
Edge string
SortOrder int
}
// DeviceTypeUpdate is the partial-update shape. Built-in types reject
// any PATCH at the store level.
type DeviceTypeUpdate struct {
Name *string
Kind *string
Icon *string
Description *string
// Ports != nil means "replace the port profile with this set".
Ports *[]DeviceTypePortCreate
}
// CreateDeviceType inserts a project-custom row + its port profile in
// one transaction. projectID must be non-zero (built-ins are seed-only).
func (s *Store) CreateDeviceType(projectID int64, dt DeviceTypeCreate) (*DeviceType, error) {
name := strings.TrimSpace(dt.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if projectID == 0 {
return nil, fmt.Errorf("%w: project_id is required (built-ins are seed-only)", ErrInvalidInput)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
// Forbid name-collisions with built-ins (UNIQUE(project_id,name)
// only enforces inside the project; built-ins have project_id IS
// NULL so the constraint doesn't catch them).
var builtinClash int
if err := s.db.QueryRow(
`SELECT COUNT(*) FROM device_types WHERE project_id IS NULL AND name = ?`, name,
).Scan(&builtinClash); err != nil {
return nil, err
}
if builtinClash > 0 {
return nil, fmt.Errorf("%w: name %q clashes with a built-in device type", ErrConflict, name)
}
kind := strings.TrimSpace(dt.Kind)
if kind == "" {
kind = "generic"
}
desc := dt.Description
var iconPtr any
if icon := strings.TrimSpace(dt.Icon); icon != "" {
iconPtr = icon
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.Exec(
`INSERT INTO device_types (project_id, name, kind, icon, description, built_in)
VALUES (?, ?, ?, ?, ?, 0)`,
projectID, name, kind, iconPtr, desc,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
for _, p := range dt.Ports {
if err := insertDeviceTypePort(tx, id, p); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetDeviceType(id)
}
func insertDeviceTypePort(tx *sql.Tx, deviceTypeID int64, p DeviceTypePortCreate) error {
if p.CableTypeID <= 0 {
return fmt.Errorf("%w: cable_type_id is required on each port row", ErrInvalidInput)
}
if p.Count <= 0 {
p.Count = 1
}
edge := strings.TrimSpace(p.Edge)
if edge == "" {
edge = "bottom"
}
if edge != "top" && edge != "bottom" && edge != "left" && edge != "right" {
return fmt.Errorf("%w: edge must be top/bottom/left/right", ErrInvalidInput)
}
_, err := tx.Exec(
`INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`,
deviceTypeID, p.CableTypeID, p.LabelPrefix, p.Count, edge, p.SortOrder,
)
if err != nil {
return mapWriteErr(err)
}
return nil
}
// GetDeviceType loads a single type row (built-in OR project-custom)
// with its port profile.
func (s *Store) GetDeviceType(id int64) (*DeviceType, error) {
dt, err := scanDeviceTypeByID(s.db, id)
if err != nil {
return nil, err
}
ports, err := s.listDeviceTypePorts(id)
if err != nil {
return nil, err
}
dt.Ports = ports
return dt, nil
}
func scanDeviceTypeByID(d *sql.DB, id int64) (*DeviceType, error) {
var dt DeviceType
var proj sql.NullInt64
var icon sql.NullString
var built int
err := d.QueryRow(
`SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at
FROM device_types WHERE id = ?`, id,
).Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built,
&dt.CreatedAt, &dt.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if proj.Valid {
v := proj.Int64
dt.ProjectID = &v
}
if icon.Valid {
dt.Icon = &icon.String
}
dt.BuiltIn = built != 0
return &dt, nil
}
// ListBuiltInDeviceTypes returns every built-in type (project_id IS NULL).
func (s *Store) ListBuiltInDeviceTypes() ([]DeviceType, error) {
return s.listDeviceTypesWhere(`project_id IS NULL`, nil)
}
// ListDeviceTypesForProject returns built-ins + the project's custom
// types, merged. Built-ins come first (insertion order), then custom by
// id.
func (s *Store) ListDeviceTypesForProject(projectID int64) ([]DeviceType, error) {
return s.listDeviceTypesWhere(
`project_id IS NULL OR project_id = ?`, []any{projectID},
)
}
func (s *Store) listDeviceTypesWhere(where string, args []any) ([]DeviceType, error) {
q := `SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at
FROM device_types WHERE ` + where +
` ORDER BY (project_id IS NOT NULL), id`
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DeviceType{}
for rows.Next() {
var dt DeviceType
var proj sql.NullInt64
var icon sql.NullString
var built int
if err := rows.Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built,
&dt.CreatedAt, &dt.UpdatedAt); err != nil {
return nil, err
}
if proj.Valid {
v := proj.Int64
dt.ProjectID = &v
}
if icon.Valid {
dt.Icon = &icon.String
}
dt.BuiltIn = built != 0
out = append(out, dt)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Hydrate ports per row. Two queries per request is fine for the
// catalog size; switch to a single JOIN-and-group if it becomes hot.
for i := range out {
ps, err := s.listDeviceTypePorts(out[i].ID)
if err != nil {
return nil, err
}
out[i].Ports = ps
}
return out, nil
}
func (s *Store) listDeviceTypePorts(deviceTypeID int64) ([]DeviceTypePort, error) {
rows, err := s.db.Query(
`SELECT id, device_type_id, cable_type_id, label_prefix, count, edge, sort_order
FROM device_type_ports WHERE device_type_id = ? ORDER BY sort_order, id`,
deviceTypeID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DeviceTypePort{}
for rows.Next() {
var p DeviceTypePort
if err := rows.Scan(&p.ID, &p.DeviceTypeID, &p.CableTypeID,
&p.LabelPrefix, &p.Count, &p.Edge, &p.SortOrder); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// UpdateDeviceType applies a partial update. Built-in rows are rejected
// with ErrForbidden. Cross-project rows are rejected with ErrNotFound.
// Replacing the port profile (Ports != nil) wipes and re-inserts.
func (s *Store) UpdateDeviceType(projectID, id int64, u DeviceTypeUpdate) (*DeviceType, error) {
cur, err := s.GetDeviceType(id)
if err != nil {
return nil, err
}
if cur.BuiltIn {
return nil, fmt.Errorf("%w: built-in device types are read-only", ErrForbidden)
}
if cur.ProjectID == nil || *cur.ProjectID != projectID {
return nil, ErrNotFound
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.Kind != nil {
v := strings.TrimSpace(*u.Kind)
if v == "" {
v = "generic"
}
cur.Kind = v
}
if u.Icon != nil {
v := strings.TrimSpace(*u.Icon)
if v == "" {
cur.Icon = nil
} else {
cur.Icon = &v
}
}
if u.Description != nil {
cur.Description = *u.Description
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var iconArg any
if cur.Icon != nil {
iconArg = *cur.Icon
}
if _, err := tx.Exec(
`UPDATE device_types
SET name = ?, kind = ?, icon = ?, description = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.Kind, iconArg, cur.Description, id,
); err != nil {
return nil, mapWriteErr(err)
}
if u.Ports != nil {
if _, err := tx.Exec(`DELETE FROM device_type_ports WHERE device_type_id = ?`, id); err != nil {
return nil, err
}
for _, p := range *u.Ports {
if err := insertDeviceTypePort(tx, id, p); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetDeviceType(id)
}
// DeleteDeviceType removes a project-custom row. Built-ins → ErrForbidden.
// Cross-project → ErrNotFound. Cascades to device_type_ports (FK CASCADE)
// and SET-NULLs the type_id on any device referencing it.
func (s *Store) DeleteDeviceType(projectID, id int64) error {
cur, err := s.GetDeviceType(id)
if err != nil {
return err
}
if cur.BuiltIn {
return fmt.Errorf("%w: built-in device types are read-only", ErrForbidden)
}
if cur.ProjectID == nil || *cur.ProjectID != projectID {
return ErrNotFound
}
if _, err := s.db.Exec(`DELETE FROM device_types WHERE id = ?`, id); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,363 @@
package db
import (
"errors"
"testing"
)
// -------------------------------------------------------- catalog (seeded)
func TestSeed_BuiltInDeviceTypes(t *testing.T) {
s := newTestStore(t)
got, err := s.ListBuiltInDeviceTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
want := []string{
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
"Screen", "Keyboard", "Mouse",
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
}
if len(got) != len(want) {
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].Name != w {
t.Errorf("[%d] = %q, want %q", i, got[i].Name, w)
}
if !got[i].BuiltIn {
t.Errorf("[%d] %q should be built_in", i, got[i].Name)
}
if got[i].ProjectID != nil {
t.Errorf("[%d] %q should have project_id=nil", i, got[i].Name)
}
}
}
func TestSeed_PortProfiles(t *testing.T) {
s := newTestStore(t)
all, _ := s.ListBuiltInDeviceTypes()
byName := map[string]DeviceType{}
for _, d := range all {
byName[d.Name] = d
}
cases := map[string]struct {
totalPorts int // sum of count across profile rows
}{
"NAS": {2}, // Power 1 + RJ45 1
"PC": {5}, // Power 1 + RJ45 1 + HDMI 1 + USB 2
"Mac": {4}, // Power 1 + HDMI 1 + USB 2
"Notebook": {3}, // Power 1 + USB 2
"TV": {3}, // Power 1 + HDMI 2
"Soundbar": {2}, // Power 1 + HDMI 1
"Switch": {6}, // Power 1 + RJ45 5
"fritz": {5}, // Power 1 + RJ45 4
"ChromeCast": {2}, // Power 1 + HDMI 1
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
"IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
"IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
"IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {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 {
dt, ok := byName[name]
if !ok {
t.Errorf("missing built-in %q", name)
continue
}
total := 0
for _, p := range dt.Ports {
total += p.Count
}
if total != want.totalPorts {
t.Errorf("%s: total ports = %d, want %d", name, total, want.totalPorts)
}
}
}
// 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)
func TestCreateDeviceType_CustomBasic(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
dt, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{
Name: "DigitalCam", Kind: "accessory",
Description: "A camera with HDMI out",
Ports: []DeviceTypePortCreate{
{CableTypeID: 1, LabelPrefix: "Power", Count: 1},
{CableTypeID: 3, LabelPrefix: "HDMI", Count: 1, SortOrder: 1},
},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if dt.BuiltIn {
t.Errorf("built_in should be false")
}
if dt.ProjectID == nil || *dt.ProjectID != p.ID {
t.Errorf("project_id mismatch: %+v", dt.ProjectID)
}
if len(dt.Ports) != 2 {
t.Errorf("port profile rows = %d, want 2", len(dt.Ports))
}
}
func TestCreateDeviceType_NameClashWithBuiltIn(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
_, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "NAS"})
if !errors.Is(err, ErrConflict) {
t.Errorf("err = %v, want ErrConflict (NAS is built-in)", err)
}
}
func TestCreateDeviceType_PerProjectUnique(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); !errors.Is(err, ErrConflict) {
t.Errorf("dup err = %v, want ErrConflict", err)
}
}
func TestUpdateDeviceType_BuiltInForbidden(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
nas := all[0]
newName := "renamed"
_, err := s.UpdateDeviceType(p.ID, nas.ID, DeviceTypeUpdate{Name: &newName})
if !errors.Is(err, ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
}
func TestDeleteDeviceType_BuiltInForbidden(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
if err := s.DeleteDeviceType(p.ID, all[0].ID); !errors.Is(err, ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
}
func TestUpdateDeviceType_CrossProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
dt, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Foo"})
newName := "bar"
if _, err := s.UpdateDeviceType(p2.ID, dt.ID, DeviceTypeUpdate{Name: &newName}); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
// -------------------------------------------------------- device + ports seed
func TestCreateDevice_SeedsPortsFromBuiltInType(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
var nasID int64
for _, dt := range all {
if dt.Name == "NAS" {
nasID = dt.ID
break
}
}
if nasID == 0 {
t.Fatal("NAS not in catalog")
}
d, err := s.CreateDevice(p.ID, DeviceCreate{
Name: "NAS-Loft", TypeID: &nasID,
X: 100, Y: 100, Width: 100, Height: 35,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if d.TypeID == nil || *d.TypeID != nasID {
t.Errorf("type_id wrong: %+v", d.TypeID)
}
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) != 2 {
t.Fatalf("port count = %d, want 2 (Power + RJ45)", len(ports))
}
for _, prt := range ports {
if prt.YOffset != 35 {
t.Errorf("port y_offset = %v, want 35 (bottom edge)", prt.YOffset)
}
if prt.XOffset <= 0 || prt.XOffset >= 100 {
t.Errorf("port x_offset = %v, want between 0 and 100", prt.XOffset)
}
if prt.Label == nil {
t.Errorf("port label = nil, want non-nil (label_prefix is set)")
}
}
}
func TestCreateDevice_SeedsPortsForPC_FourGroupsFiveTotal(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
var pcID int64
for _, dt := range all {
if dt.Name == "PC" {
pcID = dt.ID
break
}
}
if pcID == 0 {
t.Fatal("PC not in catalog")
}
if _, err := s.CreateDevice(p.ID, DeviceCreate{
Name: "Workstation", TypeID: &pcID,
X: 0, Y: 0, Width: 100, Height: 35,
}); err != nil {
t.Fatalf("create: %v", err)
}
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) != 5 {
t.Errorf("port count = %d, want 5 (Power+RJ45+HDMI+USB×2)", len(ports))
}
// USB×2 must produce two labels "USB 1" and "USB 2".
usbLabels := map[string]bool{}
for _, prt := range ports {
if prt.Label != nil && (*prt.Label == "USB 1" || *prt.Label == "USB 2") {
usbLabels[*prt.Label] = true
}
}
if !usbLabels["USB 1"] || !usbLabels["USB 2"] {
t.Errorf("USB labels missing: got %v", usbLabels)
}
}
func TestCreateDevice_NoTypeID_NoPorts(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDevice(p.ID, DeviceCreate{
Name: "Freeform", X: 0, Y: 0, Width: 100, Height: 35,
}); err != nil {
t.Fatalf("create: %v", err)
}
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) != 0 {
t.Errorf("freeform device should have 0 ports, got %d", len(ports))
}
}
func TestCreateDevice_CrossProjectCustomTypeRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
custom, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Exotic"})
_, err := s.CreateDevice(p2.ID, DeviceCreate{
Name: "Wrong", TypeID: &custom.ID,
X: 0, Y: 0, Width: 100, Height: 35,
})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput (cross-project custom type)", err)
}
}
func TestSnapshot_IncludesPorts(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
for _, dt := range all {
if dt.Name == "Mac" {
_, _ = s.CreateDevice(p.ID, DeviceCreate{
Name: "M1", TypeID: &dt.ID,
X: 0, Y: 0, Width: 100, Height: 35,
})
break
}
}
snap, _ := s.Snapshot(p.ID)
if len(snap.Ports) != 4 {
t.Errorf("snapshot.Ports = %d, want 4 (Mac: Power+HDMI+USB×2)", len(snap.Ports))
}
}

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

@@ -0,0 +1,461 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// -----------------------------------------------------------------------------
// Frames
// -----------------------------------------------------------------------------
// FrameCreate is the create-shape; x/y/width/height carry full positions.
type FrameCreate struct {
Name string
X float64
Y float64
Width float64
Height float64
}
// FrameUpdate is the partial-update shape for PATCH. project_id is
// deliberately absent — moving a frame across projects would orphan its
// devices' frame_id refs, so the API refuses to do it.
type FrameUpdate struct {
Name *string
X *float64
Y *float64
Width *float64
Height *float64
}
// CreateFrame inserts a new frame inside a project.
func (s *Store) CreateFrame(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)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
res, err := s.db.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()
return s.GetFrame(projectID, id)
}
// GetFrame loads a frame, enforcing project_id scoping.
func (s *Store) GetFrame(projectID, id int64) (*Frame, error) {
var f Frame
var ex sql.NullString
err := s.db.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(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
&ex, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if ex.Valid {
f.ExcalidrawID = &ex.String
}
return &f, nil
}
// ListFrames returns every frame in a project, ordered by created_at so
// the on-screen z-order is stable.
func (s *Store) ListFrames(projectID int64) ([]Frame, error) {
rows, err := s.db.Query(
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
FROM frames WHERE project_id = ? ORDER BY created_at, id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Frame{}
for rows.Next() {
var f Frame
var ex sql.NullString
if err := rows.Scan(&f.ID, &f.ProjectID, &f.Name, &f.X, &f.Y, &f.Width, &f.Height,
&ex, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, err
}
if ex.Valid {
f.ExcalidrawID = &ex.String
}
out = append(out, f)
}
return out, rows.Err()
}
// UpdateFrame applies a partial update. project_id stays the same — we
// don't expose moving a frame across projects.
func (s *Store) UpdateFrame(projectID, id int64, u FrameUpdate) (*Frame, error) {
cur, err := s.GetFrame(projectID, id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.Width != nil {
if *u.Width <= 0 {
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
}
cur.Width = *u.Width
}
if u.Height != nil {
if *u.Height <= 0 {
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
}
cur.Height = *u.Height
}
if _, err := s.db.Exec(
`UPDATE frames
SET name = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.Name, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetFrame(projectID, id)
}
// DeleteFrame removes a frame. Devices with `frame_id = id` keep existing
// — the schema's ON DELETE SET NULL drops their frame_id to NULL so they
// stay in the project as "outside a frame".
func (s *Store) DeleteFrame(projectID, id int64) error {
if _, err := s.GetFrame(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}
// -----------------------------------------------------------------------------
// Devices
// -----------------------------------------------------------------------------
// DeviceCreate is the create-shape. FrameID may be nil ("outside any frame").
// TypeID may be nil for a freeform device (no auto-seeded ports). If set,
// the type must be either built-in or a project-custom type belonging to
// the same project — and CreateDevice seeds the device's ports from the
// type's port profile in the same transaction.
type DeviceCreate struct {
Name string
FrameID *int64
TypeID *int64
Color string
X float64
Y float64
Width float64
Height float64
}
// DeviceUpdate is the partial-update shape. project_id deliberately not
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
// inner pointer is nil to clear. TypeID uses the same FrameRef tri-state.
type DeviceUpdate struct {
Name *string
FrameID FrameRef // see FrameRef below
TypeID FrameRef // tri-state for type_id: same shape as FrameRef
Color *string
X *float64
Y *float64
Width *float64
Height *float64
}
// FrameRef encodes a tri-state for the FrameID PATCH:
//
// Set=false → leave the field untouched
// Set=true, ID=nil → set to NULL (device leaves all frames)
// Set=true, ID=&someInt → set to that frame id (must be in same project)
type FrameRef struct {
Set bool
ID *int64
}
// CreateDevice inserts a new device. FrameID, if provided, must reference
// a frame in the same project. TypeID, if provided, must reference a
// built-in or a project-custom device_type in the same project — the
// store seeds the device's ports from that type's profile in the same
// transaction so a half-created device (row inserted, ports missing)
// can never exist.
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
name := strings.TrimSpace(d.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if d.Width <= 0 || d.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if d.FrameID != nil {
if _, err := s.GetFrame(projectID, *d.FrameID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *d.FrameID, projectID)
}
return nil, err
}
}
if d.TypeID != nil {
dt, err := s.GetDeviceType(*d.TypeID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *d.TypeID)
}
return nil, err
}
// Project-custom types must match the device's project. Built-ins
// (project_id NULL) are available to every project.
if dt.ProjectID != nil && *dt.ProjectID != projectID {
return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *d.TypeID)
}
}
color := strings.TrimSpace(d.Color)
if color == "" {
color = "#1e1e1e"
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.Exec(
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
name, color, d.X, d.Y, d.Width, d.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
deviceID, _ := res.LastInsertId()
if d.TypeID != nil {
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetDevice(projectID, deviceID)
}
// GetDevice loads a device, project-scoped.
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
var d Device
var frame, typeID sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex, &d.CreatedAt, &d.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
d.FrameID = &v
}
if typeID.Valid {
v := typeID.Int64
d.TypeID = &v
}
if ex.Valid {
d.ExcalidrawID = &ex.String
}
return &d, nil
}
// ListDevices returns devices in a project. If frameID is non-nil and
// dereferences to a value, only devices with that frame_id are returned;
// if frameID dereferences to nil (i.e. caller passed &FrameRef{Set:true,ID:nil})
// — actually this signature uses *int64 directly: pass nil for "all
// devices", or pass &someInt for "devices in that frame". The empty-
// "outside-any-frame" filter isn't exposed yet — slice 2 doesn't need it.
func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
var (
rows *sql.Rows
err error
)
if frameID != nil {
rows, err = s.db.Query(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
projectID, *frameID,
)
} else {
rows, err = s.db.Query(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
projectID,
)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []Device{}
for rows.Next() {
var d Device
var frame, typeID sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
d.FrameID = &v
}
if typeID.Valid {
v := typeID.Int64
d.TypeID = &v
}
if ex.Valid {
d.ExcalidrawID = &ex.String
}
out = append(out, d)
}
return out, rows.Err()
}
// UpdateDevice applies a partial update. FrameID is tri-state — see FrameRef.
// A FrameID set to a non-nil ID must reference a frame in the same project.
func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, error) {
cur, err := s.GetDevice(projectID, id)
if err != nil {
return nil, err
}
if u.Name != nil {
v := strings.TrimSpace(*u.Name)
if v == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
cur.Name = v
}
if u.Color != nil {
v := strings.TrimSpace(*u.Color)
if v == "" {
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
}
cur.Color = v
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.Width != nil {
if *u.Width <= 0 {
return nil, fmt.Errorf("%w: width must be positive", ErrInvalidInput)
}
cur.Width = *u.Width
}
if u.Height != nil {
if *u.Height <= 0 {
return nil, fmt.Errorf("%w: height must be positive", ErrInvalidInput)
}
cur.Height = *u.Height
}
if u.FrameID.Set {
if u.FrameID.ID != nil {
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
}
return nil, err
}
}
cur.FrameID = u.FrameID.ID
}
if u.TypeID.Set {
if u.TypeID.ID != nil {
dt, err := s.GetDeviceType(*u.TypeID.ID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *u.TypeID.ID)
}
return nil, err
}
if dt.ProjectID != nil && *dt.ProjectID != projectID {
return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *u.TypeID.ID)
}
}
cur.TypeID = u.TypeID.ID
}
if _, err := s.db.Exec(
`UPDATE devices
SET frame_id = ?, type_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.FrameID), nullableInt64(cur.TypeID),
cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetDevice(projectID, id)
}
// DeleteDevice removes a device from a project.
func (s *Store) DeleteDevice(projectID, id int64) error {
if _, err := s.GetDevice(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}
// nullableInt64 converts a *int64 into a sql.NullInt64 so we can pass it
// straight into a parameterised query.
func nullableInt64(p *int64) any {
if p == nil {
return nil
}
return *p
}

View File

@@ -0,0 +1,235 @@
package db
import (
"errors"
"testing"
)
// ----------------------------------------------------------------------- frames
func TestCreateFrame_Basics(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 10, Y: 20, Width: 800, Height: 600})
if err != nil {
t.Fatalf("create: %v", err)
}
if f.ProjectID != p.ID || f.Name != "desk" || f.Width != 800 {
t.Errorf("unexpected frame: %+v", f)
}
}
func TestCreateFrame_RejectsZeroSize(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "x", Width: 0, Height: 50}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("zero width should be ErrInvalidInput; got %v", err)
}
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "y", Width: 50, Height: 0}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("zero height should be ErrInvalidInput; got %v", err)
}
}
func TestCreateFrame_DuplicateNameInSameProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 200, Height: 70}); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate frame name should ErrConflict; got %v", err)
}
}
func TestCreateFrame_SameNameAcrossProjectsOK(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
if _, err := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("p1: %v", err)
}
if _, err := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50}); err != nil {
t.Fatalf("p2: %v", err)
}
}
func TestGetFrame_WrongProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f, _ := s.CreateFrame(p1.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
if _, err := s.GetFrame(p2.ID, f.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("cross-project GetFrame should be ErrNotFound; got %v", err)
}
}
func TestListFrames_OrderedByCreation(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
for _, n := range []string{"rack", "desk", "media"} {
if _, err := s.CreateFrame(p.ID, FrameCreate{Name: n, Width: 100, Height: 50}); err != nil {
t.Fatalf("create %s: %v", n, err)
}
}
got, _ := s.ListFrames(p.ID)
if len(got) != 3 {
t.Fatalf("len = %d", len(got))
}
if got[0].Name != "rack" || got[2].Name != "media" {
t.Errorf("order = %v", []string{got[0].Name, got[1].Name, got[2].Name})
}
}
func TestUpdateFrame_PartialFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", X: 0, Y: 0, Width: 100, Height: 50})
nx := 42.0
updated, err := s.UpdateFrame(p.ID, f.ID, FrameUpdate{X: &nx})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.X != 42 || updated.Name != "desk" || updated.Width != 100 {
t.Errorf("got %+v", updated)
}
}
func TestDeleteFrame_SetsDeviceFrameIDToNull(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, X: 10, Y: 20, Width: 100, Height: 35})
if d.FrameID == nil || *d.FrameID != f.ID {
t.Fatalf("device frame_id pre-delete = %v, want %d", d.FrameID, f.ID)
}
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
t.Fatalf("delete frame: %v", err)
}
d2, _ := s.GetDevice(p.ID, d.ID)
if d2.FrameID != nil {
t.Errorf("device frame_id post-delete = %v, want nil (SET NULL)", d2.FrameID)
}
}
// ---------------------------------------------------------------------- devices
func TestCreateDevice_DefaultsColor(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
d, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 20, Width: 100, Height: 35})
if err != nil {
t.Fatalf("create: %v", err)
}
if d.Color != "#1e1e1e" {
t.Errorf("default color = %q, want #1e1e1e", d.Color)
}
if d.FrameID != nil {
t.Errorf("frame_id = %v, want nil for unframed device", d.FrameID)
}
}
func TestCreateDevice_DuplicateNameInProject(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", X: 10, Y: 10, Width: 100, Height: 35}); !errors.Is(err, ErrConflict) {
t.Errorf("dup device name should ErrConflict; got %v", err)
}
}
func TestCreateDevice_CrossProjectFrameRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
// Try to put a LOFT device into an OFFICE frame.
_, err := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", FrameID: &f2.ID, X: 0, Y: 0, Width: 100, Height: 35})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestUpdateDevice_FrameIDTriState(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
d, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f1.ID, X: 0, Y: 0, Width: 100, Height: 35})
// Leave alone (FrameID.Set=false) — even passing a different X.
nx := 99.0
u1, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{X: &nx})
if u1.FrameID == nil || *u1.FrameID != f1.ID {
t.Errorf("frame_id should be unchanged (f1); got %v", u1.FrameID)
}
// Move to f2.
u2, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
if u2.FrameID == nil || *u2.FrameID != f2.ID {
t.Errorf("frame_id should be f2; got %v", u2.FrameID)
}
// Clear (move outside any frame).
u3, _ := s.UpdateDevice(p.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: nil}})
if u3.FrameID != nil {
t.Errorf("frame_id should be nil after Set:true,ID:nil; got %v", *u3.FrameID)
}
}
func TestUpdateDevice_RejectsCrossProjectFrame(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
d, _ := s.CreateDevice(p1.ID, DeviceCreate{Name: "Mac", X: 0, Y: 0, Width: 100, Height: 35})
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, err := s.UpdateDevice(p1.ID, d.ID, DeviceUpdate{FrameID: FrameRef{Set: true, ID: &f2.ID}})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestListDevices_FilterByFrame(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f1, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
f2, _ := s.CreateFrame(p.ID, FrameCreate{Name: "rack", Width: 100, Height: 50})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "A", FrameID: &f1.ID, Width: 100, Height: 35})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "B", FrameID: &f2.ID, Width: 100, Height: 35})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "C", Width: 100, Height: 35}) // outside
all, _ := s.ListDevices(p.ID, nil)
if len(all) != 3 {
t.Errorf("all len = %d, want 3", len(all))
}
inF1, _ := s.ListDevices(p.ID, &f1.ID)
if len(inF1) != 1 || inF1[0].Name != "A" {
t.Errorf("inF1 = %+v", inF1)
}
}
func TestSnapshot_PopulatesFramesAndDevices(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "Mac", FrameID: &f.ID, Width: 100, Height: 35})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.Frames) != 1 || len(snap.Devices) != 1 {
t.Errorf("snapshot frames=%d devices=%d", len(snap.Frames), len(snap.Devices))
}
if len(snap.CableTypes) != 5 {
t.Errorf("cable_types = %d, want 5", len(snap.CableTypes))
}
}
func TestDeleteDevice_NotFoundIsNotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteDevice(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}

180
internal/db/io_markers.go Normal file
View File

@@ -0,0 +1,180 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// IOMarker is a wall-outlet terminator inside a project. Mostly Power
// by convention; the schema doesn't enforce it.
type IOMarker struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FrameID *int64 `json:"frame_id"`
Label string `json:"label"`
X float64 `json:"x"`
Y float64 `json:"y"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// IOMarkerCreate is the create-shape.
type IOMarkerCreate struct {
FrameID *int64
Label string
X float64
Y float64
}
// IOMarkerUpdate is the partial-update shape. project_id deliberately not
// settable; frame_id uses the same tri-state shape as DeviceUpdate.FrameID.
type IOMarkerUpdate struct {
Label *string
FrameID FrameRef
X *float64
Y *float64
}
// CreateIOMarker inserts a new IO marker. If frame_id is set, it must
// reference a frame in the same project.
func (s *Store) CreateIOMarker(projectID int64, m IOMarkerCreate) (*IOMarker, error) {
label := strings.TrimSpace(m.Label)
if label == "" {
label = "IO"
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if m.FrameID != nil {
if _, err := s.GetFrame(projectID, *m.FrameID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *m.FrameID, projectID)
}
return nil, err
}
}
res, err := s.db.Exec(
`INSERT INTO io_markers (project_id, frame_id, label, x, y)
VALUES (?, ?, ?, ?, ?)`,
projectID, nullableInt64(m.FrameID), label, m.X, m.Y,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetIOMarker(projectID, id)
}
// GetIOMarker loads an IO marker, project-scoped.
func (s *Store) GetIOMarker(projectID, id int64) (*IOMarker, error) {
var m IOMarker
var frame sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, &ex, &m.CreatedAt, &m.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
m.FrameID = &v
}
if ex.Valid {
m.ExcalidrawID = &ex.String
}
return &m, nil
}
// ListIOMarkers returns every IO marker in a project, ordered by creation.
func (s *Store) ListIOMarkers(projectID int64) ([]IOMarker, error) {
rows, err := s.db.Query(
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
FROM io_markers WHERE project_id = ? ORDER BY created_at, id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []IOMarker{}
for rows.Next() {
var m IOMarker
var frame sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y,
&ex, &m.CreatedAt, &m.UpdatedAt); err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
m.FrameID = &v
}
if ex.Valid {
m.ExcalidrawID = &ex.String
}
out = append(out, m)
}
return out, rows.Err()
}
// UpdateIOMarker applies a partial update. project_id is locked; frame_id
// tri-state mirrors DeviceUpdate.FrameID.
func (s *Store) UpdateIOMarker(projectID, id int64, u IOMarkerUpdate) (*IOMarker, error) {
cur, err := s.GetIOMarker(projectID, id)
if err != nil {
return nil, err
}
if u.Label != nil {
v := strings.TrimSpace(*u.Label)
if v == "" {
return nil, fmt.Errorf("%w: label cannot be empty", ErrInvalidInput)
}
cur.Label = v
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.FrameID.Set {
if u.FrameID.ID != nil {
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
}
return nil, err
}
}
cur.FrameID = u.FrameID.ID
}
if _, err := s.db.Exec(
`UPDATE io_markers
SET frame_id = ?, label = ?, x = ?, y = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.FrameID), cur.Label, cur.X, cur.Y, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetIOMarker(projectID, id)
}
// DeleteIOMarker removes an IO marker from a project.
func (s *Store) DeleteIOMarker(projectID, id int64) error {
if _, err := s.GetIOMarker(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,113 @@
package db
import (
"errors"
"testing"
)
func TestCreateIOMarker_DefaultsLabel(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{X: 10, Y: 20})
if err != nil {
t.Fatalf("create: %v", err)
}
if m.Label != "IO" {
t.Errorf("default label = %q, want IO", m.Label)
}
if m.FrameID != nil {
t.Errorf("frame_id = %v, want nil", m.FrameID)
}
}
func TestCreateIOMarker_CustomLabel(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 0, Y: 0})
if err != nil {
t.Fatalf("create: %v", err)
}
if m.Label != "Wall A" {
t.Errorf("label = %q, want Wall A", m.Label)
}
}
func TestCreateIOMarker_CrossProjectFrameRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, err := s.CreateIOMarker(p1.ID, IOMarkerCreate{FrameID: &f2.ID, X: 0, Y: 0})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestGetIOMarker_WrongProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
m, _ := s.CreateIOMarker(p1.ID, IOMarkerCreate{X: 0, Y: 0})
if _, err := s.GetIOMarker(p2.ID, m.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("cross-project GetIOMarker should be ErrNotFound; got %v", err)
}
}
func TestUpdateIOMarker_FrameIDTriState(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 0, Y: 0})
// Leave alone — passing a different X must not clear frame_id.
nx := 99.0
u1, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{X: &nx})
if u1.FrameID == nil || *u1.FrameID != f.ID {
t.Errorf("frame_id should still be set (Set=false); got %v", u1.FrameID)
}
// Clear.
u2, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{FrameID: FrameRef{Set: true, ID: nil}})
if u2.FrameID != nil {
t.Errorf("frame_id should be nil after clear; got %v", *u2.FrameID)
}
}
func TestDeleteFrame_SetsIOMarkerFrameIDToNull(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 10, Y: 20})
if m.FrameID == nil {
t.Fatalf("pre-condition: io marker should have frame_id")
}
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
t.Fatalf("delete frame: %v", err)
}
m2, _ := s.GetIOMarker(p.ID, m.ID)
if m2.FrameID != nil {
t.Errorf("io marker frame_id post-delete = %v, want nil (SET NULL)", m2.FrameID)
}
}
func TestSnapshot_PopulatesIOMarkers(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 10, Y: 20})
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "UPS rear", X: 100, Y: 200})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.IOMarkers) != 2 {
t.Errorf("io_markers len = %d, want 2", len(snap.IOMarkers))
}
}
func TestDeleteIOMarker_NotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteIOMarker(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}

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.
-- One project ↔ one .excalidraw file in mExDraw.

View File

@@ -0,0 +1,167 @@
-- 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).
-- Per-project custom types use project_id = X.
CREATE TABLE device_types (
id INTEGER PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'generic',
icon TEXT,
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX device_types_project_idx ON device_types(project_id);
-- v4 — port profile per device type. Used to seed ports when a device
-- of that type is created.
CREATE TABLE device_type_ports (
id INTEGER PRIMARY KEY,
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label_prefix TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_id);
-- v4 — devices gain a nullable type_id. SET NULL on type-delete so we
-- never cascade-delete a device the user still wants.
ALTER TABLE devices ADD COLUMN type_id INTEGER
REFERENCES device_types(id) ON DELETE SET NULL;
CREATE INDEX devices_type_idx ON devices(type_id);
-- Seed the 14 built-in device types.
-- project_id stays NULL → built-in. The trio Screen / Keyboard / Mouse
-- was added in v4.1 to support the Home Office setup template (slice 6).
INSERT INTO device_types (name, kind, built_in, description) VALUES
('NAS', 'storage', 1, 'Network-attached storage'),
('PC', 'compute', 1, 'Desktop PC / workstation'),
('Mac', 'compute', 1, 'Mac (mini / studio / desktop)'),
('Notebook', 'compute', 1, 'Laptop / notebook'),
('TV', 'display', 1, 'Television'),
('Soundbar', 'audio', 1, 'Soundbar / AV receiver'),
('Switch', 'network', 1, 'Ethernet switch'),
('fritz', 'network', 1, 'AVM Fritz!Box router'),
('ChromeCast', 'display', 1, 'ChromeCast / streaming stick'),
('SteamLink', 'compute', 1, 'Steam Link / dedicated streaming box'),
('IOx-3', 'hub', 1, 'USB hub with 3 downstream ports'),
('IOx-6', 'hub', 1, 'USB hub with 6 downstream ports'),
('IOx-8', 'hub', 1, 'USB hub with 8 downstream ports'),
('Screen', 'display', 1, 'External monitor / display'),
('Keyboard', 'accessory', 1, 'Keyboard'),
('Mouse', 'accessory', 1, 'Mouse / pointing device');
-- Now seed device_type_ports. Each row references its device_type by
-- (SELECT id FROM device_types WHERE name = ? AND project_id IS NULL).
--
-- cable_types ids come from the 001 seed in fixed order:
-- 1=Power, 2=USB, 3=HDMI, 4=DP, 5=RJ45
--
-- label_prefix is what the seeder appends a 1..N suffix to when count>1.
-- Default edge is 'bottom'; sort_order positions the port-types from
-- left to right along that edge.
-- NAS: Power × 1, RJ45 × 1
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='NAS' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 5, 'RJ45', 1, 'bottom', 1 FROM device_types WHERE name='NAS' AND project_id IS NULL;
-- PC: Power × 1, RJ45 × 1, HDMI × 1, USB × 2
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='PC' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 5, 'RJ45', 1, 'bottom', 1 FROM device_types WHERE name='PC' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 1, 'bottom', 2 FROM device_types WHERE name='PC' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 2, 'USB', 2, 'bottom', 3 FROM device_types WHERE name='PC' AND project_id IS NULL;
-- Mac: Power × 1, HDMI × 1, USB × 2
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Mac' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='Mac' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 2, 'USB', 2, 'bottom', 2 FROM device_types WHERE name='Mac' AND project_id IS NULL;
-- Notebook: Power × 1, USB × 2
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Notebook' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 2, 'USB', 2, 'bottom', 1 FROM device_types WHERE name='Notebook' AND project_id IS NULL;
-- TV: Power × 1, HDMI × 2
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='TV' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 2, 'bottom', 1 FROM device_types WHERE name='TV' AND project_id IS NULL;
-- Soundbar: Power × 1, HDMI × 1
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Soundbar' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='Soundbar' AND project_id IS NULL;
-- Switch: Power × 1, RJ45 × 5
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Switch' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 5, 'RJ45', 5, 'bottom', 1 FROM device_types WHERE name='Switch' AND project_id IS NULL;
-- fritz: Power × 1, RJ45 × 4
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='fritz' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 5, 'RJ45', 4, 'bottom', 1 FROM device_types WHERE name='fritz' AND project_id IS NULL;
-- ChromeCast: Power × 1, HDMI × 1
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='ChromeCast' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='ChromeCast' AND project_id IS NULL;
-- SteamLink: Power × 1, HDMI × 1, USB × 2
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='SteamLink' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='SteamLink' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 2, 'USB', 2, 'bottom', 2 FROM device_types WHERE name='SteamLink' AND project_id IS NULL;
-- IOx-3: Power × 1, USB × 3
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 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, 2, 'USB', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
-- IOx-6: Power × 1, USB × 6
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 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, 2, 'USB', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
-- IOx-8: Power × 1, USB × 8
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 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, 2, 'USB', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
-- Screen: Power × 1, HDMI × 1 (v4.1)
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Screen' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='Screen' AND project_id IS NULL;
-- Keyboard: USB × 1 (v4.1)
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 2, 'USB', 1, 'bottom', 0 FROM device_types WHERE name='Keyboard' AND project_id IS NULL;
-- Mouse: USB × 1 (v4.1)
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 2, 'USB', 1, 'bottom', 0 FROM device_types WHERE name='Mouse' AND project_id IS NULL;

View File

@@ -0,0 +1,34 @@
-- CableGUI v4.1 connection requirements + solver-owned cable flag.
-- 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".
-- Many per device. (from, to) is normalised on insert as
-- (pair_lo, pair_hi) = (MIN(from, to), MAX(from, to)) so (A,B,T) and (B,A,T)
-- can't coexist (UNIQUE enforces it).
CREATE TABLE connection_requirements (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
notes TEXT NOT NULL DEFAULT '',
pair_lo INTEGER NOT NULL,
pair_hi INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (from_device_id != to_device_id),
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id)
);
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
-- Solver-owned cable flag (§5b.3): 1 = the solver placed this cable,
-- replaceable on re-solve. 0 = m hand-drew it, left alone by the solver.
-- Slice 6 ships the solver that writes auto=1; slice 7 ships hand-drawn
-- cable creation that writes auto=0.
ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0
CHECK (auto IN (0, 1));
CREATE INDEX cables_auto_idx ON cables(auto);

View File

@@ -0,0 +1,157 @@
-- CableGUI v4.1 setup templates. See docs/design.md §2.4.
--
-- A template is a named recipe of (device_types + requirements) that
-- bootstraps a project from blank to solver-ready in one apply call.
CREATE TABLE setup_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE setup_template_devices (
id INTEGER PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
suggested_name TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
CREATE TABLE setup_template_requirements (
id INTEGER PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
CHECK (from_template_device_id != to_template_device_id)
);
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
-- ---------------------------------------------------------------- Living Room
INSERT INTO setup_templates (name, description, built_in)
VALUES ('Living Room', 'TV + Soundbar + ChromeCast, HDMI between them.', 1);
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Living Room'),
(SELECT id FROM device_types WHERE name='TV' AND project_id IS NULL),
'TV', 0;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Living Room'),
(SELECT id FROM device_types WHERE name='Soundbar' AND project_id IS NULL),
'Soundbar', 1;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Living Room'),
(SELECT id FROM device_types WHERE name='ChromeCast' AND project_id IS NULL),
'ChromeCast', 2;
-- TV ↔ Soundbar (HDMI, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Living Room'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='Soundbar'),
3, 1;
-- TV ↔ ChromeCast (HDMI, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Living Room'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='ChromeCast'),
3, 1;
-- ---------------------------------------------------------------- Home Office
INSERT INTO setup_templates (name, description, built_in)
VALUES ('Home Office', 'PC + Screen + Keyboard + Mouse. HDMI + USB.', 1);
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM device_types WHERE name='PC' AND project_id IS NULL),
'PC', 0;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM device_types WHERE name='Screen' AND project_id IS NULL),
'Screen', 1;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM device_types WHERE name='Keyboard' AND project_id IS NULL),
'Keyboard', 2;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM device_types WHERE name='Mouse' AND project_id IS NULL),
'Mouse', 3;
-- PC ↔ Screen (HDMI, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Screen'),
3, 1;
-- PC ↔ Keyboard (USB, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Keyboard'),
2, 1;
-- PC ↔ Mouse (USB, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Home Office'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Mouse'),
2, 1;
-- ---------------------------------------------------------------- Server Rack
INSERT INTO setup_templates (name, description, built_in)
VALUES ('Server Rack', 'NAS + Switch + fritz. Ethernet trunk + power.', 1);
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Server Rack'),
(SELECT id FROM device_types WHERE name='NAS' AND project_id IS NULL),
'NAS', 0;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Server Rack'),
(SELECT id FROM device_types WHERE name='Switch' AND project_id IS NULL),
'Switch', 1;
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
SELECT
(SELECT id FROM setup_templates WHERE name='Server Rack'),
(SELECT id FROM device_types WHERE name='fritz' AND project_id IS NULL),
'fritz', 2;
-- NAS ↔ Switch (RJ45, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Server Rack'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='NAS'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
5, 1;
-- Switch ↔ fritz (RJ45, must)
INSERT INTO setup_template_requirements
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
SELECT
(SELECT id FROM setup_templates WHERE name='Server Rack'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='fritz'),
5, 1;

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

@@ -19,16 +19,230 @@ type CableType struct {
UpdatedAt string `json:"updated_at"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Slice 1 returns the project + the global cable_types; the other arrays
// are present but empty until later slices ship their CRUD.
type Snapshot struct {
Project Project `json:"project"`
Frames []any `json:"frames"`
Devices []any `json:"devices"`
Ports []any `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []any `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
// Frame is a sub-zone inside a project (`desk`, `rack`, …).
type Frame struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Device is a hardware item inside a project, optionally inside a frame.
// v4: type_id (nullable) lets a device inherit its port profile from a
// device_types catalog row.
type Device struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
TypeID *int64 `json:"type_id"` // nullable: freeform device when null
Name string `json:"name"`
Color string `json:"color"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DeviceType is a catalog row. Built-in rows have ProjectID nil and
// BuiltIn true. Project-custom rows have ProjectID set.
type DeviceType struct {
ID int64 `json:"id"`
ProjectID *int64 `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Icon *string `json:"icon,omitempty"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Ports []DeviceTypePort `json:"ports"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DeviceTypePort is a row of a type's port profile. The seeder uses
// (cable_type_id, count, label_prefix, edge, sort_order) to lay out
// concrete ports on a freshly-created device.
type DeviceTypePort struct {
ID int64 `json:"id"`
DeviceTypeID int64 `json:"device_type_id"`
CableTypeID int64 `json:"cable_type_id"`
LabelPrefix string `json:"label_prefix"`
Count int `json:"count"`
Edge string `json:"edge"`
SortOrder int `json:"sort_order"`
}
// Port is a connector on a device. cable_type colour drives the visual
// rendering; ports are instance-owned even when seeded from a type.
type Port struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
DeviceID int64 `json:"device_id"`
TypeID int64 `json:"type_id"` // cable type
Label *string `json:"label"`
XOffset float64 `json:"x_offset"`
YOffset float64 `json:"y_offset"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ConnectionRequirement is the solver's per-project input.
// pair_lo/pair_hi are the ordered (MIN,MAX) of (from, to) so the
// UNIQUE on (project_id, pair_lo, pair_hi, preferred_cable_type_id)
// prevents (A,B,T) AND (B,A,T) from coexisting.
type ConnectionRequirement struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FromDeviceID int64 `json:"from_device_id"`
ToDeviceID int64 `json:"to_device_id"`
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
MustConnect bool `json:"must_connect"`
Notes string `json:"notes"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Cable is a typed connection. Each endpoint is exactly one of
// (port, device, io-marker). Auto=true means the solver placed it.
type Cable struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
TypeID int64 `json:"type_id"`
Label *string `json:"label"`
FromPortID *int64 `json:"from_port_id"`
FromDeviceID *int64 `json:"from_device_id"`
FromIOID *int64 `json:"from_io_id"`
ToPortID *int64 `json:"to_port_id"`
ToDeviceID *int64 `json:"to_device_id"`
ToIOID *int64 `json:"to_io_id"`
Auto bool `json:"auto"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Bundle is a named group of cables that physically run together.
type Bundle struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Name string `json:"name"`
Auto bool `json:"auto"`
CableIDs []int64 `json:"cable_ids"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SetupTemplate is a named recipe of device-types + requirements.
type SetupTemplate struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Devices []SetupTemplateDevice `json:"devices"`
Requirements []SetupTemplateRequirement `json:"requirements"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SetupTemplateDevice struct {
ID int64 `json:"id"`
TemplateID int64 `json:"template_id"`
DeviceTypeID int64 `json:"device_type_id"`
DeviceType *DeviceType `json:"device_type,omitempty"`
SuggestedName *string `json:"suggested_name"`
SortOrder int `json:"sort_order"`
}
type SetupTemplateRequirement struct {
ID int64 `json:"id"`
TemplateID int64 `json:"template_id"`
FromTemplateDeviceID int64 `json:"from_template_device_id"`
ToTemplateDeviceID int64 `json:"to_template_device_id"`
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
MustConnect bool `json:"must_connect"`
}
// SolveResult is the response shape from POST /api/projects/:pid/solve.
type SolveResult struct {
CablesAdded []Cable `json:"cables_added"`
CablesKept []int64 `json:"cables_kept"`
CablesRemoved []int64 `json:"cables_removed"`
BundlesAdded []Bundle `json:"bundles_added"`
BundlesRemoved []int64 `json:"bundles_removed"`
Unsatisfied []UnsatisfiedReq `json:"unsatisfied"`
Warnings []string `json:"warnings"`
}
type UnsatisfiedReq struct {
RequirementID int64 `json:"requirement_id"`
Reason string `json:"reason"`
WhichSide string `json:"which_side,omitempty"` // "from" | "to" | "" when both/neither
CableType string `json:"cable_type,omitempty"` // when known
}
// ApplyTemplateResult is the response from POST /apply-template.
type ApplyTemplateResult struct {
FramesAdded []Frame `json:"frames_added"`
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
}
type SkippedTemplateDevice struct {
TemplateDeviceID int64 `json:"template_device_id"`
Reason string `json:"reason"`
}
type SkippedTemplateReq struct {
TemplateRequirementID int64 `json:"template_requirement_id"`
Reason string `json:"reason"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Arrays for collections still gated by future slices stay non-nil [] so
// JSON encodes as [] not null.
type Snapshot struct {
Project Project `json:"project"`
Frames []Frame `json:"frames"`
Devices []Device `json:"devices"`
Ports []Port `json:"ports"`
Cables []Cable `json:"cables"`
IOMarkers []IOMarker `json:"io_markers"`
Bundles []Bundle `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
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"`
}

359
internal/db/ports.go Normal file
View File

@@ -0,0 +1,359 @@
package db
import (
"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
// device_id + id so callers can group cheaply.
func (s *Store) ListPortsForProject(projectID 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 = ? ORDER BY device_id, id`, projectID,
)
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()
}
// seedPortsFromType inserts the ports for a freshly-created device using
// the type's `device_type_ports` profile. Port positions are computed by
// laying out cable-type groups evenly along the configured edge of the
// device, ordered by sort_order. Within a multi-count group the per-port
// spacing is also even. Runs inside the same transaction as the device
// insert so a failure rolls everything back.
//
// Layout strategy (v0):
// - All ports for a given type sit on the type's configured edge.
// - For each edge, compute total port count N (sum of count across
// entries on that edge) and distribute as t_i = (i + 1)/(N+1) along
// the edge length, so ports don't touch the corners.
// - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom).
// - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t.
// - Labels: '<prefix>' if count==1, '<prefix> N' (1-indexed) if count>1.
// Empty prefix → NULL label.
func (s *Store) seedPortsFromType(tx *sql.Tx, projectID, deviceID, typeID int64, width, height float64) error {
rows, err := tx.Query(
`SELECT cable_type_id, label_prefix, count, edge, sort_order
FROM device_type_ports
WHERE device_type_id = ?
ORDER BY edge, sort_order, id`, typeID,
)
if err != nil {
return err
}
type pendingPort struct {
cableTypeID int64
label *string
xOff float64
yOff float64
}
// Group rows by edge first; emit per-port y-or-x slots inside each edge.
type groupRow struct {
cableTypeID int64
labelPrefix string
count int
}
byEdge := map[string][]groupRow{}
for rows.Next() {
var g groupRow
var edge string
var sortOrder int
if err := rows.Scan(&g.cableTypeID, &g.labelPrefix, &g.count, &edge, &sortOrder); err != nil {
rows.Close()
return err
}
byEdge[edge] = append(byEdge[edge], g)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
var pending []pendingPort
for _, edge := range []string{"top", "bottom", "left", "right"} {
groups := byEdge[edge]
if len(groups) == 0 {
continue
}
total := 0
for _, g := range groups {
total += g.count
}
if total == 0 {
continue
}
// Emit ports in group + within-group order.
idx := 0
for _, g := range groups {
for k := 0; k < g.count; k++ {
t := float64(idx+1) / float64(total+1)
var xOff, yOff float64
switch edge {
case "top":
xOff, yOff = width*t, 0
case "bottom":
xOff, yOff = width*t, height
case "left":
xOff, yOff = 0, height*t
case "right":
xOff, yOff = width, height*t
}
var labelPtr *string
if g.labelPrefix != "" {
var lbl string
if g.count == 1 {
lbl = g.labelPrefix
} else {
lbl = g.labelPrefix + " " + itoa(k+1)
}
labelPtr = &lbl
}
pending = append(pending, pendingPort{
cableTypeID: g.cableTypeID, label: labelPtr,
xOff: xOff, yOff: yOff,
})
idx++
}
}
}
for _, p := range pending {
var labelArg any
if p.label != nil {
labelArg = *p.label
}
if _, err := tx.Exec(
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, deviceID, p.cableTypeID, labelArg, p.xOff, p.yOff,
); err != nil {
return mapWriteErr(err)
}
}
return nil
}
// itoa is a tiny non-allocating int-to-string for port labels.
func itoa(i int) string {
if i == 0 {
return "0"
}
buf := [20]byte{}
pos := len(buf)
neg := i < 0
if neg {
i = -i
}
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}

View File

@@ -0,0 +1,465 @@
package db
import (
"database/sql"
"errors"
"fmt"
"math"
"strings"
)
// ListSetupTemplates returns every template with its devices +
// requirements hydrated.
func (s *Store) ListSetupTemplates() ([]SetupTemplate, error) {
rows, err := s.db.Query(
`SELECT id, name, description, built_in, created_at, updated_at
FROM setup_templates ORDER BY id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SetupTemplate{}
for rows.Next() {
var t SetupTemplate
var built int
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &built,
&t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
t.BuiltIn = built != 0
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
devs, err := s.listTemplateDevices(out[i].ID)
if err != nil {
return nil, err
}
out[i].Devices = devs
reqs, err := s.listTemplateRequirements(out[i].ID)
if err != nil {
return nil, err
}
out[i].Requirements = reqs
}
return out, nil
}
// GetSetupTemplate is a one-template variant of List.
func (s *Store) GetSetupTemplate(id int64) (*SetupTemplate, error) {
var t SetupTemplate
var built int
err := s.db.QueryRow(
`SELECT id, name, description, built_in, created_at, updated_at
FROM setup_templates WHERE id = ?`, id,
).Scan(&t.ID, &t.Name, &t.Description, &built, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
t.BuiltIn = built != 0
t.Devices, err = s.listTemplateDevices(t.ID)
if err != nil {
return nil, err
}
t.Requirements, err = s.listTemplateRequirements(t.ID)
if err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) listTemplateDevices(templateID int64) ([]SetupTemplateDevice, error) {
rows, err := s.db.Query(
`SELECT id, template_id, device_type_id, suggested_name, sort_order
FROM setup_template_devices WHERE template_id = ? ORDER BY sort_order, id`,
templateID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SetupTemplateDevice{}
for rows.Next() {
var d SetupTemplateDevice
var sn sql.NullString
if err := rows.Scan(&d.ID, &d.TemplateID, &d.DeviceTypeID, &sn, &d.SortOrder); err != nil {
return nil, err
}
if sn.Valid {
v := sn.String
d.SuggestedName = &v
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Hydrate the device_type for the UI's optgroup labels.
for i := range out {
dt, err := s.GetDeviceType(out[i].DeviceTypeID)
if err == nil {
out[i].DeviceType = dt
}
}
return out, nil
}
func (s *Store) listTemplateRequirements(templateID int64) ([]SetupTemplateRequirement, error) {
rows, err := s.db.Query(
`SELECT id, template_id, from_template_device_id, to_template_device_id,
preferred_cable_type_id, must_connect
FROM setup_template_requirements WHERE template_id = ? ORDER BY id`,
templateID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SetupTemplateRequirement{}
for rows.Next() {
var r SetupTemplateRequirement
var pct sql.NullInt64
var must int
if err := rows.Scan(&r.ID, &r.TemplateID, &r.FromTemplateDeviceID, &r.ToTemplateDeviceID,
&pct, &must); err != nil {
return nil, err
}
if pct.Valid {
v := pct.Int64
r.PreferredCableTypeID = &v
}
r.MustConnect = must != 0
out = append(out, r)
}
return out, rows.Err()
}
// ApplyTemplateOptions controls per-device name overrides + opt-outs.
type ApplyTemplateOptions struct {
NameOverrides map[int64]string // template_device_id → custom name
SkipDevices map[int64]bool // template_device_id → skip
// Layout: where to place the first device in the cluster on the canvas.
OriginX, OriginY float64
}
// ApplyTemplate seeds devices + requirements from the template into
// projectID in a single transaction. Name collisions skip the device
// (recorded in skipped_devices); requirements whose endpoints both fail
// to land are also skipped.
func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOptions) (*ApplyTemplateResult, error) {
tmpl, err := s.GetSetupTemplate(templateID)
if err != nil {
return nil, err
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
out := &ApplyTemplateResult{
FramesAdded: []Frame{},
DevicesAdded: []Device{},
RequirementsAdded: []ConnectionRequirement{},
SkippedDevices: []SkippedTemplateDevice{},
RequirementsSkipped: []SkippedTemplateReq{},
}
if opts.OriginX == 0 && opts.OriginY == 0 {
opts.OriginX, opts.OriginY = 200, 200
}
// Pull existing device + frame names in the project so we can
// pre-check collisions without aborting the whole transaction.
existing, err := s.ListDevices(projectID, nil)
if err != nil {
return nil, err
}
nameTaken := map[string]bool{}
for _, d := range existing {
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()
if err != nil {
return nil, err
}
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).
tmplToDevice := map[int64]int64{}
for i, td := range tmpl.Devices {
if opts.SkipDevices[td.ID] {
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
TemplateDeviceID: td.ID, Reason: "skip requested",
})
tmplToDevice[td.ID] = 0
continue
}
name := opts.NameOverrides[td.ID]
if name == "" && td.SuggestedName != nil {
name = *td.SuggestedName
}
if name == "" {
name = fmt.Sprintf("Device %d", td.ID)
}
name = strings.TrimSpace(name)
if nameTaken[name] {
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
TemplateDeviceID: td.ID,
Reason: fmt.Sprintf("name %q already used in project", name),
})
tmplToDevice[td.ID] = 0
continue
}
// Grid cell (col, row) within the frame. Cell anchor is the
// top-left of the device rect; offsets are added to the frame's
// own (x, y) so the device sits inside the frame.
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{
Name: name,
TypeID: &td.DeviceTypeID,
FrameID: &frame.ID,
X: x,
Y: y,
Width: devW,
Height: devH,
})
if err != nil {
return nil, fmt.Errorf("seed %s: %w", name, err)
}
nameTaken[name] = true
tmplToDevice[td.ID] = d.ID
out.DevicesAdded = append(out.DevicesAdded, *d)
}
for _, tr := range tmpl.Requirements {
fromID := tmplToDevice[tr.FromTemplateDeviceID]
toID := tmplToDevice[tr.ToTemplateDeviceID]
if fromID == 0 || toID == 0 {
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
TemplateRequirementID: tr.ID,
Reason: "one or both endpoint devices were skipped",
})
continue
}
// Normalise pair_lo/pair_hi, mirror what CreateConnectionRequirement does.
lo, hi := fromID, toID
if lo > hi {
lo, hi = hi, lo
}
must := 0
if tr.MustConnect {
must = 1
}
var ctArg any
if tr.PreferredCableTypeID != nil {
ctArg = *tr.PreferredCableTypeID
}
res, err := tx.Exec(
`INSERT INTO connection_requirements
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
must_connect, notes, pair_lo, pair_hi)
VALUES (?, ?, ?, ?, ?, '', ?, ?)`,
projectID, fromID, toID, ctArg, must, lo, hi,
)
if err != nil {
// A UNIQUE collision (project already has the same requirement)
// is non-fatal — record as skipped, continue.
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
TemplateRequirementID: tr.ID,
Reason: "requirement already exists in project",
})
continue
}
return nil, err
}
rid, _ := res.LastInsertId()
out.RequirementsAdded = append(out.RequirementsAdded, ConnectionRequirement{
ID: rid,
ProjectID: projectID,
FromDeviceID: fromID,
ToDeviceID: toID,
PreferredCableTypeID: tr.PreferredCableTypeID,
MustConnect: tr.MustConnect,
})
}
if err := tx.Commit(); err != nil {
return nil, err
}
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
// ApplyTemplate so seeding the template's devices + their ports stays
// inside one atomic apply.
//
// Validation is intentionally lighter than CreateDevice: callers (only
// ApplyTemplate today) hold a tx on the single SQLite connection, so
// any "validate by reading from s.db" call would deadlock. The template's
// device_type_id + frame_id come from already-validated template rows,
// and SQLite FK constraints catch any genuine corruption on INSERT
// (mapped to ErrInvalidInput by mapWriteErr).
func (s *Store) createDeviceTx(tx *sql.Tx, projectID int64, d DeviceCreate) (*Device, error) {
name := strings.TrimSpace(d.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if d.Width <= 0 || d.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
color := strings.TrimSpace(d.Color)
if color == "" {
color = "#1e1e1e"
}
res, err := tx.Exec(
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
name, color, d.X, d.Y, d.Width, d.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
deviceID, _ := res.LastInsertId()
if d.TypeID != nil {
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
return nil, err
}
}
// Read back via the public store path is fine — the row exists in
// the in-flight tx and SQLite sees its own writes within the tx.
// Use a sub-helper that takes the tx executor for clean isolation.
return s.readDeviceTx(tx, projectID, deviceID)
}
func (s *Store) readDeviceTx(ex execer, projectID, id int64) (*Device, error) {
var d Device
var frame, typeID sql.NullInt64
var ex2 sql.NullString
err := ex.QueryRow(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex2, &d.CreatedAt, &d.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
d.FrameID = &v
}
if typeID.Valid {
v := typeID.Int64
d.TypeID = &v
}
if ex2.Valid {
d.ExcalidrawID = &ex2.String
}
return &d, nil
}

509
internal/db/solver.go Normal file
View File

@@ -0,0 +1,509 @@
package db
import (
"database/sql"
"fmt"
"sort"
)
// Solve runs the v0 algorithm (design v4.1 §5b.2) against the project.
// If preview is true, no DB writes happen — the function returns the
// diff it WOULD apply. If preview is false, the diff is applied in a
// single transaction.
//
// Algorithm:
// 1. Read all auto cables, manual cables, ports, requirements.
// 2. Reserve ports used by manual cables (auto=0) so the solver
// doesn't reuse them.
// 3. For each requirement (must_connect DESC, id ASC):
// - Resolve cable type: preferred, or T = port-types(from) ∩
// port-types(to). |T|==1 → that. |T|>1 → unsatisfied (ambiguous).
// |T|==0 → unsatisfied (no compat type).
// - Find lowest-id free port on each side. None → unsatisfied
// (no free port). Reserve both.
// - Stage an "add cable {from_port, to_port, type, auto=1}".
// 4. Endpoint-pair bundle: any pair of device endpoints with ≥ 2
// staged cables becomes an auto bundle.
// 5. Diff against existing auto cables/bundles: removed = existing
// auto rows not in the staged set; kept = those that match by
// (from_port, to_port, type); add = remaining staged rows.
func (s *Store) Solve(projectID int64, preview bool) (*SolveResult, error) {
res := &SolveResult{
CablesAdded: []Cable{},
CablesKept: []int64{},
CablesRemoved: []int64{},
BundlesAdded: []Bundle{},
BundlesRemoved: []int64{},
Unsatisfied: []UnsatisfiedReq{},
Warnings: []string{},
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
devices, err := s.ListDevices(projectID, nil)
if err != nil {
return nil, err
}
ports, err := s.ListPortsForProject(projectID)
if err != nil {
return nil, err
}
cables, err := s.ListCables(projectID)
if err != nil {
return nil, err
}
reqs, err := s.ListConnectionRequirements(projectID)
if err != nil {
return nil, err
}
bundles, err := s.ListBundles(projectID)
if err != nil {
return nil, err
}
// Index ports by (device_id, type_id), sorted by id (deterministic).
portsByDevice := map[int64][]Port{}
for _, p := range ports {
portsByDevice[p.DeviceID] = append(portsByDevice[p.DeviceID], p)
}
for did := range portsByDevice {
sort.SliceStable(portsByDevice[did], func(i, j int) bool {
return portsByDevice[did][i].ID < portsByDevice[did][j].ID
})
}
deviceByID := map[int64]Device{}
for _, d := range devices {
deviceByID[d.ID] = d
}
// Reserve ports used by manual cables.
usedPorts := map[int64]bool{}
autoCablesByID := map[int64]Cable{}
for _, c := range cables {
if c.Auto {
autoCablesByID[c.ID] = c
continue
}
if c.FromPortID != nil {
usedPorts[*c.FromPortID] = true
}
if c.ToPortID != nil {
usedPorts[*c.ToPortID] = true
}
}
// Sort requirements: must_connect DESC, id ASC.
rs := append([]ConnectionRequirement{}, reqs...)
sort.SliceStable(rs, func(i, j int) bool {
if rs[i].MustConnect != rs[j].MustConnect {
return rs[i].MustConnect
}
return rs[i].ID < rs[j].ID
})
type staged struct {
typeID int64
fromPortID int64
toPortID int64
fromDeviceID int64
toDeviceID int64
}
var staging []staged
for _, r := range rs {
_, fromOK := deviceByID[r.FromDeviceID]
_, toOK := deviceByID[r.ToDeviceID]
if !fromOK || !toOK {
// Shouldn't happen (FK CASCADE removes the row when a device
// goes), but be defensive.
continue
}
// Resolve cable type.
var typeID int64
if r.PreferredCableTypeID != nil {
typeID = *r.PreferredCableTypeID
} else {
fromTypes := map[int64]bool{}
for _, p := range portsByDevice[r.FromDeviceID] {
fromTypes[p.TypeID] = true
}
candidates := []int64{}
for _, p := range portsByDevice[r.ToDeviceID] {
if fromTypes[p.TypeID] {
// Add unique.
already := false
for _, c := range candidates {
if c == p.TypeID {
already = true
break
}
}
if !already {
candidates = append(candidates, p.TypeID)
}
}
}
if len(candidates) == 0 {
if r.MustConnect {
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
RequirementID: r.ID,
Reason: "no compatible cable type — devices share no port-type",
})
}
continue
}
if len(candidates) > 1 {
if r.MustConnect {
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
RequirementID: r.ID,
Reason: "ambiguous cable type — specify preferred_cable_type_id",
})
}
continue
}
typeID = candidates[0]
}
// Pick lowest-id free port of `typeID` on each side.
pickFree := func(deviceID, t int64) *int64 {
for _, p := range portsByDevice[deviceID] {
if p.TypeID != t {
continue
}
if usedPorts[p.ID] {
continue
}
return &p.ID
}
return nil
}
fromPort := pickFree(r.FromDeviceID, typeID)
toPort := pickFree(r.ToDeviceID, typeID)
if fromPort == nil || toPort == nil {
if r.MustConnect {
side := ""
if fromPort == nil && toPort == nil {
side = ""
} else if fromPort == nil {
side = "from"
} else {
side = "to"
}
typeName := ""
if ct, err := s.GetCableType(typeID); err == nil {
typeName = ct.Name
}
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
RequirementID: r.ID,
Reason: fmt.Sprintf("no free %s port", typeName),
WhichSide: side,
CableType: typeName,
})
}
continue
}
usedPorts[*fromPort] = true
usedPorts[*toPort] = true
staging = append(staging, staged{
typeID: typeID, fromPortID: *fromPort, toPortID: *toPort,
fromDeviceID: r.FromDeviceID, toDeviceID: r.ToDeviceID,
})
}
// Match staged → existing auto cables by (typeID, fromPortID, toPortID)
// or its reverse. Anything matched is "kept"; the rest of auto cables
// is "removed". Unmatched staged entries become "added".
type sigKey struct{ typeID, a, b int64 }
matched := map[int64]bool{} // existing auto cable IDs that match
sigToAuto := map[sigKey]int64{}
for id, c := range autoCablesByID {
if c.FromPortID == nil || c.ToPortID == nil {
continue
}
a, b := *c.FromPortID, *c.ToPortID
if a > b {
a, b = b, a
}
sigToAuto[sigKey{c.TypeID, a, b}] = id
}
var toAdd []staged
for _, st := range staging {
a, b := st.fromPortID, st.toPortID
if a > b {
a, b = b, a
}
if existingID, ok := sigToAuto[sigKey{st.typeID, a, b}]; ok {
matched[existingID] = true
res.CablesKept = append(res.CablesKept, existingID)
continue
}
toAdd = append(toAdd, st)
}
for id := range autoCablesByID {
if !matched[id] {
res.CablesRemoved = append(res.CablesRemoved, id)
}
}
sort.Slice(res.CablesKept, func(i, j int) bool { return res.CablesKept[i] < res.CablesKept[j] })
sort.Slice(res.CablesRemoved, func(i, j int) bool { return res.CablesRemoved[i] < res.CablesRemoved[j] })
// Endpoint-pair bundling for the final set of auto cables (kept + added).
// Group by unordered (deviceA, deviceB). Build the map of port_id → device_id
// for fast lookup.
portToDevice := map[int64]int64{}
for _, p := range ports {
portToDevice[p.ID] = p.DeviceID
}
type pairKey struct{ a, b int64 }
pairGroup := map[pairKey][]string{} // staged-or-kept tags (we just count)
pairOrder := []pairKey{} // first-seen order
// We'll need the final list of cables-after-apply (with their IDs) to
// build bundles. For preview, kept IDs are real, added IDs are zero;
// for apply, we'll re-bundle after inserts.
if preview {
// In preview mode, "kept" IDs are real cables; "added" are
// staged. We still compute bundles_added so the UI can show
// which cable groups will be bundled. Bundles_added carry
// `CableIDs: []` for the staged entries because they don't
// have IDs yet — the UI maps by position. cables_kept that
// belong to a bundle group also list their existing ids.
// In short, slot every staged cable into the same pair bucket
// + the kept cables.
for _, st := range staging {
da, db := st.fromDeviceID, st.toDeviceID
if da > db {
da, db = db, da
}
pk := pairKey{da, db}
if _, ok := pairGroup[pk]; !ok {
pairOrder = append(pairOrder, pk)
}
pairGroup[pk] = append(pairGroup[pk], "")
}
// Materialise preview-shape Cable structs for the added rows.
for _, st := range toAdd {
c := Cable{
ProjectID: projectID,
TypeID: st.typeID,
FromPortID: ptr(st.fromPortID),
ToPortID: ptr(st.toPortID),
Auto: true,
}
res.CablesAdded = append(res.CablesAdded, c)
}
for _, pk := range pairOrder {
if len(pairGroup[pk]) < 2 {
continue
}
a := deviceByID[pk.a].Name
b := deviceByID[pk.b].Name
res.BundlesAdded = append(res.BundlesAdded, Bundle{
ProjectID: projectID,
Name: a + " ↔ " + b,
Auto: true,
CableIDs: nil, // post-apply only
})
}
// Existing auto bundles all "would be removed" since we rebuild
// from scratch each solve (slice-6 v0 is wholesale-replace).
for _, b := range bundles {
if b.Auto {
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
}
}
return res, nil
}
// Apply mode: open a transaction, delete removed auto cables + auto
// bundles, insert added cables, re-bundle by endpoint pair.
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// Delete obsolete auto bundles (we'll rebuild).
if _, err := tx.Exec(
`DELETE FROM bundles WHERE project_id = ? AND auto = 1`, projectID,
); err != nil {
return nil, err
}
for _, b := range bundles {
if b.Auto {
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
}
}
// Delete removed auto cables.
for _, id := range res.CablesRemoved {
if _, err := tx.Exec(
`DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return nil, err
}
}
// Insert added cables. Track new ids by their staged signature for
// bundle wiring.
type addedRow struct {
id int64
staged staged
}
addedRows := []addedRow{}
for _, st := range toAdd {
c, err := s.createCable(tx, projectID, CableCreate{
TypeID: st.typeID,
From: CableEndpoint{PortID: &st.fromPortID},
To: CableEndpoint{PortID: &st.toPortID},
Auto: true,
})
if err != nil {
return nil, err
}
res.CablesAdded = append(res.CablesAdded, *c)
addedRows = append(addedRows, addedRow{id: c.ID, staged: st})
}
// Re-bundle: all auto cables (kept + added) grouped by endpoint pair.
// First, collect cable IDs per (deviceA, deviceB) — both kept (from
// matched map) and added.
groups := map[pairKey][]int64{}
order := []pairKey{}
addToGroup := func(da, db, cid int64) {
if da > db {
da, db = db, da
}
pk := pairKey{da, db}
if _, ok := groups[pk]; !ok {
order = append(order, pk)
}
groups[pk] = append(groups[pk], cid)
}
for id, c := range autoCablesByID {
if !matched[id] {
continue
}
if c.FromPortID == nil || c.ToPortID == nil {
continue
}
da := portToDevice[*c.FromPortID]
db := portToDevice[*c.ToPortID]
if da == 0 || db == 0 {
continue
}
addToGroup(da, db, id)
}
for _, ar := range addedRows {
addToGroup(ar.staged.fromDeviceID, ar.staged.toDeviceID, ar.id)
}
for _, pk := range order {
ids := groups[pk]
if len(ids) < 2 {
continue
}
a := deviceByID[pk.a].Name
b := deviceByID[pk.b].Name
bundle, err := s.createBundle(tx, projectID, BundleCreate{
Name: a + " ↔ " + b,
CableIDs: ids,
Auto: true,
}, false)
if err != nil {
return nil, err
}
res.BundlesAdded = append(res.BundlesAdded, *bundle)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return res, nil
}
func ptr[T any](v T) *T { return &v }
// PortsAndResolve adds a port to a device + re-runs Solve in one tx.
// Used by the inspector's "+ Add <type> port and re-solve" quick-fix.
type PortsAndResolveResult struct {
Port Port `json:"port"`
Solve *SolveResult `json:"solve"`
}
func (s *Store) PortsAndResolve(projectID, deviceID int64, typeID int64, label string, xOff, yOff float64) (*PortsAndResolveResult, error) {
d, err := s.GetDevice(projectID, deviceID)
if err != nil {
return nil, err
}
if _, err := s.GetCableType(typeID); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, typeID)
}
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// Default the new port to the bottom edge at the right-most existing offset.
if xOff == 0 && yOff == 0 {
xOff = d.Width / 2
yOff = d.Height
}
var labelArg any
if label != "" {
labelArg = label
}
res, err := tx.Exec(
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, deviceID, typeID, labelArg, xOff, yOff,
)
if err != nil {
return nil, mapWriteErr(err)
}
portID, _ := res.LastInsertId()
if err := tx.Commit(); err != nil {
return nil, err
}
// Now re-solve outside the tx — Solve manages its own tx for the
// apply path. This is a slight relaxation of "single round-trip" — if
// the solver run fails the port stays, but that's fine; the port is
// what m wanted regardless.
solveRes, err := s.Solve(projectID, false)
if err != nil {
return nil, err
}
// Re-fetch the port row to return its full shape.
port, err := s.getPortByID(portID)
if err != nil {
return nil, err
}
return &PortsAndResolveResult{Port: *port, Solve: solveRes}, nil
}
func (s *Store) getPortByID(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 = ?`, id,
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
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
}

329
internal/db/solver_test.go Normal file
View File

@@ -0,0 +1,329 @@
package db
import (
"testing"
)
// builtInTypeID returns the id of the named built-in device type.
func builtInTypeID(t *testing.T, s *Store, name string) int64 {
t.Helper()
all, _ := s.ListBuiltInDeviceTypes()
for _, dt := range all {
if dt.Name == name {
return dt.ID
}
}
t.Fatalf("built-in %q not found", name)
return 0
}
// ------------------------------------------------------ basic solver wins
func TestSolve_BasicNAStoSwitch(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
nasT := builtInTypeID(t, s, "NAS")
swT := builtInTypeID(t, s, "Switch")
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
rj45 := int64(5)
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
})
res, err := s.Solve(p.ID, false)
if err != nil {
t.Fatalf("solve: %v", err)
}
if len(res.CablesAdded) != 1 {
t.Fatalf("cables_added len = %d, want 1", len(res.CablesAdded))
}
if res.CablesAdded[0].TypeID != rj45 {
t.Errorf("cable type = %d, want %d (RJ45)", res.CablesAdded[0].TypeID, rj45)
}
if !res.CablesAdded[0].Auto {
t.Errorf("cable.auto should be true")
}
if len(res.Unsatisfied) != 0 {
t.Errorf("unsatisfied should be empty; got %+v", res.Unsatisfied)
}
}
func TestSolve_AmbiguousType_RequirementUnsatisfied(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Both PCs have Power + USB + HDMI + RJ45 → multiple types match.
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})
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: a.ID, ToDeviceID: b.ID, // no PreferredCableTypeID
})
res, _ := s.Solve(p.ID, true)
if len(res.CablesAdded) != 0 {
t.Errorf("ambiguous: should not add cables, got %d", len(res.CablesAdded))
}
if len(res.Unsatisfied) != 1 || res.Unsatisfied[0].Reason == "" {
t.Errorf("expected 1 unsatisfied req with non-empty reason; got %+v", res.Unsatisfied)
}
}
func TestSolve_NoFreePort_RequirementUnsatisfied(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Mouse only has 1 USB port. Two USB requirements against it should
// leave one unsatisfied.
mouseT := builtInTypeID(t, s, "Mouse")
pcT := builtInTypeID(t, s, "PC")
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
pc1, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC1", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
pc2, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC2", TypeID: &pcT, X: 400, Y: 0, Width: 100, Height: 35})
usb := int64(2)
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: mouse.ID, ToDeviceID: pc1.ID, PreferredCableTypeID: &usb,
})
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: mouse.ID, ToDeviceID: pc2.ID, PreferredCableTypeID: &usb,
})
res, _ := s.Solve(p.ID, true)
if len(res.CablesAdded) != 1 {
t.Errorf("expected 1 cable to land (one mouse USB), got %d", len(res.CablesAdded))
}
if len(res.Unsatisfied) != 1 {
t.Errorf("expected 1 unsatisfied; got %d (%+v)", len(res.Unsatisfied), res.Unsatisfied)
}
}
// ----------------------------------------------- preview vs apply semantics
func TestSolve_PreviewDoesNotWrite(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
nasT := builtInTypeID(t, s, "NAS")
swT := builtInTypeID(t, s, "Switch")
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
rj45 := int64(5)
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
})
_, _ = s.Solve(p.ID, true) // preview
cables, _ := s.ListCables(p.ID)
if len(cables) != 0 {
t.Errorf("preview wrote %d cables; want 0", len(cables))
}
}
func TestSolve_ApplyThenIdempotent(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
nasT := builtInTypeID(t, s, "NAS")
swT := builtInTypeID(t, s, "Switch")
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
rj45 := int64(5)
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
})
r1, _ := s.Solve(p.ID, false)
if len(r1.CablesAdded) != 1 {
t.Fatalf("first apply: cables_added=%d, want 1", len(r1.CablesAdded))
}
r2, _ := s.Solve(p.ID, false)
if len(r2.CablesAdded) != 0 {
t.Errorf("second apply: cables_added=%d, want 0 (idempotent)", len(r2.CablesAdded))
}
if len(r2.CablesKept) != 1 {
t.Errorf("second apply: cables_kept=%d, want 1", len(r2.CablesKept))
}
}
func TestSolve_ManualCableReservesPort(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
mouseT := builtInTypeID(t, s, "Mouse")
pcT := builtInTypeID(t, s, "PC")
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
pc, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
// Manual cable USB Mouse↔PC: claims the only mouse USB port.
ports, _ := s.ListPortsForProject(p.ID)
var mouseUSB, pcUSB int64
for _, prt := range ports {
if prt.DeviceID == mouse.ID && prt.TypeID == 2 {
mouseUSB = prt.ID
}
if prt.DeviceID == pc.ID && prt.TypeID == 2 {
pcUSB = prt.ID
break
}
}
usb := int64(2)
_, _ = s.CreateCable(p.ID, CableCreate{
TypeID: usb,
From: CableEndpoint{PortID: &mouseUSB},
To: CableEndpoint{PortID: &pcUSB},
Auto: false,
})
// Now add a requirement that also wants USB on the mouse → no free port.
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
FromDeviceID: mouse.ID, ToDeviceID: pc.ID, PreferredCableTypeID: &usb,
})
res, _ := s.Solve(p.ID, true)
if len(res.Unsatisfied) == 0 {
t.Errorf("expected unsatisfied req (manual cable should reserve the only mouse USB port)")
}
}
// -------------------------------------------------------- setup templates
func TestApplyTemplate_LivingRoom(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
}
}
if lr.ID == 0 {
t.Fatal("Living Room template not seeded")
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.DevicesAdded) != 3 {
t.Errorf("devices added = %d, want 3 (TV, Soundbar, ChromeCast)", len(res.DevicesAdded))
}
if len(res.RequirementsAdded) != 2 {
t.Errorf("requirements added = %d, want 2 (TV↔Soundbar, TV↔ChromeCast)", len(res.RequirementsAdded))
}
// Ports were seeded as part of the device creation.
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) < 6 { // TV(3) + Soundbar(2) + ChromeCast(2) = 7
t.Errorf("ports after template apply = %d, expected ≥6", len(ports))
}
}
func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
tmpls, _ := s.ListSetupTemplates()
var ho SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Home Office" {
ho = tm
break
}
}
if _, err := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{}); err != nil {
t.Fatalf("apply: %v", err)
}
res, err := s.Solve(p.ID, false)
if err != nil {
t.Fatalf("solve: %v", err)
}
if len(res.CablesAdded) != 3 {
t.Errorf("Home Office should solve to 3 cables (PC↔Screen, PC↔Keyboard, PC↔Mouse); got %d", len(res.CablesAdded))
}
if len(res.Unsatisfied) != 0 {
t.Errorf("unsatisfied = %+v, want []", res.Unsatisfied)
}
}
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) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
// Pre-create a device called "PC" so the Home Office template's PC collides.
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
tmpls, _ := s.ListSetupTemplates()
var ho SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Home Office" {
ho = tm
break
}
}
res, _ := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{})
if len(res.SkippedDevices) == 0 {
t.Errorf("expected at least one skipped device for name collision; got %+v", res.SkippedDevices)
}
if len(res.RequirementsSkipped) == 0 {
t.Errorf("PC requirements should be skipped when PC device skipped; got %+v", res.RequirementsSkipped)
}
}

View File

@@ -147,8 +147,9 @@ func (s *Store) DeleteProject(id int64, confirmName string) error {
return nil
}
// Snapshot loads the full editor-init payload for one project. In slice
// 1 the project-scoped collections are still empty.
// Snapshot loads the full editor-init payload for one project. Slice 2
// populates frames + devices; ports / cables / io_markers / bundles
// still ship empty until their slices land.
func (s *Store) Snapshot(id int64) (*Snapshot, error) {
p, err := s.GetProject(id)
if err != nil {
@@ -158,15 +159,54 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil {
return nil, err
}
frames, err := s.ListFrames(id)
if err != nil {
return nil, err
}
devices, err := s.ListDevices(id, nil)
if err != nil {
return nil, err
}
ios, err := s.ListIOMarkers(id)
if err != nil {
return nil, err
}
ports, err := s.ListPortsForProject(id)
if err != nil {
return nil, err
}
reqs, err := s.ListConnectionRequirements(id)
if err != nil {
return nil, err
}
cables, err := s.ListCables(id)
if err != nil {
return nil, err
}
bundles, err := s.ListBundles(id)
if err != nil {
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{
Project: *p,
Frames: []any{},
Devices: []any{},
Ports: []any{},
Cables: []any{},
IOMarkers: []any{},
Bundles: []any{},
CableTypes: types,
Project: *p,
Frames: frames,
Devices: devices,
Ports: ports,
Cables: cables,
IOMarkers: ios,
Bundles: bundles,
CableTypes: types,
ConnectionRequirements: reqs,
Clamps: clamps,
CableClamps: cableClamps,
}, 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)
}
}

225
internal/server/cables.go Normal file
View File

@@ -0,0 +1,225 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
type cableEndpointBody struct {
PortID *int64 `json:"port_id,omitempty"`
DeviceID *int64 `json:"device_id,omitempty"`
IOID *int64 `json:"io_id,omitempty"`
}
type cableCreate struct {
TypeID int64 `json:"type_id"`
Label string `json:"label,omitempty"`
From cableEndpointBody `json:"from"`
To cableEndpointBody `json:"to"`
Auto bool `json:"auto,omitempty"`
}
type cablePatch struct {
TypeID *int64 `json:"type_id,omitempty"`
Label *string `json:"label,omitempty"`
From *cableEndpointBody `json:"from,omitempty"`
To *cableEndpointBody `json:"to,omitempty"`
Auto *bool `json:"auto,omitempty"`
// Promote=true asks the server to set auto=false when an auto cable
// is being PATCHed (slice 6 §5b.3 — explicit promote-to-manual).
Promote bool `json:"promote,omitempty"`
}
func toCableEndpoint(b cableEndpointBody) db.CableEndpoint {
return db.CableEndpoint{PortID: b.PortID, DeviceID: b.DeviceID, IOID: b.IOID}
}
func (h *handlers) listCables(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.ListCables(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, cs)
}
func (h *handlers) createCable(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 cableCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
c, err := h.store.CreateCable(pid, db.CableCreate{
TypeID: body.TypeID, Label: body.Label,
From: toCableEndpoint(body.From), To: toCableEndpoint(body.To),
Auto: body.Auto,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, c)
}
func (h *handlers) patchCable(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 cablePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
u := db.CableUpdate{
TypeID: body.TypeID, Label: body.Label, Auto: body.Auto,
}
if body.From != nil {
ep := toCableEndpoint(*body.From)
u.From = &ep
}
if body.To != nil {
ep := toCableEndpoint(*body.To)
u.To = &ep
}
// Promote semantics: explicit promote=true OR (PATCH touched
// type/from/to AND the current cable is auto) → set auto=false.
if body.Promote {
f := false
u.Auto = &f
}
c, err := h.store.UpdateCable(pid, id, u)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, c)
}
func (h *handlers) deleteCable(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.DeleteCable(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ----------------------------------------------------------------- bundles
type bundleCreate struct {
Name string `json:"name"`
CableIDs []int64 `json:"cable_ids"`
}
type bundlePatch struct {
Name *string `json:"name,omitempty"`
CableIDs *[]int64 `json:"cable_ids,omitempty"`
}
func (h *handlers) listBundles(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
bs, err := h.store.ListBundles(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, bs)
}
func (h *handlers) createBundle(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 bundleCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
b, err := h.store.CreateBundle(pid, db.BundleCreate{
Name: body.Name, CableIDs: body.CableIDs, Auto: false,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, b)
}
func (h *handlers) patchBundle(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 bundlePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
b, err := h.store.UpdateBundle(pid, id, db.BundleUpdate{
Name: body.Name, CableIDs: body.CableIDs,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, b)
}
func (h *handlers) deleteBundle(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.DeleteBundle(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

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

@@ -0,0 +1,115 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
type connReqCreate struct {
FromDeviceID int64 `json:"from_device_id"`
ToDeviceID int64 `json:"to_device_id"`
PreferredCableTypeID *int64 `json:"preferred_cable_type_id,omitempty"`
MustConnect *bool `json:"must_connect,omitempty"`
Notes string `json:"notes,omitempty"`
}
// connReqPatch uses RawMessage for preferred_cable_type_id so the wire
// tri-state ({} / null / int) is preserved.
type connReqPatch struct {
PreferredCableTypeID json.RawMessage `json:"preferred_cable_type_id,omitempty"`
MustConnect *bool `json:"must_connect,omitempty"`
Notes *string `json:"notes,omitempty"`
}
func (h *handlers) listConnectionRequirements(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
rs, err := h.store.ListConnectionRequirements(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, rs)
}
func (h *handlers) createConnectionRequirement(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 connReqCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
cr, err := h.store.CreateConnectionRequirement(pid, db.ConnectionRequirementCreate{
FromDeviceID: body.FromDeviceID,
ToDeviceID: body.ToDeviceID,
PreferredCableTypeID: body.PreferredCableTypeID,
MustConnect: body.MustConnect,
Notes: body.Notes,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, cr)
}
func (h *handlers) patchConnectionRequirement(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 connReqPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ctRef, err := parseFrameRef(body.PreferredCableTypeID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "preferred_cable_type_id must be an integer or null")
return
}
cr, err := h.store.UpdateConnectionRequirement(pid, id, db.ConnectionRequirementUpdate{
PreferredCableTypeID: ctRef,
MustConnect: body.MustConnect,
Notes: body.Notes,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, cr)
}
func (h *handlers) deleteConnectionRequirement(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.DeleteConnectionRequirement(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,147 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
type deviceTypePortBody struct {
CableTypeID int64 `json:"cable_type_id"`
LabelPrefix string `json:"label_prefix,omitempty"`
Count int `json:"count"`
Edge string `json:"edge,omitempty"`
SortOrder int `json:"sort_order,omitempty"`
}
type deviceTypeCreate struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
Ports []deviceTypePortBody `json:"ports,omitempty"`
}
type deviceTypePatch struct {
Name *string `json:"name,omitempty"`
Kind *string `json:"kind,omitempty"`
Icon *string `json:"icon,omitempty"`
Description *string `json:"description,omitempty"`
Ports *[]deviceTypePortBody `json:"ports,omitempty"`
}
func portsToStore(body []deviceTypePortBody) []db.DeviceTypePortCreate {
out := make([]db.DeviceTypePortCreate, len(body))
for i, p := range body {
c := p.Count
if c <= 0 {
c = 1
}
out[i] = db.DeviceTypePortCreate{
CableTypeID: p.CableTypeID,
LabelPrefix: p.LabelPrefix,
Count: c,
Edge: p.Edge,
SortOrder: p.SortOrder,
}
}
return out
}
// GET /api/device-types — built-in catalog only, read-only.
func (h *handlers) listBuiltInDeviceTypes(w http.ResponseWriter, _ *http.Request) {
dts, err := h.store.ListBuiltInDeviceTypes()
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, dts)
}
// GET /api/projects/:pid/device-types — built-ins + project-custom merged.
func (h *handlers) listDeviceTypes(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
dts, err := h.store.ListDeviceTypesForProject(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, dts)
}
func (h *handlers) createDeviceType(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 deviceTypeCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
dt, err := h.store.CreateDeviceType(pid, db.DeviceTypeCreate{
Name: body.Name, Kind: body.Kind, Icon: body.Icon,
Description: body.Description, Ports: portsToStore(body.Ports),
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, dt)
}
func (h *handlers) patchDeviceType(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 deviceTypePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
u := db.DeviceTypeUpdate{
Name: body.Name, Kind: body.Kind, Icon: body.Icon, Description: body.Description,
}
if body.Ports != nil {
converted := portsToStore(*body.Ports)
u.Ports = &converted
}
dt, err := h.store.UpdateDeviceType(pid, id, u)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, dt)
}
func (h *handlers) deleteDeviceType(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.DeleteDeviceType(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

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

@@ -0,0 +1,242 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
// ---------------------------------------------------------------- frames
type frameCreate struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type framePatch struct {
Name *string `json:"name,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
func (h *handlers) listFrames(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
fs, err := h.store.ListFrames(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, fs)
}
func (h *handlers) createFrame(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 frameCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
f, err := h.store.CreateFrame(pid, db.FrameCreate{
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, f)
}
func (h *handlers) patchFrame(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 framePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
f, err := h.store.UpdateFrame(pid, id, db.FrameUpdate{
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, f)
}
func (h *handlers) deleteFrame(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.DeleteFrame(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- devices
type deviceCreate struct {
Name string `json:"name"`
FrameID *int64 `json:"frame_id,omitempty"`
TypeID *int64 `json:"type_id,omitempty"`
Color string `json:"color,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// devicePatch uses a raw `json.RawMessage` for frame_id + type_id so we
// can tell "key absent" (leave alone) from "key present and null"
// (set to NULL) from "key present with an int" (move to that target).
// Standard encoding of nullable fields in JSON PATCH.
type devicePatch struct {
Name *string `json:"name,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
TypeID json.RawMessage `json:"type_id,omitempty"`
Color *string `json:"color,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
// parseFrameRef decodes the raw frame_id field into a tri-state.
func parseFrameRef(raw json.RawMessage) (db.FrameRef, error) {
if len(raw) == 0 {
return db.FrameRef{Set: false}, nil
}
// "null" → clear; otherwise expect an integer.
if string(raw) == "null" {
return db.FrameRef{Set: true, ID: nil}, nil
}
var id int64
if err := json.Unmarshal(raw, &id); err != nil {
return db.FrameRef{}, err
}
return db.FrameRef{Set: true, ID: &id}, nil
}
func (h *handlers) listDevices(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
ds, err := h.store.ListDevices(pid, nil)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ds)
}
func (h *handlers) createDevice(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 deviceCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
Name: body.Name, FrameID: body.FrameID, TypeID: body.TypeID,
Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, d)
}
func (h *handlers) patchDevice(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 devicePatch
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), "frame_id must be an integer or null")
return
}
typeRef, err := parseFrameRef(body.TypeID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "type_id must be an integer or null")
return
}
d, err := h.store.UpdateDevice(pid, id, db.DeviceUpdate{
Name: body.Name, FrameID: ref, TypeID: typeRef, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, d)
}
func (h *handlers) deleteDevice(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.DeleteDevice(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"strconv"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/cablegui/internal/db"
)
type handlers struct {
@@ -42,6 +42,8 @@ func writeError(w http.ResponseWriter, err error, details any) {
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrForbidden):
writeJSON(w, http.StatusForbidden, errorBody{Error: err.Error(), Details: details})
default:
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
}

View File

@@ -0,0 +1,109 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
type ioMarkerCreate struct {
FrameID *int64 `json:"frame_id,omitempty"`
Label string `json:"label,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
// ioMarkerPatch mirrors devicePatch's frame_id tri-state — see
// devicePatch + parseFrameRef in frames_devices.go for the wire format.
type ioMarkerPatch struct {
Label *string `json:"label,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
}
func (h *handlers) listIOMarkers(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
ms, err := h.store.ListIOMarkers(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ms)
}
func (h *handlers) createIOMarker(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 ioMarkerCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
m, err := h.store.CreateIOMarker(pid, db.IOMarkerCreate{
FrameID: body.FrameID, Label: body.Label, X: body.X, Y: body.Y,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (h *handlers) patchIOMarker(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 ioMarkerPatch
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), "frame_id must be an integer or null")
return
}
m, err := h.store.UpdateIOMarker(pid, id, db.IOMarkerUpdate{
Label: body.Label, FrameID: ref, X: body.X, Y: body.Y,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, m)
}
func (h *handlers) deleteIOMarker(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.DeleteIOMarker(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

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"
"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
// "index.html" is at its root.
func New(store *db.Store, frontend fs.FS) http.Handler {
@@ -33,8 +33,96 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType)
mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType)
// Frames (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/frames", h.listFrames)
mux.HandleFunc("POST /api/projects/{pid}/frames", h.createFrame)
mux.HandleFunc("PATCH /api/projects/{pid}/frames/{id}", h.patchFrame)
mux.HandleFunc("DELETE /api/projects/{pid}/frames/{id}", h.deleteFrame)
// Devices (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/devices", h.listDevices)
mux.HandleFunc("POST /api/projects/{pid}/devices", h.createDevice)
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
// IO markers (project-scoped) — wall-outlet terminators
mux.HandleFunc("GET /api/projects/{pid}/io-markers", h.listIOMarkers)
mux.HandleFunc("POST /api/projects/{pid}/io-markers", h.createIOMarker)
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
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
// support full CRUD scoped to the project.
mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes)
mux.HandleFunc("GET /api/projects/{pid}/device-types", h.listDeviceTypes)
mux.HandleFunc("POST /api/projects/{pid}/device-types", h.createDeviceType)
mux.HandleFunc("PATCH /api/projects/{pid}/device-types/{id}", h.patchDeviceType)
mux.HandleFunc("DELETE /api/projects/{pid}/device-types/{id}", h.deleteDeviceType)
// Connection requirements — the solver's per-project input.
mux.HandleFunc("GET /api/projects/{pid}/connection-requirements", h.listConnectionRequirements)
mux.HandleFunc("POST /api/projects/{pid}/connection-requirements", h.createConnectionRequirement)
mux.HandleFunc("PATCH /api/projects/{pid}/connection-requirements/{id}", h.patchConnectionRequirement)
mux.HandleFunc("DELETE /api/projects/{pid}/connection-requirements/{id}", h.deleteConnectionRequirement)
// Cables — slice 6: solver writes here with auto=1; slice 7 lets m
// hand-draw with auto=0. PATCH supports `promote: true` to flip auto→0.
mux.HandleFunc("GET /api/projects/{pid}/cables", h.listCables)
mux.HandleFunc("POST /api/projects/{pid}/cables", h.createCable)
mux.HandleFunc("PATCH /api/projects/{pid}/cables/{id}", h.patchCable)
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{id}", h.deleteCable)
// Bundles — manual + auto.
mux.HandleFunc("GET /api/projects/{pid}/bundles", h.listBundles)
mux.HandleFunc("POST /api/projects/{pid}/bundles", h.createBundle)
mux.HandleFunc("PATCH /api/projects/{pid}/bundles/{id}", h.patchBundle)
mux.HandleFunc("DELETE /api/projects/{pid}/bundles/{id}", h.deleteBundle)
// Solver + quick-fix combo + setup templates.
mux.HandleFunc("POST /api/projects/{pid}/solve", h.solve)
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports-and-resolve", h.portsAndResolve)
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate)
// Slice 8 — export to mxdrw.msbls.de
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
// 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.
mux.Handle("/", http.FileServerFS(frontend))
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively
// and m sees the old main.js after every redeploy until hard-reload.
mux.Handle("/", noCache(http.FileServerFS(frontend)))
return mux
}
// noCache wraps a static handler so each response carries
// Cache-Control: no-cache. Combined with the ETag/Last-Modified headers
// http.FileServer(FS) already emits, this turns every fetch into a
// cheap revalidation request — the browser uses its cached body when
// the ETag matches but always asks first, so freshly-built assets show
// up on the next page load without a hard-reload.
//
// Applied to the static-asset handler only — API responses write their
// own headers and aren't routed through this.
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
h.ServeHTTP(w, r)
})
}

149
internal/server/solver.go Normal file
View File

@@ -0,0 +1,149 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
preview := r.URL.Query().Get("preview") == "1"
res, err := h.store.Solve(pid, preview)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, res)
}
// ports-and-resolve combo: POST a new port to a device + re-run solve in
// the same request. Used by the inspector quick-fix.
type portsAndResolveBody struct {
TypeID int64 `json:"type_id"`
Label string `json:"label,omitempty"`
XOffset float64 `json:"x_offset,omitempty"`
YOffset float64 `json:"y_offset,omitempty"`
}
func (h *handlers) portsAndResolve(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 portsAndResolveBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
res, err := h.store.PortsAndResolve(pid, id, body.TypeID, body.Label, body.XOffset, body.YOffset)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, res)
}
// -------------------------------------------------------- setup templates
func (h *handlers) listSetupTemplates(w http.ResponseWriter, _ *http.Request) {
ts, err := h.store.ListSetupTemplates()
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ts)
}
type applyTemplateBody struct {
TemplateID int64 `json:"template_id"`
NameOverrides map[string]string `json:"name_overrides,omitempty"`
SkipDevices []int64 `json:"skip_devices,omitempty"`
OriginX float64 `json:"origin_x,omitempty"`
OriginY float64 `json:"origin_y,omitempty"`
}
func (h *handlers) applyTemplate(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 applyTemplateBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
opts := db.ApplyTemplateOptions{
NameOverrides: map[int64]string{},
SkipDevices: map[int64]bool{},
OriginX: body.OriginX,
OriginY: body.OriginY,
}
// JSON keys are strings; parse to int64.
for k, v := range body.NameOverrides {
var tid int64
_, _ = fmtSscan(k, &tid)
if tid > 0 {
opts.NameOverrides[tid] = v
}
}
for _, tid := range body.SkipDevices {
opts.SkipDevices[tid] = true
}
res, err := h.store.ApplyTemplate(pid, body.TemplateID, opts)
if err != nil {
writeError(w, err, nil)
return
}
// 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.
// Inline so handlers don't pull in strconv just for one call site.
func fmtSscan(s string, out *int64) (int, error) {
var v int64
read := 0
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
v = v*10 + int64(c-'0')
read++
}
*out = v
return read, nil
}

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title>
<title>CableGUI</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<span class="brand">mCables</span>
<span class="brand">CableGUI</span>
<div class="project-picker">
<label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project">
@@ -20,9 +20,15 @@
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</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-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</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>
<main class="layout">
@@ -30,25 +36,29 @@
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Frame</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</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-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-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>
</section>
</aside>
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<defs id="canvas-defs"></defs>
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-bundles"></g>
<g id="canvas-clamps"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">
@@ -58,7 +68,9 @@
<aside class="inspector" aria-label="Inspector">
<h2 class="sidebar-heading">Inspector</h2>
<p class="muted">Nothing selected.</p>
<div id="inspector-body">
<p class="muted">Nothing selected.</p>
</div>
</aside>
</main>
@@ -111,6 +123,91 @@
</form>
</dialog>
<!-- New device (slice 4: type-aware) -->
<dialog id="modal-new-device" class="modal" aria-labelledby="nd-title">
<form method="dialog" id="form-new-device">
<h2 id="nd-title">New device</h2>
<label class="field">
<span>Type</span>
<select id="nd-type" name="type_id" required>
<option value="">Loading…</option>
</select>
</label>
<label class="field">
<span>Name</span>
<input type="text" name="name" id="nd-name" required autocomplete="off" />
</label>
<p class="form-error" id="nd-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- New / Edit connection requirement (slice 5) -->
<dialog id="modal-requirement" class="modal" aria-labelledby="rq-title">
<form method="dialog" id="form-requirement">
<h2 id="rq-title">New requirement</h2>
<label class="field">
<span>From device</span>
<select id="rq-from" name="from_device_id" required></select>
</label>
<label class="field">
<span>To device</span>
<select id="rq-to" name="to_device_id" required></select>
</label>
<label class="field">
<span>Cable type</span>
<select id="rq-cable" name="preferred_cable_type_id">
<option value="">— solver picks —</option>
</select>
</label>
<label class="field" style="flex-direction: row; align-items: center; gap: 8px;">
<input type="checkbox" id="rq-must" name="must_connect" checked />
<span style="font-size: 13px; color: var(--text);">Must connect (solver hard-requires this link)</span>
</label>
<label class="field">
<span>Notes</span>
<textarea name="notes" rows="2"></textarea>
</label>
<p class="form-error" id="rq-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Solve preview-diff (slice 6) -->
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
<div style="padding: 16px;">
<h2 id="sv-title">Solve preview</h2>
<div id="sv-body" class="sv-body"></div>
<div class="actions" style="margin-top: 12px;">
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</div>
</dialog>
<!-- Apply template (slice 6) -->
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
<form method="dialog" id="form-template">
<h2 id="tp-title">Apply setup template</h2>
<label class="field">
<span>Template</span>
<select id="tp-select" required></select>
</label>
<div id="tp-preview" class="tp-preview"></div>
<p class="form-error" id="tp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">
@@ -130,6 +227,24 @@
</form>
</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>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -165,6 +165,541 @@ body {
.muted { color: var(--text-muted); }
/* ---------- canvas elements ---------- */
.frame-rect {
fill: rgba(25, 113, 194, 0.04);
stroke: var(--accent);
stroke-width: 1.5;
stroke-dasharray: 6 4;
}
.frame-rect.selected,
.frame-rect:hover { stroke-width: 2.5; }
.frame-label {
fill: var(--accent);
font-size: 13px;
font-weight: 600;
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 {
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
.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 {
fill: var(--text);
font-size: 12px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
.svg-draggable { cursor: grab; }
.svg-draggable.dragging { cursor: grabbing; }
/* Tool cursor while a tool is armed. The `* { ... !important }` descendant
rule is the load-bearing part: without it, the `.svg-draggable` rules
on individual frame/device rects win by element specificity and
override the SVG-root cursor — so hovering a frame with +Dev armed
shows `grab`, which lies about what a click will do. */
.canvas-wrap.tool-frame #canvas,
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.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
the Power cable_type colour (#e03131). Rotated 45° rect is the
easiest way to draw a diamond that still hit-tests at the rotated
bounds (a <polygon> would also work; rect-with-rotate keeps the
same DOM shape as device/frame so the drag helpers reuse). */
.io-marker {
fill: var(--danger);
fill-opacity: 0.18;
stroke: var(--danger);
stroke-width: 1.5;
}
.io-marker.selected,
.io-marker:hover { stroke-width: 2.5; }
.io-marker-label {
fill: var(--danger);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
/* Ports — small circles laid out along the device edge. Both fill and
stroke come from the cable_type the port carries (set inline in JS)
so the port reads clearly as a coloured anchor on the device. */
.port-circle {
stroke-width: 2;
cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.port-row {
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }
/* Requirements sidebar list */
.requirement-list {
list-style: none;
padding: 0;
margin: 0 0 8px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.requirement-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 3px 6px;
border-radius: var(--radius);
cursor: pointer;
}
.requirement-row:hover { background: var(--surface-2); }
.requirement-row[aria-current="true"] {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.requirement-row .pair { color: var(--text); }
.requirement-row .pair .type { color: var(--text-muted); font-size: 11px; }
.requirement-row .badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 10px;
color: #fff;
}
.requirement-row .badge.must { background: var(--danger); }
.requirement-row .badge.nice { background: var(--text-muted); }
/* Tool-armed: drag-req tool cursor */
.canvas-wrap.tool-req #canvas,
.canvas-wrap.tool-req #canvas * { cursor: crosshair !important; }
/* Drag-line preview while dragging from device A toward device B. */
.req-drag-line {
stroke: var(--accent);
stroke-width: 2;
stroke-dasharray: 6 4;
fill: none;
pointer-events: none;
}
/* Cables on the canvas. Stroke colour comes from the cable_type;
solver-owned cables (auto=1) render with a slightly dashed pattern
so m can tell at a glance which the solver placed. */
.cable-line {
fill: none;
stroke-width: 2;
cursor: pointer;
}
.cable-line.auto { stroke-dasharray: 8 3; }
.cable-line:hover { 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 */
.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 h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 12px 0 4px;
}
.sv-body ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sv-body li {
padding: 4px 8px;
border-radius: var(--radius);
background: var(--surface-2);
}
.sv-body li.added { border-left: 3px solid #2f9e44; }
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
.sv-body li.unmet { border-left: 3px solid #f59f00; }
.sv-body li.unmet .quickfix {
display: inline-block;
margin-left: 8px;
font-size: 11px;
padding: 1px 6px;
background: var(--accent);
color: #fff;
border-radius: 10px;
cursor: pointer;
}
.tp-preview {
font-size: 13px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
margin: 8px 0;
}
.tp-preview h4 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 6px 0 4px;
}
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
.tp-preview li { padding: 2px 0; }
.tp-preview .skip {
margin-right: 6px;
font-size: 11px;
}
.rubber-band {
fill: rgba(25, 113, 194, 0.08);
stroke: var(--accent);
stroke-width: 1;
stroke-dasharray: 4 4;
pointer-events: none;
}
/* tool buttons toggle armed-state */
.btn[data-tool].armed {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* ---------- inspector ---------- */
.inspector dl {
margin: 0;
display: grid;
grid-template-columns: 80px 1fr;
gap: 4px 8px;
font-size: 12px;
}
.inspector dt { color: var(--text-muted); }
.inspector dd { margin: 0; }
.inspector .inline-input {
font: inherit;
width: 100%;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
.inspector .inline-input:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}
.inspector .section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 12px 0 6px 0;
}
.inspector .inspector-actions {
display: flex;
gap: 6px;
margin-top: 12px;
}
/* foreignObject used to inline-name a freshly-placed frame/device */
.inline-namer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.inline-namer input {
font: inherit;
font-size: 12px;
padding: 2px 4px;
border: 2px solid var(--accent);
border-radius: var(--radius);
background: #fff;
width: calc(100% - 8px);
max-width: 200px;
text-align: center;
}
/* ---------- buttons ---------- */
.btn {

View File

@@ -1,5 +1,5 @@
// 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
import (