Compare commits

..

27 Commits

Author SHA1 Message Date
mAi
2aff5eb04d feat(template): apply-template lands devices inside a named frame
Before: ApplyTemplate dropped devices in a horizontal row at fixed
canvas coords with frame_id NULL — devices appeared anywhere and m
had no way to express "these belong together".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Deploy to mDock blocked on MEXDRAW_TOKEN — picasso correctly refused
to fake the secret. m to drop value into /home/m/secrets/mcables/.env
on mdock, then redeploy.
2026-05-16 01:42:17 +02:00
mAi
8a6e8c8406 feat(ui): wire Export button — POST /sync/export + toast
Export button is no longer disabled. On click it POSTs to the export
endpoint and shows a toast next to the button:
  ✓ Exported · open in mxdrw   (with viewer URL)
  ✗ Export failed — <detail>
2026-05-16 01:35:50 +02:00
mAi
275cb5a55a feat(backend): slice 8 — export scene to mxdrw
- internal/exporter: pure BuildScene + 21-char base62 IDs, port ellipses,
  device rect+text pairs, IO diamonds, arrow bindings, legend texts.
  Bundles intentionally omitted per design §4.1.
- internal/db: PersistExcalidrawIDs idempotent updater per project.
- internal/server: POST /api/projects/:pid/sync/export — loads snapshot,
  mints/reuses excalidraw_ids, PUTs scene to mxdrw with bearer auth.
  Returns viewer URL + element_count + mxdrw response.

Roundtrip hand-tested against mxdrw.msbls.de: scene saved, IDs stable
across re-exports.
2026-05-16 01:35:46 +02:00
mAi
a81dbe2f8c merge: fix apply-template UX hole
apply-template now auto-solves by default (?solve=0 opt-out for power
users) and returns combined {template_apply, solve} response.
Frontend reloads via activateProject() after Apply, so devices +
cables render immediately without manual Solve click.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -43,8 +43,9 @@ JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
|---|---|---|
| `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. |
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
| `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
### Tests

View File

@@ -14,7 +14,7 @@ services:
- 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.
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
- /home/m/secrets/mcables/.env
volumes:
- /home/m/stacks/mcables/data:/app/data

View File

@@ -453,18 +453,26 @@ Office setup template:
| 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 |
| IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
| **Screen** | display | Power × 1; HDMI × 1 |
| **Keyboard** | accessory | USB × 1 |
| **Mouse** | accessory | USB × 1 |
| **Multi-plug 3** | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| **Multi-plug 4** | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
| **Multi-plug 5** | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
| **Multi-plug 6** | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| **Wifi-plug** | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing
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.
v5 (migration 005) added the Multi-plug 36 strips and the Wifi-plug
pass-through outlet. v6 (migration 006) re-shaped the IOx-* and
Multi-plug-* profiles to the "1 in on top / N out on bottom" layout —
the IOx-* devices are physical power strips, not USB hubs (m's
hardware), and the Multi-plug-* outputs are now visually distinct from
the input. Convention: `top = back`, `bottom = front`. Existing device
instances keep their already-seeded ports per §2.3 — to pick up the
new layout, delete + re-create the instance.
m can also add **project-custom types** at any time (UI: "+ New device
type" inside the device-create modal) with `project_id = current`.

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

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

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

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

View File

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

View File

@@ -0,0 +1,60 @@
package db
import (
"database/sql"
)
// PersistExcalidrawIDs writes the assignments returned by the exporter
// back onto the corresponding rows. Idempotent: only updates rows whose
// excalidraw_id is currently NULL (the first export "owns" the id; later
// exports reuse it so mxdrw's collab cursors / undo history survive).
//
// Caller passes one map per kind; keys are the in-project row ids,
// values are the 21-char Excalidraw element ids the exporter minted.
func (s *Store) PersistExcalidrawIDs(projectID int64,
frames, devices, ports, ios, cables map[int64]string,
) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := updateExIDs(tx, "frames", projectID, frames); err != nil {
return err
}
if err := updateExIDs(tx, "devices", projectID, devices); err != nil {
return err
}
if err := updateExIDs(tx, "ports", projectID, ports); err != nil {
return err
}
if err := updateExIDs(tx, "io_markers", projectID, ios); err != nil {
return err
}
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
return err
}
return tx.Commit()
}
func updateExIDs(tx *sql.Tx, table string, projectID int64, m map[int64]string) error {
if len(m) == 0 {
return nil
}
stmt, err := tx.Prepare(
`UPDATE ` + table + `
SET excalidraw_id = ?
WHERE id = ? AND project_id = ? AND excalidraw_id IS NULL`,
)
if err != nil {
return err
}
defer stmt.Close()
for id, exID := range m {
if _, err := stmt.Exec(exID, id, projectID); err != nil {
return err
}
}
return nil
}

View File

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

View File

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

View File

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

View File

