Compare commits

..

40 Commits

Author SHA1 Message Date
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
mAi
905c75c6db test+docs: store coverage + README for slice 1
Adds table-driven store tests:
- projects: drawing_name auto-default, explicit-name accept, empty-name
  reject, duplicate-name conflict, ordered list, GetProject not-found,
  partial PATCH semantics, blank-drawing-name re-default on PATCH,
  ?confirm=<name> guardrail (wrong / empty / correct), snapshot returns
  the 5 globally-seeded cable_types
- cable_types: 5 seeded with the legend colours, global UNIQUE(name),
  rename + recolour, RESTRICT-blocked delete when a cable references the
  type (with count surfaced via CountCablesUsingType), unused delete
  succeeds, project deletion does NOT cascade into cable_types

go test -race ./... passes. Updates README.md with run instructions,
env vars, the slice-1 API surface, and the slice roadmap.
2026-05-15 16:48:29 +02:00
mAi
c13000ee7e feat: frontend shell — project picker, legend, modals (new project / cable type / delete), URL ?project= state 2026-05-15 16:45:29 +02:00
mAi
1e3988161b feat: http server — net/http (Go 1.22 ServeMux), /api/healthz + projects + cable-types, JSON errors 2026-05-15 16:45:29 +02:00
mAi
255d52e7c4 feat: db store — projects + cable_types CRUD with sentinel errors and confirm-name guardrail 2026-05-15 16:45:29 +02:00
mAi
cd34dde133 feat: db migrations runner + 001_init.sql (full v3 schema, 5 cable_types seeded) 2026-05-15 16:45:29 +02:00
mAi
b6eb29a103 chore: untrack .m/ worker-local mai event log 2026-05-15 16:40:41 +02:00
mAi
e55993ca53 bootstrap: go module, skeleton dirs, Makefile, main.go entrypoint 2026-05-15 16:40:14 +02:00
mAi
14f0d74e44 merge: design v3 (framework, multi-project, mDock deploy)
inventor shift done by picasso. docs/design.md (760 lines) + CLAUDE.md
locked. m approved coder shift.

