Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
78b37f442d fix(docker): pre-create /app/data 1000:1000 so the binary can mkdir at boot
The first redeploy after slice 2 turned up a latent bug: without a bind
mount at /app/data, the container as UID 1000 can't os.MkdirAll
/app/data because /app (created by WORKDIR) is owned by root with 0755.

mDock works because the bind mount overlays /app/data with the host dir
(owned by m:m), so MkdirAll on the pre-existing dir is a no-op. But any
ephemeral run (smoke tests, CI, anyone running the image without -v)
crashes with 'mkdir /app/data: permission denied'.

Fix: build the empty data dir in the builder stage with `chown -R
1000:1000`, then COPY --from=build --chown=1000:1000 it into the
distroless final image. The bind mount continues to overlay it on
mDock without needing changes.

Verified: docker run -d (no -v) → /api/healthz returns {"status":"ok"}
and the data dir is writable.
2026-05-15 18:27:40 +02:00
41 changed files with 192 additions and 9988 deletions

View File

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

View File

@@ -20,17 +20,25 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-o /out/mcables \
./cmd/mcables
# Pre-create the runtime data dir with the right owner in the builder
# stage, then COPY it into the distroless final image. Distroless has
# no shell + no mkdir, so this is the canonical pattern for "writable
# subdir under a non-root user".
RUN mkdir -p /out/data && chown -R 1000:1000 /out/data
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
COPY --from=build --chown=1000:1000 /out/data /app/data
ENV MCABLES_ADDR=0.0.0.0:7777 \
MCABLES_DB=/app/data/mcables.db
EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /home/m/stacks/mcables/data is owned by m:m, so the container can write
# to it without chowning the host dir. distroless/static-debian12 accepts
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
# /home/m/stacks/mcables/data is owned by m:m, so the container can
# write to it without chowning the host dir. distroless/static-debian12
# accepts arbitrary numeric UIDs; the Go binary doesn't need a
# /etc/passwd entry.
USER 1000:1000
ENTRYPOINT ["/app/mcables"]

View File

@@ -43,9 +43,8 @@ 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` | `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. |
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
### Tests

View File