@@ -111,6 +111,102 @@ type ConnectionRequirement struct {
UpdatedAt string `json:"updated_at"`
}
// Cable is a typed connection. Each endpoint is exactly one of
// (port, device, io-marker). Auto=true means the solver placed it.
type Cable struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
TypeID int64 `json:"type_id"`
Label *string `json:"label"`
FromPortID *int64 `json:"from_port_id"`
FromDeviceID *int64 `json:"from_device_id"`
FromIOID *int64 `json:"from_io_id"`
ToPortID *int64 `json:"to_port_id"`
ToDeviceID *int64 `json:"to_device_id"`
ToIOID *int64 `json:"to_io_id"`
Auto bool `json:"auto"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Bundle is a named group of cables that physically run together.
type Bundle struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Name string `json:"name"`
Auto bool `json:"auto"`
CableIDs []int64 `json:"cable_ids"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SetupTemplate is a named recipe of device-types + requirements.
type SetupTemplate struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Devices []SetupTemplateDevice `json:"devices"`
Requirements []SetupTemplateRequirement `json:"requirements"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SetupTemplateDevice struct {
ID int64 `json:"id"`
TemplateID int64 `json:"template_id"`
DeviceTypeID int64 `json:"device_type_id"`
DeviceType *DeviceType `json:"device_type,omitempty"`
SuggestedName *string `json:"suggested_name"`
SortOrder int `json:"sort_order"`
}
type SetupTemplateRequirement struct {
ID int64 `json:"id"`
TemplateID int64 `json:"template_id"`
FromTemplateDeviceID int64 `json:"from_template_device_id"`
ToTemplateDeviceID int64 `json:"to_template_device_id"`
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
MustConnect bool `json:"must_connect"`
}
// SolveResult is the response shape from POST /api/projects/:pid/solve.
type SolveResult struct {
CablesAdded []Cable `json:"cables_added"`
CablesKept []int64 `json:"cables_kept"`
CablesRemoved []int64 `json:"cables_removed"`
BundlesAdded []Bundle `json:"bundles_added"`
BundlesRemoved []int64 `json:"bundles_removed"`
Unsatisfied []UnsatisfiedReq `json:"unsatisfied"`
Warnings []string `json:"warnings"`
}
type UnsatisfiedReq struct {
RequirementID int64 `json:"requirement_id"`
Reason string `json:"reason"`
WhichSide string `json:"which_side,omitempty"` // "from" | "to" | "" when both/neither
CableType string `json:"cable_type,omitempty"` // when known
}
// ApplyTemplateResult is the response from POST /apply-template.
type ApplyTemplateResult struct {
FramesAdded []Frame `json:"frames_added"`
DevicesAdded []Device `json:"devices_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
}
type SkippedTemplateDevice struct {
TemplateDeviceID int64 `json:"template_device_id"`
Reason string `json:"reason"`
}
type SkippedTemplateReq struct {
TemplateRequirementID int64 `json:"template_requirement_id"`
Reason string `json:"reason"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Arrays for collections still gated by future slices stay non-nil [] so
// JSON encodes as [] not null.
@@ -119,9 +215,9 @@ type Snapshot struct {
Frames []Frame `json:"frames"`
Devices []Device `json:"devices"`
Ports []Port `json:"ports"`
Cables []any `json:"cables"`
Cables []Cable `json:"cables"`
IOMarkers []IOMarker `json:"io_markers"`
Bundles []any `json:"bundles"`
Bundles []Bundle `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
}

View File

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

View File

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

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

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

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

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

View File

@@ -179,14 +179,22 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil {
return nil, err
}
cables, err := s.ListCables(id)
if err != nil {
return nil, err
}
bundles, err := s.ListBundles(id)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: frames,
Devices: devices,
Ports: ports,
Cables: []any{},
Cables: cables,
IOMarkers: ios,
Bundles: []any{},
Bundles: bundles,
CableTypes: types,
ConnectionRequirements: reqs,
}, nil

View File