Next: slice 1 (bootstrap + project CRUD) on mai/picasso/slice-1.
2026-05-15 16:38:02 +02:00
38 changed files with 7700 additions and 117 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
mcables
# 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

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Local DB
data/*.db
data/*.db-wal
data/*.db-shm
# mai worker-local logs (per-worktree, not source)
.m/
# Build artefacts
bin/
mcables
# Editor
.vscode/
.idea/
*.swp

View File

@@ -2,11 +2,21 @@
## 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"). mCables runs a solver that emits the cable
plan + bundle recommendations. mCables 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.
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.
**Memory group_id:** `mcables`
@@ -19,13 +29,25 @@ interface. The backend serves the UI and the API; there is no
- 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
@@ -45,16 +67,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
@@ -137,12 +174,14 @@ Legend colours (global, seeded once by migration 001):
## 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
#
# mCables — 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/mcables \
./cmd/mcables
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
ENV MCABLES_ADDR=0.0.0.0:7777 \
MCABLES_DB=/app/data/mcables.db
EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /home/m/stacks/mcables/data is owned by m:m, so the container can write
# 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/mcables"]

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: build run test typecheck fmt clean
BIN := bin/mcables
PKG := ./...
build:
@mkdir -p bin
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables
run:
go run ./cmd/mcables
test:
go test -race $(PKG)
typecheck:
@if [ -f web/tsconfig.json ]; then \
cd web && tsc --noEmit; \
else \
echo "web/tsconfig.json not present yet — typecheck skipped"; \
fi
fmt:
gofmt -s -w .
clean:
rm -rf bin

168
README.md
View File

@@ -1,37 +1,155 @@
# mCables
Cable management for m's setup — visual interface + SQLite inventory, generating + updating Excalidraw diagrams via mExDraw.
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
*project*; each project is backed by exactly one `.excalidraw` drawing on
mxdrw.msbls.de.
## Status
Bootstrap. Architecture sketch below; implementation pending.
Slice 1 — bootstrap shipped. Projects + global cable types are
end-to-end; the SVG canvas is intentionally empty until slice 2.
## Goal
Track devices, ports, and cables across m's setups (server rack, office, living room). Generate / update Excalidraw diagrams from the inventory. Detect bundles of parallel cables. Visualise cable types by colour (RJ45, DP, HDMI, USB, Power, …).
m's existing drawing is the seed: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw — devices are rectangles, ports are ellipses positioned on the device, cables are arrows from port to port, cable type is encoded via colour with a legend.
## Architecture sketch
| Layer | Tech | Role |
| Slice | What's in it | Status |
|---|---|---|
| Storage | SQLite (`~/.m/mcables.db`) | `devices`, `ports`, `cables`, `cable_types`, `bundles`, `frames` |
| Backend | Go | HTTP API serving the visual frontend, mExDraw integration for diagram I/O |
| Frontend | Visual web UI | Browser-based editor (no CLI). Add/edit devices and cables, see live preview |
| Output | mExDraw via MCP | Render + update Excalidraw drawings |
| Project tracking | mBrian `topic-mcables` | Decisions, status, links to drawings — not the data itself |
| 1 | Project CRUD, global cable types, empty SVG canvas, project picker | ✅ |
| 2 | Frames + devices, drag-to-position | pending |
| 3 | Ports + cables (click-port → click-port) | pending |
| 4 | IO markers + cable-type editing | pending |
| 5 | Export to mxdrw.msbls.de | pending |
## Tech decisions (open)
## Run it
- Frontend stack — vanilla TS + small UI lib, or a framework (Svelte / Preact)?
- Diagram import from the existing `Cable-Management.excalidraw` — one-shot migration script that parses bindings → DB rows.
- Layout algorithm for bundle suggestions — parallel cables along the same path get bundled visually.
```sh
go run ./cmd/mcables
# open http://localhost:7777
```
These get resolved in the first design pass.
Or built:
## Refs
```sh
make build
./bin/mcables
```
- m's seed drawing: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw
- mExDraw MCP: `mcp__mexdraw__*`
- Related: mBrian `topic-msbls` (infrastructure inventory)
The binary serves the frontend from an embedded `web/static/` and the
JSON API under `/api/`. SQLite lives at `./data/mcables.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. |
### Tests
```sh
make test # go test -race ./...
```
Store-level tests cover projects + cable-types CRUD, the
`drawing_name` auto-default, the `?confirm=<name>` guardrail on
`DELETE /api/projects/:pid`, and the `ON DELETE RESTRICT` on a
referenced cable type.
## API (slice 1)
```
GET /api/healthz → 200 {"status":"ok"}
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name?, description?}
drawing_name defaults to "<name>.excalidraw"
GET /api/projects/:pid → {project, cable_types, frames, devices, …}
PATCH /api/projects/:pid ← partial
DELETE /api/projects/:pid?confirm=<name> ← confirm must equal current name
GET /api/cable-types → [CableType, …] (global)
POST /api/cable-types ← {name, color}
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
mCables runs on **mDock** at `http://mdock:7777` as a docker-compose
service under `/home/m/stacks/mcables/`. Pattern matches the other
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
no reverse proxy, LAN-trusted.
### Manual deploy (first roll)
1. **Build + push the image** (from any host with docker; today the
image lives in mAi's Gitea namespace because mAi doesn't have write
access to `m/`):
```sh
docker build -t mgit.msbls.de/mai/mcables:latest .
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \
| docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/mai/mcables:latest
```
2. **Prepare directories on mDock** (one-time):
```sh
ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \
&& touch /home/m/secrets/mcables/.env \
&& chmod 0600 /home/m/secrets/mcables/.env'
scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml
```
3. **Pull + start**:
```sh
ssh mdock 'cd /home/m/stacks/mcables && 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/mcables && docker compose pull && docker compose up -d'`.
### Persistence
SQLite lives at `/home/m/stacks/mcables/data/mcables.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
conventions, slices, mDock deploy notes).
- `CLAUDE.md` — project instructions for mai workers.
## Architecture
| Layer | Tech |
|---|---|
| DB | SQLite via `modernc.org/sqlite` (cgo-free), WAL, FKs on |
| Backend | Go 1.22+ `net/http` ServeMux pattern routing, single binary |
| Frontend | Vanilla ES modules + SVG, no build step, embedded via `embed.FS` |
| Export (slice 5) | mExDraw HTTP API on mxdrw.msbls.de |
LAN-trusted, no auth.

0
data/.gitkeep Normal file
View File

20
docker-compose.yml Normal file
View File

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

View File

@@ -1,36 +1,95 @@
# mCables — Design v3
# mCables — Design v4.1
Cable-management **framework** for m's setup. Inventor shift 1 design,
revised after m's round-4 answers (2026-05-15) — for m's review.
Cable-management **framework + solver** for m's setup. Inventor shift 1
design, revised through v2 (rescope to multi-project framework), v3
(global cable_types + guardrails), v4 (solver-as-core), and now
**v4.1 — six locked answers from m's v4 review**.
> **What changed in v4.1** (tight pass on v4)
> 1. **mCables is a schematic, not a physical-routing tool.** Cables are
> straight lines between endpoints; the solver and the renderer do not
> care about paths, trunks, frame edges, or cable-tray polylines.
> "Maximum bundling" reduces to the v3 rule: **≥2 cables between the
> same endpoint pair → bundle them.** All path-routing language has
> been stripped from §5b.1, §5b.2, §7, §8, §9.
> 2. **Solver fires on the Solve button (v0).** Live-solve stays in §8
> slices 9+ as an opt-in toggle.
> 3. **Unmet-requirement quick-fix**: when the solver returns
> `unsatisfied[]`, the device inspector renders a red badge per unmet
> requirement with a single button — **"+ Add &lt;type&gt; port to
> &lt;device&gt; and re-solve"** — that POSTs a new port to the
> device AND immediately re-runs `POST /api/projects/:pid/solve` in
> the same UI action. See §5b.4 + §7 inspector-states.
> 4. **Setup templates fold INTO v4.1.** New tables `setup_templates`,
> `setup_template_devices`, `setup_template_requirements` in
> migration 004 + 3 built-in templates ('Living Room', 'Home Office',
> 'Server Rack'). New endpoints `GET /api/setup-templates` and
> `POST /api/projects/:pid/apply-template`. UI: a "Templates" panel
> in the New Project flow + an "Apply template" action on an empty
> project. See new §2.4 + slice 6 fold-in below.
> 5. **Catalog distribution: SQL seed** in migration 002 (no change).
> 6. **Promote to manual: explicit button** on the cable inspector
> (no change).
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
the *visual-grammar reference*, not as a bootstrap import target),
`mai-memory` (`mcables`, `m`), and a live survey of mDock services for the
deploy conventions (§10).
the *visual-grammar reference*, not a bootstrap import target),
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification:
> **What changed in v3** (mechanical deltas on top of v2)
> - `cable_types` is now a **global** table — one set shared across all
> projects. Migration 001 seeds the 5 defaults once. `POST /api/projects`
> no longer seeds types. API moved to top-level `/api/cable-types`.
> Renaming/recolouring a type affects every project.
> - `devices` gains `UNIQUE (project_id, name)` — no two devices in the
> same project can share a name.
> - `projects.drawing_name` is auto-filled `<name>.excalidraw` server-side
> when omitted on POST; editable via PATCH.
> - `DELETE /api/projects/:pid` requires `?confirm=<name>` query param;
> server checks it matches the project's current name. 400 otherwise.
> "we provide a cable manager — I say what devices we have, the app tells
> me how to bundle cables and how the most efficient connection looks like"
mCables shifts from a manual draw-and-click editor to a **solver** that
takes a list of devices + the connections m needs and emits the cable
plan + bundle recommendations. The manual editor stays (it's the only way
to inspect + tweak the plan) but is no longer the primary surface.
> **What changed in v4** (new mental model on top of v3 mechanics)
> - **Hybrid device-type catalog** (§2.1, §3.1). A built-in `device_types`
> table seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz,
> ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port
> profiles (`device_type_ports` rows: cable_type + count + label).
> Adding a device → pick a type → ports auto-seed. m can override per
> instance (this PC has 3 USB, not 2). Catalog is extendable per project.
> - **`connection_requirements` table** (§2.2). m declares "NAS must
> connect to Switch via RJ45" once. Many per device. The solver consumes
> these.
> - **`POST /api/projects/:pid/solve` endpoint** (§3.2). Reads devices +
> their ports + connection_requirements + frame positions, emits a diff
> of `cables` + `bundles`. Two modes: `?preview=1` returns the diff
> without applying; default applies.
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
> two or more cables share the same endpoint pair, group them into one
> bundle. No path or trunk geometry — mCables is a wiring schematic,
> not a routing tool. v4.1 strips all path/trunk language from the v4
> draft.
> - **UI: device-type dropdown** on device-create, **Connection
> Requirements** left panel, **Solve** button next to Export. Inspector
> shows type + ports + unmet requirements (selected device) or the
> driving requirement + bundle (selected cable).
> - **Slices reshape** (§8). Catalog seeding lands early (slice 1.5); the
> solver MVP and connection-requirements UI move ahead of the
> bundle-rendering polish.
>
> **What carried over from v2**
> - mCables is a framework: top-level `projects` table; LOFT and OFFICE
> are separate projects, each backed by one drawing.
> - No runtime importer. The seed drawing is reference material only.
> `/api/sync/import` is out of MVP; only `POST .../sync/export` ships.
> - IO diamonds are wall-outlet terminators (type=Power by convention,
> not enforced in schema). UI soft-warns on non-Power cables to an IO.
> - No cable inventory metadata. Purely visual structure for v0.
> - DB at `./data/mcables.db` (project-local, gitignored).
> - Deploy: raw docker / docker-compose on mDock (not Dokploy).
> - Bind `0.0.0.0:7777` on the LAN, no auth.
> **What carried over from v3 (unchanged in v4)**
> - mCables is a framework: top-level `projects`, each backed by one
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
> tri-state on PATCH.
> - IO diamonds = wall-outlet terminators (type=Power by convention).
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
> - No cable inventory metadata; visual + connectivity structure only.
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose.
>
> **What's superseded in v4**
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
> tweak path on the solver output, but is no longer the *primary* device-
> connecting flow. The solve button is the headline action.
> - The v3 §8 slice order changes — catalog + types-driven devices + solver
> come earlier; the manual-draw-cable slice slides later. See new §8.
---
@@ -134,6 +193,49 @@ CREATE TABLE cable_types (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- v4 — device-type catalog. Seeded built-in types live globally (so
-- multiple projects share the "NAS" definition without duplication).
-- Per-project custom types are also allowed (project_id non-null for those).
-- Renaming a built-in type doesn't propagate retroactively to existing
-- devices that already had their ports seeded — they own their port set
-- from the moment they were created.
CREATE TABLE device_types (
id INTEGER PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
-- NULL = built-in (shared), non-null = project-custom
name TEXT NOT NULL, -- "NAS", "PC", "TV", "Switch", "IOx-8", "Custom-Foo"
kind TEXT NOT NULL DEFAULT 'generic',
-- coarse category for UI grouping: 'storage', 'compute',
-- 'display', 'audio', 'network', 'hub', 'accessory',
-- 'generic'
icon TEXT, -- emoji or short symbol (🖥, 📺, 🔊, 📡) — UI hint
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0, -- 1 for migration-seeded rows, 0 for user-created
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name) -- two projects can both have a custom "Foo";
-- built-ins (project_id NULL) get UNIQUE on name globally
);
CREATE INDEX device_types_project_idx ON device_types(project_id);
-- v4 — port profile per device type. "NAS has 1 Power + 1 RJ45" is two
-- rows; "PC has 1 Power + 1 RJ45 + 1 HDMI + 2 USB" is four rows.
-- When a device is created with type_id=X, the seeder inserts `count`
-- rows into the `ports` table for each device_type_ports entry,
-- numbering label as "<label_prefix> N" if count > 1.
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 '', -- "HDMI", "USB", "Power" — UI label root
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
-- Position hint: the seeder lays ports along the device edge using
-- these biases (0..1 along the edge fraction). NULL = even spread.
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);
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
@@ -154,10 +256,19 @@ CREATE INDEX frames_project_idx ON frames(project_id);
-- Devices live in a frame (and transitively in a project).
-- Stored project_id is denormalised for cheap project-scoped queries; FK
-- to frame_id is the structural truth. Both are kept consistent in code.
--
-- v4 — type_id (nullable) lets a device inherit its port profile from
-- a `device_types` row. Once ports are seeded the device "owns" them;
-- changing/clearing type_id later does not retroactively re-seed (m's
-- per-instance overrides survive). Custom freeform devices (no template)
-- keep type_id NULL — that's the v3 "just a rectangle" device.
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL,
-- v4: nullable; SET NULL on type delete so we don't
-- cascade-delete a device the user still wants
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
@@ -172,6 +283,7 @@ CREATE TABLE devices (
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
CREATE INDEX devices_type_idx ON devices(type_id);
-- Ports belong to a device. x_offset/y_offset are relative to the device's
-- top-left so ports follow when the device moves. project_id denormalised.
@@ -260,8 +372,211 @@ CREATE TABLE bundle_cables (
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
-- v4 — connection_requirements: the input m gives the solver.
-- "NAS must connect to Switch via RJ45" is one row. Many per device.
--
-- preferred_cable_type_id is the cable type m intends — the solver
-- needs it to match port colours. NULL means "solver picks" (the solver
-- will pick the unique cable_type that is compatible with both ends'
-- available port types; if ambiguous it surfaces an error for m).
--
-- must_connect = 1 (default) means the solver MUST satisfy this; an
-- unsatisfiable must_connect surfaces as a hard error in the solve
-- result. must_connect = 0 = "nice to have, drop if you run out of
-- ports". Used for templates that over-spec.
--
-- The (from_device_id, to_device_id) pair is normalised on insert so
-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered
-- pair + cable type prevents duplicates.
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 '',
-- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set
-- in code on insert; the UNIQUE then prevents (A,B,Power) AND
-- (B,A,Power) from coexisting. Stored alongside the m-facing
-- from/to so the UI doesn't have to denormalise.
pair_lo INTEGER NOT NULL,
pair_hi INTEGER NOT NULL,
CHECK (from_device_id != to_device_id),
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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);
```
### 2.1 Migration sequence
- **001_init.sql** (v3) — projects, frames, devices (no type_id), ports,
cable_types (5 seeded), io_markers, cables, bundles, bundle_cables.
- **002_device_catalog.sql** (v4) — `device_types` +
`device_type_ports`. Seeds the built-in catalog (§2.2). Adds
`devices.type_id` (`ALTER TABLE devices ADD COLUMN type_id INTEGER
REFERENCES device_types(id) ON DELETE SET NULL`) and the matching
index.
- **003_connection_requirements.sql** (v4) — `connection_requirements`.
Also adds `cables.auto` (`ALTER TABLE cables ADD COLUMN auto INTEGER
NOT NULL DEFAULT 0`) so the solver can distinguish its rows from
m's hand-drawn ones (§5b.3).
- **004_setup_templates.sql** (v4.1 NEW) — `setup_templates` +
`setup_template_devices` + `setup_template_requirements`. Seeds 3
built-in templates ('Living Room', 'Home Office', 'Server Rack').
Slices 1 and 2 already shipped 001. Slice 4 lands 002; slice 5 lands
003; slice 6 lands 004 alongside the solver MVP + templates UI.
### 2.2 Built-in catalog seed (002 INSERTs)
The 14 built-in types m's setup uses today, with their default port
profiles. Stored as `(project_id NULL, built_in 1)`. v4.1 added the
three peripheral types (Screen, Keyboard, Mouse) to support the Home
Office setup template:
| `device_types.name` | `kind` | Default ports (cable_type × count) |
|---|---|---|
| NAS | storage | Power × 1; RJ45 × 1 |
| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 |
| Mac | compute | Power × 1; HDMI × 1; USB × 2 |
| Notebook | compute | Power × 1; USB × 2 |
| TV | display | Power × 1; HDMI × 2 |
| Soundbar | audio | Power × 1; HDMI × 1 |
| Switch | network | Power × 1; RJ45 × 5 |
| fritz | network | Power × 1; RJ45 × 4 |
| ChromeCast | display | Power × 1; HDMI × 1 |
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) |
| IOx-6 | hub | Power × 1; USB × 6 |
| IOx-8 | hub | Power × 1; USB × 8 |
| **Screen** | display | Power × 1; HDMI × 1 |
| **Keyboard** | accessory | USB × 1 |
| **Mouse** | accessory | USB × 1 |
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing
shows them in red because most carry Power, but they also hub USB). v0
seeds them as USB hubs; m overrides per-instance. The catalog is editable
in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3
profile once and not re-override every instance.
m can also add **project-custom types** at any time (UI: "+ New device
type" inside the device-create modal) with `project_id = current`.
### 2.3 Why ports are still instance-owned
When m picks a type to create a device, the seeder calls `count` × INSERT
into `ports`. From that moment on, ports are instance-level rows owned by
that device. Deleting a port from this PC doesn't touch other PCs;
changing a type's port profile (in slice 4.5) doesn't retroactively
re-seed already-created devices — it only affects subsequent device
creations.
Trade-off acknowledged: m may want a "re-seed from type" action later
(slice 5+) to wipe + reset a device's ports. Out of v0 scope; not
blocked by the schema.
### 2.4 Setup templates (v4.1 NEW)
A setup template is a named recipe of "device-types to add + connection
requirements between them" that bootstraps a project from blank to
solver-ready in one click. m's three archetypes:
| Template name | Devices | Default requirements |
|---|---|---|
| **Living Room** | TV, Soundbar, ChromeCast | TV ↔ Soundbar (HDMI, must); TV ↔ ChromeCast (HDMI, must) |
| **Home Office** | PC, Screen, Keyboard, Mouse | PC ↔ Screen (HDMI, must); PC ↔ Keyboard (USB, must); PC ↔ Mouse (USB, must) |
| **Server Rack** | NAS, Switch, fritz | NAS ↔ Switch (RJ45, must); Switch ↔ fritz (RJ45, must); fritz ↔ NAS (Power, nice) |
> "Screen", "Keyboard", "Mouse" are added to the v4 built-in catalog
> alongside the existing 11 (Screen: Power × 1 + HDMI × 1; Keyboard: USB × 1;
> Mouse: USB × 1). Migration 002 grows to seed 14 built-ins.
Schema (`004_setup_templates.sql`):
```sql
-- A named recipe: a list of device types + requirements between them.
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'))
);
-- The devices a template stamps into a project. suggested_name is
-- pre-filled into the apply-template form; m can override.
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, -- "TV", "Bedroom TV", "Mac (work)"
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
-- Requirements between devices in the template, addressed by
-- `setup_template_devices.id` (not the runtime device id — they're
-- resolved at apply time).
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);
```
API:
```
GET /api/setup-templates → [SetupTemplate {id, name, description, built_in,
devices: [{id, device_type_id,
device_type: {…},
suggested_name, sort_order}],
requirements: [{id, from_template_device_id,
to_template_device_id,
preferred_cable_type_id,
must_connect}]}, …]
Read-only; built-ins are not editable via API in v4.1.
POST /api/projects/:pid/apply-template ← {
template_id: <int>,
name_overrides: { <template_device_id>: "<name>", … },
skip_devices: [<template_device_id>, …] # optional
}
→ {
devices_added: [Device, …],
requirements_added: [ConnectionRequirement, …],
skipped_devices: [{template_device_id, reason}, …]
}
Idempotency:
- A name collision with an existing device in the
project skips that template device (reason = "name
already in use"). Caller can pass `name_overrides`
to resolve.
- Requirements whose endpoints both resolve fire;
any whose endpoint was skipped are themselves
skipped (logged in `requirements_skipped[]` — same
shape).
The whole call runs in a single transaction.
```
The seed migration creates the 3 built-ins + their template_devices and
template_requirements rows referencing the 14 built-in `device_types` and
the 5 built-in `cable_types`. No project_id anywhere — templates are
global.
**FK shape — why `project_id` on every project-scoped row, not just transitively:**
The structural truth is `cable → port → device → frame → project`. But
@@ -328,8 +643,11 @@ PATCH /api/projects/:pid/frames/:id
DELETE /api/projects/:pid/frames/:id
GET /api/projects/:pid/devices
POST /api/projects/:pid/devices ← {name, frame_id?, x, y, width, height, color?}
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag)
POST /api/projects/:pid/devices ← {name, type_id?, frame_id?, x, y, width, height, color?}
v4: type_id (optional) seeds ports from the catalog;
without it, a freeform device (no ports) is created.
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
clearing does NOT delete existing ports (instance-owned).
DELETE /api/projects/:pid/devices/:id
GET /api/projects/:pid/devices/:id/ports
@@ -354,12 +672,90 @@ GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …]
PATCH /api/projects/:pid/bundles/:id
DELETE /api/projects/:pid/bundles/:id
# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows)
GET /api/device-types → built-in catalog (project_id NULL) — read-only listing
GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged
POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]}
Creates a project-custom row (built_in=0); inserts
device_type_ports rows in the same transaction.
PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable;
mutating a built-in row → 403 (UI hides edit affordance).
Editing ports replaces the device_type_ports rows;
existing devices' ports are NOT retroactively reseeded.
DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403.
ON DELETE SET NULL on devices.type_id so devices
keep their already-seeded ports.
# v4 — Connection requirements (the solver's input)
GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …]
POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id,
preferred_cable_type_id?, must_connect?, notes?}
Server normalises (from, to) into (pair_lo, pair_hi)
before insert; duplicate (project, pair_lo, pair_hi,
preferred_cable_type_id) → 409 conflict.
PATCH /api/projects/:pid/connection-requirements/:id
DELETE /api/projects/:pid/connection-requirements/:id
# v4 — Solver
POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying)
→ {
cables_added: [Cable, …],
cables_kept: [int, …], # ids preserved by the diff
cables_removed: [int, …], # ids deleted (auto cables only)
bundles_added: [{Bundle, cable_ids: [int]}, …],
bundles_removed: [int, …],
unsatisfied: [{requirement_id, reason}, …],
warnings: [string, …],
}
Default applies in a single transaction. ?preview=1
returns the same shape without writing. User-created
cables (auto=0 in the cables table; see §5.1) are
never touched — the solver only adds/removes its own.
# v4 — Solver quick-fix combo endpoint (powers the inspector's
# "+ Add <type> port to <device> and re-solve" button — §5b.4).
POST /api/projects/:pid/devices/:id/ports-and-resolve
← {type_id: <int>,
label?: <str>,
x_offset?: <num>, y_offset?: <num>}
→ {port: Port, solve: <solve response>}
Single tx: inserts the port + re-runs solve. Used by
the quick-fix UI so the unmet badge resolves in one
server round-trip.
# v4.1 — Setup templates
GET /api/setup-templates → [SetupTemplate, …]
Read-only listing of built-in (and any project-custom,
post-v4.1) templates with their device/requirement
shapes (see §2.4).
POST /api/projects/:pid/apply-template ← {template_id: <int>,
name_overrides?: { <template_device_id>: "<name>" },
skip_devices?: [<template_device_id>, …]}
→ {devices_added: [Device, …],
requirements_added: [ConnectionRequirement, …],
skipped_devices: [{template_device_id, reason}, …],
requirements_skipped: [{template_requirement_id, reason}, …]}
Idempotent in spirit: name collisions surface in
skipped_devices; m resolves with name_overrides on
re-apply. Whole call is one transaction.
# Sync — export only in MVP
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
(overwrites previous version; mExDraw keeps
git-version-history sidecar)
```
### 3.1 v4 wire-shape additions
- `ConnectionRequirement` (response):
`{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}`.
- `DeviceType` (response):
`{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}`.
- `cables` gets an `auto: bool` field on the row (slice 5.5 migration adds
the column with default 0; the solver sets 1 on its own creations). The
v3 cable rows m hand-drew keep `auto=0`. `POST /api/.../cables`
continues to default `auto=0`; only the solver writes `auto=1`.
No `POST /api/sync/import` in MVP. Import is post-MVP and only ever serves
a one-shot migration use case (e.g. seeding LOFT from the legacy
Cable-Management drawing if m later changes his mind).
@@ -447,6 +843,139 @@ they're ignored in v0 (open question §9).
---
## 5b. v4 — Solver
The solver is the headline addition in v4. m's product-vision sentence
maps onto it directly:
> "I say what devices we have, the app tells me how to bundle cables and
> how the most efficient connection looks like"
The solver reads a project's `devices` (with their `ports`) and
`connection_requirements`, and writes a set of solver-owned `cables`
(rows with `auto=1`) + `bundles`. m's hand-drawn cables (`auto=0`) are
left strictly alone — the solver only adds and removes its own.
### 5b.1 Objective: maximum bundling — schematic only
mCables is a **schematic**, not a physical-routing tool. Cables are
straight lines between endpoints; the solver has no model of walls,
floors, cable trays, or path geometry. "Maximum bundling" therefore
reduces to a single rule on the schematic:
> When two or more cables share the same endpoint pair (device A ↔
> device B), group them into one bundle.
This is the v3 endpoint-pair rule, applied to the solver's output. m's
"visually cleaner setups" benefit comes from the bundle being a single
labelled set in the inspector + a single mixed-colour glyph in the
render (slice 9+), rather than from any path optimisation. Anything
about trunks, frame-edge corridors, or auto-routing is out of scope —
filed for "post-v0 ambient" in §8.
### 5b.2 Algorithm (v0)
Pure function. No graph search; no LP; no path optimisation. Single
pass with greedy port allocation.
```
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
let port_free := {port_id -> bool} initialised TRUE for every port
minus ports already used by manual cables (auto=0)
for each requirement r in order(must_connect DESC, id ASC):
let ct = r.preferred_cable_type_id
?? auto_pick_cable_type(r.from_device, r.to_device)
?? fail("ambiguous")
let pa = first_free_port(r.from_device, ct, port_free)
let pb = first_free_port(r.to_device, ct, port_free)
if !pa or !pb:
if r.must_connect: unsatisfied.push({r.id, reason})
else: skip
continue
port_free[pa] = port_free[pb] = false
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
for each (device_a, device_b) pair with ≥ 2 add-cables:
bundles_add.push({auto=1, cables: those add-cables})
// Diff against auto_cables_before to compute remove[] (any prior auto
// cable whose (from, to, type) doesn't appear in add[]).
remove = auto_cables_before - add
return {add, remove, bundles_add, unsatisfied}
```
`first_free_port(device, cable_type, free_map)` picks the lowest-id port
on the device whose `type_id` matches and that is still free, returning
NULL if none. The `lowest-id` tiebreak is deterministic so repeated
solves produce the same plan.
`auto_pick_cable_type(from, to)` (used when `preferred_cable_type_id` is
NULL): find the set of cable types `T = ports(from).types ∩
ports(to).types`. If `|T| == 1`, return it. If `|T| > 1`, fail
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
as a "specify type" inline edit on the requirement.
### 5b.3 Solver-owned vs. user-owned cables
`cables.auto` distinguishes them.
| Operation | Effect on `auto=0` cables | Effect on `auto=1` cables |
|---|---|---|
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
| DELETE cables | applies | applies |
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
This way a manual cable m doesn't want the solver to second-guess
survives every solve. If m wants the solver to take it over, he deletes
his hand-drawn cable and re-solves; the solver re-creates an equivalent
auto cable.
### 5b.4 When solver fails — quick-fix UX
Three classes of failure surface in the response's `unsatisfied[]`:
1. **No compatible cable type** — `T = ports(from).types ∩
ports(to).types` is empty (e.g. a Power-only device to an HDMI-only
device).
2. **Ambiguous cable type** — `|T| > 1`, no preferred set on the
requirement.
3. **No free port** — the cable type matches but every port on one side
is already used.
The solver does **not** auto-add ports without m's consent. v4.1 ships
an explicit one-click quick-fix per class of failure, surfaced as a red
badge on the affected device in the inspector (§7) and as a button on
each `unsatisfied[]` entry in the preview-diff modal:
| Failure class | Quick-fix button | What it does |
|---|---|---|
| No compatible cable type | **"+ Add &lt;preferred_type&gt; port to &lt;device&gt; and re-solve"** | POST `/api/projects/:pid/devices/:id/ports` with `type_id=preferred_type` + sensible default offset, then immediately POST `/solve` again. The preferred_type is the requirement's `preferred_cable_type_id`. If the requirement has no preferred type, the button reads "Specify cable type" and opens an inline cable-type picker on the requirement instead. |
| Ambiguous cable type | **"Specify cable type"** | Opens an inline picker on the requirement row with the candidates from `T` pre-listed. On select → PATCH the requirement → re-solve. |
| No free port | **"+ Add &lt;type&gt; port to &lt;device&gt; and re-solve"** | Same as the no-compat case but the `type` is already determined (it's the requirement's preferred or auto-picked type). Adds a port on whichever side ran out (the response's `reason` carries `which_side`). |
All three quick-fixes do their work in a single round-trip request from
the UI perspective: the click fires a POST that either chains the port
insert + the re-solve server-side, or fires both calls back-to-back from
the client (server-side chaining is simpler — see §3.2 for the endpoint
shape).
The quick-fix never adds a port silently; the button text always names
the device + cable type so m sees what's about to mutate.
### 5b.5 Preview vs. apply
`?preview=1` returns the same shape without writing. The UI shows a diff
modal with `add[]`, `remove[]`, `unsatisfied[]`; m clicks Apply to fire
the same endpoint without `preview=1`. Default (no flag) applies
immediately. Live-solve (no Solve button — every requirement edit
triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
---
## 6. Sync — export-only for v0
```
@@ -522,16 +1051,54 @@ the new project (which has 5 seeded cable types and no frames yet).
The currently active project's id is kept in URL state
(`/?project=LOFT`) so reload returns to the same project.
### v4.1 — Flow: apply a setup template
The New Project modal gains a **"or start from a template"** section
under the description field. Each built-in template ('Living Room',
'Home Office', 'Server Rack') is a clickable card listing its devices +
the requirement edges between them. Selecting one expands an inline
override form:
- A pre-filled name for each template device (m can edit each, e.g.
rename `TV` to `Bedroom TV`).
- Per-device "skip" checkbox.
On Create, the server does `POST /api/projects` first; on success,
immediately fires `POST /api/projects/:pid/apply-template` with the
collected overrides. The response's `devices_added` + `requirements_added`
are merged into the local snapshot and the project switches to it,
already populated.
For an already-existing empty project, the inspector's project header
shows an **"Apply template"** action that opens the same override form
without the project-create round-trip.
Once the template has stamped its devices + requirements, hit **Solve**
(§7 "Flow: run the solver") to produce the wired diagram.
### Flow: add a frame
1. `+ Frm` in the left toolbar (or `F`).
2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
3. Name prompt centered in the frame; Enter → `POST .../frames`.
### Flow: add a device
### Flow: add a device (v4 — type-aware)
Unchanged from v1: `+ Dev` (or `D`) → click on canvas → rectangle placed
(falls into whichever frame it lands in) → name → `POST .../devices`.
1. `+ Dev` (or `D`) → click on canvas → device placeholder appears.
2. **First field in the inline namer: type dropdown** (replaces the
v1 plain-name input). Options pulled from
`GET /api/projects/:pid/device-types` — built-ins listed first
grouped by `kind`, then project-custom rows, then `Custom (no type)`.
Typing in the dropdown filters by `name` (m types "n" → NAS jumps
to top). Below the dropdown: a name input pre-filled with the type
name + a digit if a same-named device already exists ("PC", "PC-2").
3. Hit Enter → `POST .../devices` with `type_id` + name. The server
seeds the ports from `device_type_ports` in the same transaction
and returns the device with its `ports`.
4. Picking `Custom (no type)` keeps the v3 behaviour: rectangle, no
ports, m adds ports manually via the inspector.
5. The device renders with its ports already visible along the
configured edge.
### Flow: add a port
@@ -581,54 +1148,140 @@ In the inspector with nothing else selected, "Bundle suggestions" pulls
on the diagram + an Accept button. Manual: shift-click multiple cables →
"Group as bundle" → name it → save.
### v4 — Flow: declare connection requirements
The left sidebar gains a **Requirements** section under the legend:
```
Cable types
Power, USB, HDMI, DP, RJ45, + Type
Requirements ← new in v4
NAS ↔ Switch RJ45 must
PC ↔ TV HDMI must
Mac ↔ Soundbar HDMI nice
+ Requirement
```
Click `+ Requirement` → modal with two device pickers (autocomplete from
the project's current devices), a cable-type picker (defaults to
auto-resolve if the device pair has only one matching type), and a
must/nice toggle. `POST .../connection-requirements`.
Alternative gesture (no tool armed, no selection): **drag from device A
to device B** to seed a requirement modal with the pair pre-filled. The
solver-edge preview drags out from the source device's edge in a thin
dashed line until release.
m can also right-click a requirement row → edit / delete.
### v4 — Flow: run the solver
Header gains a **Solve** button next to **Export**.
1. Click Solve (or `S`) → `POST /api/projects/:pid/solve?preview=1`.
2. A diff modal opens listing `add[]`, `remove[]`, `unsatisfied[]` — the
canvas behind it dims and previews the new cables in a translucent
stroke + the to-be-removed cables in a strikethrough red.
3. Buttons:
- **Apply** → fires `POST .../solve` (no `preview`), applies in one
transaction, closes the modal, re-renders canvas with the real
cables in place.
- **Cancel** → leaves everything as it was.
4. Unsatisfied requirements get their own list at the bottom of the
modal, each with a quick-action button: "Specify type", "+ Add port
to device X", or "Drop requirement (set must=0)".
If `unsatisfied[]` is non-empty, the Solve button stays in a
soft-error state (yellow) until either every requirement is satisfiable
or m explicitly accepts the partial plan.
### v4 — Inspector states
| Selection | Inspector shows |
|---|---|
| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines |
| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons |
| frame | name (editable), x/y/w/h, contained-device count, delete |
| **device** | name + type + icon, ports grid (type / label / connected? / +Port), **unmet requirements list** with red badges. Each badge carries a single quick-fix button — "+ Add &lt;type&gt; port to &lt;device&gt; and re-solve" (no-compat-type / no-free-port cases) or "Specify cable type" (ambiguous case) per §5b.4. delete |
| **port** | type, label, parent device, current cable (if any), delete |
| **cable (auto=1)** | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) |
| cable (auto=0) | as v3 — type, source/target, label, delete |
| bundle | name, member cables (clickable to focus), the endpoint pair (`Device A ↔ Device B`), auto-detected flag |
### Keyboard
`P` switch project (opens picker), `F` add frame, `D` add device,
`I` add IO marker, `T` start cable from selected port,
`E` export current project, `Esc` cancel, `Backspace` delete selection,
`?` show shortcuts.
`P` switch project, `F` add frame, `D` add device, `I` add IO marker,
`T` start cable from selected port, `R` add requirement,
**`S` solve project (v4)**, `E` export, `Esc` cancel, `Backspace` delete
selection, `?` show shortcuts.
---
## 8. First slices
## 8. First slices — v4 reshape
Each slice ends with something m can click. The first coder shift takes
slices 14 as the MVP; slice 5 (export) is the round-trip end.
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead
of the original 3-5 because the solver depends on the catalog + the
requirements model, not on manual cable drawing. The old "manual port +
cable draw" slice is still in scope as a tweak path on the solver
output, but it follows the solver instead of leading.
| # | Slice | What's shipped |
|---|---|---|
| 1 | **Bootstrap + project CRUD** | `cmd/mcables` Go binary, SQLite migrations. Migration 001 seeds the 5 default cable types (Power/USB/HDMI/DP/RJ45) **globally, once**. `internal/db` store. `POST /api/projects` auto-fills `drawing_name = <name>.excalidraw` when omitted. `DELETE /api/projects/:pid?confirm=<name>` with name-match guardrail. `GET /api/projects` lists them. `GET /api/projects/:pid` returns a (mostly empty) snapshot. `GET /api/cable-types` returns the 5 seeded rows. Frontend `index.html` + `main.js` shows the project picker, a "+ New Project" modal, and an empty SVG canvas with the legend rendered from the global `cable_types` table. m can create LOFT, see it picked, see no devices. |
| 2 | **Add frame, add device, drag-to-position** | `+ Frm` and `+ Dev` tools work. Devices and frames persist. Drag-to-position writes back to DB on `pointerup`. Reload returns to the same layout. m builds LOFT's `desk` and `rack` frames and drops in his first devices. |
| 3 | **Add port, draw cable** | `+ Port` (with a device selected) places type-coloured ports on device edges with offsets. Click-port → click-port creates a cable. Cables auto-route as straight lines. Inspector shows the cable's type, endpoints, label. m wires up the first end-to-end cable. |
| 4 | **IO markers + cable-type editing** | `+ IO` places a wall-outlet diamond. Cable-from-port → IO commits as `to_io_id`. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new types. m can fully recreate LOFT's visual model from scratch. |
| 5 | **Export to mxdrw.msbls.de** | `POST .../sync/export` generates a `.excalidraw` scene that reproduces the seed's visual grammar (ports as positional ellipses, IO as diamonds, legend as text in the top-left), writes it via mExDraw API, and stores the assigned `excalidraw_id`s for stability on re-export. m sees LOFT in Excalidraw and confirms the look matches the seed. |
| # | Slice | Status | What's shipped |
|---|---|---|---|
| 1 | **Bootstrap + project CRUD + global cable_types** | ✅ shipped | See git: branch `mai/picasso/slice-1-bootstrap`. |
| 2 | **Frames + devices + drag** | ✅ shipped | See git: branch `mai/picasso/slice-2-frames-devices`. |
| **3 (was 4)** | **IO markers + cable-type editing** | pending | Unchanged scope. `+ IO` places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. `+ Type` adds new global types. |
| **4 (NEW)** | **Device-type catalog + type-aware device create** | pending | Migration 002: `device_types` + `device_type_ports`, seeded with the 11 built-ins (§2.2). Migration adds `devices.type_id`. API: `GET /api/device-types`, `GET /api/projects/:pid/device-types`. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking `Custom (no type)` falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. |
| **4.5 (NEW)** | **Manage device-type catalog (per project)** | pending | Modal: `POST/PATCH/DELETE /api/projects/:pid/device-types` for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by `UNIQUE(project_id, name)` + a separate code-level check against built-ins). |
| **5 (NEW)** | **Connection requirements UI + CRUD** | pending | Migration 003: `connection_requirements`. API: full CRUD under `/api/projects/:pid/connection-requirements`. Frontend: left-sidebar "Requirements" section, `+ Requirement` modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
| **6 (v4.1 EXPANDED)** | **Solver MVP + Solve button + setup templates** | pending | `POST /api/projects/:pid/solve` with `?preview=1` support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling. Migration 003 adds `cables.auto`. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan + unmet requirements (each with its quick-fix button per §5b.4) → applies. **Folded in v4.1: setup templates.** Migration 004 adds `setup_templates` + `setup_template_devices` + `setup_template_requirements` and seeds 3 built-ins ('Living Room', 'Home Office', 'Server Rack'). API: `GET /api/setup-templates`, `POST /api/projects/:pid/apply-template`. UI: a "Templates" section in the New Project modal + an "Apply template" action on empty projects → seeds devices + requirements in one transaction → Solve produces the wired diagram. |
| **7 (was 3, slimmed)** | **Manual port + manual cable draw** | pending | The v3 flow as a tweak path on solver output. `+ Port` on an instance-owned device; click-port → click-port creates a hand-drawn cable (`auto=0`). Used to override the solver's choices or to extend its plan. |
| **8 (was 5)** | **Export to mxdrw.msbls.de** | pending | `POST .../sync/export` writes a `.excalidraw` scene per the visual grammar (§4). Bundles ignored on export in v0. |
Slices 6+ (not promised for the first coder shift):
bundle suggestions UI; bundle rendering (thick path with mixed-colour
fan-out); cable type "warn on cross-type port-to-port"; cable inventory
metadata (length/SKU) if m later wants it; dark mode.
Slices 9+ (not promised for the first coder shift):
- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast. Opt-in toggle in project settings.
- Bundle rendering in the SVG (a single thick line with mixed-colour stops between the endpoint pair, plus a small badge with the cable count). Cables in a bundle still render as their individual lines underneath; the bundle is a visual overlay m can toggle.
- "Re-seed from type" action on a device.
- Custom setup templates (m authors them in-UI, not just the built-in three).
- Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode.
Out of scope, period (would change mCables's mental model): path
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
3D, anything that treats a cable as more than a labelled endpoint pair.
---
## 9. Open questions for m — all resolved in v3
## 9. Open questions for m — all closed in v4.1
All six v2 questions are now answered. Locked answers:
The six v4 questions are now answered. Locked answers:
1. **Drawing-name policy**server-side default `<name>.excalidraw` on
POST when omitted; editable via PATCH. (§3)
2. **Device-name uniqueness within a project** → `UNIQUE (project_id,
devices.name)` enforced at the schema level. (§2)
3. **Non-Power IO markers** → no `type_id` on `io_markers` for v0.
Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
4. **Bundle render in export v1** → bundles ignored on export until slice
6+. (§4, §5)
5. **Cross-project cable types** → `cable_types` is fully **global**. One
shared legend; renaming/recolouring affects every project. (§2, §3, §7)
6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=<name>`
required; server validates name match, returns 400 otherwise. (§3)
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.**
Cables are straight lines between endpoints. The solver does not
route, the renderer does not route, and "maximum bundling" reduces to
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
cable tray, or frame-edge corridor is **out of scope, period**
(§8 "Out of scope, period").
2. **Live solve or button-only?** → **Button-only for v0.** Live-solve
stays parked at slice 9+ as an opt-in.
3. **No-compatible-port-pair UX.** → **Explicit quick-fix.** The
unsatisfied-requirement badge in the inspector carries a single
button — "+ Add &lt;type&gt; port to &lt;device&gt; and re-solve" —
that POSTs the port AND fires `/solve` in one UI action. The button
text always names the device + type, so m sees what's about to
mutate (§5b.4 + §7).
4. **Setup templates.** → **Folded INTO v4.1, in slice 6.** Migration 004
adds `setup_templates` + child tables + 3 built-ins. `GET
/api/setup-templates` and `POST /api/projects/:pid/apply-template`
ship alongside the solver (§2.4 + §3 + slice 6 in §8). Custom
templates (m authors his own) parked at slice 9+.
5. **Catalog distribution.** → **SQL seed in migration 002.** No
external file loader.
6. **Promote to manual.** → **Explicit button** on the cable inspector
(§7 row "cable (auto=1)"). PATCHes that only update labels stay auto.
No open design questions remain. The coder shift is gated on m's
go/no-go for v3 — not on any unanswered design question from picasso.
go/no-go for v4.1 — not on any unanswered design question from picasso.
---
@@ -777,4 +1430,4 @@ gitignored.
---
DESIGN v3 READY — coder shift gated
DESIGN v4.1 READY FOR REVIEW

16
go.mod Normal file
View File

@@ -0,0 +1,16 @@
module mgit.msbls.de/m/mcables
go 1.25.5
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1 // indirect
)

21
go.sum Normal file
View File

@@ -0,0 +1,21 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

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)
}
}

47
internal/db/db.go Normal file
View File

@@ -0,0 +1,47 @@
// Package db owns SQLite access for mCables: 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
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens (or creates) the SQLite file at path and returns a Store
// with WAL + foreign keys + busy_timeout configured.
func Open(path string) (*Store, error) {
// `_pragma` query params are honoured by modernc.org/sqlite for
// connection-time PRAGMA setup. journal_mode WAL is persistent
// across opens; the others apply per-connection.
dsn := fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)",
path,
)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := d.Ping(); err != nil {
_ = d.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
// Single writer keeps things deterministic for a local-LAN tool;
// reads scale fine in WAL.
d.SetMaxOpenConns(1)
return &Store{db: d}, nil
}
// Store is the application's handle on the SQLite database.
type Store struct {
db *sql.DB
}
// DB returns the underlying *sql.DB. Used by Migrate and (sparingly) by
// callers that need a raw query escape hatch.
func (s *Store) DB() *sql.DB { return s.db }
// Close releases the database.
func (s *Store) Close() error { return s.db.Close() }

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,283 @@
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",
}
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 1 + USB 3
"IOx-6": {7}, // Power 1 + USB 6
"IOx-8": {9}, // Power 1 + USB 8
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1
}
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)
}
}
}
// -------------------------------------------------------- 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,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)
}
}

94
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,94 @@
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
"strings"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Migrate applies any pending SQL files from migrations/*.sql in
// lexicographic order against the given *sql.DB. Applied filenames are
// tracked in schema_migrations so each runs at most once. Idempotent.
func Migrate(d *sql.DB) error {
if _, err := d.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied, err := loadApplied(d)
if err != nil {
return err
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
if applied[name] {
continue
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if err := runMigration(d, name, string(body)); err != nil {
return err
}
}
return nil
}
func loadApplied(d *sql.DB) (map[string]bool, error) {
rows, err := d.Query("SELECT name FROM schema_migrations")
if err != nil {
return nil, fmt.Errorf("load applied: %w", err)
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out[n] = true
}
return out, rows.Err()
}
func runMigration(d *sql.DB, name, body string) error {
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("begin %s: %w", name, err)
}
if _, err := tx.Exec(body); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (name) VALUES (?)", name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,144 @@
-- mCables 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.
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
drawing_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: GLOBAL legend, shared across all projects.
-- Seeded once below with the 5 defaults.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 1200,
height REAL NOT NULL DEFAULT 800,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX frames_project_idx ON frames(project_id);
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
CREATE TABLE ports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
x_offset REAL NOT NULL,
y_offset REAL NOT 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 ports_project_idx ON ports(project_id);
CREATE INDEX ports_device_idx ON ports(device_id);
CREATE INDEX ports_type_idx ON ports(type_id);
CREATE TABLE io_markers (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
label TEXT NOT NULL DEFAULT 'IO',
x REAL NOT NULL,
y REAL NOT 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 io_markers_project_idx ON io_markers(project_id);
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
CREATE TABLE cables (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
),
CHECK (
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX cables_project_idx ON cables(project_id);
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
CREATE INDEX cables_type_idx ON cables(type_id);
CREATE TABLE bundles (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto 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 bundles_project_idx ON bundles(project_id);
CREATE TABLE bundle_cables (
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
-- Seed the 5 default cable types, once.
INSERT INTO cable_types (name, color) VALUES
('Power', '#e03131'),
('USB', '#2f9e44'),
('HDMI', '#1971c2'),
('DP', '#9c36b5'),
('RJ45', '#ffd500');

View File

@@ -0,0 +1,167 @@
-- mCables 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 @@
-- mCables 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);

127
internal/db/models.go Normal file
View File

@@ -0,0 +1,127 @@
package db
// Project is the top-level entity. One project ↔ one .excalidraw drawing.
type Project struct {
ID int64 `json:"id"`
Name string `json:"name"`
DrawingName string `json:"drawing_name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CableType is global. Renaming/recolouring affects every project.
type CableType struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// 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"`
}
// 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 []any `json:"cables"`
IOMarkers []IOMarker `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
}

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

@@ -0,0 +1,180 @@
package db
import (
"database/sql"
)
// 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:])
}

343
internal/db/store.go Normal file
View File

@@ -0,0 +1,343 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// Sentinel errors callers can match against. The server layer maps these
// to HTTP status codes.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict") // UNIQUE violation
ErrInUse = errors.New("in use") // cable_type referenced by a cable
ErrConfirmName = errors.New("confirm name missing or mismatched")
ErrInvalidInput = errors.New("invalid input")
)
// -----------------------------------------------------------------------------
// Projects
// -----------------------------------------------------------------------------
// CreateProject inserts a new project. drawingName, if empty, defaults to
// "<name>.excalidraw". name and drawingName are trimmed; an empty name
// after trimming is rejected.
func (s *Store) CreateProject(name, drawingName, description string) (*Project, error) {
name = strings.TrimSpace(name)
drawingName = strings.TrimSpace(drawingName)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if drawingName == "" {
drawingName = name + ".excalidraw"
}
res, err := s.db.Exec(
`INSERT INTO projects (name, drawing_name, description) VALUES (?, ?, ?)`,
name, drawingName, description,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetProject(id)
}
// GetProject loads a project by ID.
func (s *Store) GetProject(id int64) (*Project, error) {
var p Project
err := s.db.QueryRow(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
// ListProjects returns every project ordered by name.
func (s *Store) ListProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ProjectUpdate carries partial fields for PATCH. A nil pointer means
// "leave this field untouched".
type ProjectUpdate struct {
Name *string
DrawingName *string
Description *string
}
// UpdateProject applies the partial update. Empty struct = no-op (just
// bumps updated_at). Empty Name (after trim) is rejected; whitespace-only
// DrawingName is treated as "use <name>.excalidraw" — same default as
// CreateProject.
func (s *Store) UpdateProject(id int64, u ProjectUpdate) (*Project, error) {
cur, err := s.GetProject(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.DrawingName != nil {
v := strings.TrimSpace(*u.DrawingName)
if v == "" {
v = cur.Name + ".excalidraw"
}
cur.DrawingName = v
}
if u.Description != nil {
cur.Description = *u.Description
}
if _, err := s.db.Exec(
`UPDATE projects
SET name = ?, drawing_name = ?, description = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.DrawingName, cur.Description, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetProject(id)
}
// DeleteProject removes the project (cascading frames, devices, ports,
// cables, io_markers, bundles, bundle_cables). confirmName must match the
// project's current name; otherwise ErrConfirmName is returned and nothing
// is deleted.
func (s *Store) DeleteProject(id int64, confirmName string) error {
p, err := s.GetProject(id)
if err != nil {
return err
}
if confirmName != p.Name {
return ErrConfirmName
}
if _, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id); err != nil {
return err
}
return nil
}
// 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 {
return nil, err
}
types, err := s.ListCableTypes()
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
}
return &Snapshot{
Project: *p,
Frames: frames,
Devices: devices,
Ports: ports,
Cables: []any{},
IOMarkers: ios,
Bundles: []any{},
CableTypes: types,
ConnectionRequirements: reqs,
}, nil
}
// -----------------------------------------------------------------------------
// Cable types (global)
// -----------------------------------------------------------------------------
// CreateCableType inserts a global cable type. name must be globally unique.
func (s *Store) CreateCableType(name, color string) (*CableType, error) {
name = strings.TrimSpace(name)
color = strings.TrimSpace(color)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if color == "" {
return nil, fmt.Errorf("%w: color is required", ErrInvalidInput)
}
res, err := s.db.Exec(
`INSERT INTO cable_types (name, color) VALUES (?, ?)`, name, color,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetCableType(id)
}
// GetCableType loads a cable type by ID.
func (s *Store) GetCableType(id int64) (*CableType, error) {
var t CableType
err := s.db.QueryRow(
`SELECT id, name, color, created_at, updated_at
FROM cable_types WHERE id = ?`, id,
).Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &t, nil
}
// ListCableTypes returns every cable type ordered by id (insertion order,
// so the legend renders in the same order across reloads).
func (s *Store) ListCableTypes() ([]CableType, error) {
rows, err := s.db.Query(
`SELECT id, name, color, created_at, updated_at
FROM cable_types ORDER BY id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableType{}
for rows.Next() {
var t CableType
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// CableTypeUpdate is the partial-update shape for PATCH.
type CableTypeUpdate struct {
Name *string
Color *string
}
// UpdateCableType applies a partial update.
func (s *Store) UpdateCableType(id int64, u CableTypeUpdate) (*CableType, error) {
cur, err := s.GetCableType(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 _, err := s.db.Exec(
`UPDATE cable_types
SET name = ?, color = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.Color, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetCableType(id)
}
// DeleteCableType removes a cable type. SQLite enforces ON DELETE RESTRICT
// from cables.type_id and ports.type_id; we surface that as ErrInUse plus
// the count of referencing cables (so the UI can show "blocked by N cables").
func (s *Store) DeleteCableType(id int64) error {
if _, err := s.GetCableType(id); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM cable_types WHERE id = ?`, id); err != nil {
if isForeignKeyConstraint(err) {
return ErrInUse
}
return err
}
return nil
}
// CountCablesUsingType returns how many cables reference this cable_type.
// Used by the server to enrich a 409 InUse response with a helpful number.
func (s *Store) CountCablesUsingType(id int64) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM cables WHERE type_id = ?`, id).Scan(&n)
return n, err
}
// -----------------------------------------------------------------------------
// Error mapping
// -----------------------------------------------------------------------------
// mapWriteErr classifies SQLite write errors into our sentinel errors so
// the handler layer can pick the right HTTP status. Falls through to the
// raw error for anything we don't recognise.
func mapWriteErr(err error) error {
if err == nil {
return nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "UNIQUE constraint failed"):
return fmt.Errorf("%w: %s", ErrConflict, msg)
case strings.Contains(msg, "FOREIGN KEY constraint failed"):
return fmt.Errorf("%w: %s", ErrInUse, msg)
case strings.Contains(msg, "CHECK constraint failed"):
return fmt.Errorf("%w: %s", ErrInvalidInput, msg)
}
return err
}
func isForeignKeyConstraint(err error) bool {
return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
}

281
internal/db/store_test.go Normal file
View File

@@ -0,0 +1,281 @@
package db
import (
"errors"
"path/filepath"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
if err := Migrate(s.DB()); err != nil {
t.Fatalf("migrate: %v", err)
}
return s
}
// --------------------------------------------------------------------- projects
func TestCreateProject_DefaultsDrawingName(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject("LOFT", "", "")
if err != nil {
t.Fatalf("create: %v", err)
}
if p.Name != "LOFT" {
t.Errorf("name = %q, want LOFT", p.Name)
}
if p.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw", p.DrawingName)
}
}
func TestCreateProject_AcceptsExplicitDrawingName(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject("OFFICE", "office-rack.excalidraw", "rack only")
if err != nil {
t.Fatalf("create: %v", err)
}
if p.DrawingName != "office-rack.excalidraw" {
t.Errorf("drawing_name = %q, want office-rack.excalidraw", p.DrawingName)
}
if p.Description != "rack only" {
t.Errorf("description = %q", p.Description)
}
}
func TestCreateProject_EmptyNameRejected(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateProject(" ", "", ""); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
}
func TestCreateProject_DuplicateNameRejected(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateProject("LOFT", "", ""); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := s.CreateProject("LOFT", "", ""); !errors.Is(err, ErrConflict) {
t.Fatalf("second create err = %v, want ErrConflict", err)
}
}
func TestListProjects_OrderedByName(t *testing.T) {
s := newTestStore(t)
for _, name := range []string{"OFFICE", "LOFT", "GARAGE"} {
if _, err := s.CreateProject(name, "", ""); err != nil {
t.Fatalf("create %s: %v", name, err)
}
}
got, err := s.ListProjects()
if err != nil {
t.Fatalf("list: %v", err)
}
want := []string{"GARAGE", "LOFT", "OFFICE"}
for i, p := range got {
if p.Name != want[i] {
t.Errorf("[%d] = %q, want %q", i, p.Name, want[i])
}
}
}
func TestGetProject_NotFound(t *testing.T) {
s := newTestStore(t)
if _, err := s.GetProject(999); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUpdateProject_PartialFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
newName := "LOFT-2"
updated, err := s.UpdateProject(p.ID, ProjectUpdate{Name: &newName})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "LOFT-2" {
t.Errorf("name = %q, want LOFT-2", updated.Name)
}
// drawing_name should not auto-change from a Name update — it's only
// auto-defaulted when drawing_name is explicitly set to empty.
if updated.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw (unchanged)", updated.DrawingName)
}
}
func TestUpdateProject_BlankDrawingNameDefaults(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "old.excalidraw", "")
blank := " "
updated, err := s.UpdateProject(p.ID, ProjectUpdate{DrawingName: &blank})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw", updated.DrawingName)
}
}
func TestDeleteProject_ConfirmGuardrail(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Wrong name → no delete.
if err := s.DeleteProject(p.ID, "OFFICE"); !errors.Is(err, ErrConfirmName) {
t.Fatalf("wrong-name err = %v, want ErrConfirmName", err)
}
if _, err := s.GetProject(p.ID); err != nil {
t.Fatalf("project should still exist: %v", err)
}
// Empty confirm → no delete.
if err := s.DeleteProject(p.ID, ""); !errors.Is(err, ErrConfirmName) {
t.Fatalf("empty-confirm err = %v, want ErrConfirmName", err)
}
// Correct name → delete.
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
t.Fatalf("correct-name delete: %v", err)
}
if _, err := s.GetProject(p.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("project should be gone: %v", err)
}
}
func TestSnapshot_IncludesGlobalCableTypes(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if snap.Project.ID != p.ID {
t.Errorf("project.id = %d, want %d", snap.Project.ID, p.ID)
}
if len(snap.CableTypes) != 5 {
t.Errorf("cable_types len = %d, want 5 (the seeded defaults)", len(snap.CableTypes))
}
if snap.Frames == nil || snap.Devices == nil || snap.Ports == nil ||
snap.Cables == nil || snap.IOMarkers == nil || snap.Bundles == nil {
t.Errorf("snapshot collections must be non-nil arrays, not null, for slice-1 JSON output")
}
}
// ------------------------------------------------------------------ cable_types
func TestListCableTypes_SeededFive(t *testing.T) {
s := newTestStore(t)
ts, err := s.ListCableTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
wantNames := []string{"Power", "USB", "HDMI", "DP", "RJ45"}
if len(ts) != 5 {
t.Fatalf("len = %d, want 5", len(ts))
}
for i, want := range wantNames {
if ts[i].Name != want {
t.Errorf("[%d].Name = %q, want %q", i, ts[i].Name, want)
}
if ts[i].Color == "" {
t.Errorf("[%d].Color empty", i)
}
}
}
func TestCreateCableType_GlobalUnique(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateCableType("Audio", "#ff0000"); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := s.CreateCableType("Audio", "#00ff00"); !errors.Is(err, ErrConflict) {
t.Fatalf("dup err = %v, want ErrConflict", err)
}
}
func TestUpdateCableType_RenameAndRecolour(t *testing.T) {
s := newTestStore(t)
ts, _ := s.ListCableTypes()
hdmi := ts[2] // seed order: Power, USB, HDMI, DP, RJ45
newName := "HDMI-2.1"
newColor := "#000000"
updated, err := s.UpdateCableType(hdmi.ID, CableTypeUpdate{Name: &newName, Color: &newColor})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "HDMI-2.1" || updated.Color != "#000000" {
t.Errorf("got %+v", updated)
}
}
func TestDeleteCableType_BlockedByCable(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Reach the seeded Power cable type.
ts, _ := s.ListCableTypes()
power := ts[0]
// Wire up a minimal cable referencing the Power type via the raw DB
// (the typed device/port API ships in slice 2+). The schema CHECK
// requires exactly one endpoint each side — use device-level binding
// against placeholder rows.
d := s.DB()
res, err := d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderA")
if err != nil {
t.Fatalf("insert device A: %v", err)
}
deviceA, _ := res.LastInsertId()
res, err = d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderB")
if err != nil {
t.Fatalf("insert device B: %v", err)
}
deviceB, _ := res.LastInsertId()
if _, err := d.Exec(`INSERT INTO cables
(project_id, type_id, from_device_id, to_device_id)
VALUES (?, ?, ?, ?)`, p.ID, power.ID, deviceA, deviceB); err != nil {
t.Fatalf("insert cable: %v", err)
}
// Now delete → must be blocked.
if err := s.DeleteCableType(power.ID); !errors.Is(err, ErrInUse) {
t.Fatalf("delete err = %v, want ErrInUse", err)
}
n, err := s.CountCablesUsingType(power.ID)
if err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Errorf("count = %d, want 1", n)
}
}
func TestDeleteCableType_UnusedSucceeds(t *testing.T) {
s := newTestStore(t)
t2, _ := s.CreateCableType("Audio", "#000000")
if err := s.DeleteCableType(t2.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}
func TestDeleteProject_DoesNotTouchCableTypes(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
t.Fatalf("delete: %v", err)
}
ts, _ := s.ListCableTypes()
if len(ts) != 5 {
t.Errorf("cable_types should survive project deletion; got %d, want 5", len(ts))
}
}

View File

@@ -0,0 +1,115 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/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/mcables/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)
}

View File

@@ -0,0 +1,242 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/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)
}

238
internal/server/handlers.go Normal file
View File

@@ -0,0 +1,238 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"mgit.msbls.de/m/mcables/internal/db"
)
type handlers struct {
store *db.Store
}
// ---------------------------------------------------------------- utility
// writeJSON encodes v as JSON at the given status. Errors during encoding
// are logged-silent (the response has already started) — this is the
// last-resort path; callers should validate inputs early.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error string `json:"error"`
Details any `json:"details,omitempty"`
}
// writeError maps a Store sentinel to an HTTP status + JSON body.
func writeError(w http.ResponseWriter, err error, details any) {
switch {
case errors.Is(err, db.ErrNotFound):
writeJSON(w, http.StatusNotFound, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrConflict):
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInUse):
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrConfirmName):
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})
}
}
func parseInt64Path(r *http.Request, key string) (int64, bool) {
raw := r.PathValue(key)
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// ---------------------------------------------------------------- health
func (h *handlers) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// ---------------------------------------------------------------- projects
type projectCreate struct {
Name string `json:"name"`
DrawingName string `json:"drawing_name"`
Description string `json:"description"`
}
type projectPatch struct {
Name *string `json:"name,omitempty"`
DrawingName *string `json:"drawing_name,omitempty"`
Description *string `json:"description,omitempty"`
}
func (h *handlers) listProjects(w http.ResponseWriter, _ *http.Request) {
ps, err := h.store.ListProjects()
if err != nil {
writeError(w, err, nil)
return
}
if ps == nil {
ps = []db.Project{}
}
writeJSON(w, http.StatusOK, ps)
}
func (h *handlers) createProject(w http.ResponseWriter, r *http.Request) {
var body projectCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.CreateProject(body.Name, body.DrawingName, body.Description)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *handlers) getProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
snap, err := h.store.Snapshot(id)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, snap)
}
func (h *handlers) patchProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body projectPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.UpdateProject(id, db.ProjectUpdate{
Name: body.Name,
DrawingName: body.DrawingName,
Description: body.Description,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *handlers) deleteProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
confirm := r.URL.Query().Get("confirm")
if confirm == "" {
writeError(w, db.ErrConfirmName,
"DELETE requires ?confirm=<project name> matching the project's current name")
return
}
if err := h.store.DeleteProject(id, confirm); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- cable_types
type cableTypeCreate struct {
Name string `json:"name"`
Color string `json:"color"`
}
type cableTypePatch struct {
Name *string `json:"name,omitempty"`
Color *string `json:"color,omitempty"`
}
func (h *handlers) listCableTypes(w http.ResponseWriter, _ *http.Request) {
ts, err := h.store.ListCableTypes()
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ts)
}
func (h *handlers) createCableType(w http.ResponseWriter, r *http.Request) {
var body cableTypeCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
t, err := h.store.CreateCableType(body.Name, body.Color)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (h *handlers) patchCableType(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body cableTypePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
t, err := h.store.UpdateCableType(id, db.CableTypeUpdate{
Name: body.Name,
Color: body.Color,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *handlers) deleteCableType(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteCableType(id); err != nil {
// On ErrInUse, count referencing cables so the client can show
// "blocked by N cables".
if errors.Is(err, db.ErrInUse) {
n, _ := h.store.CountCablesUsingType(id)
writeError(w, err, map[string]int{"in_use_by_cables": n})
return
}
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,109 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/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)
}

91
internal/server/server.go Normal file
View File

@@ -0,0 +1,91 @@
// Package server wires the HTTP API + the embedded frontend onto a
// single net/http handler. Routes use Go 1.22 ServeMux pattern matching
// (no router framework).
package server
import (
"io/fs"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
// New returns an http.Handler serving the mCables 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 {
mux := http.NewServeMux()
h := &handlers{store: store}
// Health
mux.HandleFunc("GET /api/healthz", h.healthz)
// Projects
mux.HandleFunc("GET /api/projects", h.listProjects)
mux.HandleFunc("POST /api/projects", h.createProject)
mux.HandleFunc("GET /api/projects/{pid}", h.getProject)
mux.HandleFunc("PATCH /api/projects/{pid}", h.patchProject)
mux.HandleFunc("DELETE /api/projects/{pid}", h.deleteProject)
// Cable types (global)
mux.HandleFunc("GET /api/cable-types", h.listCableTypes)
mux.HandleFunc("POST /api/cable-types", h.createCableType)
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)
// 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)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively
// 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)
})
}

199
web/static/index.html Normal file
View File

@@ -0,0 +1,199 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<span class="brand">mCables</span>
<div class="project-picker">
<label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project">
<option value="">— no project —</option>
</select>
<button type="button" id="btn-new-project" class="btn">+ Project</button>
<button type="button" id="btn-delete-project" class="btn btn-danger" hidden>
Delete
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</button>
</header>
<main class="layout">
<aside class="sidebar" aria-label="Tools">
<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="requirements">
<h2 class="sidebar-heading">Requirements</h2>
<ul id="requirement-list" class="requirement-list"></ul>
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
<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-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
</ul>
</section>
</aside>
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">
Pick or create a project to start drawing.
</p>
</section>
<aside class="inspector" aria-label="Inspector">
<h2 class="sidebar-heading">Inspector</h2>
<div id="inspector-body">
<p class="muted">Nothing selected.</p>
</div>
</aside>
</main>
<!-- New Project modal -->
<dialog id="modal-new-project" class="modal" aria-labelledby="np-title">
<form method="dialog" id="form-new-project">
<h2 id="np-title">New project</h2>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Drawing name</span>
<input type="text" name="drawing_name" autocomplete="off"
placeholder="auto: <name>.excalidraw" />
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="2"></textarea>
</label>
<p class="form-error" id="np-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 Cable Type modal -->
<dialog id="modal-cable-type" class="modal" aria-labelledby="ct-title">
<form method="dialog" id="form-cable-type">
<h2 id="ct-title">Cable type</h2>
<p class="banner">
Cable types are shared across all projects. Renaming or recolouring
affects every project.
</p>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" name="color" value="#1971c2" />
</label>
<p class="form-error" id="ct-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>
<!-- 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>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">
<h2 id="dp-title">Delete project</h2>
<p>
This will cascade-delete every frame, device, port, cable, IO marker
and bundle in the project. <strong>Cable types are global and are not affected.</strong>
</p>
<p>Type the project name to confirm:</p>
<input type="text" name="confirm" required autocomplete="off"
id="dp-confirm-input" />
<p class="form-error" id="dp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-danger">Delete</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

1555
web/static/main.js Normal file

File diff suppressed because it is too large Load Diff

473
web/static/style.css Normal file
View File

@@ -0,0 +1,473 @@
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f4f4f5;
--border: #d4d4d8;
--text: #18181b;
--text-muted: #71717a;
--accent: #1971c2;
--danger: #e03131;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
--radius: 4px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ---------- topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 600;
font-size: 15px;
}
.project-picker {
display: flex;
align-items: center;
gap: 6px;
}
.topbar-spacer { flex: 1; }
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: 220px 1fr 280px;
flex: 1;
min-height: 0;
}
.sidebar,
.inspector {
background: var(--surface);
padding: 12px;
overflow-y: auto;
}
.sidebar { border-right: 1px solid var(--border); }
.inspector { border-left: 1px solid var(--border); }
.sidebar-heading {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.tool-list,
.legend-list {
list-style: none;
padding: 0;
margin: 0 0 8px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: var(--radius);
cursor: pointer;
}
.legend-row:hover { background: var(--surface-2); }
.legend-row[aria-current="true"] {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(0, 0, 0, 0.15);
flex-shrink: 0;
}
.legend-name { flex: 1; }
.legend-edit {
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-muted);
padding: 2px 4px;
border-radius: 2px;
font-size: 12px;
}
.legend-edit:hover { color: var(--text); background: var(--surface-2); }
/* ---------- canvas ---------- */
.canvas-wrap {
position: relative;
overflow: hidden;
background: #f7f7f7;
background-image:
linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
background-size: 50px 50px;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
.empty-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
background: rgba(255, 255, 255, 0.85);
padding: 8px 14px;
border-radius: var(--radius);
}
.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;
pointer-events: none;
}
.device-rect {
fill: #fff;
stroke: var(--text);
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)); }
.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 * { cursor: crosshair !important; }
/* 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. The fill is
white so the port is visible regardless of the underlying device's
stroke; the stroke colour comes from the cable_type the port carries
(set inline in JS). */
.port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
pointer-events: none; /* slice 4 — selection happens at device-level */
}
.port-row {
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 0;
}
.port-row .swatch {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
}
.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;
}
.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 {
font: inherit;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: var(--radius);
cursor: pointer;
box-shadow: var(--shadow);
}
.btn:hover { background: var(--surface-2); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
.btn-tiny { padding: 2px 8px; font-size: 12px; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: #155da3; }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: #b02828; }
/* ---------- dialog ---------- */
.modal {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0;
width: 380px;
max-width: calc(100vw - 32px);
background: var(--surface);
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
}
.modal::backdrop { background: rgba(0,0,0,0.3); }
.modal form { padding: 16px; }
.modal h2 { margin: 0 0 12px 0; font-size: 16px; }
.modal .banner {
background: #fff8e1;
border: 1px solid #f5d76e;
color: #5b4500;
padding: 8px 10px;
border-radius: var(--radius);
font-size: 13px;
margin: 0 0 12px 0;
}
.modal .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.modal .form-error {
color: var(--danger);
font-size: 13px;
margin: 6px 0 0 0;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 0 10px 0;
}
.field span {
font-size: 12px;
color: var(--text-muted);
}
.field input,
.field textarea {
font: inherit;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
width: 100%;
}
.field input:focus,
.field textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}

23
web/web.go Normal file
View File

@@ -0,0 +1,23 @@
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
// via embed.FS so deploying mCables means shipping one file.
package web
import (
"embed"
"io/fs"
)
//go:embed all:static
var assets embed.FS
// Static returns the frontend filesystem rooted at the package's static/
// dir so callers see index.html at "/".
func Static() fs.FS {
sub, err := fs.Sub(assets, "static")
if err != nil {
// embed sub-rooting can only fail if "static" doesn't exist,
// which is a build-time error. Panic is the right shape.
panic(err)
}
return sub
}