@@ -14,7 +14,7 @@ services:
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
env_file:
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
- /home/m/secrets/mcables/.env
volumes:
- /home/m/stacks/mcables/data:/app/data

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,363 +0,0 @@
package db
import (
"errors"
"testing"
)
// -------------------------------------------------------- catalog (seeded)
func TestSeed_BuiltInDeviceTypes(t *testing.T) {
s := newTestStore(t)
got, err := s.ListBuiltInDeviceTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
want := []string{
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
"Screen", "Keyboard", "Mouse",
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
}
if len(got) != len(want) {
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].Name != w {
t.Errorf("[%d] = %q, want %q", i, got[i].Name, w)
}
if !got[i].BuiltIn {
t.Errorf("[%d] %q should be built_in", i, got[i].Name)
}
if got[i].ProjectID != nil {
t.Errorf("[%d] %q should have project_id=nil", i, got[i].Name)
}
}
}
func TestSeed_PortProfiles(t *testing.T) {
s := newTestStore(t)
all, _ := s.ListBuiltInDeviceTypes()
byName := map[string]DeviceType{}
for _, d := range all {
byName[d.Name] = d
}
cases := map[string]struct {
totalPorts int // sum of count across profile rows
}{
"NAS": {2}, // Power 1 + RJ45 1
"PC": {5}, // Power 1 + RJ45 1 + HDMI 1 + USB 2
"Mac": {4}, // Power 1 + HDMI 1 + USB 2
"Notebook": {3}, // Power 1 + USB 2
"TV": {3}, // Power 1 + HDMI 2
"Soundbar": {2}, // Power 1 + HDMI 1
"Switch": {6}, // Power 1 + RJ45 5
"fritz": {5}, // Power 1 + RJ45 4
"ChromeCast": {2}, // Power 1 + HDMI 1
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
"IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
"IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
"IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
"Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1
"Multi-plug 3": {4}, // Power In 1 + Power Out 3 (after v6)
"Multi-plug 4": {5}, // Power In 1 + Power Out 4 (after v6)
"Multi-plug 5": {6}, // Power In 1 + Power Out 5 (after v6)
"Multi-plug 6": {7}, // Power In 1 + Power Out 6 (after v6)
"Wifi-plug": {2}, // Power In 1 + Power Out 1 (after v6)
}
for name, want := range cases {
dt, ok := byName[name]
if !ok {
t.Errorf("missing built-in %q", name)
continue
}
total := 0
for _, p := range dt.Ports {
total += p.Count
}
if total != want.totalPorts {
t.Errorf("%s: total ports = %d, want %d", name, total, want.totalPorts)
}
}
}
// TestSeed_PowerHubs locks down the post-migration-006 port profile for
// every power-distribution device type: IOx-3/6/8, Multi-plug 3/4/5/6,
// and Wifi-plug. Each carries exactly two profile rows — a single
// "Power In" port on the top (back) edge and N "Power Out" ports on the
// bottom (front) edge, where N is the device-specific output count.
//
// This test covers the v5 catalog identity (kind, icon, built-in) for
// the 5 power-distribution types and the v6 port-profile fix for all
// 8 hubs in one table.
func TestSeed_PowerHubs(t *testing.T) {
s := newTestStore(t)
all, err := s.ListBuiltInDeviceTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 21 {
t.Errorf("built-in count = %d, want 21 (16 from v4 + 5 from v5)", len(all))
}
byName := map[string]DeviceType{}
for _, d := range all {
byName[d.Name] = d
}
cases := []struct {
name string
// kind/icon are only set for the 5 v5-power types; empty means
// "don't check" (the IOx-* keep their v4-seeded kind=hub icon=nil).
kind string
icon string
outCount int // N — number of "Power Out" outlets on the bottom edge
}{
// v5 catalog (kind+icon checked)
{name: "Multi-plug 3", kind: "hub", icon: "🔌", outCount: 3},
{name: "Multi-plug 4", kind: "hub", icon: "🔌", outCount: 4},
{name: "Multi-plug 5", kind: "hub", icon: "🔌", outCount: 5},
{name: "Multi-plug 6", kind: "hub", icon: "🔌", outCount: 6},
{name: "Wifi-plug", kind: "accessory", icon: "📶", outCount: 1},
// v4 hubs re-shaped by v6 (kind/icon left blank → not checked)
{name: "IOx-3", outCount: 3},
{name: "IOx-6", outCount: 6},
{name: "IOx-8", outCount: 8},
}
for _, c := range cases {
dt, ok := byName[c.name]
if !ok {
t.Errorf("missing %q", c.name)
continue
}
if !dt.BuiltIn {
t.Errorf("%s: built_in should be true", c.name)
}
if dt.ProjectID != nil {
t.Errorf("%s: project_id should be nil", c.name)
}
if c.kind != "" && dt.Kind != c.kind {
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
}
if c.icon != "" && (dt.Icon == nil || *dt.Icon != c.icon) {
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
}
if len(dt.Ports) != 2 {
t.Errorf("%s: expected 2 port-profile rows, got %d", c.name, len(dt.Ports))
continue
}
in := dt.Ports[0]
out := dt.Ports[1]
if in.CableTypeID != 1 || in.Count != 1 || in.Edge != "top" || in.LabelPrefix != "Power In" {
t.Errorf("%s: Power In row mismatch: %+v", c.name, in)
}
if out.CableTypeID != 1 || out.Count != c.outCount || out.Edge != "bottom" || out.LabelPrefix != "Power Out" {
t.Errorf("%s: Power Out row mismatch: %+v (want count=%d)", c.name, out, c.outCount)
}
}
}
// -------------------------------------------------------- CRUD (custom rows)
func TestCreateDeviceType_CustomBasic(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
dt, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{
Name: "DigitalCam", Kind: "accessory",
Description: "A camera with HDMI out",
Ports: []DeviceTypePortCreate{
{CableTypeID: 1, LabelPrefix: "Power", Count: 1},
{CableTypeID: 3, LabelPrefix: "HDMI", Count: 1, SortOrder: 1},
},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if dt.BuiltIn {
t.Errorf("built_in should be false")
}
if dt.ProjectID == nil || *dt.ProjectID != p.ID {
t.Errorf("project_id mismatch: %+v", dt.ProjectID)
}
if len(dt.Ports) != 2 {
t.Errorf("port profile rows = %d, want 2", len(dt.Ports))
}
}
func TestCreateDeviceType_NameClashWithBuiltIn(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
_, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "NAS"})
if !errors.Is(err, ErrConflict) {
t.Errorf("err = %v, want ErrConflict (NAS is built-in)", err)
}
}
func TestCreateDeviceType_PerProjectUnique(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); err != nil {
t.Fatalf("first: %v", err)
}
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); !errors.Is(err, ErrConflict) {
t.Errorf("dup err = %v, want ErrConflict", err)
}
}
func TestUpdateDeviceType_BuiltInForbidden(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
nas := all[0]
newName := "renamed"
_, err := s.UpdateDeviceType(p.ID, nas.ID, DeviceTypeUpdate{Name: &newName})
if !errors.Is(err, ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
}
func TestDeleteDeviceType_BuiltInForbidden(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
if err := s.DeleteDeviceType(p.ID, all[0].ID); !errors.Is(err, ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
}
func TestUpdateDeviceType_CrossProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
dt, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Foo"})
newName := "bar"
if _, err := s.UpdateDeviceType(p2.ID, dt.ID, DeviceTypeUpdate{Name: &newName}); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
// -------------------------------------------------------- device + ports seed
func TestCreateDevice_SeedsPortsFromBuiltInType(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
var nasID int64
for _, dt := range all {
if dt.Name == "NAS" {
nasID = dt.ID
break
}
}
if nasID == 0 {
t.Fatal("NAS not in catalog")
}
d, err := s.CreateDevice(p.ID, DeviceCreate{
Name: "NAS-Loft", TypeID: &nasID,
X: 100, Y: 100, Width: 100, Height: 35,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if d.TypeID == nil || *d.TypeID != nasID {
t.Errorf("type_id wrong: %+v", d.TypeID)
}
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) != 2 {
t.Fatalf("port count = %d, want 2 (Power + RJ45)", len(ports))
}
for _, prt := range ports {
if prt.YOffset != 35 {
t.Errorf("port y_offset = %v, want 35 (bottom edge)", prt.YOffset)
}
if prt.XOffset <= 0 || prt.XOffset >= 100 {
t.Errorf("port x_offset = %v, want between 0 and 100", prt.XOffset)
}
if prt.Label == nil {
t.Errorf("port label = nil, want non-nil (label_prefix is set)")
}
}
}
func TestCreateDevice_SeedsPortsForPC_FourGroupsFiveTotal(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
var pcID int64
for _, dt := range all {
if dt.Name == "PC" {
pcID = dt.ID
break
}
}
if pcID == 0 {
t.Fatal("PC not in catalog")
}
if _, err := s.CreateDevice(p.ID, DeviceCreate{
Name: "Workstation", TypeID: &pcID,
X: 0, Y: 0, Width: 100, Height: 35,
}); err != nil {
t.Fatalf("create: %v", err)
}
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) != 5 {
t.Errorf("port count = %d, want 5 (Power+RJ45+HDMI+USB×2)", len(ports))
}
// USB×2 must produce two labels "USB 1" and "USB 2".
usbLabels := map[string]bool{}
for _, prt := range ports {
if prt.Label != nil && (*prt.Label == "USB 1" || *prt.Label == "USB 2") {
usbLabels[*prt.Label] = true
}
}
if !usbLabels["USB 1"] || !usbLabels["USB 2"] {
t.Errorf("USB labels missing: got %v", usbLabels)
}
}
func TestCreateDevice_NoTypeID_NoPorts(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if _, err := s.CreateDevice(p.ID, DeviceCreate{
Name: "Freeform", X: 0, Y: 0, Width: 100, Height: 35,
}); err != nil {
t.Fatalf("create: %v", err)
}
ports, _ := s.ListPortsForProject(p.ID)
if len(ports) != 0 {
t.Errorf("freeform device should have 0 ports, got %d", len(ports))
}
}
func TestCreateDevice_CrossProjectCustomTypeRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
custom, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Exotic"})
_, err := s.CreateDevice(p2.ID, DeviceCreate{
Name: "Wrong", TypeID: &custom.ID,
X: 0, Y: 0, Width: 100, Height: 35,
})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput (cross-project custom type)", err)
}
}
func TestSnapshot_IncludesPorts(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
all, _ := s.ListBuiltInDeviceTypes()
for _, dt := range all {
if dt.Name == "Mac" {
_, _ = s.CreateDevice(p.ID, DeviceCreate{
Name: "M1", TypeID: &dt.ID,
X: 0, Y: 0, Width: 100, Height: 35,
})
break
}
}
snap, _ := s.Snapshot(p.ID)
if len(snap.Ports) != 4 {
t.Errorf("snapshot.Ports = %d, want 4 (Mac: Power+HDMI+USB×2)", len(snap.Ports))
}
}