@@ -0,0 +1,563 @@
// Package exporter builds an Excalidraw scene JSON from a project
// snapshot per docs/design.md §4 ("Export — DB → Excalidraw").
//
// The exporter is a pure function on a *db.Snapshot — no DB access, no
// IO — so it's trivial to unit-test against fixtures and gives the
// caller (the HTTP handler) a clean handoff: build scene → upload.
package exporter
import (
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"mgit.msbls.de/m/mcables/internal/db"
)
// Scene is the top-level Excalidraw file format. Keys mirror what the
// official Excalidraw JSON contains (we only emit the keys mxdrw cares
// about for rendering — `appState`, `files`, `libraryItems` etc. can be
// added later if m needs them).
type Scene struct {
Type string `json:"type"`
Version int `json:"version"`
Source string `json:"source"`
Elements []Element `json:"elements"`
AppState AppState `json:"appState"`
Files Files `json:"files"`
}
type AppState struct {
GridSize *int `json:"gridSize"`
ViewBackground string `json:"viewBackgroundColor"`
}
type Files struct{}
// Element is one node in the scene. Excalidraw's wire format has a lot
// of optional fields; we only emit the ones that matter for the shapes
// we draw. Extra null/zero fields are fine in Excalidraw (it merges
// defaults). Pointer fields stay nil-omitted via omitempty so the
// payload stays clean.
type Element struct {
ID string `json:"id"`
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Angle float64 `json:"angle"`
StrokeColor string `json:"strokeColor"`
BackgroundColor string `json:"backgroundColor"`
FillStyle string `json:"fillStyle"`
StrokeWidth int `json:"strokeWidth"`
StrokeStyle string `json:"strokeStyle"`
Roughness int `json:"roughness"`
Opacity int `json:"opacity"`
GroupIDs []string `json:"groupIds"`
FrameID *string `json:"frameId"`
Roundness *Roundness `json:"roundness"`
Seed int64 `json:"seed"`
Version int `json:"version"`
VersionNonce int64 `json:"versionNonce"`
IsDeleted bool `json:"isDeleted"`
BoundElements []BoundRef `json:"boundElements,omitempty"`
Updated int64 `json:"updated"`
Link *string `json:"link"`
Locked bool `json:"locked"`
// Element-type-specific extras
Name string `json:"name,omitempty"`
// Text-element fields
Text string `json:"text,omitempty"`
FontSize int `json:"fontSize,omitempty"`
FontFamily int `json:"fontFamily,omitempty"`
TextAlign string `json:"textAlign,omitempty"`
VerticalAlign string `json:"verticalAlign,omitempty"`
ContainerID *string `json:"containerId,omitempty"`
OriginalText string `json:"originalText,omitempty"`
LineHeight float64 `json:"lineHeight,omitempty"`
// Arrow-element fields
Points [][2]float64 `json:"points,omitempty"`
StartBinding *Binding `json:"startBinding,omitempty"`
EndBinding *Binding `json:"endBinding,omitempty"`
StartArrowhead *string `json:"startArrowhead,omitempty"`
EndArrowhead *string `json:"endArrowhead,omitempty"`
LastCommittedPoint *[2]float64 `json:"lastCommittedPoint,omitempty"`
}
type Roundness struct {
Type int `json:"type"`
}
type BoundRef struct {
ID string `json:"id"`
Type string `json:"type"`
}
type Binding struct {
ElementID string `json:"elementId"`
Focus float64 `json:"focus"`
Gap float64 `json:"gap"`
}
// IDAssignment is the result of running BuildScene: the scene to upload
// + the per-row excalidraw_id assignments that the caller should
// persist so the next export reuses the same ids (Excalidraw collab
// cursors / comments / undo history survive that way; design §4.2).
type IDAssignment struct {
Frames map[int64]string `json:"frames"`
Devices map[int64]string `json:"devices"`
Ports map[int64]string `json:"ports"`
IOMarkers map[int64]string `json:"io_markers"`
Cables map[int64]string `json:"cables"`
}
// BuildScene transforms a project snapshot into an Excalidraw Scene +
// the id-assignment side-table.
//
// nowMilli is the Updated timestamp (one millisecond stamp for every
// element keeps re-exports consistent — mxdrw treats wildly-different
// updateds as edit-noise).
//
// genID is a 21-char ID factory. Tests pass a deterministic generator
// to lock element ids down across asserts. Production uses Generate21.
func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene, *IDAssignment) {
a := &IDAssignment{
Frames: map[int64]string{},
Devices: map[int64]string{},
Ports: map[int64]string{},
IOMarkers: map[int64]string{},
Cables: map[int64]string{},
}
// idFor: reuse the existing excalidraw_id if present, else mint one.
idFor := func(existing *string) string {
if existing != nil && *existing != "" {
return *existing
}
return genID()
}
cableTypeColor := map[int64]string{}
for _, t := range snap.CableTypes {
cableTypeColor[t.ID] = t.Color
}
// We'll need: device-id → element-id, port-id → element-id, io-id → element-id
// for binding arrows.
deviceElID := map[int64]string{}
portElID := map[int64]string{}
ioElID := map[int64]string{}
frameElID := map[int64]string{}
var els []Element
// Frames first (Excalidraw renders later elements on top; frames are
// containers that go on the bottom).
for _, f := range snap.Frames {
elID := idFor(f.ExcalidrawID)
a.Frames[f.ID] = elID
frameElID[f.ID] = elID
els = append(els, Element{
ID: elID,
Type: "frame",
X: f.X,
Y: f.Y,
Width: f.Width,
Height: f.Height,
StrokeColor: "#bbbbbb",
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
Name: f.Name,
})
}
// Devices: rectangle + bound text with the device's name. Excalidraw
// uses a `containerId` pointer on the text to bind it to the rect,
// and `boundElements` on the rect to point back at the text.
for _, d := range snap.Devices {
rectID := idFor(d.ExcalidrawID)
a.Devices[d.ID] = rectID
deviceElID[d.ID] = rectID
textID := genID()
var frameRef *string
if d.FrameID != nil {
if v, ok := frameElID[*d.FrameID]; ok {
frameRef = &v
}
}
// Rect
els = append(els, Element{
ID: rectID,
Type: "rectangle",
X: d.X,
Y: d.Y,
Width: d.Width,
Height: d.Height,
StrokeColor: d.Color,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Roundness: &Roundness{Type: 3},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
})
// Bound text — name centered on the rect.
els = append(els, Element{
ID: textID,
Type: "text",
X: d.X,
Y: d.Y + d.Height/2 - 8,
Width: d.Width,
Height: 16,
StrokeColor: d.Color,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
Text: d.Name,
OriginalText: d.Name,
FontSize: 16,
FontFamily: 1,
TextAlign: "center",
VerticalAlign: "middle",
ContainerID: &rectID,
LineHeight: 1.25,
})
}
// Ports — small ellipses at device.x + port.x_offset (positional,
// not containerId-bound per the seed drawing's grammar; design §4.1).
for _, p := range snap.Ports {
elID := idFor(p.ExcalidrawID)
a.Ports[p.ID] = elID
portElID[p.ID] = elID
// Locate the parent device for absolute pos + frame ref.
var dev *db.Device
for i := range snap.Devices {
if snap.Devices[i].ID == p.DeviceID {
dev = &snap.Devices[i]
break
}
}
if dev == nil {
continue
}
var frameRef *string
if dev.FrameID != nil {
if v, ok := frameElID[*dev.FrameID]; ok {
frameRef = &v
}
}
color := cableTypeColor[p.TypeID]
if color == "" {
color = "#1e1e1e"
}
els = append(els, Element{
ID: elID,
Type: "ellipse",
X: dev.X + p.XOffset - 6,
Y: dev.Y + p.YOffset - 4,
Width: 12,
Height: 9,
StrokeColor: color,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Roundness: &Roundness{Type: 2},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
})
}
// IO markers — diamonds with bound "IO" (or m's label) text.
powerColor := ""
for _, t := range snap.CableTypes {
if t.Name == "Power" {
powerColor = t.Color
break
}
}
if powerColor == "" {
powerColor = "#e03131"
}
for _, m := range snap.IOMarkers {
elID := idFor(m.ExcalidrawID)
a.IOMarkers[m.ID] = elID
ioElID[m.ID] = elID
textID := genID()
var frameRef *string
if m.FrameID != nil {
if v, ok := frameElID[*m.FrameID]; ok {
frameRef = &v
}
}
els = append(els, Element{
ID: elID,
Type: "diamond",
X: m.X,
Y: m.Y,
Width: 30,
Height: 30,
StrokeColor: powerColor,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Roundness: &Roundness{Type: 2},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
})
els = append(els, Element{
ID: textID,
Type: "text",
X: m.X,
Y: m.Y + 7,
Width: 30,
Height: 16,
StrokeColor: powerColor,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
Text: m.Label,
OriginalText: m.Label,
FontSize: 11,
FontFamily: 1,
TextAlign: "center",
VerticalAlign: "middle",
ContainerID: &elID,
LineHeight: 1.25,
})
}
// Cables — arrows with startBinding/endBinding to the port / device /
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
// "to" points) come from the same anchor logic the canvas uses.
for _, c := range snap.Cables {
elID := idFor(c.ExcalidrawID)
a.Cables[c.ID] = elID
fromAnchor, fromRef := exportAnchor(c.FromPortID, c.FromDeviceID, c.FromIOID,
snap, deviceElID, portElID, ioElID)
toAnchor, toRef := exportAnchor(c.ToPortID, c.ToDeviceID, c.ToIOID,
snap, deviceElID, portElID, ioElID)
// fromRef/toRef are nil when the endpoint row vanished (manual
// cable referencing a deleted port, say). Skip rather than emit
// a half-bound arrow.
if fromRef == nil || toRef == nil {
continue
}
color := cableTypeColor[c.TypeID]
if color == "" {
color = "#1e1e1e"
}
startArr := ""
endArr := "arrow"
els = append(els, Element{
ID: elID,
Type: "arrow",
X: fromAnchor[0],
Y: fromAnchor[1],
Width: toAnchor[0] - fromAnchor[0],
Height: toAnchor[1] - fromAnchor[1],
StrokeColor: color,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 2,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
StartArrowhead: &startArr,
EndArrowhead: &endArr,
StartBinding: bindingPtr(fromRef),
EndBinding: bindingPtr(toRef),
})
}
// Legend in the top-left of the first frame (or at 20,20 if there
// are no frames). One text row per cable_type, stacked vertically.
legendX, legendY := 20.0, 20.0
if len(snap.Frames) > 0 {
legendX = snap.Frames[0].X + 10
legendY = snap.Frames[0].Y + 10
}
for i, t := range snap.CableTypes {
els = append(els, Element{
ID: genID(),
Type: "text",
X: legendX,
Y: legendY + float64(i*18),
Width: 80,
Height: 16,
StrokeColor: t.Color,
BackgroundColor: "transparent",
FillStyle: "solid",
StrokeWidth: 1,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
Text: t.Name,
OriginalText: t.Name,
FontSize: 16,
FontFamily: 1,
TextAlign: "left",
VerticalAlign: "top",
LineHeight: 1.25,
})
}
scene := &Scene{
Type: "excalidraw",
Version: 2,
Source: "mcables",
Elements: els,
AppState: AppState{
GridSize: nil,
ViewBackground: "#ffffff",
},
Files: Files{},
}
return scene, a
}
func bindingPtr(b *Binding) *Binding {
if b == nil {
return nil
}
return b
}
// exportAnchor returns (x,y) + a Binding for the endpoint kind passed in.
func exportAnchor(portID, deviceID, ioID *int64, snap *db.Snapshot,
devElID, portElID, ioElID map[int64]string,
) ([2]float64, *Binding) {
if portID != nil {
// Find the port + its parent device.
for _, p := range snap.Ports {
if p.ID != *portID {
continue
}
for _, d := range snap.Devices {
if d.ID == p.DeviceID {
id := portElID[p.ID]
return [2]float64{d.X + p.XOffset, d.Y + p.YOffset}, &Binding{ElementID: id, Focus: 0, Gap: 1}
}
}
}
}
if deviceID != nil {
for _, d := range snap.Devices {
if d.ID != *deviceID {
continue
}
id := devElID[d.ID]
return [2]float64{d.X + d.Width/2, d.Y + d.Height/2}, &Binding{ElementID: id, Focus: 0, Gap: 1}
}
}
if ioID != nil {
for _, m := range snap.IOMarkers {
if m.ID != *ioID {
continue
}
id := ioElID[m.ID]
return [2]float64{m.X + 15, m.Y + 15}, &Binding{ElementID: id, Focus: 0, Gap: 1}
}
}
return [2]float64{}, nil
}
// Generate21 mints a 21-char base62 identifier, the shape Excalidraw
// uses for element ids (nanoid-style). crypto/rand source.
func Generate21() string {
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
buf := make([]byte, 21)
max := big.NewInt(int64(len(alphabet)))
for i := range buf {
n, err := rand.Int(rand.Reader, max)
if err != nil {
// crypto/rand failure is unrecoverable in practice; fall back
// to a deterministic alphabet position so callers see a panic-
// adjacent symptom rather than a half-initialised id.
return fmt.Sprintf("crypto-rand-failed-%d", i)
}
buf[i] = alphabet[n.Int64()]
}
return string(buf)
}
// randInt returns a non-negative int64 derived from crypto/rand for
// Excalidraw's `seed` / `versionNonce`. Excalidraw treats these as
// noise — only the IDs and the structural fields matter.
func randInt() int64 {
n, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return 0
}
return n.Int64()
}
// MarshalScene returns the scene as Excalidraw-flavoured JSON.
func MarshalScene(s *Scene) ([]byte, error) {
return json.Marshal(s)
}

View File

@@ -0,0 +1,165 @@
package exporter
import (
"encoding/json"
"strings"
"testing"
"mgit.msbls.de/m/mcables/internal/db"
)
// deterministic id generator for tests
func newSeq() func() string {
i := 0
return func() string {
i++
return "id" + strings.Repeat("0", 19-len(itoa(i))) + itoa(i)
}
}
func itoa(i int) string {
if i == 0 {
return "0"
}
buf := [20]byte{}
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
return string(buf[pos:])
}
func sampleSnapshot() *db.Snapshot {
pid := int64(1)
devID := int64(10)
devID2 := int64(11)
portID := int64(100)
portID2 := int64(101)
ioID := int64(200)
return &db.Snapshot{
Project: db.Project{ID: pid, Name: "LOFT", DrawingName: "LOFT.excalidraw"},
Frames: []db.Frame{
{ID: 1, ProjectID: pid, Name: "desk", X: 100, Y: 100, Width: 800, Height: 500},
},
Devices: []db.Device{
{ID: devID, ProjectID: pid, Name: "NAS", Color: "#1e1e1e", X: 200, Y: 200, Width: 100, Height: 35, FrameID: ptr(int64(1))},
{ID: devID2, ProjectID: pid, Name: "Switch", Color: "#1e1e1e", X: 400, Y: 200, Width: 100, Height: 35},
},
Ports: []db.Port{
{ID: portID, ProjectID: pid, DeviceID: devID, TypeID: 5, XOffset: 50, YOffset: 35},
{ID: portID2, ProjectID: pid, DeviceID: devID2, TypeID: 5, XOffset: 50, YOffset: 35},
},
IOMarkers: []db.IOMarker{
{ID: ioID, ProjectID: pid, Label: "Wall A", X: 50, Y: 50},
},
Cables: []db.Cable{
{ID: 1000, ProjectID: pid, TypeID: 5,
FromPortID: &portID, ToPortID: &portID2, Auto: false},
},
CableTypes: []db.CableType{
{ID: 1, Name: "Power", Color: "#e03131"},
{ID: 2, Name: "USB", Color: "#2f9e44"},
{ID: 3, Name: "HDMI", Color: "#1971c2"},
{ID: 4, Name: "DP", Color: "#9c36b5"},
{ID: 5, Name: "RJ45", Color: "#ffd500"},
},
}
}
func ptr[T any](v T) *T { return &v }
func TestBuildScene_BasicShape(t *testing.T) {
snap := sampleSnapshot()
scene, ids := BuildScene(snap, 1700000000000, newSeq())
if scene.Type != "excalidraw" || scene.Version != 2 {
t.Errorf("bad header: %+v", scene)
}
// frame(1) + device-rect+text(2 each) + ports(2) + io+text(2) +
// cable(1) + legend(5) = 1 + 4 + 2 + 2 + 1 + 5 = 15.
if len(scene.Elements) < 15 {
t.Errorf("element count = %d, want ≥15", len(scene.Elements))
}
if len(ids.Frames) != 1 || len(ids.Devices) != 2 || len(ids.Ports) != 2 ||
len(ids.IOMarkers) != 1 || len(ids.Cables) != 1 {
t.Errorf("id assignment shape wrong: %+v", ids)
}
}
func TestBuildScene_ReusesExistingExcalidrawIDs(t *testing.T) {
snap := sampleSnapshot()
// Pre-assign an excalidraw_id on the first device.
preset := "preset0000000000000NAS"[:21]
snap.Devices[0].ExcalidrawID = &preset
_, ids := BuildScene(snap, 1700000000000, newSeq())
if ids.Devices[snap.Devices[0].ID] != preset {
t.Errorf("preset id not reused: got %q, want %q", ids.Devices[snap.Devices[0].ID], preset)
}
}
func TestBuildScene_ArrowsBindToPorts(t *testing.T) {
snap := sampleSnapshot()
scene, ids := BuildScene(snap, 1700000000000, newSeq())
// The arrow's startBinding should reference the from-port's element id.
fromPortElID := ids.Ports[100]
toPortElID := ids.Ports[101]
var found *Element
for i := range scene.Elements {
if scene.Elements[i].Type == "arrow" {
found = &scene.Elements[i]
break
}
}
if found == nil {
t.Fatal("no arrow in scene")
}
if found.StartBinding == nil || found.StartBinding.ElementID != fromPortElID {
t.Errorf("start binding wrong: %+v", found.StartBinding)
}
if found.EndBinding == nil || found.EndBinding.ElementID != toPortElID {
t.Errorf("end binding wrong: %+v", found.EndBinding)
}
}
func TestBuildScene_BundlesIgnored(t *testing.T) {
snap := sampleSnapshot()
// Snapshot.Bundles is unused in the exporter for v0 per design §4.1.
// Add some and confirm no bundle elements appear in the scene.
snap.Bundles = []db.Bundle{{ID: 1, Name: "trunk", CableIDs: []int64{1000}}}
scene, _ := BuildScene(snap, 1700000000000, newSeq())
for _, e := range scene.Elements {
if strings.Contains(e.Type, "bundle") {
t.Errorf("bundle element leaked into scene: %+v", e)
}
}
}
func TestMarshalScene_IsJSON(t *testing.T) {
snap := sampleSnapshot()
scene, _ := BuildScene(snap, 1700000000000, newSeq())
b, err := MarshalScene(scene)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var roundtrip map[string]any
if err := json.Unmarshal(b, &roundtrip); err != nil {
t.Fatalf("roundtrip: %v", err)
}
if roundtrip["type"] != "excalidraw" {
t.Errorf("type field = %v, want excalidraw", roundtrip["type"])
}
}
func TestGenerate21(t *testing.T) {
a := Generate21()
b := Generate21()
if len(a) != 21 || len(b) != 21 {
t.Errorf("len wrong: %d / %d", len(a), len(b))
}
if a == b {
t.Errorf("ids collide: %q == %q", a, b)
}
}

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

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

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