View File

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

@@ -166,14 +166,9 @@ func (s *Store) DeleteFrame(projectID, id int64) error {
// -----------------------------------------------------------------------------
// DeviceCreate is the create-shape. FrameID may be nil ("outside any frame").
// TypeID may be nil for a freeform device (no auto-seeded ports). If set,
// the type must be either built-in or a project-custom type belonging to
// the same project — and CreateDevice seeds the device's ports from the
// type's port profile in the same transaction.
type DeviceCreate struct {
Name string
FrameID *int64
TypeID *int64
Color string
X float64
Y float64
@@ -184,11 +179,10 @@ type DeviceCreate struct {
// DeviceUpdate is the partial-update shape. project_id deliberately not
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
// inner pointer is nil to clear. TypeID uses the same FrameRef tri-state.
// inner pointer is nil to clear.
type DeviceUpdate struct {
Name *string
FrameID FrameRef // see FrameRef below
TypeID FrameRef // tri-state for type_id: same shape as FrameRef
Color *string
X *float64
Y *float64
@@ -207,11 +201,7 @@ type FrameRef struct {
}
// CreateDevice inserts a new device. FrameID, if provided, must reference
// a frame in the same project. TypeID, if provided, must reference a
// built-in or a project-custom device_type in the same project — the
// store seeds the device's ports from that type's profile in the same
// transaction so a half-created device (row inserted, ports missing)
// can never exist.
// a frame in the same project.
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
name := strings.TrimSpace(d.Name)
if name == "" {
@@ -231,62 +221,32 @@ func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
return nil, err
}
}
if d.TypeID != nil {
dt, err := s.GetDeviceType(*d.TypeID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *d.TypeID)
}
return nil, err
}
// Project-custom types must match the device's project. Built-ins
// (project_id NULL) are available to every project.
if dt.ProjectID != nil && *dt.ProjectID != projectID {
return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *d.TypeID)
}
}
color := strings.TrimSpace(d.Color)
if color == "" {
color = "#1e1e1e"
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.Exec(
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
name, color, d.X, d.Y, d.Width, d.Height,
res, err := s.db.Exec(
`INSERT INTO devices (project_id, frame_id, name, color, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
projectID, nullableInt64(d.FrameID), name, color, d.X, d.Y, d.Width, d.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
deviceID, _ := res.LastInsertId()
if d.TypeID != nil {
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.GetDevice(projectID, deviceID)
id, _ := res.LastInsertId()
return s.GetDevice(projectID, id)
}
// GetDevice loads a device, project-scoped.
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
var d Device
var frame, typeID sql.NullInt64
var frame sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
`SELECT id, project_id, frame_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,
).Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex, &d.CreatedAt, &d.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -298,10 +258,6 @@ func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
v := frame.Int64
d.FrameID = &v
}
if typeID.Valid {
v := typeID.Int64
d.TypeID = &v
}
if ex.Valid {
d.ExcalidrawID = &ex.String
}
@@ -321,13 +277,13 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
)
if frameID != nil {
rows, err = s.db.Query(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
projectID, *frameID,
)
} else {
rows, err = s.db.Query(
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
projectID,
)
@@ -339,9 +295,9 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
out := []Device{}
for rows.Next() {
var d Device
var frame, typeID sql.NullInt64
var frame sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, err
}
@@ -349,10 +305,6 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
v := frame.Int64
d.FrameID = &v
}
if typeID.Valid {
v := typeID.Int64
d.TypeID = &v
}
if ex.Valid {
d.ExcalidrawID = &ex.String
}
@@ -411,27 +363,11 @@ func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, erro
}
cur.FrameID = u.FrameID.ID
}
if u.TypeID.Set {
if u.TypeID.ID != nil {
dt, err := s.GetDeviceType(*u.TypeID.ID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *u.TypeID.ID)
}
return nil, err
}
if dt.ProjectID != nil && *dt.ProjectID != projectID {
return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *u.TypeID.ID)
}
}
cur.TypeID = u.TypeID.ID
}
if _, err := s.db.Exec(
`UPDATE devices
SET frame_id = ?, type_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
SET frame_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.FrameID), nullableInt64(cur.TypeID),
cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
nullableInt64(cur.FrameID), cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -1,359 +0,0 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports.
type PortCreate struct {
TypeID int64
Label string
XOffset float64
YOffset float64
}
// PortUpdate is the partial-update shape.
type PortUpdate struct {
TypeID *int64
Label *string
XOffset *float64
YOffset *float64
}
// CreatePort inserts a port on a device. The device must exist in the
// project; the cable type must exist globally.
func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) {
if _, err := s.GetDevice(projectID, deviceID); err != nil {
return nil, err
}
if _, err := s.GetCableType(p.TypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID)
}
return nil, err
}
label := strings.TrimSpace(p.Label)
var labelArg any
if label != "" {
labelArg = label
}
res, err := s.db.Exec(
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetPort(projectID, id)
}
// GetPort loads a port by id, project-scoped.
func (s *Store) GetPort(projectID, id int64) (*Port, error) {
var p Port
var label, ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
return &p, nil
}
// UpdatePort applies a partial update.
func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) {
cur, err := s.GetPort(projectID, id)
if err != nil {
return nil, err
}
if u.TypeID != nil {
if _, err := s.GetCableType(*u.TypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
}
return nil, err
}
cur.TypeID = *u.TypeID
}
if u.Label != nil {
v := strings.TrimSpace(*u.Label)
if v == "" {
cur.Label = nil
} else {
cur.Label = &v
}
}
if u.XOffset != nil {
cur.XOffset = *u.XOffset
}
if u.YOffset != nil {
cur.YOffset = *u.YOffset
}
var labelArg any
if cur.Label != nil {
labelArg = *cur.Label
}
if _, err := s.db.Exec(
`UPDATE ports
SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetPort(projectID, id)
}
// DeletePort removes a port from a device. The schema's
// ON DELETE SET NULL on cables.from_port_id / to_port_id collides with
// the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so
// we instead cascade-delete any cables that referenced the port on
// either side — same effect from m's POV: the cable is gone, m can
// re-draw if he still wants it.
func (s *Store) DeletePort(projectID, id int64) error {
if _, err := s.GetPort(projectID, id); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`,
projectID, id, id,
); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return tx.Commit()
}
// ListPortsForDevice returns every port on one device, project-scoped.
func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) {
rows, err := s.db.Query(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`,
projectID, deviceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Port{}
for rows.Next() {
var p Port
var label, ex sql.NullString
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
out = append(out, p)
}
return out, rows.Err()
}
// ListPortsForProject returns every port in a project, ordered by
// device_id + id so callers can group cheaply.
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {
rows, err := s.db.Query(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE project_id = ? ORDER BY device_id, id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Port{}
for rows.Next() {
var p Port
var label, ex sql.NullString
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID,
&label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
out = append(out, p)
}
return out, rows.Err()
}
// seedPortsFromType inserts the ports for a freshly-created device using
// the type's `device_type_ports` profile. Port positions are computed by
// laying out cable-type groups evenly along the configured edge of the
// device, ordered by sort_order. Within a multi-count group the per-port
// spacing is also even. Runs inside the same transaction as the device
// insert so a failure rolls everything back.
//
// Layout strategy (v0):
// - All ports for a given type sit on the type's configured edge.
// - For each edge, compute total port count N (sum of count across
// entries on that edge) and distribute as t_i = (i + 1)/(N+1) along
// the edge length, so ports don't touch the corners.
// - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom).
// - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t.
// - Labels: '<prefix>' if count==1, '<prefix> N' (1-indexed) if count>1.
// Empty prefix → NULL label.
func (s *Store) seedPortsFromType(tx *sql.Tx, projectID, deviceID, typeID int64, width, height float64) error {
rows, err := tx.Query(
`SELECT cable_type_id, label_prefix, count, edge, sort_order
FROM device_type_ports
WHERE device_type_id = ?
ORDER BY edge, sort_order, id`, typeID,
)
if err != nil {
return err
}
type pendingPort struct {
cableTypeID int64
label *string
xOff float64
yOff float64
}
// Group rows by edge first; emit per-port y-or-x slots inside each edge.
type groupRow struct {
cableTypeID int64
labelPrefix string
count int
}
byEdge := map[string][]groupRow{}
for rows.Next() {
var g groupRow
var edge string
var sortOrder int
if err := rows.Scan(&g.cableTypeID, &g.labelPrefix, &g.count, &edge, &sortOrder); err != nil {
rows.Close()
return err
}
byEdge[edge] = append(byEdge[edge], g)
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
var pending []pendingPort
for _, edge := range []string{"top", "bottom", "left", "right"} {
groups := byEdge[edge]
if len(groups) == 0 {
continue
}
total := 0
for _, g := range groups {
total += g.count
}
if total == 0 {
continue
}
// Emit ports in group + within-group order.
idx := 0
for _, g := range groups {
for k := 0; k < g.count; k++ {
t := float64(idx+1) / float64(total+1)
var xOff, yOff float64
switch edge {
case "top":
xOff, yOff = width*t, 0
case "bottom":
xOff, yOff = width*t, height
case "left":
xOff, yOff = 0, height*t
case "right":
xOff, yOff = width, height*t
}
var labelPtr *string
if g.labelPrefix != "" {
var lbl string
if g.count == 1 {
lbl = g.labelPrefix
} else {
lbl = g.labelPrefix + " " + itoa(k+1)
}
labelPtr = &lbl
}
pending = append(pending, pendingPort{
cableTypeID: g.cableTypeID, label: labelPtr,
xOff: xOff, yOff: yOff,
})
idx++
}
}
}
for _, p := range pending {
var labelArg any
if p.label != nil {
labelArg = *p.label
}
if _, err := tx.Exec(
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, deviceID, p.cableTypeID, labelArg, p.xOff, p.yOff,
); err != nil {
return mapWriteErr(err)
}
}
return nil
}
// itoa is a tiny non-allocating int-to-string for port labels.
func itoa(i int) string {
if i == 0 {
return "0"
}
buf := [20]byte{}
pos := len(buf)
neg := i < 0
if neg {
i = -i
}
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}

View File

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

View File

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

View File

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

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

View File

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

@@ -1,165 +0,0 @@
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)
}
}