@@ -0,0 +1,122 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/mcables/internal/exporter"
)
// syncExport runs the project's snapshot through the exporter, persists
// the assigned excalidraw_ids, then PUTs the scene to mxdrw.msbls.de.
func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
base := os.Getenv("MEXDRAW_BASE_URL")
if base == "" {
base = "https://mxdrw.msbls.de"
}
user := os.Getenv("MEXDRAW_USER")
pass := os.Getenv("MEXDRAW_PASS")
if user == "" || pass == "" {
writeJSON(w, http.StatusBadRequest, errorBody{
Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
})
return
}
snap, err := h.store.Snapshot(pid)
if err != nil {
writeError(w, err, nil)
return
}
now := time.Now().UnixMilli()
scene, ids := exporter.BuildScene(snap, now, exporter.Generate21)
// Persist the freshly-assigned ids so the next export reuses them.
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
// only updates rows whose excalidraw_id is still NULL).
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
return
}
payload, err := exporter.MarshalScene(scene)
if err != nil {
writeError(w, fmt.Errorf("marshal scene: %w", err), nil)
return
}
drawingName := snap.Project.DrawingName
if !strings.HasSuffix(drawingName, ".excalidraw") {
drawingName += ".excalidraw"
}
url := strings.TrimSuffix(base, "/") + "/api/drawings/" + drawingName
// Sane network timeout; mxdrw is on the LAN so this should be quick.
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
if err != nil {
writeError(w, fmt.Errorf("build PUT: %w", err), nil)
return
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(user, pass)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, errorBody{
Error: "mxdrw unreachable",
Details: err.Error(),
})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
writeJSON(w, http.StatusBadGateway, errorBody{
Error: fmt.Sprintf("mxdrw rejected upload (%d)", resp.StatusCode),
Details: map[string]any{
"status": resp.StatusCode,
"body": string(body),
"url": url,
},
})
return
}
// Best-effort parse — mxdrw returns whatever it returns; we surface
// the public viewer URL no matter what.
var serverEcho any
_ = json.Unmarshal(body, &serverEcho)
viewerURL := strings.TrimSuffix(base, "/") + "/draw/" + strings.TrimSuffix(drawingName, ".excalidraw") + ".excalidraw"
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"drawing_name": drawingName,
"url": viewerURL,
"element_count": len(scene.Elements),
"mxdrw_response": serverEcho,
})
}
// noLeak prevents unused-import errors if errors-pkg ever becomes unused
// after a refactor — keeps the import light.
var _ = errors.New

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

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

View File

@@ -51,6 +51,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
// Ports — slice 7 lets m add/edit/remove instance ports on a device.
mux.HandleFunc("GET /api/projects/{pid}/devices/{id}/ports", h.listPortsForDevice)
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports", h.createPort)
mux.HandleFunc("PATCH /api/projects/{pid}/ports/{id}", h.patchPort)
mux.HandleFunc("DELETE /api/projects/{pid}/ports/{id}", h.deletePort)
// Device-type catalog. Built-ins are read-only; project-custom rows
// support full CRUD scoped to the project.
mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes)
@@ -65,6 +71,28 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/projects/{pid}/connection-requirements/{id}", h.patchConnectionRequirement)
mux.HandleFunc("DELETE /api/projects/{pid}/connection-requirements/{id}", h.deleteConnectionRequirement)
// Cables — slice 6: solver writes here with auto=1; slice 7 lets m
// hand-draw with auto=0. PATCH supports `promote: true` to flip auto→0.
mux.HandleFunc("GET /api/projects/{pid}/cables", h.listCables)
mux.HandleFunc("POST /api/projects/{pid}/cables", h.createCable)
mux.HandleFunc("PATCH /api/projects/{pid}/cables/{id}", h.patchCable)
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{id}", h.deleteCable)
// Bundles — manual + auto.
mux.HandleFunc("GET /api/projects/{pid}/bundles", h.listBundles)
mux.HandleFunc("POST /api/projects/{pid}/bundles", h.createBundle)
mux.HandleFunc("PATCH /api/projects/{pid}/bundles/{id}", h.patchBundle)
mux.HandleFunc("DELETE /api/projects/{pid}/bundles/{id}", h.deleteBundle)
// Solver + quick-fix combo + setup templates.
mux.HandleFunc("POST /api/projects/{pid}/solve", h.solve)
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports-and-resolve", h.portsAndResolve)
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate)
// Slice 8 — export to mxdrw.msbls.de
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
// 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

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