View File

@@ -1,225 +0,0 @@
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)
}

View File

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

View File

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

View File

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

View File

@@ -110,7 +110,6 @@ func (h *handlers) deleteFrame(w http.ResponseWriter, r *http.Request) {
type deviceCreate struct {
Name string `json:"name"`
FrameID *int64 `json:"frame_id,omitempty"`
TypeID *int64 `json:"type_id,omitempty"`
Color string `json:"color,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
@@ -118,14 +117,13 @@ type deviceCreate struct {
Height float64 `json:"height"`
}
// devicePatch uses a raw `json.RawMessage` for frame_id + type_id so we
// can tell "key absent" (leave alone) from "key present and null"
// (set to NULL) from "key present with an int" (move to that target).
// Standard encoding of nullable fields in JSON PATCH.
// devicePatch uses a raw `json.RawMessage` for frame_id so we can tell
// "key absent" (leave alone) from "key present and null" (set to NULL)
// from "key present with an int" (move to that frame). Standard encoding
// of nullable fields in JSON PATCH.
type devicePatch struct {
Name *string `json:"name,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
TypeID json.RawMessage `json:"type_id,omitempty"`
Color *string `json:"color,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
@@ -175,8 +173,7 @@ func (h *handlers) createDevice(w http.ResponseWriter, r *http.Request) {
return
}
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
Name: body.Name, FrameID: body.FrameID, TypeID: body.TypeID,
Color: body.Color,
Name: body.Name, FrameID: body.FrameID, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
@@ -207,13 +204,8 @@ func (h *handlers) patchDevice(w http.ResponseWriter, r *http.Request) {
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
return
}
typeRef, err := parseFrameRef(body.TypeID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "type_id must be an integer or null")
return
}
d, err := h.store.UpdateDevice(pid, id, db.DeviceUpdate{
Name: body.Name, FrameID: ref, TypeID: typeRef, Color: body.Color,
Name: body.Name, FrameID: ref, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {

View File

@@ -42,8 +42,6 @@ func writeError(w http.ResponseWriter, err error, details any) {
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrForbidden):
writeJSON(w, http.StatusForbidden, errorBody{Error: err.Error(), Details: details})
default:
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
}

View File

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

View File

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

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

View File

@@ -1,149 +0,0 @@
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,15 +20,9 @@
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
<span class="zoom-cluster">
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
</span>
<span id="toast" class="toast" hidden></span>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</button>
</header>
<main class="layout">
@@ -43,9 +37,8 @@
<ul class="tool-list">
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" 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>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
</ul>
</section>
</aside>
@@ -120,91 +113,6 @@
</form>
</dialog>
<!-- New device (slice 4: type-aware) -->
<dialog id="modal-new-device" class="modal" aria-labelledby="nd-title">
<form method="dialog" id="form-new-device">
<h2 id="nd-title">New device</h2>
<label class="field">
<span>Type</span>
<select id="nd-type" name="type_id" required>
<option value="">Loading…</option>
</select>
</label>
<label class="field">
<span>Name</span>
<input type="text" name="name" id="nd-name" required autocomplete="off" />
</label>
<p class="form-error" id="nd-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- New / Edit connection requirement (slice 5) -->
<dialog id="modal-requirement" class="modal" aria-labelledby="rq-title">
<form method="dialog" id="form-requirement">
<h2 id="rq-title">New requirement</h2>
<label class="field">
<span>From device</span>
<select id="rq-from" name="from_device_id" required></select>
</label>
<label class="field">
<span>To device</span>
<select id="rq-to" name="to_device_id" required></select>
</label>
<label class="field">
<span>Cable type</span>
<select id="rq-cable" name="preferred_cable_type_id">
<option value="">— solver picks —</option>
</select>
</label>
<label class="field" style="flex-direction: row; align-items: center; gap: 8px;">
<input type="checkbox" id="rq-must" name="must_connect" checked />
<span style="font-size: 13px; color: var(--text);">Must connect (solver hard-requires this link)</span>
</label>
<label class="field">
<span>Notes</span>
<textarea name="notes" rows="2"></textarea>
</label>
<p class="form-error" id="rq-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Solve preview-diff (slice 6) -->
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
<div style="padding: 16px;">
<h2 id="sv-title">Solve preview</h2>
<div id="sv-body" class="sv-body"></div>
<div class="actions" style="margin-top: 12px;">
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</div>
</dialog>
<!-- Apply template (slice 6) -->
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
<form method="dialog" id="form-template">
<h2 id="tp-title">Apply setup template</h2>
<label class="field">
<span>Template</span>
<select id="tp-select" required></select>
</label>
<div id="tp-preview" class="tp-preview"></div>
<p class="form-error" id="tp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Apply</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">
@@ -224,24 +132,6 @@
</form>
</dialog>
<!-- Admin: projects + cable types + device types + setup templates -->
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
<div class="admin-shell">
<header class="admin-header">
<h2 id="adm-title">Admin</h2>
<button type="button" class="btn btn-link admin-close" data-close></button>
</header>
<nav class="admin-tabs" role="tablist">
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
<button type="button" class="admin-tab" data-admin-tab="requirements" role="tab">Requirements</button>
</nav>
<section class="admin-body" id="admin-body" role="tabpanel"></section>
</div>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -183,27 +183,14 @@ 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; }
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
/* Bottom-right resize affordance per device. Subtle grey by default,
stronger on hover so m can find it without it dominating the rect. */
.device-resize-handle {
fill: rgba(120, 120, 120, 0.35);
stroke: rgba(60, 60, 60, 0.45);
stroke-width: 1;
cursor: nwse-resize;
}
.device-resize-handle:hover {
fill: rgba(60, 60, 60, 0.65);
}
.device-label {
fill: var(--text);
font-size: 12px;
@@ -216,367 +203,9 @@ body {
.svg-draggable { cursor: grab; }
.svg-draggable.dragging { cursor: grabbing; }
/* Tool cursor while a tool is armed. The `* { ... !important }` descendant
rule is the load-bearing part: without it, the `.svg-draggable` rules
on individual frame/device rects win by element specificity and
override the SVG-root cursor — so hovering a frame with +Dev armed
shows `grab`, which lies about what a click will do. */
/* tool cursor on the empty canvas while a tool is armed */
.canvas-wrap.tool-frame #canvas,
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-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));
}
/* Zoom cluster — % + Fit button next to Admin. */
.zoom-cluster {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
#zoom-pct {
font-size: 12px;
color: var(--text-muted);
min-width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.canvas-wrap.panning #canvas,
.canvas-wrap.panning #canvas * { cursor: grabbing !important; }
.canvas-wrap.space-pan-ready #canvas,
.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; }
/* Header toast — slice 8 export feedback */
.toast {
display: inline-block;
margin-left: 12px;
font-size: 13px;
padding: 4px 10px;
border-radius: var(--radius);
background: var(--surface-2);
color: var(--text);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast.ok { background: #e8f5e9; color: #1b5e20; }
.toast.error { background: #fdecea; color: #911313; }
.toast a { color: inherit; text-decoration: underline; }
/* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the
easiest way to draw a diamond that still hit-tests at the rotated
bounds (a <polygon> would also work; rect-with-rotate keeps the
same DOM shape as device/frame so the drag helpers reuse). */
.io-marker {
fill: var(--danger);
fill-opacity: 0.18;
stroke: var(--danger);
stroke-width: 1.5;
}
.io-marker.selected,
.io-marker:hover { stroke-width: 2.5; }
.io-marker-label {
fill: var(--danger);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
/* Ports — small circles laid out along the device edge. Both fill and
stroke come from the cable_type the port carries (set inline in JS)
so the port reads clearly as a coloured anchor on the device. */
.port-circle {
stroke-width: 2;
cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.port-row {
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }
/* Requirements sidebar list */
.requirement-list {
list-style: none;
padding: 0;
margin: 0 0 8px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.requirement-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 3px 6px;
border-radius: var(--radius);
cursor: pointer;
}
.requirement-row:hover { background: var(--surface-2); }
.requirement-row[aria-current="true"] {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.requirement-row .pair { color: var(--text); }
.requirement-row .pair .type { color: var(--text-muted); font-size: 11px; }
.requirement-row .badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 10px;
color: #fff;
}
.requirement-row .badge.must { background: var(--danger); }
.requirement-row .badge.nice { background: var(--text-muted); }
/* Tool-armed: drag-req tool cursor */
.canvas-wrap.tool-req #canvas,
.canvas-wrap.tool-req #canvas * { cursor: crosshair !important; }
/* Drag-line preview while dragging from device A toward device B. */
.req-drag-line {
stroke: var(--accent);
stroke-width: 2;
stroke-dasharray: 6 4;
fill: none;
pointer-events: none;
}
/* Cables on the canvas. Stroke colour comes from the cable_type;
solver-owned cables (auto=1) render with a slightly dashed pattern
so m can tell at a glance which the solver placed. */
.cable-line {
fill: none;
stroke-width: 2;
cursor: pointer;
}
.cable-line.auto { stroke-dasharray: 8 3; }
.cable-line:hover { stroke-width: 4; }
.cable-line.selected { stroke-width: 4; }
/* Endpoint handles — only rendered for the currently-selected cable.
Grab cursor on idle, grabbing while dragging (.replugging on root). */
.cable-handle {
cursor: grab;
stroke-width: 2;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
}
.cable-handle:hover { stroke-width: 3; }
.canvas-wrap.replugging .cable-handle,
.canvas-wrap.replugging #canvas * { cursor: grabbing !important; }
/* Solve preview-diff modal */
.modal-wide { width: 560px; }
/* Admin modal — wider, tabbed */
.modal-wide.admin-shell-host { width: 760px; }
#modal-admin { width: 760px; max-width: 90vw; }
.admin-shell { padding: 16px; min-height: 460px; }
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.admin-header h2 { margin: 0; }
.admin-close { font-size: 16px; padding: 4px 8px; }
.admin-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.admin-tab {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 8px 12px;
font: inherit;
color: var(--text-muted);
cursor: pointer;
}
.admin-tab:hover { color: var(--text); }
.admin-tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
.admin-body {
font-size: 13px;
max-height: 60vh;
overflow-y: auto;
}
.admin-row {
display: grid;
gap: 6px 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.admin-row:last-child { border-bottom: 0; }
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
.admin-row .field input,
.admin-row .field textarea,
.admin-row .field select {
width: 100%;
font: inherit;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
.admin-row.locked { opacity: 0.85; }
.admin-row .locked-badge {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--surface-2);
color: var(--text-muted);
}
.admin-row-title {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 4px;
}
.admin-row-title .swatch { display: inline-block; }
.admin-empty { color: var(--text-muted); padding: 16px 0; }
.admin-add-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.port-profile-list {
margin: 4px 0 0 0;
padding: 0;
list-style: none;
font-size: 12px;
color: var(--text-muted);
}
.port-profile-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.tmpl-detail {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--text-muted);
}
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
.sv-body { font-size: 13px; }
.sv-body h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 12px 0 4px;
}
.sv-body ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sv-body li {
padding: 4px 8px;
border-radius: var(--radius);
background: var(--surface-2);
}
.sv-body li.added { border-left: 3px solid #2f9e44; }
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
.sv-body li.unmet { border-left: 3px solid #f59f00; }
.sv-body li.unmet .quickfix {
display: inline-block;
margin-left: 8px;
font-size: 11px;
padding: 1px 6px;
background: var(--accent);
color: #fff;
border-radius: 10px;
cursor: pointer;
}
.tp-preview {
font-size: 13px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
margin: 8px 0;
}
.tp-preview h4 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 6px 0 4px;
}
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
.tp-preview li { padding: 2px 0; }
.tp-preview .skip {
margin-right: 6px;
font-size: 11px;
}
.canvas-wrap.tool-device #canvas { cursor: crosshair; }
.rubber-band {
fill: rgba(25, 113, 194, 0.08);