@@ -0,0 +1,149 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
preview := r.URL.Query().Get("preview") == "1"
res, err := h.store.Solve(pid, preview)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, res)
}
// ports-and-resolve combo: POST a new port to a device + re-run solve in
// the same request. Used by the inspector quick-fix.
type portsAndResolveBody struct {
TypeID int64 `json:"type_id"`
Label string `json:"label,omitempty"`
XOffset float64 `json:"x_offset,omitempty"`
YOffset float64 `json:"y_offset,omitempty"`
}
func (h *handlers) portsAndResolve(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body portsAndResolveBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
res, err := h.store.PortsAndResolve(pid, id, body.TypeID, body.Label, body.XOffset, body.YOffset)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, res)
}
// -------------------------------------------------------- setup templates
func (h *handlers) listSetupTemplates(w http.ResponseWriter, _ *http.Request) {
ts, err := h.store.ListSetupTemplates()
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ts)
}
type applyTemplateBody struct {
TemplateID int64 `json:"template_id"`
NameOverrides map[string]string `json:"name_overrides,omitempty"`
SkipDevices []int64 `json:"skip_devices,omitempty"`
OriginX float64 `json:"origin_x,omitempty"`
OriginY float64 `json:"origin_y,omitempty"`
}
func (h *handlers) applyTemplate(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body applyTemplateBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
opts := db.ApplyTemplateOptions{
NameOverrides: map[int64]string{},
SkipDevices: map[int64]bool{},
OriginX: body.OriginX,
OriginY: body.OriginY,
}
// JSON keys are strings; parse to int64.
for k, v := range body.NameOverrides {
var tid int64
_, _ = fmtSscan(k, &tid)
if tid > 0 {
opts.NameOverrides[tid] = v
}
}
for _, tid := range body.SkipDevices {
opts.SkipDevices[tid] = true
}
res, err := h.store.ApplyTemplate(pid, body.TemplateID, opts)
if err != nil {
writeError(w, err, nil)
return
}
// Auto-solve by default. ?solve=0 opts out for power users who want
// to inspect the seeded devices/requirements before the solver runs.
// This is THE fix for the v6 UX hole: m hit Apply, saw an empty
// canvas because nothing reloaded *and* nothing solved. With the
// frontend re-snapshotting after the POST returns and the response
// already carrying solver output, m sees the wired diagram in one click.
skipSolve := r.URL.Query().Get("solve") == "0"
combined := map[string]any{"template_apply": res}
if !skipSolve {
solveRes, err := h.store.Solve(pid, false)
if err != nil {
// Apply succeeded but Solve failed — don't 500 the whole
// call. Return template_apply with the solve error inline so
// the UI can recover (devices are there; m can re-solve).
combined["solve_error"] = err.Error()
} else {
combined["solve"] = solveRes
}
}
writeJSON(w, http.StatusOK, combined)
}
// fmtSscan parses a base-10 int from a string, returning (n, nil) on success.
// Inline so handlers don't pull in strconv just for one call site.
func fmtSscan(s string, out *int64) (int, error) {
var v int64
read := 0
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
v = v*10 + int64(c-'0')
read++
}
*out = v
return read, nil
}

View File

@@ -20,9 +20,10 @@
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</button>
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<span id="toast" class="toast" hidden></span>
</header>
<main class="layout">
@@ -44,7 +45,7 @@
<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>
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
</ul>
</section>
</aside>
@@ -175,6 +176,35 @@
</form>
</dialog>
<!-- Solve preview-diff (slice 6) -->
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
<div style="padding: 16px;">
<h2 id="sv-title">Solve preview</h2>
<div id="sv-body" class="sv-body"></div>
<div class="actions" style="margin-top: 12px;">
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</div>
</dialog>
<!-- Apply template (slice 6) -->
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
<form method="dialog" id="form-template">
<h2 id="tp-title">Apply setup template</h2>
<label class="field">
<span>Template</span>
<select id="tp-select" required></select>
</label>
<div id="tp-preview" class="tp-preview"></div>
<p class="form-error" id="tp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">

File diff suppressed because it is too large Load Diff

View File

@@ -183,9 +183,10 @@ body {
pointer-events: none;
}
/* Stroke + fill come from the device's user-set colour, written as
inline style in renderCanvas — leaving them out of .device-rect so
the author CSS doesn't override the inline style. */
.device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5;
}
.device-rect.selected { stroke-width: 3; }
@@ -213,7 +214,46 @@ body {
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-port #canvas,
.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
.btn-link {
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
font: inherit;
padding: 0 4px;
line-height: 1;
}
.btn-link:hover { color: var(--danger); }
/* Highlight a port that's been picked as the cable-draw source. */
.port-circle.cable-from {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
/* Header toast — slice 8 export feedback */
.toast {
display: inline-block;
margin-left: 12px;
font-size: 13px;
padding: 4px 10px;
border-radius: var(--radius);
background: var(--surface-2);
color: var(--text);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast.ok { background: #e8f5e9; color: #1b5e20; }
.toast.error { background: #fdecea; color: #911313; }
.toast a { color: inherit; text-decoration: underline; }
/* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the
@@ -238,15 +278,16 @@ body {
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). */
/* Ports — small circles laid out along the device edge. Both fill and
stroke come from the cable_type the port carries (set inline in JS)
so the port reads clearly as a coloured anchor on the device. */
.port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
pointer-events: none; /* slice 4 — selection happens at device-level */
cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.port-row {
@@ -257,11 +298,15 @@ body {
font-size: 12px;
padding: 2px 0;
}
.port-row .swatch {
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }
@@ -316,6 +361,76 @@ body {
pointer-events: none;
}
/* Cables on the canvas. Stroke colour comes from the cable_type;
solver-owned cables (auto=1) render with a slightly dashed pattern
so m can tell at a glance which the solver placed. */
.cable-line {
fill: none;
stroke-width: 2;
cursor: pointer;
}
.cable-line.auto { stroke-dasharray: 8 3; }
.cable-line:hover { stroke-width: 4; }
.cable-line.selected { stroke-width: 4; }
/* Solve preview-diff modal */
.modal-wide { width: 560px; }
.sv-body { font-size: 13px; }
.sv-body h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 12px 0 4px;
}
.sv-body ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sv-body li {
padding: 4px 8px;
border-radius: var(--radius);
background: var(--surface-2);
}
.sv-body li.added { border-left: 3px solid #2f9e44; }
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
.sv-body li.unmet { border-left: 3px solid #f59f00; }
.sv-body li.unmet .quickfix {
display: inline-block;
margin-left: 8px;
font-size: 11px;
padding: 1px 6px;
background: var(--accent);
color: #fff;
border-radius: 10px;
cursor: pointer;
}
.tp-preview {
font-size: 13px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
margin: 8px 0;
}
.tp-preview h4 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 6px 0 4px;
}
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
.tp-preview li { padding: 2px 0; }
.tp-preview .skip {
margin-right: 6px;
font-size: 11px;
}
.rubber-band {
fill: rgba(25, 113, 194, 0.08);
stroke: var(--accent);