Compare commits
40 Commits
mai/sherlo
...
mai/picass
| Author | SHA1 | Date | |
|---|---|---|---|
| f1af2820e1 | |||
| 3276cfeb17 | |||
| 82cf5a3052 | |||
| 5d055ad521 | |||
| 93b276875e | |||
| 205e9eab26 | |||
| fe6f86593e | |||
| a7835468a1 | |||
| 8a6e8c8406 | |||
| 275cb5a55a | |||
| a81dbe2f8c | |||
| 2cd981d3ae | |||
| 0c7d165ed6 | |||
| 9625d97efc | |||
| f9c245fbcc | |||
| c61bff7cf2 | |||
| 1d226844d1 | |||
| c681b01aff | |||
| c8bda7a222 | |||
| b93c42a6e0 | |||
| 75b826c583 | |||
| 6b830a54b9 | |||
| 9af4b6caa3 | |||
| d8637de4a0 | |||
| 88821c0f21 | |||
| 7f0b6e4fab | |||
| 0a34dce398 | |||
| 8cb237fe8e | |||
| 2b26f63c86 | |||
| 08385b0d9f | |||
| a3f0586296 | |||
| d114bfb547 | |||
| 1ea6082948 | |||
| 376ffd8197 | |||
| e42b351280 | |||
| e862a06e9d | |||
| 4f862e741a | |||
| 29e221e080 | |||
| c7dfbe010c | |||
| 12804619b2 |
89
CLAUDE.md
89
CLAUDE.md
@@ -2,11 +2,21 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Cable-management **framework** for m's setup. Each cable-managed environment
|
Cable-management **framework + solver** for m's setup. m declares his
|
||||||
(LOFT, OFFICE, …) is a separate **mCables project**, and each project is
|
**devices** and the **connection requirements** between them ("NAS must
|
||||||
backed by exactly one Excalidraw drawing. The framework provides a visual
|
connect to Switch via RJ45"). mCables runs a solver that emits the cable
|
||||||
web interface backed by a Go HTTP API and SQLite, plus an export pipeline
|
plan + bundle recommendations. mCables is a **schematic**, not a
|
||||||
that writes `.excalidraw` files via mExDraw.
|
physical-routing tool — cables are straight lines between endpoints; the
|
||||||
|
"maximum bundling" objective is satisfied by the endpoint-pair rule
|
||||||
|
(when two or more cables share the same A↔B endpoint pair, group them
|
||||||
|
into one bundle). The visual editor is still there for tweaking the
|
||||||
|
plan, but the solver is the headline.
|
||||||
|
|
||||||
|
Each cable-managed environment (LOFT, OFFICE, …) is a separate
|
||||||
|
**mCables project**, and each project is backed by exactly one Excalidraw
|
||||||
|
drawing. The framework provides a visual web interface backed by a Go
|
||||||
|
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
|
||||||
|
files via mExDraw.
|
||||||
|
|
||||||
**Memory group_id:** `mcables`
|
**Memory group_id:** `mcables`
|
||||||
|
|
||||||
@@ -19,13 +29,25 @@ interface. The backend serves the UI and the API; there is no
|
|||||||
- A reusable framework for tracking devices, ports, cables, cable types,
|
- A reusable framework for tracking devices, ports, cables, cable types,
|
||||||
bundles, frames — **scoped per project** (LOFT and OFFICE are separate
|
bundles, frames — **scoped per project** (LOFT and OFFICE are separate
|
||||||
projects, each a separate drawing).
|
projects, each a separate drawing).
|
||||||
- A visual editor in the browser: switch projects, add frames/devices/ports,
|
- A **solver** that, given the project's devices + connection
|
||||||
click ports to wire up cables, pick cable types from a per-project legend.
|
requirements, emits the cable plan + bundle recommendations.
|
||||||
- A one-way export from the DB to the corresponding `.excalidraw` drawing
|
Objective: maximum bundling via the endpoint-pair rule (schematic
|
||||||
on `mxdrw.msbls.de` whenever m clicks Export — DB is authoritative,
|
only — no path/trunk/cable-tray modelling).
|
||||||
Excalidraw is the projection.
|
- A **hybrid device-type catalog**: 14 built-in types (NAS, PC, Mac,
|
||||||
- Bundle detection: parallel cables along the same path within a project
|
Notebook, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink,
|
||||||
get grouped + colour-bundled in the diagram.
|
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.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -45,16 +67,31 @@ interface. The backend serves the UI and the API; there is no
|
|||||||
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
|
- **Frames** sub-divide a project (LOFT has `desk`, `rack`, `media`;
|
||||||
OFFICE has `desk`, `server`). Frames are not projects — they're zones
|
OFFICE has `desk`, `server`). Frames are not projects — they're zones
|
||||||
within one drawing.
|
within one drawing.
|
||||||
- Every device, port, cable, IO marker, and bundle is **project-scoped**
|
- Every device, port, cable, IO marker, bundle, and **connection
|
||||||
(`project_id` denormalised onto every row, with `ON DELETE CASCADE` from
|
requirement** is **project-scoped** (`project_id` denormalised onto
|
||||||
`projects`). `UNIQUE (project_id, devices.name)` — no two devices in
|
every row, with `ON DELETE CASCADE` from `projects`).
|
||||||
one project share a name.
|
`UNIQUE (project_id, devices.name)` — no two devices in one project
|
||||||
|
share a name.
|
||||||
- **Cable types are global.** A single shared `cable_types` table —
|
- **Cable types are global.** A single shared `cable_types` table —
|
||||||
no `project_id`. The five defaults (Power/USB/HDMI/DP/RJ45) are seeded
|
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
|
by migration 001 once, not per project. Renaming or recolouring a type
|
||||||
affects every project's legend immediately.
|
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
|
- **Project deletion guardrail.** `DELETE /api/projects/:pid` requires
|
||||||
`?confirm=<name>` matching the project's current name. 400 otherwise.
|
`?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
|
## Branch Strategy
|
||||||
|
|
||||||
@@ -137,12 +174,14 @@ Legend colours (global, seeded once by migration 001):
|
|||||||
|
|
||||||
## Worker Preferences
|
## Worker Preferences
|
||||||
|
|
||||||
- **First shift = inventor** (design pass): conventions, schema, API,
|
- **Inventor shifts** (design passes): conventions, schema, API, export
|
||||||
export pipeline, mDock deploy plan, UI flows, slices. Output:
|
pipeline, mDock deploy plan, UI flows, slices. Output: `docs/design.md`
|
||||||
`docs/design.md` + open questions for m.
|
+ open questions for m. v1–v4 are versioned in the doc's header callout.
|
||||||
- **Second shift = coder** (after m's go on the design): bootstrap repo
|
- **Coder shifts** (after m's go on a design version): build to the
|
||||||
skeleton (Go module, SQLite migrations, server, exporter, frontend
|
current design.md. Current state: slice 1 (project CRUD + global
|
||||||
scaffold). Take slices 1–4 first (project CRUD, frames/devices, ports
|
cable_types) and slice 2 (frames + devices + drag) are merged; design
|
||||||
and cables, IO + cable-type editing); slice 5 (Excalidraw export) closes
|
v4 reshapes slices 3+ (IO + cable-type editing → device-type catalog →
|
||||||
the round-trip.
|
device-type manage → connection-requirements UI → solver → manual
|
||||||
- Use **Sonnet** for both — greenfield, structure matters more than depth.
|
port/cable draw → export). See `docs/design.md` §8 for the current
|
||||||
|
sequence.
|
||||||
|
- Use **Sonnet** for both — structure matters more than depth.
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
|
||||||
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
|
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
|
||||||
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
|
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
|
||||||
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
|
| `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
|
||||||
|
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
- MCABLES_ADDR=0.0.0.0:7777
|
- MCABLES_ADDR=0.0.0.0:7777
|
||||||
- MCABLES_DB=/app/data/mcables.db
|
- MCABLES_DB=/app/data/mcables.db
|
||||||
env_file:
|
env_file:
|
||||||
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
|
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
|
||||||
- /home/m/secrets/mcables/.env
|
- /home/m/secrets/mcables/.env
|
||||||
volumes:
|
volumes:
|
||||||
- /home/m/stacks/mcables/data:/app/data
|
- /home/m/stacks/mcables/data:/app/data
|
||||||
|
|||||||
795
docs/design.md
795
docs/design.md
@@ -1,36 +1,95 @@
|
|||||||
# mCables — Design v3
|
# mCables — Design v4.1
|
||||||
|
|
||||||
Cable-management **framework** for m's setup. Inventor shift 1 design,
|
Cable-management **framework + solver** for m's setup. Inventor shift 1
|
||||||
revised after m's round-4 answers (2026-05-15) — for m's review.
|
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 <type> port to
|
||||||
|
> <device> and re-solve"** — that POSTs a new port to the
|
||||||
|
> device AND immediately re-runs `POST /api/projects/:pid/solve` in
|
||||||
|
> the same UI action. See §5b.4 + §7 inspector-states.
|
||||||
|
> 4. **Setup templates fold INTO v4.1.** New tables `setup_templates`,
|
||||||
|
> `setup_template_devices`, `setup_template_requirements` in
|
||||||
|
> migration 004 + 3 built-in templates ('Living Room', 'Home Office',
|
||||||
|
> 'Server Rack'). New endpoints `GET /api/setup-templates` and
|
||||||
|
> `POST /api/projects/:pid/apply-template`. UI: a "Templates" panel
|
||||||
|
> in the New Project flow + an "Apply template" action on an empty
|
||||||
|
> project. See new §2.4 + slice 6 fold-in below.
|
||||||
|
> 5. **Catalog distribution: SQL seed** in migration 002 (no change).
|
||||||
|
> 6. **Promote to manual: explicit button** on the cable inspector
|
||||||
|
> (no change).
|
||||||
|
|
||||||
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
|
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
|
||||||
the *visual-grammar reference*, not as a bootstrap import target),
|
the *visual-grammar reference*, not a bootstrap import target),
|
||||||
`mai-memory` (`mcables`, `m`), and a live survey of mDock services for the
|
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy
|
||||||
deploy conventions (§10).
|
conventions (§10). v4 driven by m's product-vision clarification:
|
||||||
|
|
||||||
> **What changed in v3** (mechanical deltas on top of v2)
|
> "we provide a cable manager — I say what devices we have, the app tells
|
||||||
> - `cable_types` is now a **global** table — one set shared across all
|
> me how to bundle cables and how the most efficient connection looks like"
|
||||||
> projects. Migration 001 seeds the 5 defaults once. `POST /api/projects`
|
|
||||||
> no longer seeds types. API moved to top-level `/api/cable-types`.
|
mCables shifts from a manual draw-and-click editor to a **solver** that
|
||||||
> Renaming/recolouring a type affects every project.
|
takes a list of devices + the connections m needs and emits the cable
|
||||||
> - `devices` gains `UNIQUE (project_id, name)` — no two devices in the
|
plan + bundle recommendations. The manual editor stays (it's the only way
|
||||||
> same project can share a name.
|
to inspect + tweak the plan) but is no longer the primary surface.
|
||||||
> - `projects.drawing_name` is auto-filled `<name>.excalidraw` server-side
|
|
||||||
> when omitted on POST; editable via PATCH.
|
> **What changed in v4** (new mental model on top of v3 mechanics)
|
||||||
> - `DELETE /api/projects/:pid` requires `?confirm=<name>` query param;
|
> - **Hybrid device-type catalog** (§2.1, §3.1). A built-in `device_types`
|
||||||
> server checks it matches the project's current name. 400 otherwise.
|
> table seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz,
|
||||||
|
> ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port
|
||||||
|
> profiles (`device_type_ports` rows: cable_type + count + label).
|
||||||
|
> Adding a device → pick a type → ports auto-seed. m can override per
|
||||||
|
> instance (this PC has 3 USB, not 2). Catalog is extendable per project.
|
||||||
|
> - **`connection_requirements` table** (§2.2). m declares "NAS must
|
||||||
|
> connect to Switch via RJ45" once. Many per device. The solver consumes
|
||||||
|
> these.
|
||||||
|
> - **`POST /api/projects/:pid/solve` endpoint** (§3.2). Reads devices +
|
||||||
|
> their ports + connection_requirements + frame positions, emits a diff
|
||||||
|
> of `cables` + `bundles`. Two modes: `?preview=1` returns the diff
|
||||||
|
> without applying; default applies.
|
||||||
|
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
|
||||||
|
> two or more cables share the same endpoint pair, group them into one
|
||||||
|
> bundle. No path or trunk geometry — mCables is a wiring schematic,
|
||||||
|
> not a routing tool. v4.1 strips all path/trunk language from the v4
|
||||||
|
> draft.
|
||||||
|
> - **UI: device-type dropdown** on device-create, **Connection
|
||||||
|
> Requirements** left panel, **Solve** button next to Export. Inspector
|
||||||
|
> shows type + ports + unmet requirements (selected device) or the
|
||||||
|
> driving requirement + bundle (selected cable).
|
||||||
|
> - **Slices reshape** (§8). Catalog seeding lands early (slice 1.5); the
|
||||||
|
> solver MVP and connection-requirements UI move ahead of the
|
||||||
|
> bundle-rendering polish.
|
||||||
>
|
>
|
||||||
> **What carried over from v2**
|
> **What carried over from v3 (unchanged in v4)**
|
||||||
> - mCables is a framework: top-level `projects` table; LOFT and OFFICE
|
> - mCables is a framework: top-level `projects`, each backed by one
|
||||||
> are separate projects, each backed by one drawing.
|
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
|
||||||
> - No runtime importer. The seed drawing is reference material only.
|
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
|
||||||
> `/api/sync/import` is out of MVP; only `POST .../sync/export` ships.
|
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
|
||||||
> - IO diamonds are wall-outlet terminators (type=Power by convention,
|
> tri-state on PATCH.
|
||||||
> not enforced in schema). UI soft-warns on non-Power cables to an IO.
|
> - IO diamonds = wall-outlet terminators (type=Power by convention).
|
||||||
> - No cable inventory metadata. Purely visual structure for v0.
|
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
|
||||||
> - DB at `./data/mcables.db` (project-local, gitignored).
|
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
|
||||||
> - Deploy: raw docker / docker-compose on mDock (not Dokploy).
|
> - No cable inventory metadata; visual + connectivity structure only.
|
||||||
> - Bind `0.0.0.0:7777` on the LAN, no auth.
|
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
|
||||||
|
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose.
|
||||||
|
>
|
||||||
|
> **What's superseded in v4**
|
||||||
|
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
|
||||||
|
> tweak path on the solver output, but is no longer the *primary* device-
|
||||||
|
> connecting flow. The solve button is the headline action.
|
||||||
|
> - The v3 §8 slice order changes — catalog + types-driven devices + solver
|
||||||
|
> come earlier; the manual-draw-cable slice slides later. See new §8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,6 +193,49 @@ CREATE TABLE cable_types (
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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'.
|
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
|
||||||
CREATE TABLE frames (
|
CREATE TABLE frames (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -154,10 +256,19 @@ CREATE INDEX frames_project_idx ON frames(project_id);
|
|||||||
-- Devices live in a frame (and transitively in a project).
|
-- Devices live in a frame (and transitively in a project).
|
||||||
-- Stored project_id is denormalised for cheap project-scoped queries; FK
|
-- Stored project_id is denormalised for cheap project-scoped queries; FK
|
||||||
-- to frame_id is the structural truth. Both are kept consistent in code.
|
-- 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 (
|
CREATE TABLE devices (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
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,
|
name TEXT NOT NULL,
|
||||||
color TEXT NOT NULL DEFAULT '#1e1e1e',
|
color TEXT NOT NULL DEFAULT '#1e1e1e',
|
||||||
x REAL NOT NULL,
|
x REAL NOT NULL,
|
||||||
@@ -172,6 +283,7 @@ CREATE TABLE devices (
|
|||||||
);
|
);
|
||||||
CREATE INDEX devices_project_idx ON devices(project_id);
|
CREATE INDEX devices_project_idx ON devices(project_id);
|
||||||
CREATE INDEX devices_frame_idx ON devices(frame_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
|
-- 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.
|
-- top-left so ports follow when the device moves. project_id denormalised.
|
||||||
@@ -260,8 +372,219 @@ CREATE TABLE bundle_cables (
|
|||||||
PRIMARY KEY (bundle_id, cable_id)
|
PRIMARY KEY (bundle_id, cable_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(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 3–6 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:**
|
**FK shape — why `project_id` on every project-scoped row, not just transitively:**
|
||||||
|
|
||||||
The structural truth is `cable → port → device → frame → project`. But
|
The structural truth is `cable → port → device → frame → project`. But
|
||||||
@@ -328,8 +651,11 @@ PATCH /api/projects/:pid/frames/:id
|
|||||||
DELETE /api/projects/:pid/frames/:id
|
DELETE /api/projects/:pid/frames/:id
|
||||||
|
|
||||||
GET /api/projects/:pid/devices
|
GET /api/projects/:pid/devices
|
||||||
POST /api/projects/:pid/devices ← {name, frame_id?, x, y, width, height, color?}
|
POST /api/projects/:pid/devices ← {name, type_id?, frame_id?, x, y, width, height, color?}
|
||||||
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag)
|
v4: type_id (optional) seeds ports from the catalog;
|
||||||
|
without it, a freeform device (no ports) is created.
|
||||||
|
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
|
||||||
|
clearing does NOT delete existing ports (instance-owned).
|
||||||
DELETE /api/projects/:pid/devices/:id
|
DELETE /api/projects/:pid/devices/:id
|
||||||
|
|
||||||
GET /api/projects/:pid/devices/:id/ports
|
GET /api/projects/:pid/devices/:id/ports
|
||||||
@@ -354,12 +680,90 @@ GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …]
|
|||||||
PATCH /api/projects/:pid/bundles/:id
|
PATCH /api/projects/:pid/bundles/:id
|
||||||
DELETE /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
|
# Sync — export only in MVP
|
||||||
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
|
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
|
||||||
(overwrites previous version; mExDraw keeps
|
(overwrites previous version; mExDraw keeps
|
||||||
git-version-history sidecar)
|
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
|
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
|
a one-shot migration use case (e.g. seeding LOFT from the legacy
|
||||||
Cable-Management drawing if m later changes his mind).
|
Cable-Management drawing if m later changes his mind).
|
||||||
@@ -447,6 +851,139 @@ they're ignored in v0 (open question §9).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5b. v4 — Solver
|
||||||
|
|
||||||
|
The solver is the headline addition in v4. m's product-vision sentence
|
||||||
|
maps onto it directly:
|
||||||
|
|
||||||
|
> "I say what devices we have, the app tells me how to bundle cables and
|
||||||
|
> how the most efficient connection looks like"
|
||||||
|
|
||||||
|
The solver reads a project's `devices` (with their `ports`) and
|
||||||
|
`connection_requirements`, and writes a set of solver-owned `cables`
|
||||||
|
(rows with `auto=1`) + `bundles`. m's hand-drawn cables (`auto=0`) are
|
||||||
|
left strictly alone — the solver only adds and removes its own.
|
||||||
|
|
||||||
|
### 5b.1 Objective: maximum bundling — schematic only
|
||||||
|
|
||||||
|
mCables is a **schematic**, not a physical-routing tool. Cables are
|
||||||
|
straight lines between endpoints; the solver has no model of walls,
|
||||||
|
floors, cable trays, or path geometry. "Maximum bundling" therefore
|
||||||
|
reduces to a single rule on the schematic:
|
||||||
|
|
||||||
|
> When two or more cables share the same endpoint pair (device A ↔
|
||||||
|
> device B), group them into one bundle.
|
||||||
|
|
||||||
|
This is the v3 endpoint-pair rule, applied to the solver's output. m's
|
||||||
|
"visually cleaner setups" benefit comes from the bundle being a single
|
||||||
|
labelled set in the inspector + a single mixed-colour glyph in the
|
||||||
|
render (slice 9+), rather than from any path optimisation. Anything
|
||||||
|
about trunks, frame-edge corridors, or auto-routing is out of scope —
|
||||||
|
filed for "post-v0 ambient" in §8.
|
||||||
|
|
||||||
|
### 5b.2 Algorithm (v0)
|
||||||
|
|
||||||
|
Pure function. No graph search; no LP; no path optimisation. Single
|
||||||
|
pass with greedy port allocation.
|
||||||
|
|
||||||
|
```
|
||||||
|
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
|
||||||
|
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
|
||||||
|
let port_free := {port_id -> bool} initialised TRUE for every port
|
||||||
|
minus ports already used by manual cables (auto=0)
|
||||||
|
|
||||||
|
for each requirement r in order(must_connect DESC, id ASC):
|
||||||
|
let ct = r.preferred_cable_type_id
|
||||||
|
?? auto_pick_cable_type(r.from_device, r.to_device)
|
||||||
|
?? fail("ambiguous")
|
||||||
|
let pa = first_free_port(r.from_device, ct, port_free)
|
||||||
|
let pb = first_free_port(r.to_device, ct, port_free)
|
||||||
|
if !pa or !pb:
|
||||||
|
if r.must_connect: unsatisfied.push({r.id, reason})
|
||||||
|
else: skip
|
||||||
|
continue
|
||||||
|
port_free[pa] = port_free[pb] = false
|
||||||
|
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
|
||||||
|
|
||||||
|
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
|
||||||
|
for each (device_a, device_b) pair with ≥ 2 add-cables:
|
||||||
|
bundles_add.push({auto=1, cables: those add-cables})
|
||||||
|
|
||||||
|
// Diff against auto_cables_before to compute remove[] (any prior auto
|
||||||
|
// cable whose (from, to, type) doesn't appear in add[]).
|
||||||
|
remove = auto_cables_before - add
|
||||||
|
return {add, remove, bundles_add, unsatisfied}
|
||||||
|
```
|
||||||
|
|
||||||
|
`first_free_port(device, cable_type, free_map)` picks the lowest-id port
|
||||||
|
on the device whose `type_id` matches and that is still free, returning
|
||||||
|
NULL if none. The `lowest-id` tiebreak is deterministic so repeated
|
||||||
|
solves produce the same plan.
|
||||||
|
|
||||||
|
`auto_pick_cable_type(from, to)` (used when `preferred_cable_type_id` is
|
||||||
|
NULL): find the set of cable types `T = ports(from).types ∩
|
||||||
|
ports(to).types`. If `|T| == 1`, return it. If `|T| > 1`, fail
|
||||||
|
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
|
||||||
|
as a "specify type" inline edit on the requirement.
|
||||||
|
|
||||||
|
### 5b.3 Solver-owned vs. user-owned cables
|
||||||
|
|
||||||
|
`cables.auto` distinguishes them.
|
||||||
|
|
||||||
|
| Operation | Effect on `auto=0` cables | Effect on `auto=1` cables |
|
||||||
|
|---|---|---|
|
||||||
|
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
|
||||||
|
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
|
||||||
|
| DELETE cables | applies | applies |
|
||||||
|
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
|
||||||
|
|
||||||
|
This way a manual cable m doesn't want the solver to second-guess
|
||||||
|
survives every solve. If m wants the solver to take it over, he deletes
|
||||||
|
his hand-drawn cable and re-solves; the solver re-creates an equivalent
|
||||||
|
auto cable.
|
||||||
|
|
||||||
|
### 5b.4 When solver fails — quick-fix UX
|
||||||
|
|
||||||
|
Three classes of failure surface in the response's `unsatisfied[]`:
|
||||||
|
|
||||||
|
1. **No compatible cable type** — `T = ports(from).types ∩
|
||||||
|
ports(to).types` is empty (e.g. a Power-only device to an HDMI-only
|
||||||
|
device).
|
||||||
|
2. **Ambiguous cable type** — `|T| > 1`, no preferred set on the
|
||||||
|
requirement.
|
||||||
|
3. **No free port** — the cable type matches but every port on one side
|
||||||
|
is already used.
|
||||||
|
|
||||||
|
The solver does **not** auto-add ports without m's consent. v4.1 ships
|
||||||
|
an explicit one-click quick-fix per class of failure, surfaced as a red
|
||||||
|
badge on the affected device in the inspector (§7) and as a button on
|
||||||
|
each `unsatisfied[]` entry in the preview-diff modal:
|
||||||
|
|
||||||
|
| Failure class | Quick-fix button | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| No compatible cable type | **"+ Add <preferred_type> port to <device> 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 <type> port to <device> 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
|
## 6. Sync — export-only for v0
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -522,16 +1059,54 @@ the new project (which has 5 seeded cable types and no frames yet).
|
|||||||
The currently active project's id is kept in URL state
|
The currently active project's id is kept in URL state
|
||||||
(`/?project=LOFT`) so reload returns to the same project.
|
(`/?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
|
### Flow: add a frame
|
||||||
|
|
||||||
1. `+ Frm` in the left toolbar (or `F`).
|
1. `+ Frm` in the left toolbar (or `F`).
|
||||||
2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
|
2. Click + drag on the canvas → rubber-band rectangle becomes a frame.
|
||||||
3. Name prompt centered in the frame; Enter → `POST .../frames`.
|
3. Name prompt centered in the frame; Enter → `POST .../frames`.
|
||||||
|
|
||||||
### Flow: add a device
|
### Flow: add a device (v4 — type-aware)
|
||||||
|
|
||||||
Unchanged from v1: `+ Dev` (or `D`) → click on canvas → rectangle placed
|
1. `+ Dev` (or `D`) → click on canvas → device placeholder appears.
|
||||||
(falls into whichever frame it lands in) → name → `POST .../devices`.
|
2. **First field in the inline namer: type dropdown** (replaces the
|
||||||
|
v1 plain-name input). Options pulled from
|
||||||
|
`GET /api/projects/:pid/device-types` — built-ins listed first
|
||||||
|
grouped by `kind`, then project-custom rows, then `Custom (no type)`.
|
||||||
|
Typing in the dropdown filters by `name` (m types "n" → NAS jumps
|
||||||
|
to top). Below the dropdown: a name input pre-filled with the type
|
||||||
|
name + a digit if a same-named device already exists ("PC", "PC-2").
|
||||||
|
3. Hit Enter → `POST .../devices` with `type_id` + name. The server
|
||||||
|
seeds the ports from `device_type_ports` in the same transaction
|
||||||
|
and returns the device with its `ports`.
|
||||||
|
4. Picking `Custom (no type)` keeps the v3 behaviour: rectangle, no
|
||||||
|
ports, m adds ports manually via the inspector.
|
||||||
|
5. The device renders with its ports already visible along the
|
||||||
|
configured edge.
|
||||||
|
|
||||||
### Flow: add a port
|
### Flow: add a port
|
||||||
|
|
||||||
@@ -581,54 +1156,140 @@ In the inspector with nothing else selected, "Bundle suggestions" pulls
|
|||||||
on the diagram + an Accept button. Manual: shift-click multiple cables →
|
on the diagram + an Accept button. Manual: shift-click multiple cables →
|
||||||
"Group as bundle" → name it → save.
|
"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 <type> port to <device> 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
|
### Keyboard
|
||||||
|
|
||||||
`P` switch project (opens picker), `F` add frame, `D` add device,
|
`P` switch project, `F` add frame, `D` add device, `I` add IO marker,
|
||||||
`I` add IO marker, `T` start cable from selected port,
|
`T` start cable from selected port, `R` add requirement,
|
||||||
`E` export current project, `Esc` cancel, `Backspace` delete selection,
|
**`S` solve project (v4)**, `E` export, `Esc` cancel, `Backspace` delete
|
||||||
`?` show shortcuts.
|
selection, `?` show shortcuts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. First slices
|
## 8. First slices — v4 reshape
|
||||||
|
|
||||||
Each slice ends with something m can click. The first coder shift takes
|
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead
|
||||||
slices 1–4 as the MVP; slice 5 (export) is the round-trip end.
|
of the original 3-5 because the solver depends on the catalog + the
|
||||||
|
requirements model, not on manual cable drawing. The old "manual port +
|
||||||
|
cable draw" slice is still in scope as a tweak path on the solver
|
||||||
|
output, but it follows the solver instead of leading.
|
||||||
|
|
||||||
| # | Slice | What's shipped |
|
| # | Slice | Status | 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. |
|
| 1 | **Bootstrap + project CRUD + global cable_types** | ✅ shipped | See git: branch `mai/picasso/slice-1-bootstrap`. |
|
||||||
| 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. |
|
| 2 | **Frames + devices + drag** | ✅ shipped | See git: branch `mai/picasso/slice-2-frames-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. |
|
| **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 | **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. |
|
| **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. |
|
||||||
| 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. |
|
| **4.5 (NEW)** | **Manage device-type catalog (per project)** | pending | Modal: `POST/PATCH/DELETE /api/projects/:pid/device-types` for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by `UNIQUE(project_id, name)` + a separate code-level check against built-ins). |
|
||||||
|
| **5 (NEW)** | **Connection requirements UI + CRUD** | pending | Migration 003: `connection_requirements`. API: full CRUD under `/api/projects/:pid/connection-requirements`. Frontend: left-sidebar "Requirements" section, `+ Requirement` modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
|
||||||
|
| **6 (v4.1 EXPANDED)** | **Solver MVP + Solve button + setup templates** | pending | `POST /api/projects/:pid/solve` with `?preview=1` support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling. Migration 003 adds `cables.auto`. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan + unmet requirements (each with its quick-fix button per §5b.4) → applies. **Folded in v4.1: setup templates.** Migration 004 adds `setup_templates` + `setup_template_devices` + `setup_template_requirements` and seeds 3 built-ins ('Living Room', 'Home Office', 'Server Rack'). API: `GET /api/setup-templates`, `POST /api/projects/:pid/apply-template`. UI: a "Templates" section in the New Project modal + an "Apply template" action on empty projects → seeds devices + requirements in one transaction → Solve produces the wired diagram. |
|
||||||
|
| **7 (was 3, slimmed)** | **Manual port + manual cable draw** | pending | The v3 flow as a tweak path on solver output. `+ Port` on an instance-owned device; click-port → click-port creates a hand-drawn cable (`auto=0`). Used to override the solver's choices or to extend its plan. |
|
||||||
|
| **8 (was 5)** | **Export to mxdrw.msbls.de** | pending | `POST .../sync/export` writes a `.excalidraw` scene per the visual grammar (§4). Bundles ignored on export in v0. |
|
||||||
|
|
||||||
Slices 6+ (not promised for the first coder shift):
|
Slices 9+ (not promised for the first coder shift):
|
||||||
bundle suggestions UI; bundle rendering (thick path with mixed-colour
|
- 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.
|
||||||
fan-out); cable type "warn on cross-type port-to-port"; cable inventory
|
- 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.
|
||||||
metadata (length/SKU) if m later wants it; dark mode.
|
- "Re-seed from type" action on a device.
|
||||||
|
- Custom setup templates (m authors them in-UI, not just the built-in three).
|
||||||
|
- Cable inventory metadata (length/SKU) if m later wants it.
|
||||||
|
- Dark mode.
|
||||||
|
|
||||||
|
Out of scope, period (would change mCables's mental model): path
|
||||||
|
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
|
||||||
|
3D, anything that treats a cable as more than a labelled endpoint pair.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Open questions for m — all resolved in v3
|
## 9. Open questions for m — all closed in v4.1
|
||||||
|
|
||||||
All six v2 questions are now answered. Locked answers:
|
The six v4 questions are now answered. Locked answers:
|
||||||
|
|
||||||
1. **Drawing-name policy** → server-side default `<name>.excalidraw` on
|
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.**
|
||||||
POST when omitted; editable via PATCH. (§3)
|
Cables are straight lines between endpoints. The solver does not
|
||||||
2. **Device-name uniqueness within a project** → `UNIQUE (project_id,
|
route, the renderer does not route, and "maximum bundling" reduces to
|
||||||
devices.name)` enforced at the schema level. (§2)
|
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
|
||||||
3. **Non-Power IO markers** → no `type_id` on `io_markers` for v0.
|
cable tray, or frame-edge corridor is **out of scope, period**
|
||||||
Power-by-convention; UI soft-warns on non-Power cables to an IO. (§2, §7)
|
(§8 "Out of scope, period").
|
||||||
4. **Bundle render in export v1** → bundles ignored on export until slice
|
2. **Live solve or button-only?** → **Button-only for v0.** Live-solve
|
||||||
6+. (§4, §5)
|
stays parked at slice 9+ as an opt-in.
|
||||||
5. **Cross-project cable types** → `cable_types` is fully **global**. One
|
3. **No-compatible-port-pair UX.** → **Explicit quick-fix.** The
|
||||||
shared legend; renaming/recolouring affects every project. (§2, §3, §7)
|
unsatisfied-requirement badge in the inspector carries a single
|
||||||
6. **Project deletion guardrail** → `DELETE /api/projects/:pid?confirm=<name>`
|
button — "+ Add <type> port to <device> and re-solve" —
|
||||||
required; server validates name match, returns 400 otherwise. (§3)
|
that POSTs the port AND fires `/solve` in one UI action. The button
|
||||||
|
text always names the device + type, so m sees what's about to
|
||||||
|
mutate (§5b.4 + §7).
|
||||||
|
4. **Setup templates.** → **Folded INTO v4.1, in slice 6.** Migration 004
|
||||||
|
adds `setup_templates` + child tables + 3 built-ins. `GET
|
||||||
|
/api/setup-templates` and `POST /api/projects/:pid/apply-template`
|
||||||
|
ship alongside the solver (§2.4 + §3 + slice 6 in §8). Custom
|
||||||
|
templates (m authors his own) parked at slice 9+.
|
||||||
|
5. **Catalog distribution.** → **SQL seed in migration 002.** No
|
||||||
|
external file loader.
|
||||||
|
6. **Promote to manual.** → **Explicit button** on the cable inspector
|
||||||
|
(§7 row "cable (auto=1)"). PATCHes that only update labels stay auto.
|
||||||
|
|
||||||
No open design questions remain. The coder shift is gated on m's
|
No open design questions remain. The coder shift is gated on m's
|
||||||
go/no-go for v3 — not on any unanswered design question from picasso.
|
go/no-go for v4.1 — not on any unanswered design question from picasso.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -777,4 +1438,4 @@ gitignored.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
DESIGN v3 READY — coder shift gated
|
DESIGN v4.1 READY FOR REVIEW
|
||||||
|
|||||||
222
internal/db/bundles.go
Normal file
222
internal/db/bundles.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BundleCreate is the create-shape: a name + the cable IDs to include.
|
||||||
|
// Auto=true means the solver created the bundle; user-created bundles
|
||||||
|
// stay auto=0 and survive a re-solve.
|
||||||
|
type BundleCreate struct {
|
||||||
|
Name string
|
||||||
|
CableIDs []int64
|
||||||
|
Auto bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type BundleUpdate struct {
|
||||||
|
Name *string
|
||||||
|
CableIDs *[]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBundle inserts a bundle + its cable_bundle rows in one tx.
|
||||||
|
func (s *Store) CreateBundle(projectID int64, b BundleCreate) (*Bundle, error) {
|
||||||
|
return s.createBundle(s.db, projectID, b, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createBundle(ex execer, projectID int64, b BundleCreate, ownTx bool) (*Bundle, error) {
|
||||||
|
name := strings.TrimSpace(b.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
// When the caller already holds a tx (ownTx=false), do all validation
|
||||||
|
// against `ex` (the tx executor) — calling Store methods that hit
|
||||||
|
// s.db would deadlock against the connection the tx is holding under
|
||||||
|
// MaxOpenConns(1).
|
||||||
|
for _, cid := range b.CableIDs {
|
||||||
|
if _, err := s.getCableTx(ex, projectID, cid); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: cable_id %d not in project", ErrInvalidInput, cid)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
autoInt := 0
|
||||||
|
if b.Auto {
|
||||||
|
autoInt = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx *sql.Tx
|
||||||
|
var err error
|
||||||
|
useEx := ex
|
||||||
|
if ownTx {
|
||||||
|
tx, err = s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
useEx = tx
|
||||||
|
}
|
||||||
|
res, err := useEx.Exec(
|
||||||
|
`INSERT INTO bundles (project_id, name, auto) VALUES (?, ?, ?)`,
|
||||||
|
projectID, name, autoInt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
for _, cid := range b.CableIDs {
|
||||||
|
if _, err := useEx.Exec(
|
||||||
|
`INSERT INTO bundle_cables (bundle_id, cable_id) VALUES (?, ?)`, id, cid,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownTx {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetBundle(projectID, id)
|
||||||
|
}
|
||||||
|
// In tx-inheriting mode, build the response struct locally — the
|
||||||
|
// caller will re-fetch via GetBundle after commit if it needs more.
|
||||||
|
out := &Bundle{
|
||||||
|
ID: id, ProjectID: projectID, Name: name, Auto: b.Auto, CableIDs: append([]int64(nil), b.CableIDs...),
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetBundle(projectID, id int64) (*Bundle, error) {
|
||||||
|
var b Bundle
|
||||||
|
var autoInt int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, project_id, name, auto, created_at, updated_at
|
||||||
|
FROM bundles WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
).Scan(&b.ID, &b.ProjectID, &b.Name, &autoInt, &b.CreatedAt, &b.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.Auto = autoInt != 0
|
||||||
|
ids, err := s.bundleCableIDs(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.CableIDs = ids
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) bundleCableIDs(bundleID int64) ([]int64, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT cable_id FROM bundle_cables WHERE bundle_id = ? ORDER BY cable_id`, bundleID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v int64
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBundles returns every bundle in a project, ordered by id.
|
||||||
|
func (s *Store) ListBundles(projectID int64) ([]Bundle, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, project_id, name, auto, created_at, updated_at
|
||||||
|
FROM bundles WHERE project_id = ? ORDER BY id`, projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Bundle{}
|
||||||
|
for rows.Next() {
|
||||||
|
var b Bundle
|
||||||
|
var autoInt int
|
||||||
|
if err := rows.Scan(&b.ID, &b.ProjectID, &b.Name, &autoInt,
|
||||||
|
&b.CreatedAt, &b.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.Auto = autoInt != 0
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
ids, err := s.bundleCableIDs(out[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[i].CableIDs = ids
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBundle: name + cable set are mutable. Replacing cables wipes
|
||||||
|
// bundle_cables and re-inserts in one tx.
|
||||||
|
func (s *Store) UpdateBundle(projectID, id int64, u BundleUpdate) (*Bundle, error) {
|
||||||
|
cur, err := s.GetBundle(projectID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.Name != nil {
|
||||||
|
v := strings.TrimSpace(*u.Name)
|
||||||
|
if v == "" {
|
||||||
|
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
cur.Name = v
|
||||||
|
}
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE bundles SET name = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
cur.Name, id,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
if u.CableIDs != nil {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM bundle_cables WHERE bundle_id = ?`, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, cid := range *u.CableIDs {
|
||||||
|
if _, err := s.getCableTx(tx, projectID, cid); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: cable_id %d not in project", ErrInvalidInput, cid)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO bundle_cables (bundle_id, cable_id) VALUES (?, ?)`, id, cid,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetBundle(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteBundle(projectID, id int64) error {
|
||||||
|
if _, err := s.GetBundle(projectID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`DELETE FROM bundles WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
371
internal/db/cables.go
Normal file
371
internal/db/cables.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CableEndpoint identifies one side of a cable. Exactly one of PortID /
|
||||||
|
// DeviceID / IOID must be non-nil; the store enforces this.
|
||||||
|
type CableEndpoint struct {
|
||||||
|
PortID *int64
|
||||||
|
DeviceID *int64
|
||||||
|
IOID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CableCreate is the create-shape for /api/projects/:pid/cables.
|
||||||
|
// auto=false (default) marks the cable as m-drawn; the solver writes
|
||||||
|
// auto=true when it places its rows.
|
||||||
|
type CableCreate struct {
|
||||||
|
TypeID int64
|
||||||
|
Label string
|
||||||
|
From CableEndpoint
|
||||||
|
To CableEndpoint
|
||||||
|
Auto bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CableUpdate is a partial update. PATCHing endpoint or type on an
|
||||||
|
// auto=1 cable should promote it to manual; handler logic does that
|
||||||
|
// (see slice 6 §5b.3).
|
||||||
|
type CableUpdate struct {
|
||||||
|
TypeID *int64
|
||||||
|
Label *string
|
||||||
|
From *CableEndpoint
|
||||||
|
To *CableEndpoint
|
||||||
|
Auto *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCable inserts a cable. Validates that the endpoints exist in
|
||||||
|
// the same project, that exactly one of (port/device/io) is set per side,
|
||||||
|
// and that the cable type is real.
|
||||||
|
func (s *Store) CreateCable(projectID int64, c CableCreate) (*Cable, error) {
|
||||||
|
return s.createCable(s.db, projectID, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCable on a TX-or-DB executor; solver uses the tx form.
|
||||||
|
func (s *Store) createCable(ex execer, projectID int64, c CableCreate) (*Cable, error) {
|
||||||
|
if err := s.validateEndpointEx(ex, projectID, "from", c.From); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.validateEndpointEx(ex, projectID, "to", c.To); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.assertCableTypeEx(ex, c.TypeID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
autoInt := 0
|
||||||
|
if c.Auto {
|
||||||
|
autoInt = 1
|
||||||
|
}
|
||||||
|
res, err := ex.Exec(
|
||||||
|
`INSERT INTO cables
|
||||||
|
(project_id, type_id, label,
|
||||||
|
from_port_id, from_device_id, from_io_id,
|
||||||
|
to_port_id, to_device_id, to_io_id,
|
||||||
|
auto)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, c.TypeID, nullableString(c.Label),
|
||||||
|
nullableInt64(c.From.PortID), nullableInt64(c.From.DeviceID), nullableInt64(c.From.IOID),
|
||||||
|
nullableInt64(c.To.PortID), nullableInt64(c.To.DeviceID), nullableInt64(c.To.IOID),
|
||||||
|
autoInt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.getCableTx(ex, projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateEndpoint is the s.db variant for public CRUD callers.
|
||||||
|
func (s *Store) validateEndpoint(projectID int64, label string, e CableEndpoint) error {
|
||||||
|
return s.validateEndpointEx(s.db, projectID, label, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateEndpointEx runs the same checks against any executor so the
|
||||||
|
// solver can call createCable inside its tx without deadlocking on the
|
||||||
|
// MaxOpenConns(1) connection that the tx holds.
|
||||||
|
func (s *Store) validateEndpointEx(ex execer, projectID int64, label string, e CableEndpoint) error {
|
||||||
|
count := 0
|
||||||
|
if e.PortID != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if e.DeviceID != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if e.IOID != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return fmt.Errorf("%w: %s must specify exactly one of port/device/io", ErrInvalidInput, label)
|
||||||
|
}
|
||||||
|
if e.PortID != nil {
|
||||||
|
var pid int64
|
||||||
|
err := ex.QueryRow(`SELECT project_id FROM ports WHERE id = ?`, *e.PortID).Scan(&pid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("%w: %s port_id %d not found", ErrInvalidInput, label, *e.PortID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pid != projectID {
|
||||||
|
return fmt.Errorf("%w: %s port_id %d is in another project", ErrInvalidInput, label, *e.PortID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.DeviceID != nil {
|
||||||
|
var pid int64
|
||||||
|
err := ex.QueryRow(`SELECT project_id FROM devices WHERE id = ?`, *e.DeviceID).Scan(&pid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || (err == nil && pid != projectID) {
|
||||||
|
return fmt.Errorf("%w: %s device_id %d not in project", ErrInvalidInput, label, *e.DeviceID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.IOID != nil {
|
||||||
|
var pid int64
|
||||||
|
err := ex.QueryRow(`SELECT project_id FROM io_markers WHERE id = ?`, *e.IOID).Scan(&pid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || (err == nil && pid != projectID) {
|
||||||
|
return fmt.Errorf("%w: %s io_id %d not in project", ErrInvalidInput, label, *e.IOID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCableTypeEx is a lightweight existence check against any executor.
|
||||||
|
func (s *Store) assertCableTypeEx(ex execer, id int64) error {
|
||||||
|
var dummy int64
|
||||||
|
err := ex.QueryRow(`SELECT id FROM cable_types WHERE id = ?`, id).Scan(&dummy)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetCable(projectID, id int64) (*Cable, error) {
|
||||||
|
return s.getCableTx(s.db, projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getCableTx(ex execer, projectID, id int64) (*Cable, error) {
|
||||||
|
var c Cable
|
||||||
|
var fp, fd, fio, tp, td, tio sql.NullInt64
|
||||||
|
var label, ex2 sql.NullString
|
||||||
|
var autoInt int
|
||||||
|
err := ex.QueryRow(
|
||||||
|
`SELECT id, project_id, type_id, label,
|
||||||
|
from_port_id, from_device_id, from_io_id,
|
||||||
|
to_port_id, to_device_id, to_io_id,
|
||||||
|
auto, excalidraw_id, created_at, updated_at
|
||||||
|
FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
).Scan(&c.ID, &c.ProjectID, &c.TypeID, &label,
|
||||||
|
&fp, &fd, &fio, &tp, &td, &tio,
|
||||||
|
&autoInt, &ex2, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if label.Valid {
|
||||||
|
v := label.String
|
||||||
|
c.Label = &v
|
||||||
|
}
|
||||||
|
if fp.Valid {
|
||||||
|
v := fp.Int64
|
||||||
|
c.FromPortID = &v
|
||||||
|
}
|
||||||
|
if fd.Valid {
|
||||||
|
v := fd.Int64
|
||||||
|
c.FromDeviceID = &v
|
||||||
|
}
|
||||||
|
if fio.Valid {
|
||||||
|
v := fio.Int64
|
||||||
|
c.FromIOID = &v
|
||||||
|
}
|
||||||
|
if tp.Valid {
|
||||||
|
v := tp.Int64
|
||||||
|
c.ToPortID = &v
|
||||||
|
}
|
||||||
|
if td.Valid {
|
||||||
|
v := td.Int64
|
||||||
|
c.ToDeviceID = &v
|
||||||
|
}
|
||||||
|
if tio.Valid {
|
||||||
|
v := tio.Int64
|
||||||
|
c.ToIOID = &v
|
||||||
|
}
|
||||||
|
c.Auto = autoInt != 0
|
||||||
|
if ex2.Valid {
|
||||||
|
c.ExcalidrawID = &ex2.String
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCables returns every cable in a project.
|
||||||
|
func (s *Store) ListCables(projectID int64) ([]Cable, error) {
|
||||||
|
return s.listCablesTx(s.db, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listCablesTx(ex execer, projectID int64) ([]Cable, error) {
|
||||||
|
rows, err := ex.Query(
|
||||||
|
`SELECT id, project_id, type_id, label,
|
||||||
|
from_port_id, from_device_id, from_io_id,
|
||||||
|
to_port_id, to_device_id, to_io_id,
|
||||||
|
auto, excalidraw_id, created_at, updated_at
|
||||||
|
FROM cables WHERE project_id = ? ORDER BY id`, projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Cable{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Cable
|
||||||
|
var fp, fd, fio, tp, td, tio sql.NullInt64
|
||||||
|
var label, ex2 sql.NullString
|
||||||
|
var autoInt int
|
||||||
|
if err := rows.Scan(&c.ID, &c.ProjectID, &c.TypeID, &label,
|
||||||
|
&fp, &fd, &fio, &tp, &td, &tio,
|
||||||
|
&autoInt, &ex2, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if label.Valid {
|
||||||
|
v := label.String
|
||||||
|
c.Label = &v
|
||||||
|
}
|
||||||
|
if fp.Valid {
|
||||||
|
v := fp.Int64
|
||||||
|
c.FromPortID = &v
|
||||||
|
}
|
||||||
|
if fd.Valid {
|
||||||
|
v := fd.Int64
|
||||||
|
c.FromDeviceID = &v
|
||||||
|
}
|
||||||
|
if fio.Valid {
|
||||||
|
v := fio.Int64
|
||||||
|
c.FromIOID = &v
|
||||||
|
}
|
||||||
|
if tp.Valid {
|
||||||
|
v := tp.Int64
|
||||||
|
c.ToPortID = &v
|
||||||
|
}
|
||||||
|
if td.Valid {
|
||||||
|
v := td.Int64
|
||||||
|
c.ToDeviceID = &v
|
||||||
|
}
|
||||||
|
if tio.Valid {
|
||||||
|
v := tio.Int64
|
||||||
|
c.ToIOID = &v
|
||||||
|
}
|
||||||
|
c.Auto = autoInt != 0
|
||||||
|
if ex2.Valid {
|
||||||
|
c.ExcalidrawID = &ex2.String
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCable applies a partial update. Caller-controlled — promote-to-
|
||||||
|
// manual semantics live at the handler level (§5b.3: any PATCH touching
|
||||||
|
// type/endpoint promotes auto→0).
|
||||||
|
func (s *Store) UpdateCable(projectID, id int64, u CableUpdate) (*Cable, error) {
|
||||||
|
cur, err := s.GetCable(projectID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.TypeID != nil {
|
||||||
|
if _, err := s.GetCableType(*u.TypeID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cur.TypeID = *u.TypeID
|
||||||
|
}
|
||||||
|
if u.Label != nil {
|
||||||
|
v := strings.TrimSpace(*u.Label)
|
||||||
|
if v == "" {
|
||||||
|
cur.Label = nil
|
||||||
|
} else {
|
||||||
|
cur.Label = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.From != nil {
|
||||||
|
if err := s.validateEndpoint(projectID, "from", *u.From); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cur.FromPortID = u.From.PortID
|
||||||
|
cur.FromDeviceID = u.From.DeviceID
|
||||||
|
cur.FromIOID = u.From.IOID
|
||||||
|
}
|
||||||
|
if u.To != nil {
|
||||||
|
if err := s.validateEndpoint(projectID, "to", *u.To); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cur.ToPortID = u.To.PortID
|
||||||
|
cur.ToDeviceID = u.To.DeviceID
|
||||||
|
cur.ToIOID = u.To.IOID
|
||||||
|
}
|
||||||
|
if u.Auto != nil {
|
||||||
|
cur.Auto = *u.Auto
|
||||||
|
}
|
||||||
|
autoInt := 0
|
||||||
|
if cur.Auto {
|
||||||
|
autoInt = 1
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE cables
|
||||||
|
SET type_id = ?, label = ?,
|
||||||
|
from_port_id = ?, from_device_id = ?, from_io_id = ?,
|
||||||
|
to_port_id = ?, to_device_id = ?, to_io_id = ?,
|
||||||
|
auto = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND project_id = ?`,
|
||||||
|
cur.TypeID, nullableStringPtr(cur.Label),
|
||||||
|
nullableInt64(cur.FromPortID), nullableInt64(cur.FromDeviceID), nullableInt64(cur.FromIOID),
|
||||||
|
nullableInt64(cur.ToPortID), nullableInt64(cur.ToDeviceID), nullableInt64(cur.ToIOID),
|
||||||
|
autoInt, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return s.GetCable(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCable removes a cable from a project.
|
||||||
|
func (s *Store) DeleteCable(projectID, id int64) error {
|
||||||
|
if _, err := s.GetCable(projectID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableString → for label-style strings: "" → SQL NULL.
|
||||||
|
func nullableString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func nullableStringPtr(p *string) any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// execer abstracts *sql.DB and *sql.Tx for store helpers used by both
|
||||||
|
// the public API and inside transactions (e.g. the solver).
|
||||||
|
type execer interface {
|
||||||
|
Exec(query string, args ...any) (sql.Result, error)
|
||||||
|
Query(query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRow(query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
192
internal/db/connection_requirements.go
Normal file
192
internal/db/connection_requirements.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionRequirementCreate is the create-shape. Server normalises
|
||||||
|
// from/to into (pair_lo, pair_hi) so (A,B,T) and (B,A,T) collide.
|
||||||
|
type ConnectionRequirementCreate struct {
|
||||||
|
FromDeviceID int64
|
||||||
|
ToDeviceID int64
|
||||||
|
PreferredCableTypeID *int64
|
||||||
|
MustConnect *bool // pointer so "absent" defaults to true
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionRequirementUpdate is the partial-update shape. project_id +
|
||||||
|
// the device pair are immutable post-create (changing either is best
|
||||||
|
// modelled as delete-then-create — keeps pair_lo/pair_hi semantics simple).
|
||||||
|
type ConnectionRequirementUpdate struct {
|
||||||
|
PreferredCableTypeID FrameRef // tri-state: leave / set / clear
|
||||||
|
MustConnect *bool
|
||||||
|
Notes *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConnectionRequirement inserts a new requirement. Validates that
|
||||||
|
// both devices live in projectID, that from != to, and that the
|
||||||
|
// (project, pair_lo, pair_hi, preferred_cable_type_id) tuple is unique.
|
||||||
|
func (s *Store) CreateConnectionRequirement(projectID int64, r ConnectionRequirementCreate) (*ConnectionRequirement, error) {
|
||||||
|
if r.FromDeviceID == r.ToDeviceID {
|
||||||
|
return nil, fmt.Errorf("%w: from_device_id and to_device_id must differ", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if _, err := s.GetProject(projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.GetDevice(projectID, r.FromDeviceID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: from_device_id %d not in project %d", ErrInvalidInput, r.FromDeviceID, projectID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.GetDevice(projectID, r.ToDeviceID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: to_device_id %d not in project %d", ErrInvalidInput, r.ToDeviceID, projectID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.PreferredCableTypeID != nil {
|
||||||
|
if _, err := s.GetCableType(*r.PreferredCableTypeID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *r.PreferredCableTypeID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
must := true
|
||||||
|
if r.MustConnect != nil {
|
||||||
|
must = *r.MustConnect
|
||||||
|
}
|
||||||
|
mustInt := 0
|
||||||
|
if must {
|
||||||
|
mustInt = 1
|
||||||
|
}
|
||||||
|
lo, hi := r.FromDeviceID, r.ToDeviceID
|
||||||
|
if lo > hi {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`INSERT INTO connection_requirements
|
||||||
|
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
||||||
|
must_connect, notes, pair_lo, pair_hi)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, r.FromDeviceID, r.ToDeviceID, nullableInt64(r.PreferredCableTypeID),
|
||||||
|
mustInt, r.Notes, lo, hi,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetConnectionRequirement(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionRequirement loads one by id, project-scoped.
|
||||||
|
func (s *Store) GetConnectionRequirement(projectID, id int64) (*ConnectionRequirement, error) {
|
||||||
|
var r ConnectionRequirement
|
||||||
|
var ct sql.NullInt64
|
||||||
|
var must int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
||||||
|
must_connect, notes, created_at, updated_at
|
||||||
|
FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
).Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
|
||||||
|
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ct.Valid {
|
||||||
|
v := ct.Int64
|
||||||
|
r.PreferredCableTypeID = &v
|
||||||
|
}
|
||||||
|
r.MustConnect = must != 0
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConnectionRequirements returns every requirement in a project,
|
||||||
|
// ordered by id (insertion order).
|
||||||
|
func (s *Store) ListConnectionRequirements(projectID int64) ([]ConnectionRequirement, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
||||||
|
must_connect, notes, created_at, updated_at
|
||||||
|
FROM connection_requirements WHERE project_id = ? ORDER BY id`, projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []ConnectionRequirement{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r ConnectionRequirement
|
||||||
|
var ct sql.NullInt64
|
||||||
|
var must int
|
||||||
|
if err := rows.Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
|
||||||
|
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ct.Valid {
|
||||||
|
v := ct.Int64
|
||||||
|
r.PreferredCableTypeID = &v
|
||||||
|
}
|
||||||
|
r.MustConnect = must != 0
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConnectionRequirement applies a partial update. preferred_cable_type_id
|
||||||
|
// uses the FrameRef tri-state; must_connect + notes are plain pointers.
|
||||||
|
// The (from, to) pair is immutable on PATCH — delete + recreate to change.
|
||||||
|
func (s *Store) UpdateConnectionRequirement(projectID, id int64, u ConnectionRequirementUpdate) (*ConnectionRequirement, error) {
|
||||||
|
cur, err := s.GetConnectionRequirement(projectID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.PreferredCableTypeID.Set {
|
||||||
|
if u.PreferredCableTypeID.ID != nil {
|
||||||
|
if _, err := s.GetCableType(*u.PreferredCableTypeID.ID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *u.PreferredCableTypeID.ID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur.PreferredCableTypeID = u.PreferredCableTypeID.ID
|
||||||
|
}
|
||||||
|
if u.MustConnect != nil {
|
||||||
|
cur.MustConnect = *u.MustConnect
|
||||||
|
}
|
||||||
|
if u.Notes != nil {
|
||||||
|
cur.Notes = *u.Notes
|
||||||
|
}
|
||||||
|
mustInt := 0
|
||||||
|
if cur.MustConnect {
|
||||||
|
mustInt = 1
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE connection_requirements
|
||||||
|
SET preferred_cable_type_id = ?, must_connect = ?, notes = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND project_id = ?`,
|
||||||
|
nullableInt64(cur.PreferredCableTypeID), mustInt, cur.Notes, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return s.GetConnectionRequirement(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConnectionRequirement removes a requirement by id, project-scoped.
|
||||||
|
func (s *Store) DeleteConnectionRequirement(projectID, id int64) error {
|
||||||
|
if _, err := s.GetConnectionRequirement(projectID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`DELETE FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
181
internal/db/connection_requirements_test.go
Normal file
181
internal/db/connection_requirements_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTwoDevices(t *testing.T, s *Store) (int64, int64, int64) {
|
||||||
|
t.Helper()
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", X: 0, Y: 0, Width: 100, Height: 35})
|
||||||
|
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", X: 200, Y: 0, Width: 100, Height: 35})
|
||||||
|
return p.ID, a.ID, b.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConnReq_Basic(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, b := setupTwoDevices(t, s)
|
||||||
|
rj45 := int64(5)
|
||||||
|
r, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if !r.MustConnect {
|
||||||
|
t.Errorf("must_connect default should be true")
|
||||||
|
}
|
||||||
|
if r.PreferredCableTypeID == nil || *r.PreferredCableTypeID != rj45 {
|
||||||
|
t.Errorf("preferred_cable_type_id wrong: %+v", r.PreferredCableTypeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConnReq_PairNormalisationRejectsReverse(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, b := setupTwoDevices(t, s)
|
||||||
|
rj45 := int64(5)
|
||||||
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first: %v", err)
|
||||||
|
}
|
||||||
|
// (B, A, RJ45) should collide on UNIQUE (pair_lo, pair_hi, type).
|
||||||
|
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: b, ToDeviceID: a, PreferredCableTypeID: &rj45,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("reverse pair err = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConnReq_DifferentCableTypesCoexist(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, b := setupTwoDevices(t, s)
|
||||||
|
rj45, power := int64(5), int64(1)
|
||||||
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("rj45: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &power,
|
||||||
|
}); err != nil {
|
||||||
|
t.Errorf("power on same pair should be allowed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConnReq_SelfLoopRejected(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, _ := setupTwoDevices(t, s)
|
||||||
|
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: a,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("self-loop err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConnReq_CrossProjectDeviceRejected(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, _ := setupTwoDevices(t, s)
|
||||||
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||||
|
b2, _ := s.CreateDevice(p2.ID, DeviceCreate{Name: "X", X: 0, Y: 0, Width: 100, Height: 35})
|
||||||
|
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b2.ID,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("cross-project to-device err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateConnReq_NullCableTypeUniqueByPair(t *testing.T) {
|
||||||
|
// Two NULL-cable-type reqs on the same pair are NOT a conflict in
|
||||||
|
// SQLite (NULL != NULL in UNIQUE comparisons). This is fine — they
|
||||||
|
// represent "solver picks" both times; the second wins when solving.
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, b := setupTwoDevices(t, s)
|
||||||
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b,
|
||||||
|
}); err != nil {
|
||||||
|
t.Errorf("second NULL-type req should be allowed (SQLite NULL != NULL): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConnReq_PartialFields(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
pid, a, b := setupTwoDevices(t, s)
|
||||||
|
rj45, power := int64(5), int64(1)
|
||||||
|
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
||||||
|
})
|
||||||
|
notes := "important"
|
||||||
|
must := false
|
||||||
|
updated, err := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
|
||||||
|
PreferredCableTypeID: FrameRef{Set: true, ID: &power},
|
||||||
|
MustConnect: &must,
|
||||||
|
Notes: ¬es,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
351
internal/db/device_types.go
Normal file
351
internal/db/device_types.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrForbidden is the sentinel for "you can't mutate this row" — used by
|
||||||
|
// PATCH/DELETE on built-in device_types.
|
||||||
|
var ErrForbidden = errors.New("forbidden")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// device_types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DeviceTypeCreate is the shape POSTed under /api/projects/:pid/device-types.
|
||||||
|
// project_id is the URL :pid; the caller never passes it in the body.
|
||||||
|
type DeviceTypeCreate struct {
|
||||||
|
Name string
|
||||||
|
Kind string
|
||||||
|
Icon string
|
||||||
|
Description string
|
||||||
|
Ports []DeviceTypePortCreate
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceTypePortCreate is one row in the type's port profile.
|
||||||
|
type DeviceTypePortCreate struct {
|
||||||
|
CableTypeID int64
|
||||||
|
LabelPrefix string
|
||||||
|
Count int
|
||||||
|
Edge string
|
||||||
|
SortOrder int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceTypeUpdate is the partial-update shape. Built-in types reject
|
||||||
|
// any PATCH at the store level.
|
||||||
|
type DeviceTypeUpdate struct {
|
||||||
|
Name *string
|
||||||
|
Kind *string
|
||||||
|
Icon *string
|
||||||
|
Description *string
|
||||||
|
// Ports != nil means "replace the port profile with this set".
|
||||||
|
Ports *[]DeviceTypePortCreate
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeviceType inserts a project-custom row + its port profile in
|
||||||
|
// one transaction. projectID must be non-zero (built-ins are seed-only).
|
||||||
|
func (s *Store) CreateDeviceType(projectID int64, dt DeviceTypeCreate) (*DeviceType, error) {
|
||||||
|
name := strings.TrimSpace(dt.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if projectID == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: project_id is required (built-ins are seed-only)", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if _, err := s.GetProject(projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Forbid name-collisions with built-ins (UNIQUE(project_id,name)
|
||||||
|
// only enforces inside the project; built-ins have project_id IS
|
||||||
|
// NULL so the constraint doesn't catch them).
|
||||||
|
var builtinClash int
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM device_types WHERE project_id IS NULL AND name = ?`, name,
|
||||||
|
).Scan(&builtinClash); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if builtinClash > 0 {
|
||||||
|
return nil, fmt.Errorf("%w: name %q clashes with a built-in device type", ErrConflict, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := strings.TrimSpace(dt.Kind)
|
||||||
|
if kind == "" {
|
||||||
|
kind = "generic"
|
||||||
|
}
|
||||||
|
desc := dt.Description
|
||||||
|
var iconPtr any
|
||||||
|
if icon := strings.TrimSpace(dt.Icon); icon != "" {
|
||||||
|
iconPtr = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO device_types (project_id, name, kind, icon, description, built_in)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||||
|
projectID, name, kind, iconPtr, desc,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
for _, p := range dt.Ports {
|
||||||
|
if err := insertDeviceTypePort(tx, id, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetDeviceType(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertDeviceTypePort(tx *sql.Tx, deviceTypeID int64, p DeviceTypePortCreate) error {
|
||||||
|
if p.CableTypeID <= 0 {
|
||||||
|
return fmt.Errorf("%w: cable_type_id is required on each port row", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if p.Count <= 0 {
|
||||||
|
p.Count = 1
|
||||||
|
}
|
||||||
|
edge := strings.TrimSpace(p.Edge)
|
||||||
|
if edge == "" {
|
||||||
|
edge = "bottom"
|
||||||
|
}
|
||||||
|
if edge != "top" && edge != "bottom" && edge != "left" && edge != "right" {
|
||||||
|
return fmt.Errorf("%w: edge must be top/bottom/left/right", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(
|
||||||
|
`INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
deviceTypeID, p.CableTypeID, p.LabelPrefix, p.Count, edge, p.SortOrder,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceType loads a single type row (built-in OR project-custom)
|
||||||
|
// with its port profile.
|
||||||
|
func (s *Store) GetDeviceType(id int64) (*DeviceType, error) {
|
||||||
|
dt, err := scanDeviceTypeByID(s.db, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ports, err := s.listDeviceTypePorts(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dt.Ports = ports
|
||||||
|
return dt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDeviceTypeByID(d *sql.DB, id int64) (*DeviceType, error) {
|
||||||
|
var dt DeviceType
|
||||||
|
var proj sql.NullInt64
|
||||||
|
var icon sql.NullString
|
||||||
|
var built int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at
|
||||||
|
FROM device_types WHERE id = ?`, id,
|
||||||
|
).Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built,
|
||||||
|
&dt.CreatedAt, &dt.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if proj.Valid {
|
||||||
|
v := proj.Int64
|
||||||
|
dt.ProjectID = &v
|
||||||
|
}
|
||||||
|
if icon.Valid {
|
||||||
|
dt.Icon = &icon.String
|
||||||
|
}
|
||||||
|
dt.BuiltIn = built != 0
|
||||||
|
return &dt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBuiltInDeviceTypes returns every built-in type (project_id IS NULL).
|
||||||
|
func (s *Store) ListBuiltInDeviceTypes() ([]DeviceType, error) {
|
||||||
|
return s.listDeviceTypesWhere(`project_id IS NULL`, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeviceTypesForProject returns built-ins + the project's custom
|
||||||
|
// types, merged. Built-ins come first (insertion order), then custom by
|
||||||
|
// id.
|
||||||
|
func (s *Store) ListDeviceTypesForProject(projectID int64) ([]DeviceType, error) {
|
||||||
|
return s.listDeviceTypesWhere(
|
||||||
|
`project_id IS NULL OR project_id = ?`, []any{projectID},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listDeviceTypesWhere(where string, args []any) ([]DeviceType, error) {
|
||||||
|
q := `SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at
|
||||||
|
FROM device_types WHERE ` + where +
|
||||||
|
` ORDER BY (project_id IS NOT NULL), id`
|
||||||
|
rows, err := s.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []DeviceType{}
|
||||||
|
for rows.Next() {
|
||||||
|
var dt DeviceType
|
||||||
|
var proj sql.NullInt64
|
||||||
|
var icon sql.NullString
|
||||||
|
var built int
|
||||||
|
if err := rows.Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built,
|
||||||
|
&dt.CreatedAt, &dt.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if proj.Valid {
|
||||||
|
v := proj.Int64
|
||||||
|
dt.ProjectID = &v
|
||||||
|
}
|
||||||
|
if icon.Valid {
|
||||||
|
dt.Icon = &icon.String
|
||||||
|
}
|
||||||
|
dt.BuiltIn = built != 0
|
||||||
|
out = append(out, dt)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Hydrate ports per row. Two queries per request is fine for the
|
||||||
|
// catalog size; switch to a single JOIN-and-group if it becomes hot.
|
||||||
|
for i := range out {
|
||||||
|
ps, err := s.listDeviceTypePorts(out[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[i].Ports = ps
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listDeviceTypePorts(deviceTypeID int64) ([]DeviceTypePort, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, device_type_id, cable_type_id, label_prefix, count, edge, sort_order
|
||||||
|
FROM device_type_ports WHERE device_type_id = ? ORDER BY sort_order, id`,
|
||||||
|
deviceTypeID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []DeviceTypePort{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p DeviceTypePort
|
||||||
|
if err := rows.Scan(&p.ID, &p.DeviceTypeID, &p.CableTypeID,
|
||||||
|
&p.LabelPrefix, &p.Count, &p.Edge, &p.SortOrder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDeviceType applies a partial update. Built-in rows are rejected
|
||||||
|
// with ErrForbidden. Cross-project rows are rejected with ErrNotFound.
|
||||||
|
// Replacing the port profile (Ports != nil) wipes and re-inserts.
|
||||||
|
func (s *Store) UpdateDeviceType(projectID, id int64, u DeviceTypeUpdate) (*DeviceType, error) {
|
||||||
|
cur, err := s.GetDeviceType(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cur.BuiltIn {
|
||||||
|
return nil, fmt.Errorf("%w: built-in device types are read-only", ErrForbidden)
|
||||||
|
}
|
||||||
|
if cur.ProjectID == nil || *cur.ProjectID != projectID {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Name != nil {
|
||||||
|
v := strings.TrimSpace(*u.Name)
|
||||||
|
if v == "" {
|
||||||
|
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
cur.Name = v
|
||||||
|
}
|
||||||
|
if u.Kind != nil {
|
||||||
|
v := strings.TrimSpace(*u.Kind)
|
||||||
|
if v == "" {
|
||||||
|
v = "generic"
|
||||||
|
}
|
||||||
|
cur.Kind = v
|
||||||
|
}
|
||||||
|
if u.Icon != nil {
|
||||||
|
v := strings.TrimSpace(*u.Icon)
|
||||||
|
if v == "" {
|
||||||
|
cur.Icon = nil
|
||||||
|
} else {
|
||||||
|
cur.Icon = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Description != nil {
|
||||||
|
cur.Description = *u.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var iconArg any
|
||||||
|
if cur.Icon != nil {
|
||||||
|
iconArg = *cur.Icon
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE device_types
|
||||||
|
SET name = ?, kind = ?, icon = ?, description = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
cur.Name, cur.Kind, iconArg, cur.Description, id,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
if u.Ports != nil {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM device_type_ports WHERE device_type_id = ?`, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range *u.Ports {
|
||||||
|
if err := insertDeviceTypePort(tx, id, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetDeviceType(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDeviceType removes a project-custom row. Built-ins → ErrForbidden.
|
||||||
|
// Cross-project → ErrNotFound. Cascades to device_type_ports (FK CASCADE)
|
||||||
|
// and SET-NULLs the type_id on any device referencing it.
|
||||||
|
func (s *Store) DeleteDeviceType(projectID, id int64) error {
|
||||||
|
cur, err := s.GetDeviceType(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cur.BuiltIn {
|
||||||
|
return fmt.Errorf("%w: built-in device types are read-only", ErrForbidden)
|
||||||
|
}
|
||||||
|
if cur.ProjectID == nil || *cur.ProjectID != projectID {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM device_types WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
363
internal/db/device_types_test.go
Normal file
363
internal/db/device_types_test.go
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -------------------------------------------------------- catalog (seeded)
|
||||||
|
|
||||||
|
func TestSeed_BuiltInDeviceTypes(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
got, err := s.ListBuiltInDeviceTypes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
|
||||||
|
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
|
||||||
|
"Screen", "Keyboard", "Mouse",
|
||||||
|
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Name != w {
|
||||||
|
t.Errorf("[%d] = %q, want %q", i, got[i].Name, w)
|
||||||
|
}
|
||||||
|
if !got[i].BuiltIn {
|
||||||
|
t.Errorf("[%d] %q should be built_in", i, got[i].Name)
|
||||||
|
}
|
||||||
|
if got[i].ProjectID != nil {
|
||||||
|
t.Errorf("[%d] %q should have project_id=nil", i, got[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeed_PortProfiles(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
all, _ := s.ListBuiltInDeviceTypes()
|
||||||
|
byName := map[string]DeviceType{}
|
||||||
|
for _, d := range all {
|
||||||
|
byName[d.Name] = d
|
||||||
|
}
|
||||||
|
cases := map[string]struct {
|
||||||
|
totalPorts int // sum of count across profile rows
|
||||||
|
}{
|
||||||
|
"NAS": {2}, // Power 1 + RJ45 1
|
||||||
|
"PC": {5}, // Power 1 + RJ45 1 + HDMI 1 + USB 2
|
||||||
|
"Mac": {4}, // Power 1 + HDMI 1 + USB 2
|
||||||
|
"Notebook": {3}, // Power 1 + USB 2
|
||||||
|
"TV": {3}, // Power 1 + HDMI 2
|
||||||
|
"Soundbar": {2}, // Power 1 + HDMI 1
|
||||||
|
"Switch": {6}, // Power 1 + RJ45 5
|
||||||
|
"fritz": {5}, // Power 1 + RJ45 4
|
||||||
|
"ChromeCast": {2}, // Power 1 + HDMI 1
|
||||||
|
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
|
||||||
|
"IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
|
||||||
|
"IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
|
||||||
|
"IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
|
||||||
|
"Screen": {2}, // Power 1 + HDMI 1
|
||||||
|
"Keyboard": {1}, // USB 1
|
||||||
|
"Mouse": {1}, // USB 1
|
||||||
|
"Multi-plug 3": {4}, // Power In 1 + Power Out 3 (after v6)
|
||||||
|
"Multi-plug 4": {5}, // Power In 1 + Power Out 4 (after v6)
|
||||||
|
"Multi-plug 5": {6}, // Power In 1 + Power Out 5 (after v6)
|
||||||
|
"Multi-plug 6": {7}, // Power In 1 + Power Out 6 (after v6)
|
||||||
|
"Wifi-plug": {2}, // Power In 1 + Power Out 1 (after v6)
|
||||||
|
}
|
||||||
|
for name, want := range cases {
|
||||||
|
dt, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing built-in %q", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, p := range dt.Ports {
|
||||||
|
total += p.Count
|
||||||
|
}
|
||||||
|
if total != want.totalPorts {
|
||||||
|
t.Errorf("%s: total ports = %d, want %d", name, total, want.totalPorts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeed_PowerHubs locks down the post-migration-006 port profile for
|
||||||
|
// every power-distribution device type: IOx-3/6/8, Multi-plug 3/4/5/6,
|
||||||
|
// and Wifi-plug. Each carries exactly two profile rows — a single
|
||||||
|
// "Power In" port on the top (back) edge and N "Power Out" ports on the
|
||||||
|
// bottom (front) edge, where N is the device-specific output count.
|
||||||
|
//
|
||||||
|
// This test covers the v5 catalog identity (kind, icon, built-in) for
|
||||||
|
// the 5 power-distribution types and the v6 port-profile fix for all
|
||||||
|
// 8 hubs in one table.
|
||||||
|
func TestSeed_PowerHubs(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
all, err := s.ListBuiltInDeviceTypes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 21 {
|
||||||
|
t.Errorf("built-in count = %d, want 21 (16 from v4 + 5 from v5)", len(all))
|
||||||
|
}
|
||||||
|
byName := map[string]DeviceType{}
|
||||||
|
for _, d := range all {
|
||||||
|
byName[d.Name] = d
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
// kind/icon are only set for the 5 v5-power types; empty means
|
||||||
|
// "don't check" (the IOx-* keep their v4-seeded kind=hub icon=nil).
|
||||||
|
kind string
|
||||||
|
icon string
|
||||||
|
outCount int // N — number of "Power Out" outlets on the bottom edge
|
||||||
|
}{
|
||||||
|
// v5 catalog (kind+icon checked)
|
||||||
|
{name: "Multi-plug 3", kind: "hub", icon: "🔌", outCount: 3},
|
||||||
|
{name: "Multi-plug 4", kind: "hub", icon: "🔌", outCount: 4},
|
||||||
|
{name: "Multi-plug 5", kind: "hub", icon: "🔌", outCount: 5},
|
||||||
|
{name: "Multi-plug 6", kind: "hub", icon: "🔌", outCount: 6},
|
||||||
|
{name: "Wifi-plug", kind: "accessory", icon: "📶", outCount: 1},
|
||||||
|
// v4 hubs re-shaped by v6 (kind/icon left blank → not checked)
|
||||||
|
{name: "IOx-3", outCount: 3},
|
||||||
|
{name: "IOx-6", outCount: 6},
|
||||||
|
{name: "IOx-8", outCount: 8},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
dt, ok := byName[c.name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing %q", c.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !dt.BuiltIn {
|
||||||
|
t.Errorf("%s: built_in should be true", c.name)
|
||||||
|
}
|
||||||
|
if dt.ProjectID != nil {
|
||||||
|
t.Errorf("%s: project_id should be nil", c.name)
|
||||||
|
}
|
||||||
|
if c.kind != "" && dt.Kind != c.kind {
|
||||||
|
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
|
||||||
|
}
|
||||||
|
if c.icon != "" && (dt.Icon == nil || *dt.Icon != c.icon) {
|
||||||
|
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
|
||||||
|
}
|
||||||
|
if len(dt.Ports) != 2 {
|
||||||
|
t.Errorf("%s: expected 2 port-profile rows, got %d", c.name, len(dt.Ports))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := dt.Ports[0]
|
||||||
|
out := dt.Ports[1]
|
||||||
|
if in.CableTypeID != 1 || in.Count != 1 || in.Edge != "top" || in.LabelPrefix != "Power In" {
|
||||||
|
t.Errorf("%s: Power In row mismatch: %+v", c.name, in)
|
||||||
|
}
|
||||||
|
if out.CableTypeID != 1 || out.Count != c.outCount || out.Edge != "bottom" || out.LabelPrefix != "Power Out" {
|
||||||
|
t.Errorf("%s: Power Out row mismatch: %+v (want count=%d)", c.name, out, c.outCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------- CRUD (custom rows)
|
||||||
|
|
||||||
|
func TestCreateDeviceType_CustomBasic(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
dt, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{
|
||||||
|
Name: "DigitalCam", Kind: "accessory",
|
||||||
|
Description: "A camera with HDMI out",
|
||||||
|
Ports: []DeviceTypePortCreate{
|
||||||
|
{CableTypeID: 1, LabelPrefix: "Power", Count: 1},
|
||||||
|
{CableTypeID: 3, LabelPrefix: "HDMI", Count: 1, SortOrder: 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if dt.BuiltIn {
|
||||||
|
t.Errorf("built_in should be false")
|
||||||
|
}
|
||||||
|
if dt.ProjectID == nil || *dt.ProjectID != p.ID {
|
||||||
|
t.Errorf("project_id mismatch: %+v", dt.ProjectID)
|
||||||
|
}
|
||||||
|
if len(dt.Ports) != 2 {
|
||||||
|
t.Errorf("port profile rows = %d, want 2", len(dt.Ports))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDeviceType_NameClashWithBuiltIn(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
_, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "NAS"})
|
||||||
|
if !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrConflict (NAS is built-in)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDeviceType_PerProjectUnique(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); err != nil {
|
||||||
|
t.Fatalf("first: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("dup err = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDeviceType_BuiltInForbidden(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
all, _ := s.ListBuiltInDeviceTypes()
|
||||||
|
nas := all[0]
|
||||||
|
newName := "renamed"
|
||||||
|
_, err := s.UpdateDeviceType(p.ID, nas.ID, DeviceTypeUpdate{Name: &newName})
|
||||||
|
if !errors.Is(err, ErrForbidden) {
|
||||||
|
t.Errorf("err = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDeviceType_BuiltInForbidden(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
all, _ := s.ListBuiltInDeviceTypes()
|
||||||
|
if err := s.DeleteDeviceType(p.ID, all[0].ID); !errors.Is(err, ErrForbidden) {
|
||||||
|
t.Errorf("err = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDeviceType_CrossProjectIsNotFound(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p1, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||||
|
dt, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Foo"})
|
||||||
|
newName := "bar"
|
||||||
|
if _, err := s.UpdateDeviceType(p2.ID, dt.ID, DeviceTypeUpdate{Name: &newName}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------- device + ports seed
|
||||||
|
|
||||||
|
func TestCreateDevice_SeedsPortsFromBuiltInType(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
all, _ := s.ListBuiltInDeviceTypes()
|
||||||
|
var nasID int64
|
||||||
|
for _, dt := range all {
|
||||||
|
if dt.Name == "NAS" {
|
||||||
|
nasID = dt.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nasID == 0 {
|
||||||
|
t.Fatal("NAS not in catalog")
|
||||||
|
}
|
||||||
|
d, err := s.CreateDevice(p.ID, DeviceCreate{
|
||||||
|
Name: "NAS-Loft", TypeID: &nasID,
|
||||||
|
X: 100, Y: 100, Width: 100, Height: 35,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if d.TypeID == nil || *d.TypeID != nasID {
|
||||||
|
t.Errorf("type_id wrong: %+v", d.TypeID)
|
||||||
|
}
|
||||||
|
ports, _ := s.ListPortsForProject(p.ID)
|
||||||
|
if len(ports) != 2 {
|
||||||
|
t.Fatalf("port count = %d, want 2 (Power + RJ45)", len(ports))
|
||||||
|
}
|
||||||
|
for _, prt := range ports {
|
||||||
|
if prt.YOffset != 35 {
|
||||||
|
t.Errorf("port y_offset = %v, want 35 (bottom edge)", prt.YOffset)
|
||||||
|
}
|
||||||
|
if prt.XOffset <= 0 || prt.XOffset >= 100 {
|
||||||
|
t.Errorf("port x_offset = %v, want between 0 and 100", prt.XOffset)
|
||||||
|
}
|
||||||
|
if prt.Label == nil {
|
||||||
|
t.Errorf("port label = nil, want non-nil (label_prefix is set)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDevice_SeedsPortsForPC_FourGroupsFiveTotal(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
all, _ := s.ListBuiltInDeviceTypes()
|
||||||
|
var pcID int64
|
||||||
|
for _, dt := range all {
|
||||||
|
if dt.Name == "PC" {
|
||||||
|
pcID = dt.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pcID == 0 {
|
||||||
|
t.Fatal("PC not in catalog")
|
||||||
|
}
|
||||||
|
if _, err := s.CreateDevice(p.ID, DeviceCreate{
|
||||||
|
Name: "Workstation", TypeID: &pcID,
|
||||||
|
X: 0, Y: 0, Width: 100, Height: 35,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
ports, _ := s.ListPortsForProject(p.ID)
|
||||||
|
if len(ports) != 5 {
|
||||||
|
t.Errorf("port count = %d, want 5 (Power+RJ45+HDMI+USB×2)", len(ports))
|
||||||
|
}
|
||||||
|
// USB×2 must produce two labels "USB 1" and "USB 2".
|
||||||
|
usbLabels := map[string]bool{}
|
||||||
|
for _, prt := range ports {
|
||||||
|
if prt.Label != nil && (*prt.Label == "USB 1" || *prt.Label == "USB 2") {
|
||||||
|
usbLabels[*prt.Label] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !usbLabels["USB 1"] || !usbLabels["USB 2"] {
|
||||||
|
t.Errorf("USB labels missing: got %v", usbLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDevice_NoTypeID_NoPorts(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
if _, err := s.CreateDevice(p.ID, DeviceCreate{
|
||||||
|
Name: "Freeform", X: 0, Y: 0, Width: 100, Height: 35,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
ports, _ := s.ListPortsForProject(p.ID)
|
||||||
|
if len(ports) != 0 {
|
||||||
|
t.Errorf("freeform device should have 0 ports, got %d", len(ports))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDevice_CrossProjectCustomTypeRejected(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p1, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||||
|
custom, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Exotic"})
|
||||||
|
_, err := s.CreateDevice(p2.ID, DeviceCreate{
|
||||||
|
Name: "Wrong", TypeID: &custom.ID,
|
||||||
|
X: 0, Y: 0, Width: 100, Height: 35,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInput (cross-project custom type)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshot_IncludesPorts(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
all, _ := s.ListBuiltInDeviceTypes()
|
||||||
|
for _, dt := range all {
|
||||||
|
if dt.Name == "Mac" {
|
||||||
|
_, _ = s.CreateDevice(p.ID, DeviceCreate{
|
||||||
|
Name: "M1", TypeID: &dt.ID,
|
||||||
|
X: 0, Y: 0, Width: 100, Height: 35,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap, _ := s.Snapshot(p.ID)
|
||||||
|
if len(snap.Ports) != 4 {
|
||||||
|
t.Errorf("snapshot.Ports = %d, want 4 (Mac: Power+HDMI+USB×2)", len(snap.Ports))
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/db/excalidraw_ids.go
Normal file
60
internal/db/excalidraw_ids.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersistExcalidrawIDs writes the assignments returned by the exporter
|
||||||
|
// back onto the corresponding rows. Idempotent: only updates rows whose
|
||||||
|
// excalidraw_id is currently NULL (the first export "owns" the id; later
|
||||||
|
// exports reuse it so mxdrw's collab cursors / undo history survive).
|
||||||
|
//
|
||||||
|
// Caller passes one map per kind; keys are the in-project row ids,
|
||||||
|
// values are the 21-char Excalidraw element ids the exporter minted.
|
||||||
|
func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||||
|
frames, devices, ports, ios, cables map[int64]string,
|
||||||
|
) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if err := updateExIDs(tx, "frames", projectID, frames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "devices", projectID, devices); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "ports", projectID, ports); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "io_markers", projectID, ios); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateExIDs(tx *sql.Tx, table string, projectID int64, m map[int64]string) error {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stmt, err := tx.Prepare(
|
||||||
|
`UPDATE ` + table + `
|
||||||
|
SET excalidraw_id = ?
|
||||||
|
WHERE id = ? AND project_id = ? AND excalidraw_id IS NULL`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for id, exID := range m {
|
||||||
|
if _, err := stmt.Exec(exID, id, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -166,9 +166,14 @@ func (s *Store) DeleteFrame(projectID, id int64) error {
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// DeviceCreate is the create-shape. FrameID may be nil ("outside any frame").
|
// 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 {
|
type DeviceCreate struct {
|
||||||
Name string
|
Name string
|
||||||
FrameID *int64
|
FrameID *int64
|
||||||
|
TypeID *int64
|
||||||
Color string
|
Color string
|
||||||
X float64
|
X float64
|
||||||
Y float64
|
Y float64
|
||||||
@@ -179,10 +184,11 @@ type DeviceCreate struct {
|
|||||||
// DeviceUpdate is the partial-update shape. project_id deliberately not
|
// DeviceUpdate is the partial-update shape. project_id deliberately not
|
||||||
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
|
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
|
||||||
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
|
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
|
||||||
// inner pointer is nil to clear.
|
// inner pointer is nil to clear. TypeID uses the same FrameRef tri-state.
|
||||||
type DeviceUpdate struct {
|
type DeviceUpdate struct {
|
||||||
Name *string
|
Name *string
|
||||||
FrameID FrameRef // see FrameRef below
|
FrameID FrameRef // see FrameRef below
|
||||||
|
TypeID FrameRef // tri-state for type_id: same shape as FrameRef
|
||||||
Color *string
|
Color *string
|
||||||
X *float64
|
X *float64
|
||||||
Y *float64
|
Y *float64
|
||||||
@@ -201,7 +207,11 @@ type FrameRef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateDevice inserts a new device. FrameID, if provided, must reference
|
// CreateDevice inserts a new device. FrameID, if provided, must reference
|
||||||
// a frame in the same project.
|
// a frame in the same project. TypeID, if provided, must reference a
|
||||||
|
// built-in or a project-custom device_type in the same project — the
|
||||||
|
// store seeds the device's ports from that type's profile in the same
|
||||||
|
// transaction so a half-created device (row inserted, ports missing)
|
||||||
|
// can never exist.
|
||||||
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
|
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
|
||||||
name := strings.TrimSpace(d.Name)
|
name := strings.TrimSpace(d.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -221,32 +231,62 @@ func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
|
|||||||
return nil, err
|
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)
|
color := strings.TrimSpace(d.Color)
|
||||||
if color == "" {
|
if color == "" {
|
||||||
color = "#1e1e1e"
|
color = "#1e1e1e"
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.db.Exec(
|
tx, err := s.db.Begin()
|
||||||
`INSERT INTO devices (project_id, frame_id, name, color, x, y, width, height)
|
if err != nil {
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
return nil, err
|
||||||
projectID, nullableInt64(d.FrameID), name, color, d.X, d.Y, d.Width, d.Height,
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
||||||
|
name, color, d.X, d.Y, d.Width, d.Height,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapWriteErr(err)
|
return nil, mapWriteErr(err)
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
deviceID, _ := res.LastInsertId()
|
||||||
return s.GetDevice(projectID, id)
|
|
||||||
|
if d.TypeID != nil {
|
||||||
|
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetDevice(projectID, deviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDevice loads a device, project-scoped.
|
// GetDevice loads a device, project-scoped.
|
||||||
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
|
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
|
||||||
var d Device
|
var d Device
|
||||||
var frame sql.NullInt64
|
var frame, typeID sql.NullInt64
|
||||||
var ex sql.NullString
|
var ex sql.NullString
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
`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,
|
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
).Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||||
&ex, &d.CreatedAt, &d.UpdatedAt)
|
&ex, &d.CreatedAt, &d.UpdatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -258,6 +298,10 @@ func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
|
|||||||
v := frame.Int64
|
v := frame.Int64
|
||||||
d.FrameID = &v
|
d.FrameID = &v
|
||||||
}
|
}
|
||||||
|
if typeID.Valid {
|
||||||
|
v := typeID.Int64
|
||||||
|
d.TypeID = &v
|
||||||
|
}
|
||||||
if ex.Valid {
|
if ex.Valid {
|
||||||
d.ExcalidrawID = &ex.String
|
d.ExcalidrawID = &ex.String
|
||||||
}
|
}
|
||||||
@@ -277,13 +321,13 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
|||||||
)
|
)
|
||||||
if frameID != nil {
|
if frameID != nil {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||||
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
|
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
|
||||||
projectID, *frameID,
|
projectID, *frameID,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||||
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
|
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
|
||||||
projectID,
|
projectID,
|
||||||
)
|
)
|
||||||
@@ -295,9 +339,9 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
|||||||
out := []Device{}
|
out := []Device{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d Device
|
var d Device
|
||||||
var frame sql.NullInt64
|
var frame, typeID sql.NullInt64
|
||||||
var ex sql.NullString
|
var ex sql.NullString
|
||||||
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||||
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -305,6 +349,10 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
|||||||
v := frame.Int64
|
v := frame.Int64
|
||||||
d.FrameID = &v
|
d.FrameID = &v
|
||||||
}
|
}
|
||||||
|
if typeID.Valid {
|
||||||
|
v := typeID.Int64
|
||||||
|
d.TypeID = &v
|
||||||
|
}
|
||||||
if ex.Valid {
|
if ex.Valid {
|
||||||
d.ExcalidrawID = &ex.String
|
d.ExcalidrawID = &ex.String
|
||||||
}
|
}
|
||||||
@@ -363,11 +411,27 @@ func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, erro
|
|||||||
}
|
}
|
||||||
cur.FrameID = u.FrameID.ID
|
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(
|
if _, err := s.db.Exec(
|
||||||
`UPDATE devices
|
`UPDATE devices
|
||||||
SET frame_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
SET frame_id = ?, type_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
||||||
WHERE id = ? AND project_id = ?`,
|
WHERE id = ? AND project_id = ?`,
|
||||||
nullableInt64(cur.FrameID), cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
nullableInt64(cur.FrameID), nullableInt64(cur.TypeID),
|
||||||
|
cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, mapWriteErr(err)
|
return nil, mapWriteErr(err)
|
||||||
}
|
}
|
||||||
|
|||||||
180
internal/db/io_markers.go
Normal file
180
internal/db/io_markers.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IOMarker is a wall-outlet terminator inside a project. Mostly Power
|
||||||
|
// by convention; the schema doesn't enforce it.
|
||||||
|
type IOMarker struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
FrameID *int64 `json:"frame_id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IOMarkerCreate is the create-shape.
|
||||||
|
type IOMarkerCreate struct {
|
||||||
|
FrameID *int64
|
||||||
|
Label string
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IOMarkerUpdate is the partial-update shape. project_id deliberately not
|
||||||
|
// settable; frame_id uses the same tri-state shape as DeviceUpdate.FrameID.
|
||||||
|
type IOMarkerUpdate struct {
|
||||||
|
Label *string
|
||||||
|
FrameID FrameRef
|
||||||
|
X *float64
|
||||||
|
Y *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIOMarker inserts a new IO marker. If frame_id is set, it must
|
||||||
|
// reference a frame in the same project.
|
||||||
|
func (s *Store) CreateIOMarker(projectID int64, m IOMarkerCreate) (*IOMarker, error) {
|
||||||
|
label := strings.TrimSpace(m.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = "IO"
|
||||||
|
}
|
||||||
|
if _, err := s.GetProject(projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m.FrameID != nil {
|
||||||
|
if _, err := s.GetFrame(projectID, *m.FrameID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *m.FrameID, projectID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`INSERT INTO io_markers (project_id, frame_id, label, x, y)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
projectID, nullableInt64(m.FrameID), label, m.X, m.Y,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetIOMarker(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIOMarker loads an IO marker, project-scoped.
|
||||||
|
func (s *Store) GetIOMarker(projectID, id int64) (*IOMarker, error) {
|
||||||
|
var m IOMarker
|
||||||
|
var frame sql.NullInt64
|
||||||
|
var ex sql.NullString
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
|
||||||
|
FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
).Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, &ex, &m.CreatedAt, &m.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if frame.Valid {
|
||||||
|
v := frame.Int64
|
||||||
|
m.FrameID = &v
|
||||||
|
}
|
||||||
|
if ex.Valid {
|
||||||
|
m.ExcalidrawID = &ex.String
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIOMarkers returns every IO marker in a project, ordered by creation.
|
||||||
|
func (s *Store) ListIOMarkers(projectID int64) ([]IOMarker, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
|
||||||
|
FROM io_markers WHERE project_id = ? ORDER BY created_at, id`, projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []IOMarker{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m IOMarker
|
||||||
|
var frame sql.NullInt64
|
||||||
|
var ex sql.NullString
|
||||||
|
if err := rows.Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y,
|
||||||
|
&ex, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if frame.Valid {
|
||||||
|
v := frame.Int64
|
||||||
|
m.FrameID = &v
|
||||||
|
}
|
||||||
|
if ex.Valid {
|
||||||
|
m.ExcalidrawID = &ex.String
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateIOMarker applies a partial update. project_id is locked; frame_id
|
||||||
|
// tri-state mirrors DeviceUpdate.FrameID.
|
||||||
|
func (s *Store) UpdateIOMarker(projectID, id int64, u IOMarkerUpdate) (*IOMarker, error) {
|
||||||
|
cur, err := s.GetIOMarker(projectID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.Label != nil {
|
||||||
|
v := strings.TrimSpace(*u.Label)
|
||||||
|
if v == "" {
|
||||||
|
return nil, fmt.Errorf("%w: label cannot be empty", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
cur.Label = v
|
||||||
|
}
|
||||||
|
if u.X != nil {
|
||||||
|
cur.X = *u.X
|
||||||
|
}
|
||||||
|
if u.Y != nil {
|
||||||
|
cur.Y = *u.Y
|
||||||
|
}
|
||||||
|
if u.FrameID.Set {
|
||||||
|
if u.FrameID.ID != nil {
|
||||||
|
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur.FrameID = u.FrameID.ID
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE io_markers
|
||||||
|
SET frame_id = ?, label = ?, x = ?, y = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND project_id = ?`,
|
||||||
|
nullableInt64(cur.FrameID), cur.Label, cur.X, cur.Y, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return s.GetIOMarker(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIOMarker removes an IO marker from a project.
|
||||||
|
func (s *Store) DeleteIOMarker(projectID, id int64) error {
|
||||||
|
if _, err := s.GetIOMarker(projectID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`DELETE FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
113
internal/db/io_markers_test.go
Normal file
113
internal/db/io_markers_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateIOMarker_DefaultsLabel(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{X: 10, Y: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if m.Label != "IO" {
|
||||||
|
t.Errorf("default label = %q, want IO", m.Label)
|
||||||
|
}
|
||||||
|
if m.FrameID != nil {
|
||||||
|
t.Errorf("frame_id = %v, want nil", m.FrameID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIOMarker_CustomLabel(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 0, Y: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if m.Label != "Wall A" {
|
||||||
|
t.Errorf("label = %q, want Wall A", m.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIOMarker_CrossProjectFrameRejected(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p1, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||||
|
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||||
|
_, err := s.CreateIOMarker(p1.ID, IOMarkerCreate{FrameID: &f2.ID, X: 0, Y: 0})
|
||||||
|
if !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIOMarker_WrongProjectIsNotFound(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p1, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||||
|
m, _ := s.CreateIOMarker(p1.ID, IOMarkerCreate{X: 0, Y: 0})
|
||||||
|
if _, err := s.GetIOMarker(p2.ID, m.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("cross-project GetIOMarker should be ErrNotFound; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateIOMarker_FrameIDTriState(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
||||||
|
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 0, Y: 0})
|
||||||
|
|
||||||
|
// Leave alone — passing a different X must not clear frame_id.
|
||||||
|
nx := 99.0
|
||||||
|
u1, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{X: &nx})
|
||||||
|
if u1.FrameID == nil || *u1.FrameID != f.ID {
|
||||||
|
t.Errorf("frame_id should still be set (Set=false); got %v", u1.FrameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear.
|
||||||
|
u2, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{FrameID: FrameRef{Set: true, ID: nil}})
|
||||||
|
if u2.FrameID != nil {
|
||||||
|
t.Errorf("frame_id should be nil after clear; got %v", *u2.FrameID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFrame_SetsIOMarkerFrameIDToNull(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
|
||||||
|
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 10, Y: 20})
|
||||||
|
if m.FrameID == nil {
|
||||||
|
t.Fatalf("pre-condition: io marker should have frame_id")
|
||||||
|
}
|
||||||
|
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
|
||||||
|
t.Fatalf("delete frame: %v", err)
|
||||||
|
}
|
||||||
|
m2, _ := s.GetIOMarker(p.ID, m.ID)
|
||||||
|
if m2.FrameID != nil {
|
||||||
|
t.Errorf("io marker frame_id post-delete = %v, want nil (SET NULL)", m2.FrameID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshot_PopulatesIOMarkers(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 10, Y: 20})
|
||||||
|
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "UPS rear", X: 100, Y: 200})
|
||||||
|
snap, err := s.Snapshot(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if len(snap.IOMarkers) != 2 {
|
||||||
|
t.Errorf("io_markers len = %d, want 2", len(snap.IOMarkers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteIOMarker_NotFound(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
p, _ := s.CreateProject("LOFT", "", "")
|
||||||
|
if err := s.DeleteIOMarker(p.ID, 999); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("got %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
167
internal/db/migrations/002_device_catalog.sql
Normal file
167
internal/db/migrations/002_device_catalog.sql
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
-- mCables v4 device-type catalog. See docs/design.md §2.1 + §2.2.
|
||||||
|
|
||||||
|
-- v4 — device-type catalog. Built-in types live globally (project_id NULL).
|
||||||
|
-- Per-project custom types use project_id = X.
|
||||||
|
CREATE TABLE device_types (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'generic',
|
||||||
|
icon TEXT,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
built_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX device_types_project_idx ON device_types(project_id);
|
||||||
|
|
||||||
|
-- v4 — port profile per device type. Used to seed ports when a device
|
||||||
|
-- of that type is created.
|
||||||
|
CREATE TABLE device_type_ports (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
|
||||||
|
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
|
||||||
|
label_prefix TEXT NOT NULL DEFAULT '',
|
||||||
|
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
|
||||||
|
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_id);
|
||||||
|
|
||||||
|
-- v4 — devices gain a nullable type_id. SET NULL on type-delete so we
|
||||||
|
-- never cascade-delete a device the user still wants.
|
||||||
|
ALTER TABLE devices ADD COLUMN type_id INTEGER
|
||||||
|
REFERENCES device_types(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX devices_type_idx ON devices(type_id);
|
||||||
|
|
||||||
|
-- Seed the 14 built-in device types.
|
||||||
|
-- project_id stays NULL → built-in. The trio Screen / Keyboard / Mouse
|
||||||
|
-- was added in v4.1 to support the Home Office setup template (slice 6).
|
||||||
|
INSERT INTO device_types (name, kind, built_in, description) VALUES
|
||||||
|
('NAS', 'storage', 1, 'Network-attached storage'),
|
||||||
|
('PC', 'compute', 1, 'Desktop PC / workstation'),
|
||||||
|
('Mac', 'compute', 1, 'Mac (mini / studio / desktop)'),
|
||||||
|
('Notebook', 'compute', 1, 'Laptop / notebook'),
|
||||||
|
('TV', 'display', 1, 'Television'),
|
||||||
|
('Soundbar', 'audio', 1, 'Soundbar / AV receiver'),
|
||||||
|
('Switch', 'network', 1, 'Ethernet switch'),
|
||||||
|
('fritz', 'network', 1, 'AVM Fritz!Box router'),
|
||||||
|
('ChromeCast', 'display', 1, 'ChromeCast / streaming stick'),
|
||||||
|
('SteamLink', 'compute', 1, 'Steam Link / dedicated streaming box'),
|
||||||
|
('IOx-3', 'hub', 1, 'USB hub with 3 downstream ports'),
|
||||||
|
('IOx-6', 'hub', 1, 'USB hub with 6 downstream ports'),
|
||||||
|
('IOx-8', 'hub', 1, 'USB hub with 8 downstream ports'),
|
||||||
|
('Screen', 'display', 1, 'External monitor / display'),
|
||||||
|
('Keyboard', 'accessory', 1, 'Keyboard'),
|
||||||
|
('Mouse', 'accessory', 1, 'Mouse / pointing device');
|
||||||
|
|
||||||
|
-- Now seed device_type_ports. Each row references its device_type by
|
||||||
|
-- (SELECT id FROM device_types WHERE name = ? AND project_id IS NULL).
|
||||||
|
--
|
||||||
|
-- cable_types ids come from the 001 seed in fixed order:
|
||||||
|
-- 1=Power, 2=USB, 3=HDMI, 4=DP, 5=RJ45
|
||||||
|
--
|
||||||
|
-- label_prefix is what the seeder appends a 1..N suffix to when count>1.
|
||||||
|
-- Default edge is 'bottom'; sort_order positions the port-types from
|
||||||
|
-- left to right along that edge.
|
||||||
|
|
||||||
|
-- NAS: Power × 1, RJ45 × 1
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='NAS' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 5, 'RJ45', 1, 'bottom', 1 FROM device_types WHERE name='NAS' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- PC: Power × 1, RJ45 × 1, HDMI × 1, USB × 2
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='PC' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 5, 'RJ45', 1, 'bottom', 1 FROM device_types WHERE name='PC' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 1, 'bottom', 2 FROM device_types WHERE name='PC' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 2, 'bottom', 3 FROM device_types WHERE name='PC' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Mac: Power × 1, HDMI × 1, USB × 2
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Mac' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='Mac' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 2, 'bottom', 2 FROM device_types WHERE name='Mac' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Notebook: Power × 1, USB × 2
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Notebook' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 2, 'bottom', 1 FROM device_types WHERE name='Notebook' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- TV: Power × 1, HDMI × 2
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='TV' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 2, 'bottom', 1 FROM device_types WHERE name='TV' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Soundbar: Power × 1, HDMI × 1
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Soundbar' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='Soundbar' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Switch: Power × 1, RJ45 × 5
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Switch' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 5, 'RJ45', 5, 'bottom', 1 FROM device_types WHERE name='Switch' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- fritz: Power × 1, RJ45 × 4
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='fritz' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 5, 'RJ45', 4, 'bottom', 1 FROM device_types WHERE name='fritz' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- ChromeCast: Power × 1, HDMI × 1
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='ChromeCast' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='ChromeCast' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- SteamLink: Power × 1, HDMI × 1, USB × 2
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='SteamLink' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='SteamLink' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 2, 'bottom', 2 FROM device_types WHERE name='SteamLink' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- IOx-3: Power × 1, USB × 3
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- IOx-6: Power × 1, USB × 6
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- IOx-8: Power × 1, USB × 8
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Screen: Power × 1, HDMI × 1 (v4.1)
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 1, 'bottom', 0 FROM device_types WHERE name='Screen' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 3, 'HDMI', 1, 'bottom', 1 FROM device_types WHERE name='Screen' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Keyboard: USB × 1 (v4.1)
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 1, 'bottom', 0 FROM device_types WHERE name='Keyboard' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Mouse: USB × 1 (v4.1)
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 2, 'USB', 1, 'bottom', 0 FROM device_types WHERE name='Mouse' AND project_id IS NULL;
|
||||||
34
internal/db/migrations/003_connection_requirements.sql
Normal file
34
internal/db/migrations/003_connection_requirements.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- mCables v4.1 connection requirements + solver-owned cable flag.
|
||||||
|
-- See docs/design.md §2.1 + §2 connection_requirements + §5b.3.
|
||||||
|
|
||||||
|
-- The solver's input: "device A must connect to device B via cable type T".
|
||||||
|
-- Many per device. (from, to) is normalised on insert as
|
||||||
|
-- (pair_lo, pair_hi) = (MIN(from, to), MAX(from, to)) so (A,B,T) and (B,A,T)
|
||||||
|
-- can't coexist (UNIQUE enforces it).
|
||||||
|
CREATE TABLE connection_requirements (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||||||
|
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
pair_lo INTEGER NOT NULL,
|
||||||
|
pair_hi INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
CHECK (from_device_id != to_device_id),
|
||||||
|
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
|
||||||
|
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
|
||||||
|
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
|
||||||
|
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
|
||||||
|
|
||||||
|
-- Solver-owned cable flag (§5b.3): 1 = the solver placed this cable,
|
||||||
|
-- replaceable on re-solve. 0 = m hand-drew it, left alone by the solver.
|
||||||
|
-- Slice 6 ships the solver that writes auto=1; slice 7 ships hand-drawn
|
||||||
|
-- cable creation that writes auto=0.
|
||||||
|
ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (auto IN (0, 1));
|
||||||
|
CREATE INDEX cables_auto_idx ON cables(auto);
|
||||||
157
internal/db/migrations/004_setup_templates.sql
Normal file
157
internal/db/migrations/004_setup_templates.sql
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
-- mCables v4.1 setup templates. See docs/design.md §2.4.
|
||||||
|
--
|
||||||
|
-- A template is a named recipe of (device_types + requirements) that
|
||||||
|
-- bootstraps a project from blank to solver-ready in one apply call.
|
||||||
|
|
||||||
|
CREATE TABLE setup_templates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
built_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE setup_template_devices (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||||
|
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
|
||||||
|
suggested_name TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
|
||||||
|
|
||||||
|
CREATE TABLE setup_template_requirements (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||||
|
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||||
|
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||||
|
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||||||
|
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||||||
|
CHECK (from_template_device_id != to_template_device_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- Living Room
|
||||||
|
INSERT INTO setup_templates (name, description, built_in)
|
||||||
|
VALUES ('Living Room', 'TV + Soundbar + ChromeCast, HDMI between them.', 1);
|
||||||
|
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||||
|
(SELECT id FROM device_types WHERE name='TV' AND project_id IS NULL),
|
||||||
|
'TV', 0;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||||
|
(SELECT id FROM device_types WHERE name='Soundbar' AND project_id IS NULL),
|
||||||
|
'Soundbar', 1;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||||
|
(SELECT id FROM device_types WHERE name='ChromeCast' AND project_id IS NULL),
|
||||||
|
'ChromeCast', 2;
|
||||||
|
|
||||||
|
-- TV ↔ Soundbar (HDMI, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='Soundbar'),
|
||||||
|
3, 1;
|
||||||
|
-- TV ↔ ChromeCast (HDMI, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='ChromeCast'),
|
||||||
|
3, 1;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- Home Office
|
||||||
|
INSERT INTO setup_templates (name, description, built_in)
|
||||||
|
VALUES ('Home Office', 'PC + Screen + Keyboard + Mouse. HDMI + USB.', 1);
|
||||||
|
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM device_types WHERE name='PC' AND project_id IS NULL),
|
||||||
|
'PC', 0;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM device_types WHERE name='Screen' AND project_id IS NULL),
|
||||||
|
'Screen', 1;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM device_types WHERE name='Keyboard' AND project_id IS NULL),
|
||||||
|
'Keyboard', 2;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM device_types WHERE name='Mouse' AND project_id IS NULL),
|
||||||
|
'Mouse', 3;
|
||||||
|
|
||||||
|
-- PC ↔ Screen (HDMI, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Screen'),
|
||||||
|
3, 1;
|
||||||
|
-- PC ↔ Keyboard (USB, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Keyboard'),
|
||||||
|
2, 1;
|
||||||
|
-- PC ↔ Mouse (USB, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Mouse'),
|
||||||
|
2, 1;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- Server Rack
|
||||||
|
INSERT INTO setup_templates (name, description, built_in)
|
||||||
|
VALUES ('Server Rack', 'NAS + Switch + fritz. Ethernet trunk + power.', 1);
|
||||||
|
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||||
|
(SELECT id FROM device_types WHERE name='NAS' AND project_id IS NULL),
|
||||||
|
'NAS', 0;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||||
|
(SELECT id FROM device_types WHERE name='Switch' AND project_id IS NULL),
|
||||||
|
'Switch', 1;
|
||||||
|
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||||
|
(SELECT id FROM device_types WHERE name='fritz' AND project_id IS NULL),
|
||||||
|
'fritz', 2;
|
||||||
|
|
||||||
|
-- NAS ↔ Switch (RJ45, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='NAS'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
|
||||||
|
5, 1;
|
||||||
|
-- Switch ↔ fritz (RJ45, must)
|
||||||
|
INSERT INTO setup_template_requirements
|
||||||
|
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
|
||||||
|
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='fritz'),
|
||||||
|
5, 1;
|
||||||
32
internal/db/migrations/005_catalog_power.sql
Normal file
32
internal/db/migrations/005_catalog_power.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- mCables v5 — catalog: power-distribution devices.
|
||||||
|
-- Adds 5 built-in device_types (project_id NULL, built_in=1).
|
||||||
|
--
|
||||||
|
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The
|
||||||
|
-- solver treats every Power port identically regardless of in/out
|
||||||
|
-- direction; m knows which end is which from the physical setup.
|
||||||
|
--
|
||||||
|
-- Wifi-plug is a pass-through outlet (Power × 2: one in, one out).
|
||||||
|
|
||||||
|
INSERT INTO device_types (name, kind, icon, built_in, description) VALUES
|
||||||
|
('Multi-plug 3', 'hub', '🔌', 1, '3-way power strip (1 in + 3 out)'),
|
||||||
|
('Multi-plug 4', 'hub', '🔌', 1, '4-way power strip (1 in + 4 out)'),
|
||||||
|
('Multi-plug 5', 'hub', '🔌', 1, '5-way power strip (1 in + 5 out)'),
|
||||||
|
('Multi-plug 6', 'hub', '🔌', 1, '6-way power strip (1 in + 6 out)'),
|
||||||
|
('Wifi-plug', 'accessory', '📶', 1, 'WiFi-controllable pass-through outlet');
|
||||||
|
|
||||||
|
-- Port profiles. cable_types id 1 = Power (seeded in 001).
|
||||||
|
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 4, 'bottom', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 5, 'bottom', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 6, 'bottom', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 7, 'bottom', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power', 2, 'bottom', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||||
87
internal/db/migrations/006_fix_power_hubs.sql
Normal file
87
internal/db/migrations/006_fix_power_hubs.sql
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
-- mCables v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
|
||||||
|
--
|
||||||
|
-- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N),
|
||||||
|
-- but m's physical IOx-* devices are power strips (1 power input on
|
||||||
|
-- the back, N power outputs on the front). v5's Multi-plug 3/4/5/6
|
||||||
|
-- profiles also lumped every Power port on the bottom edge without
|
||||||
|
-- distinguishing the input from the outputs.
|
||||||
|
--
|
||||||
|
-- This migration replaces the port profile for the 8 power-distribution
|
||||||
|
-- types with the canonical "1 in (top/back) + N out (bottom/front)"
|
||||||
|
-- layout. Convention: top=back, bottom=front.
|
||||||
|
--
|
||||||
|
-- N for each type:
|
||||||
|
-- IOx-3 / Multi-plug 3 → 3 outputs
|
||||||
|
-- IOx-6 → 6 outputs
|
||||||
|
-- IOx-8 → 8 outputs
|
||||||
|
-- Multi-plug 4 → 4 outputs
|
||||||
|
-- Multi-plug 5 → 5 outputs
|
||||||
|
-- Multi-plug 6 → 6 outputs
|
||||||
|
-- Wifi-plug → 1 output (it's a pass-through outlet)
|
||||||
|
--
|
||||||
|
-- Existing devices m may have created with the old profile keep their
|
||||||
|
-- already-seeded ports — per design §2.3, ports are instance-owned. To
|
||||||
|
-- get the new layout on an existing instance, delete it and re-create.
|
||||||
|
--
|
||||||
|
-- cable_types id 1 = Power (seeded in 001).
|
||||||
|
|
||||||
|
-- 1) Drop the existing port-profile rows for each affected type.
|
||||||
|
DELETE FROM device_type_ports
|
||||||
|
WHERE device_type_id IN (
|
||||||
|
SELECT id FROM device_types
|
||||||
|
WHERE project_id IS NULL
|
||||||
|
AND name IN (
|
||||||
|
'IOx-3', 'IOx-6', 'IOx-8',
|
||||||
|
'Multi-plug 3', 'Multi-plug 4', 'Multi-plug 5', 'Multi-plug 6',
|
||||||
|
'Wifi-plug'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2) Insert the canonical (1 in on top, N out on bottom) profile.
|
||||||
|
-- IOx-3 — 1 in + 3 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- IOx-6 — 1 in + 6 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- IOx-8 — 1 in + 8 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Multi-plug 3 — 1 in + 3 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Multi-plug 4 — 1 in + 4 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 4, 'bottom', 1 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Multi-plug 5 — 1 in + 5 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 5, 'bottom', 1 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Multi-plug 6 — 1 in + 6 out
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||||
|
|
||||||
|
-- Wifi-plug — 1 in + 1 out (pass-through outlet)
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||||
|
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||||
|
SELECT id, 1, 'Power Out', 1, 'bottom', 1 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||||
@@ -34,10 +34,13 @@ type Frame struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Device is a hardware item inside a project, optionally inside a frame.
|
// 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 {
|
type Device struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ProjectID int64 `json:"project_id"`
|
ProjectID int64 `json:"project_id"`
|
||||||
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
|
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
|
||||||
|
TypeID *int64 `json:"type_id"` // nullable: freeform device when null
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
@@ -49,16 +52,171 @@ type Device struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceType is a catalog row. Built-in rows have ProjectID nil and
|
||||||
|
// BuiltIn true. Project-custom rows have ProjectID set.
|
||||||
|
type DeviceType struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID *int64 `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Icon *string `json:"icon,omitempty"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
BuiltIn bool `json:"built_in"`
|
||||||
|
Ports []DeviceTypePort `json:"ports"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceTypePort is a row of a type's port profile. The seeder uses
|
||||||
|
// (cable_type_id, count, label_prefix, edge, sort_order) to lay out
|
||||||
|
// concrete ports on a freshly-created device.
|
||||||
|
type DeviceTypePort struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
DeviceTypeID int64 `json:"device_type_id"`
|
||||||
|
CableTypeID int64 `json:"cable_type_id"`
|
||||||
|
LabelPrefix string `json:"label_prefix"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Edge string `json:"edge"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port is a connector on a device. cable_type colour drives the visual
|
||||||
|
// rendering; ports are instance-owned even when seeded from a type.
|
||||||
|
type Port struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
DeviceID int64 `json:"device_id"`
|
||||||
|
TypeID int64 `json:"type_id"` // cable type
|
||||||
|
Label *string `json:"label"`
|
||||||
|
XOffset float64 `json:"x_offset"`
|
||||||
|
YOffset float64 `json:"y_offset"`
|
||||||
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionRequirement is the solver's per-project input.
|
||||||
|
// pair_lo/pair_hi are the ordered (MIN,MAX) of (from, to) so the
|
||||||
|
// UNIQUE on (project_id, pair_lo, pair_hi, preferred_cable_type_id)
|
||||||
|
// prevents (A,B,T) AND (B,A,T) from coexisting.
|
||||||
|
type ConnectionRequirement struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
FromDeviceID int64 `json:"from_device_id"`
|
||||||
|
ToDeviceID int64 `json:"to_device_id"`
|
||||||
|
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
|
||||||
|
MustConnect bool `json:"must_connect"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cable is a typed connection. Each endpoint is exactly one of
|
||||||
|
// (port, device, io-marker). Auto=true means the solver placed it.
|
||||||
|
type Cable struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
TypeID int64 `json:"type_id"`
|
||||||
|
Label *string `json:"label"`
|
||||||
|
FromPortID *int64 `json:"from_port_id"`
|
||||||
|
FromDeviceID *int64 `json:"from_device_id"`
|
||||||
|
FromIOID *int64 `json:"from_io_id"`
|
||||||
|
ToPortID *int64 `json:"to_port_id"`
|
||||||
|
ToDeviceID *int64 `json:"to_device_id"`
|
||||||
|
ToIOID *int64 `json:"to_io_id"`
|
||||||
|
Auto bool `json:"auto"`
|
||||||
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle is a named group of cables that physically run together.
|
||||||
|
type Bundle struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Auto bool `json:"auto"`
|
||||||
|
CableIDs []int64 `json:"cable_ids"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupTemplate is a named recipe of device-types + requirements.
|
||||||
|
type SetupTemplate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
BuiltIn bool `json:"built_in"`
|
||||||
|
Devices []SetupTemplateDevice `json:"devices"`
|
||||||
|
Requirements []SetupTemplateRequirement `json:"requirements"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupTemplateDevice struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TemplateID int64 `json:"template_id"`
|
||||||
|
DeviceTypeID int64 `json:"device_type_id"`
|
||||||
|
DeviceType *DeviceType `json:"device_type,omitempty"`
|
||||||
|
SuggestedName *string `json:"suggested_name"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupTemplateRequirement struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TemplateID int64 `json:"template_id"`
|
||||||
|
FromTemplateDeviceID int64 `json:"from_template_device_id"`
|
||||||
|
ToTemplateDeviceID int64 `json:"to_template_device_id"`
|
||||||
|
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
|
||||||
|
MustConnect bool `json:"must_connect"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SolveResult is the response shape from POST /api/projects/:pid/solve.
|
||||||
|
type SolveResult struct {
|
||||||
|
CablesAdded []Cable `json:"cables_added"`
|
||||||
|
CablesKept []int64 `json:"cables_kept"`
|
||||||
|
CablesRemoved []int64 `json:"cables_removed"`
|
||||||
|
BundlesAdded []Bundle `json:"bundles_added"`
|
||||||
|
BundlesRemoved []int64 `json:"bundles_removed"`
|
||||||
|
Unsatisfied []UnsatisfiedReq `json:"unsatisfied"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnsatisfiedReq struct {
|
||||||
|
RequirementID int64 `json:"requirement_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
WhichSide string `json:"which_side,omitempty"` // "from" | "to" | "" when both/neither
|
||||||
|
CableType string `json:"cable_type,omitempty"` // when known
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyTemplateResult is the response from POST /apply-template.
|
||||||
|
type ApplyTemplateResult struct {
|
||||||
|
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.
|
// 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
|
// Arrays for collections still gated by future slices stay non-nil [] so
|
||||||
// JSON encodes as [] not null.
|
// JSON encodes as [] not null.
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Project Project `json:"project"`
|
Project Project `json:"project"`
|
||||||
Frames []Frame `json:"frames"`
|
Frames []Frame `json:"frames"`
|
||||||
Devices []Device `json:"devices"`
|
Devices []Device `json:"devices"`
|
||||||
Ports []any `json:"ports"`
|
Ports []Port `json:"ports"`
|
||||||
Cables []any `json:"cables"`
|
Cables []Cable `json:"cables"`
|
||||||
IOMarkers []any `json:"io_markers"`
|
IOMarkers []IOMarker `json:"io_markers"`
|
||||||
Bundles []any `json:"bundles"`
|
Bundles []Bundle `json:"bundles"`
|
||||||
CableTypes []CableType `json:"cable_types"`
|
CableTypes []CableType `json:"cable_types"`
|
||||||
|
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
|
||||||
}
|
}
|
||||||
|
|||||||
359
internal/db/ports.go
Normal file
359
internal/db/ports.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports.
|
||||||
|
type PortCreate struct {
|
||||||
|
TypeID int64
|
||||||
|
Label string
|
||||||
|
XOffset float64
|
||||||
|
YOffset float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortUpdate is the partial-update shape.
|
||||||
|
type PortUpdate struct {
|
||||||
|
TypeID *int64
|
||||||
|
Label *string
|
||||||
|
XOffset *float64
|
||||||
|
YOffset *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePort inserts a port on a device. The device must exist in the
|
||||||
|
// project; the cable type must exist globally.
|
||||||
|
func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) {
|
||||||
|
if _, err := s.GetDevice(projectID, deviceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.GetCableType(p.TypeID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(p.Label)
|
||||||
|
var labelArg any
|
||||||
|
if label != "" {
|
||||||
|
labelArg = label
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.GetPort(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPort loads a port by id, project-scoped.
|
||||||
|
func (s *Store) GetPort(projectID, id int64) (*Port, error) {
|
||||||
|
var p Port
|
||||||
|
var label, ex sql.NullString
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||||
|
excalidraw_id, created_at, updated_at
|
||||||
|
FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
||||||
|
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if label.Valid {
|
||||||
|
v := label.String
|
||||||
|
p.Label = &v
|
||||||
|
}
|
||||||
|
if ex.Valid {
|
||||||
|
p.ExcalidrawID = &ex.String
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePort applies a partial update.
|
||||||
|
func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) {
|
||||||
|
cur, err := s.GetPort(projectID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.TypeID != nil {
|
||||||
|
if _, err := s.GetCableType(*u.TypeID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cur.TypeID = *u.TypeID
|
||||||
|
}
|
||||||
|
if u.Label != nil {
|
||||||
|
v := strings.TrimSpace(*u.Label)
|
||||||
|
if v == "" {
|
||||||
|
cur.Label = nil
|
||||||
|
} else {
|
||||||
|
cur.Label = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.XOffset != nil {
|
||||||
|
cur.XOffset = *u.XOffset
|
||||||
|
}
|
||||||
|
if u.YOffset != nil {
|
||||||
|
cur.YOffset = *u.YOffset
|
||||||
|
}
|
||||||
|
var labelArg any
|
||||||
|
if cur.Label != nil {
|
||||||
|
labelArg = *cur.Label
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE ports
|
||||||
|
SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND project_id = ?`,
|
||||||
|
cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return s.GetPort(projectID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePort removes a port from a device. The schema's
|
||||||
|
// ON DELETE SET NULL on cables.from_port_id / to_port_id collides with
|
||||||
|
// the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so
|
||||||
|
// we instead cascade-delete any cables that referenced the port on
|
||||||
|
// either side — same effect from m's POV: the cable is gone, m can
|
||||||
|
// re-draw if he still wants it.
|
||||||
|
func (s *Store) DeletePort(projectID, id int64) error {
|
||||||
|
if _, err := s.GetPort(projectID, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`,
|
||||||
|
projectID, id, id,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPortsForDevice returns every port on one device, project-scoped.
|
||||||
|
func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||||
|
excalidraw_id, created_at, updated_at
|
||||||
|
FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`,
|
||||||
|
projectID, deviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Port{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Port
|
||||||
|
var label, ex sql.NullString
|
||||||
|
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
||||||
|
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if label.Valid {
|
||||||
|
v := label.String
|
||||||
|
p.Label = &v
|
||||||
|
}
|
||||||
|
if ex.Valid {
|
||||||
|
p.ExcalidrawID = &ex.String
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPortsForProject returns every port in a project, ordered by
|
||||||
|
// device_id + id so callers can group cheaply.
|
||||||
|
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||||
|
excalidraw_id, created_at, updated_at
|
||||||
|
FROM ports WHERE project_id = ? ORDER BY device_id, id`, projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Port{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Port
|
||||||
|
var label, ex sql.NullString
|
||||||
|
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID,
|
||||||
|
&label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if label.Valid {
|
||||||
|
v := label.String
|
||||||
|
p.Label = &v
|
||||||
|
}
|
||||||
|
if ex.Valid {
|
||||||
|
p.ExcalidrawID = &ex.String
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPortsFromType inserts the ports for a freshly-created device using
|
||||||
|
// the type's `device_type_ports` profile. Port positions are computed by
|
||||||
|
// laying out cable-type groups evenly along the configured edge of the
|
||||||
|
// device, ordered by sort_order. Within a multi-count group the per-port
|
||||||
|
// spacing is also even. Runs inside the same transaction as the device
|
||||||
|
// insert so a failure rolls everything back.
|
||||||
|
//
|
||||||
|
// Layout strategy (v0):
|
||||||
|
// - All ports for a given type sit on the type's configured edge.
|
||||||
|
// - For each edge, compute total port count N (sum of count across
|
||||||
|
// entries on that edge) and distribute as t_i = (i + 1)/(N+1) along
|
||||||
|
// the edge length, so ports don't touch the corners.
|
||||||
|
// - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom).
|
||||||
|
// - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t.
|
||||||
|
// - Labels: '<prefix>' if count==1, '<prefix> N' (1-indexed) if count>1.
|
||||||
|
// Empty prefix → NULL label.
|
||||||
|
func (s *Store) seedPortsFromType(tx *sql.Tx, projectID, deviceID, typeID int64, width, height float64) error {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
`SELECT cable_type_id, label_prefix, count, edge, sort_order
|
||||||
|
FROM device_type_ports
|
||||||
|
WHERE device_type_id = ?
|
||||||
|
ORDER BY edge, sort_order, id`, typeID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type pendingPort struct {
|
||||||
|
cableTypeID int64
|
||||||
|
label *string
|
||||||
|
xOff float64
|
||||||
|
yOff float64
|
||||||
|
}
|
||||||
|
// Group rows by edge first; emit per-port y-or-x slots inside each edge.
|
||||||
|
type groupRow struct {
|
||||||
|
cableTypeID int64
|
||||||
|
labelPrefix string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
byEdge := map[string][]groupRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var g groupRow
|
||||||
|
var edge string
|
||||||
|
var sortOrder int
|
||||||
|
if err := rows.Scan(&g.cableTypeID, &g.labelPrefix, &g.count, &edge, &sortOrder); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byEdge[edge] = append(byEdge[edge], g)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending []pendingPort
|
||||||
|
for _, edge := range []string{"top", "bottom", "left", "right"} {
|
||||||
|
groups := byEdge[edge]
|
||||||
|
if len(groups) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
total += g.count
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Emit ports in group + within-group order.
|
||||||
|
idx := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
for k := 0; k < g.count; k++ {
|
||||||
|
t := float64(idx+1) / float64(total+1)
|
||||||
|
var xOff, yOff float64
|
||||||
|
switch edge {
|
||||||
|
case "top":
|
||||||
|
xOff, yOff = width*t, 0
|
||||||
|
case "bottom":
|
||||||
|
xOff, yOff = width*t, height
|
||||||
|
case "left":
|
||||||
|
xOff, yOff = 0, height*t
|
||||||
|
case "right":
|
||||||
|
xOff, yOff = width, height*t
|
||||||
|
}
|
||||||
|
var labelPtr *string
|
||||||
|
if g.labelPrefix != "" {
|
||||||
|
var lbl string
|
||||||
|
if g.count == 1 {
|
||||||
|
lbl = g.labelPrefix
|
||||||
|
} else {
|
||||||
|
lbl = g.labelPrefix + " " + itoa(k+1)
|
||||||
|
}
|
||||||
|
labelPtr = &lbl
|
||||||
|
}
|
||||||
|
pending = append(pending, pendingPort{
|
||||||
|
cableTypeID: g.cableTypeID, label: labelPtr,
|
||||||
|
xOff: xOff, yOff: yOff,
|
||||||
|
})
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range pending {
|
||||||
|
var labelArg any
|
||||||
|
if p.label != nil {
|
||||||
|
labelArg = *p.label
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, deviceID, p.cableTypeID, labelArg, p.xOff, p.yOff,
|
||||||
|
); err != nil {
|
||||||
|
return mapWriteErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa is a tiny non-allocating int-to-string for port labels.
|
||||||
|
func itoa(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
buf := [20]byte{}
|
||||||
|
pos := len(buf)
|
||||||
|
neg := i < 0
|
||||||
|
if neg {
|
||||||
|
i = -i
|
||||||
|
}
|
||||||
|
for i > 0 {
|
||||||
|
pos--
|
||||||
|
buf[pos] = byte('0' + i%10)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
pos--
|
||||||
|
buf[pos] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[pos:])
|
||||||
|
}
|
||||||
367
internal/db/setup_templates.go
Normal file
367
internal/db/setup_templates.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"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{
|
||||||
|
DevicesAdded: []Device{},
|
||||||
|
RequirementsAdded: []ConnectionRequirement{},
|
||||||
|
SkippedDevices: []SkippedTemplateDevice{},
|
||||||
|
RequirementsSkipped: []SkippedTemplateReq{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OriginX == 0 && opts.OriginY == 0 {
|
||||||
|
opts.OriginX, opts.OriginY = 200, 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull existing device 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
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// Lay out devices in a horizontal row near the origin, 150 px apart.
|
||||||
|
x := opts.OriginX + float64(i)*150
|
||||||
|
y := opts.OriginY
|
||||||
|
// Use createDeviceTx so the port-seeding share the same transaction.
|
||||||
|
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
|
||||||
|
Name: name,
|
||||||
|
TypeID: &td.DeviceTypeID,
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
Width: 100,
|
||||||
|
Height: 35,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDeviceTx is a tx-aware variant of CreateDevice used by
|
||||||
|
// ApplyTemplate so seeding the template's devices + their ports stays
|
||||||
|
// inside one atomic apply.
|
||||||
|
//
|
||||||
|
// Validation is intentionally lighter than CreateDevice: callers (only
|
||||||
|
// ApplyTemplate today) hold a tx on the single SQLite connection, so
|
||||||
|
// any "validate by reading from s.db" call would deadlock. The template's
|
||||||
|
// device_type_id + frame_id come from already-validated template rows,
|
||||||
|
// and SQLite FK constraints catch any genuine corruption on INSERT
|
||||||
|
// (mapped to ErrInvalidInput by mapWriteErr).
|
||||||
|
func (s *Store) createDeviceTx(tx *sql.Tx, projectID int64, d DeviceCreate) (*Device, error) {
|
||||||
|
name := strings.TrimSpace(d.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if d.Width <= 0 || d.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
color := strings.TrimSpace(d.Color)
|
||||||
|
if color == "" {
|
||||||
|
color = "#1e1e1e"
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
||||||
|
name, color, d.X, d.Y, d.Width, d.Height,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
deviceID, _ := res.LastInsertId()
|
||||||
|
if d.TypeID != nil {
|
||||||
|
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Read back via the public store path is fine — the row exists in
|
||||||
|
// the in-flight tx and SQLite sees its own writes within the tx.
|
||||||
|
// Use a sub-helper that takes the tx executor for clean isolation.
|
||||||
|
return s.readDeviceTx(tx, projectID, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) readDeviceTx(ex execer, projectID, id int64) (*Device, error) {
|
||||||
|
var d Device
|
||||||
|
var frame, typeID sql.NullInt64
|
||||||
|
var ex2 sql.NullString
|
||||||
|
err := ex.QueryRow(
|
||||||
|
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||||
|
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||||
|
&ex2, &d.CreatedAt, &d.UpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if frame.Valid {
|
||||||
|
v := frame.Int64
|
||||||
|
d.FrameID = &v
|
||||||
|
}
|
||||||
|
if typeID.Valid {
|
||||||
|
v := typeID.Int64
|
||||||
|
d.TypeID = &v
|
||||||
|
}
|
||||||
|
if ex2.Valid {
|
||||||
|
d.ExcalidrawID = &ex2.String
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
509
internal/db/solver.go
Normal file
509
internal/db/solver.go
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Solve runs the v0 algorithm (design v4.1 §5b.2) against the project.
|
||||||
|
// If preview is true, no DB writes happen — the function returns the
|
||||||
|
// diff it WOULD apply. If preview is false, the diff is applied in a
|
||||||
|
// single transaction.
|
||||||
|
//
|
||||||
|
// Algorithm:
|
||||||
|
// 1. Read all auto cables, manual cables, ports, requirements.
|
||||||
|
// 2. Reserve ports used by manual cables (auto=0) so the solver
|
||||||
|
// doesn't reuse them.
|
||||||
|
// 3. For each requirement (must_connect DESC, id ASC):
|
||||||
|
// - Resolve cable type: preferred, or T = port-types(from) ∩
|
||||||
|
// port-types(to). |T|==1 → that. |T|>1 → unsatisfied (ambiguous).
|
||||||
|
// |T|==0 → unsatisfied (no compat type).
|
||||||
|
// - Find lowest-id free port on each side. None → unsatisfied
|
||||||
|
// (no free port). Reserve both.
|
||||||
|
// - Stage an "add cable {from_port, to_port, type, auto=1}".
|
||||||
|
// 4. Endpoint-pair bundle: any pair of device endpoints with ≥ 2
|
||||||
|
// staged cables becomes an auto bundle.
|
||||||
|
// 5. Diff against existing auto cables/bundles: removed = existing
|
||||||
|
// auto rows not in the staged set; kept = those that match by
|
||||||
|
// (from_port, to_port, type); add = remaining staged rows.
|
||||||
|
func (s *Store) Solve(projectID int64, preview bool) (*SolveResult, error) {
|
||||||
|
res := &SolveResult{
|
||||||
|
CablesAdded: []Cable{},
|
||||||
|
CablesKept: []int64{},
|
||||||
|
CablesRemoved: []int64{},
|
||||||
|
BundlesAdded: []Bundle{},
|
||||||
|
BundlesRemoved: []int64{},
|
||||||
|
Unsatisfied: []UnsatisfiedReq{},
|
||||||
|
Warnings: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.GetProject(projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
devices, err := s.ListDevices(projectID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ports, err := s.ListPortsForProject(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cables, err := s.ListCables(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqs, err := s.ListConnectionRequirements(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bundles, err := s.ListBundles(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index ports by (device_id, type_id), sorted by id (deterministic).
|
||||||
|
portsByDevice := map[int64][]Port{}
|
||||||
|
for _, p := range ports {
|
||||||
|
portsByDevice[p.DeviceID] = append(portsByDevice[p.DeviceID], p)
|
||||||
|
}
|
||||||
|
for did := range portsByDevice {
|
||||||
|
sort.SliceStable(portsByDevice[did], func(i, j int) bool {
|
||||||
|
return portsByDevice[did][i].ID < portsByDevice[did][j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deviceByID := map[int64]Device{}
|
||||||
|
for _, d := range devices {
|
||||||
|
deviceByID[d.ID] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve ports used by manual cables.
|
||||||
|
usedPorts := map[int64]bool{}
|
||||||
|
autoCablesByID := map[int64]Cable{}
|
||||||
|
for _, c := range cables {
|
||||||
|
if c.Auto {
|
||||||
|
autoCablesByID[c.ID] = c
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.FromPortID != nil {
|
||||||
|
usedPorts[*c.FromPortID] = true
|
||||||
|
}
|
||||||
|
if c.ToPortID != nil {
|
||||||
|
usedPorts[*c.ToPortID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort requirements: must_connect DESC, id ASC.
|
||||||
|
rs := append([]ConnectionRequirement{}, reqs...)
|
||||||
|
sort.SliceStable(rs, func(i, j int) bool {
|
||||||
|
if rs[i].MustConnect != rs[j].MustConnect {
|
||||||
|
return rs[i].MustConnect
|
||||||
|
}
|
||||||
|
return rs[i].ID < rs[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
type staged struct {
|
||||||
|
typeID int64
|
||||||
|
fromPortID int64
|
||||||
|
toPortID int64
|
||||||
|
fromDeviceID int64
|
||||||
|
toDeviceID int64
|
||||||
|
}
|
||||||
|
var staging []staged
|
||||||
|
|
||||||
|
for _, r := range rs {
|
||||||
|
_, fromOK := deviceByID[r.FromDeviceID]
|
||||||
|
_, toOK := deviceByID[r.ToDeviceID]
|
||||||
|
if !fromOK || !toOK {
|
||||||
|
// Shouldn't happen (FK CASCADE removes the row when a device
|
||||||
|
// goes), but be defensive.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve cable type.
|
||||||
|
var typeID int64
|
||||||
|
if r.PreferredCableTypeID != nil {
|
||||||
|
typeID = *r.PreferredCableTypeID
|
||||||
|
} else {
|
||||||
|
fromTypes := map[int64]bool{}
|
||||||
|
for _, p := range portsByDevice[r.FromDeviceID] {
|
||||||
|
fromTypes[p.TypeID] = true
|
||||||
|
}
|
||||||
|
candidates := []int64{}
|
||||||
|
for _, p := range portsByDevice[r.ToDeviceID] {
|
||||||
|
if fromTypes[p.TypeID] {
|
||||||
|
// Add unique.
|
||||||
|
already := false
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c == p.TypeID {
|
||||||
|
already = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !already {
|
||||||
|
candidates = append(candidates, p.TypeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
if r.MustConnect {
|
||||||
|
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
||||||
|
RequirementID: r.ID,
|
||||||
|
Reason: "no compatible cable type — devices share no port-type",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(candidates) > 1 {
|
||||||
|
if r.MustConnect {
|
||||||
|
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
||||||
|
RequirementID: r.ID,
|
||||||
|
Reason: "ambiguous cable type — specify preferred_cable_type_id",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
typeID = candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick lowest-id free port of `typeID` on each side.
|
||||||
|
pickFree := func(deviceID, t int64) *int64 {
|
||||||
|
for _, p := range portsByDevice[deviceID] {
|
||||||
|
if p.TypeID != t {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usedPorts[p.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &p.ID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fromPort := pickFree(r.FromDeviceID, typeID)
|
||||||
|
toPort := pickFree(r.ToDeviceID, typeID)
|
||||||
|
if fromPort == nil || toPort == nil {
|
||||||
|
if r.MustConnect {
|
||||||
|
side := ""
|
||||||
|
if fromPort == nil && toPort == nil {
|
||||||
|
side = ""
|
||||||
|
} else if fromPort == nil {
|
||||||
|
side = "from"
|
||||||
|
} else {
|
||||||
|
side = "to"
|
||||||
|
}
|
||||||
|
typeName := ""
|
||||||
|
if ct, err := s.GetCableType(typeID); err == nil {
|
||||||
|
typeName = ct.Name
|
||||||
|
}
|
||||||
|
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
||||||
|
RequirementID: r.ID,
|
||||||
|
Reason: fmt.Sprintf("no free %s port", typeName),
|
||||||
|
WhichSide: side,
|
||||||
|
CableType: typeName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
usedPorts[*fromPort] = true
|
||||||
|
usedPorts[*toPort] = true
|
||||||
|
staging = append(staging, staged{
|
||||||
|
typeID: typeID, fromPortID: *fromPort, toPortID: *toPort,
|
||||||
|
fromDeviceID: r.FromDeviceID, toDeviceID: r.ToDeviceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match staged → existing auto cables by (typeID, fromPortID, toPortID)
|
||||||
|
// or its reverse. Anything matched is "kept"; the rest of auto cables
|
||||||
|
// is "removed". Unmatched staged entries become "added".
|
||||||
|
type sigKey struct{ typeID, a, b int64 }
|
||||||
|
matched := map[int64]bool{} // existing auto cable IDs that match
|
||||||
|
sigToAuto := map[sigKey]int64{}
|
||||||
|
for id, c := range autoCablesByID {
|
||||||
|
if c.FromPortID == nil || c.ToPortID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, b := *c.FromPortID, *c.ToPortID
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
sigToAuto[sigKey{c.TypeID, a, b}] = id
|
||||||
|
}
|
||||||
|
var toAdd []staged
|
||||||
|
for _, st := range staging {
|
||||||
|
a, b := st.fromPortID, st.toPortID
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
if existingID, ok := sigToAuto[sigKey{st.typeID, a, b}]; ok {
|
||||||
|
matched[existingID] = true
|
||||||
|
res.CablesKept = append(res.CablesKept, existingID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toAdd = append(toAdd, st)
|
||||||
|
}
|
||||||
|
for id := range autoCablesByID {
|
||||||
|
if !matched[id] {
|
||||||
|
res.CablesRemoved = append(res.CablesRemoved, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(res.CablesKept, func(i, j int) bool { return res.CablesKept[i] < res.CablesKept[j] })
|
||||||
|
sort.Slice(res.CablesRemoved, func(i, j int) bool { return res.CablesRemoved[i] < res.CablesRemoved[j] })
|
||||||
|
|
||||||
|
// Endpoint-pair bundling for the final set of auto cables (kept + added).
|
||||||
|
// Group by unordered (deviceA, deviceB). Build the map of port_id → device_id
|
||||||
|
// for fast lookup.
|
||||||
|
portToDevice := map[int64]int64{}
|
||||||
|
for _, p := range ports {
|
||||||
|
portToDevice[p.ID] = p.DeviceID
|
||||||
|
}
|
||||||
|
type pairKey struct{ a, b int64 }
|
||||||
|
pairGroup := map[pairKey][]string{} // staged-or-kept tags (we just count)
|
||||||
|
pairOrder := []pairKey{} // first-seen order
|
||||||
|
|
||||||
|
// We'll need the final list of cables-after-apply (with their IDs) to
|
||||||
|
// build bundles. For preview, kept IDs are real, added IDs are zero;
|
||||||
|
// for apply, we'll re-bundle after inserts.
|
||||||
|
|
||||||
|
if preview {
|
||||||
|
// In preview mode, "kept" IDs are real cables; "added" are
|
||||||
|
// staged. We still compute bundles_added so the UI can show
|
||||||
|
// which cable groups will be bundled. Bundles_added carry
|
||||||
|
// `CableIDs: []` for the staged entries because they don't
|
||||||
|
// have IDs yet — the UI maps by position. cables_kept that
|
||||||
|
// belong to a bundle group also list their existing ids.
|
||||||
|
// In short, slot every staged cable into the same pair bucket
|
||||||
|
// + the kept cables.
|
||||||
|
for _, st := range staging {
|
||||||
|
da, db := st.fromDeviceID, st.toDeviceID
|
||||||
|
if da > db {
|
||||||
|
da, db = db, da
|
||||||
|
}
|
||||||
|
pk := pairKey{da, db}
|
||||||
|
if _, ok := pairGroup[pk]; !ok {
|
||||||
|
pairOrder = append(pairOrder, pk)
|
||||||
|
}
|
||||||
|
pairGroup[pk] = append(pairGroup[pk], "")
|
||||||
|
}
|
||||||
|
// Materialise preview-shape Cable structs for the added rows.
|
||||||
|
for _, st := range toAdd {
|
||||||
|
c := Cable{
|
||||||
|
ProjectID: projectID,
|
||||||
|
TypeID: st.typeID,
|
||||||
|
FromPortID: ptr(st.fromPortID),
|
||||||
|
ToPortID: ptr(st.toPortID),
|
||||||
|
Auto: true,
|
||||||
|
}
|
||||||
|
res.CablesAdded = append(res.CablesAdded, c)
|
||||||
|
}
|
||||||
|
for _, pk := range pairOrder {
|
||||||
|
if len(pairGroup[pk]) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a := deviceByID[pk.a].Name
|
||||||
|
b := deviceByID[pk.b].Name
|
||||||
|
res.BundlesAdded = append(res.BundlesAdded, Bundle{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: a + " ↔ " + b,
|
||||||
|
Auto: true,
|
||||||
|
CableIDs: nil, // post-apply only
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Existing auto bundles all "would be removed" since we rebuild
|
||||||
|
// from scratch each solve (slice-6 v0 is wholesale-replace).
|
||||||
|
for _, b := range bundles {
|
||||||
|
if b.Auto {
|
||||||
|
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mode: open a transaction, delete removed auto cables + auto
|
||||||
|
// bundles, insert added cables, re-bundle by endpoint pair.
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Delete obsolete auto bundles (we'll rebuild).
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM bundles WHERE project_id = ? AND auto = 1`, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, b := range bundles {
|
||||||
|
if b.Auto {
|
||||||
|
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removed auto cables.
|
||||||
|
for _, id := range res.CablesRemoved {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert added cables. Track new ids by their staged signature for
|
||||||
|
// bundle wiring.
|
||||||
|
type addedRow struct {
|
||||||
|
id int64
|
||||||
|
staged staged
|
||||||
|
}
|
||||||
|
addedRows := []addedRow{}
|
||||||
|
for _, st := range toAdd {
|
||||||
|
c, err := s.createCable(tx, projectID, CableCreate{
|
||||||
|
TypeID: st.typeID,
|
||||||
|
From: CableEndpoint{PortID: &st.fromPortID},
|
||||||
|
To: CableEndpoint{PortID: &st.toPortID},
|
||||||
|
Auto: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.CablesAdded = append(res.CablesAdded, *c)
|
||||||
|
addedRows = append(addedRows, addedRow{id: c.ID, staged: st})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-bundle: all auto cables (kept + added) grouped by endpoint pair.
|
||||||
|
// First, collect cable IDs per (deviceA, deviceB) — both kept (from
|
||||||
|
// matched map) and added.
|
||||||
|
groups := map[pairKey][]int64{}
|
||||||
|
order := []pairKey{}
|
||||||
|
addToGroup := func(da, db, cid int64) {
|
||||||
|
if da > db {
|
||||||
|
da, db = db, da
|
||||||
|
}
|
||||||
|
pk := pairKey{da, db}
|
||||||
|
if _, ok := groups[pk]; !ok {
|
||||||
|
order = append(order, pk)
|
||||||
|
}
|
||||||
|
groups[pk] = append(groups[pk], cid)
|
||||||
|
}
|
||||||
|
for id, c := range autoCablesByID {
|
||||||
|
if !matched[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.FromPortID == nil || c.ToPortID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
da := portToDevice[*c.FromPortID]
|
||||||
|
db := portToDevice[*c.ToPortID]
|
||||||
|
if da == 0 || db == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addToGroup(da, db, id)
|
||||||
|
}
|
||||||
|
for _, ar := range addedRows {
|
||||||
|
addToGroup(ar.staged.fromDeviceID, ar.staged.toDeviceID, ar.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pk := range order {
|
||||||
|
ids := groups[pk]
|
||||||
|
if len(ids) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a := deviceByID[pk.a].Name
|
||||||
|
b := deviceByID[pk.b].Name
|
||||||
|
bundle, err := s.createBundle(tx, projectID, BundleCreate{
|
||||||
|
Name: a + " ↔ " + b,
|
||||||
|
CableIDs: ids,
|
||||||
|
Auto: true,
|
||||||
|
}, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.BundlesAdded = append(res.BundlesAdded, *bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
// PortsAndResolve adds a port to a device + re-runs Solve in one tx.
|
||||||
|
// Used by the inspector's "+ Add <type> port and re-solve" quick-fix.
|
||||||
|
type PortsAndResolveResult struct {
|
||||||
|
Port Port `json:"port"`
|
||||||
|
Solve *SolveResult `json:"solve"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PortsAndResolve(projectID, deviceID int64, typeID int64, label string, xOff, yOff float64) (*PortsAndResolveResult, error) {
|
||||||
|
d, err := s.GetDevice(projectID, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.GetCableType(typeID); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, typeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
// Default the new port to the bottom edge at the right-most existing offset.
|
||||||
|
if xOff == 0 && yOff == 0 {
|
||||||
|
xOff = d.Width / 2
|
||||||
|
yOff = d.Height
|
||||||
|
}
|
||||||
|
var labelArg any
|
||||||
|
if label != "" {
|
||||||
|
labelArg = label
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
projectID, deviceID, typeID, labelArg, xOff, yOff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
portID, _ := res.LastInsertId()
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Now re-solve outside the tx — Solve manages its own tx for the
|
||||||
|
// apply path. This is a slight relaxation of "single round-trip" — if
|
||||||
|
// the solver run fails the port stays, but that's fine; the port is
|
||||||
|
// what m wanted regardless.
|
||||||
|
solveRes, err := s.Solve(projectID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Re-fetch the port row to return its full shape.
|
||||||
|
port, err := s.getPortByID(portID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PortsAndResolveResult{Port: *port, Solve: solveRes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getPortByID(id int64) (*Port, error) {
|
||||||
|
var p Port
|
||||||
|
var label, ex sql.NullString
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||||
|
excalidraw_id, created_at, updated_at
|
||||||
|
FROM ports WHERE id = ?`, id,
|
||||||
|
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
||||||
|
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if label.Valid {
|
||||||
|
v := label.String
|
||||||
|
p.Label = &v
|
||||||
|
}
|
||||||
|
if ex.Valid {
|
||||||
|
p.ExcalidrawID = &ex.String
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
259
internal/db/solver_test.go
Normal file
259
internal/db/solver_test.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
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_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,15 +167,36 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
return &Snapshot{
|
||||||
Project: *p,
|
Project: *p,
|
||||||
Frames: frames,
|
Frames: frames,
|
||||||
Devices: devices,
|
Devices: devices,
|
||||||
Ports: []any{},
|
Ports: ports,
|
||||||
Cables: []any{},
|
Cables: cables,
|
||||||
IOMarkers: []any{},
|
IOMarkers: ios,
|
||||||
Bundles: []any{},
|
Bundles: bundles,
|
||||||
CableTypes: types,
|
CableTypes: types,
|
||||||
|
ConnectionRequirements: reqs,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
563
internal/exporter/exporter.go
Normal file
563
internal/exporter/exporter.go
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
// Package exporter builds an Excalidraw scene JSON from a project
|
||||||
|
// snapshot per docs/design.md §4 ("Export — DB → Excalidraw").
|
||||||
|
//
|
||||||
|
// The exporter is a pure function on a *db.Snapshot — no DB access, no
|
||||||
|
// IO — so it's trivial to unit-test against fixtures and gives the
|
||||||
|
// caller (the HTTP handler) a clean handoff: build scene → upload.
|
||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scene is the top-level Excalidraw file format. Keys mirror what the
|
||||||
|
// official Excalidraw JSON contains (we only emit the keys mxdrw cares
|
||||||
|
// about for rendering — `appState`, `files`, `libraryItems` etc. can be
|
||||||
|
// added later if m needs them).
|
||||||
|
type Scene struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Elements []Element `json:"elements"`
|
||||||
|
AppState AppState `json:"appState"`
|
||||||
|
Files Files `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppState struct {
|
||||||
|
GridSize *int `json:"gridSize"`
|
||||||
|
ViewBackground string `json:"viewBackgroundColor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Files struct{}
|
||||||
|
|
||||||
|
// Element is one node in the scene. Excalidraw's wire format has a lot
|
||||||
|
// of optional fields; we only emit the ones that matter for the shapes
|
||||||
|
// we draw. Extra null/zero fields are fine in Excalidraw (it merges
|
||||||
|
// defaults). Pointer fields stay nil-omitted via omitempty so the
|
||||||
|
// payload stays clean.
|
||||||
|
type Element struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
Angle float64 `json:"angle"`
|
||||||
|
StrokeColor string `json:"strokeColor"`
|
||||||
|
BackgroundColor string `json:"backgroundColor"`
|
||||||
|
FillStyle string `json:"fillStyle"`
|
||||||
|
StrokeWidth int `json:"strokeWidth"`
|
||||||
|
StrokeStyle string `json:"strokeStyle"`
|
||||||
|
Roughness int `json:"roughness"`
|
||||||
|
Opacity int `json:"opacity"`
|
||||||
|
GroupIDs []string `json:"groupIds"`
|
||||||
|
FrameID *string `json:"frameId"`
|
||||||
|
Roundness *Roundness `json:"roundness"`
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
VersionNonce int64 `json:"versionNonce"`
|
||||||
|
IsDeleted bool `json:"isDeleted"`
|
||||||
|
BoundElements []BoundRef `json:"boundElements,omitempty"`
|
||||||
|
Updated int64 `json:"updated"`
|
||||||
|
Link *string `json:"link"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
|
||||||
|
// Element-type-specific extras
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
|
||||||
|
// Text-element fields
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
FontSize int `json:"fontSize,omitempty"`
|
||||||
|
FontFamily int `json:"fontFamily,omitempty"`
|
||||||
|
TextAlign string `json:"textAlign,omitempty"`
|
||||||
|
VerticalAlign string `json:"verticalAlign,omitempty"`
|
||||||
|
ContainerID *string `json:"containerId,omitempty"`
|
||||||
|
OriginalText string `json:"originalText,omitempty"`
|
||||||
|
LineHeight float64 `json:"lineHeight,omitempty"`
|
||||||
|
|
||||||
|
// Arrow-element fields
|
||||||
|
Points [][2]float64 `json:"points,omitempty"`
|
||||||
|
StartBinding *Binding `json:"startBinding,omitempty"`
|
||||||
|
EndBinding *Binding `json:"endBinding,omitempty"`
|
||||||
|
StartArrowhead *string `json:"startArrowhead,omitempty"`
|
||||||
|
EndArrowhead *string `json:"endArrowhead,omitempty"`
|
||||||
|
LastCommittedPoint *[2]float64 `json:"lastCommittedPoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Roundness struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoundRef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Binding struct {
|
||||||
|
ElementID string `json:"elementId"`
|
||||||
|
Focus float64 `json:"focus"`
|
||||||
|
Gap float64 `json:"gap"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDAssignment is the result of running BuildScene: the scene to upload
|
||||||
|
// + the per-row excalidraw_id assignments that the caller should
|
||||||
|
// persist so the next export reuses the same ids (Excalidraw collab
|
||||||
|
// cursors / comments / undo history survive that way; design §4.2).
|
||||||
|
type IDAssignment struct {
|
||||||
|
Frames map[int64]string `json:"frames"`
|
||||||
|
Devices map[int64]string `json:"devices"`
|
||||||
|
Ports map[int64]string `json:"ports"`
|
||||||
|
IOMarkers map[int64]string `json:"io_markers"`
|
||||||
|
Cables map[int64]string `json:"cables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildScene transforms a project snapshot into an Excalidraw Scene +
|
||||||
|
// the id-assignment side-table.
|
||||||
|
//
|
||||||
|
// nowMilli is the Updated timestamp (one millisecond stamp for every
|
||||||
|
// element keeps re-exports consistent — mxdrw treats wildly-different
|
||||||
|
// updateds as edit-noise).
|
||||||
|
//
|
||||||
|
// genID is a 21-char ID factory. Tests pass a deterministic generator
|
||||||
|
// to lock element ids down across asserts. Production uses Generate21.
|
||||||
|
func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene, *IDAssignment) {
|
||||||
|
a := &IDAssignment{
|
||||||
|
Frames: map[int64]string{},
|
||||||
|
Devices: map[int64]string{},
|
||||||
|
Ports: map[int64]string{},
|
||||||
|
IOMarkers: map[int64]string{},
|
||||||
|
Cables: map[int64]string{},
|
||||||
|
}
|
||||||
|
// idFor: reuse the existing excalidraw_id if present, else mint one.
|
||||||
|
idFor := func(existing *string) string {
|
||||||
|
if existing != nil && *existing != "" {
|
||||||
|
return *existing
|
||||||
|
}
|
||||||
|
return genID()
|
||||||
|
}
|
||||||
|
|
||||||
|
cableTypeColor := map[int64]string{}
|
||||||
|
for _, t := range snap.CableTypes {
|
||||||
|
cableTypeColor[t.ID] = t.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll need: device-id → element-id, port-id → element-id, io-id → element-id
|
||||||
|
// for binding arrows.
|
||||||
|
deviceElID := map[int64]string{}
|
||||||
|
portElID := map[int64]string{}
|
||||||
|
ioElID := map[int64]string{}
|
||||||
|
frameElID := map[int64]string{}
|
||||||
|
|
||||||
|
var els []Element
|
||||||
|
|
||||||
|
// Frames first (Excalidraw renders later elements on top; frames are
|
||||||
|
// containers that go on the bottom).
|
||||||
|
for _, f := range snap.Frames {
|
||||||
|
elID := idFor(f.ExcalidrawID)
|
||||||
|
a.Frames[f.ID] = elID
|
||||||
|
frameElID[f.ID] = elID
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: elID,
|
||||||
|
Type: "frame",
|
||||||
|
X: f.X,
|
||||||
|
Y: f.Y,
|
||||||
|
Width: f.Width,
|
||||||
|
Height: f.Height,
|
||||||
|
StrokeColor: "#bbbbbb",
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
Name: f.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices: rectangle + bound text with the device's name. Excalidraw
|
||||||
|
// uses a `containerId` pointer on the text to bind it to the rect,
|
||||||
|
// and `boundElements` on the rect to point back at the text.
|
||||||
|
for _, d := range snap.Devices {
|
||||||
|
rectID := idFor(d.ExcalidrawID)
|
||||||
|
a.Devices[d.ID] = rectID
|
||||||
|
deviceElID[d.ID] = rectID
|
||||||
|
textID := genID()
|
||||||
|
var frameRef *string
|
||||||
|
if d.FrameID != nil {
|
||||||
|
if v, ok := frameElID[*d.FrameID]; ok {
|
||||||
|
frameRef = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Rect
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: rectID,
|
||||||
|
Type: "rectangle",
|
||||||
|
X: d.X,
|
||||||
|
Y: d.Y,
|
||||||
|
Width: d.Width,
|
||||||
|
Height: d.Height,
|
||||||
|
StrokeColor: d.Color,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
FrameID: frameRef,
|
||||||
|
Roundness: &Roundness{Type: 3},
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
|
||||||
|
})
|
||||||
|
// Bound text — name centered on the rect.
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: textID,
|
||||||
|
Type: "text",
|
||||||
|
X: d.X,
|
||||||
|
Y: d.Y + d.Height/2 - 8,
|
||||||
|
Width: d.Width,
|
||||||
|
Height: 16,
|
||||||
|
StrokeColor: d.Color,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
FrameID: frameRef,
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
Text: d.Name,
|
||||||
|
OriginalText: d.Name,
|
||||||
|
FontSize: 16,
|
||||||
|
FontFamily: 1,
|
||||||
|
TextAlign: "center",
|
||||||
|
VerticalAlign: "middle",
|
||||||
|
ContainerID: &rectID,
|
||||||
|
LineHeight: 1.25,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ports — small ellipses at device.x + port.x_offset (positional,
|
||||||
|
// not containerId-bound per the seed drawing's grammar; design §4.1).
|
||||||
|
for _, p := range snap.Ports {
|
||||||
|
elID := idFor(p.ExcalidrawID)
|
||||||
|
a.Ports[p.ID] = elID
|
||||||
|
portElID[p.ID] = elID
|
||||||
|
// Locate the parent device for absolute pos + frame ref.
|
||||||
|
var dev *db.Device
|
||||||
|
for i := range snap.Devices {
|
||||||
|
if snap.Devices[i].ID == p.DeviceID {
|
||||||
|
dev = &snap.Devices[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dev == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var frameRef *string
|
||||||
|
if dev.FrameID != nil {
|
||||||
|
if v, ok := frameElID[*dev.FrameID]; ok {
|
||||||
|
frameRef = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
color := cableTypeColor[p.TypeID]
|
||||||
|
if color == "" {
|
||||||
|
color = "#1e1e1e"
|
||||||
|
}
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: elID,
|
||||||
|
Type: "ellipse",
|
||||||
|
X: dev.X + p.XOffset - 6,
|
||||||
|
Y: dev.Y + p.YOffset - 4,
|
||||||
|
Width: 12,
|
||||||
|
Height: 9,
|
||||||
|
StrokeColor: color,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
FrameID: frameRef,
|
||||||
|
Roundness: &Roundness{Type: 2},
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IO markers — diamonds with bound "IO" (or m's label) text.
|
||||||
|
powerColor := ""
|
||||||
|
for _, t := range snap.CableTypes {
|
||||||
|
if t.Name == "Power" {
|
||||||
|
powerColor = t.Color
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if powerColor == "" {
|
||||||
|
powerColor = "#e03131"
|
||||||
|
}
|
||||||
|
for _, m := range snap.IOMarkers {
|
||||||
|
elID := idFor(m.ExcalidrawID)
|
||||||
|
a.IOMarkers[m.ID] = elID
|
||||||
|
ioElID[m.ID] = elID
|
||||||
|
textID := genID()
|
||||||
|
var frameRef *string
|
||||||
|
if m.FrameID != nil {
|
||||||
|
if v, ok := frameElID[*m.FrameID]; ok {
|
||||||
|
frameRef = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: elID,
|
||||||
|
Type: "diamond",
|
||||||
|
X: m.X,
|
||||||
|
Y: m.Y,
|
||||||
|
Width: 30,
|
||||||
|
Height: 30,
|
||||||
|
StrokeColor: powerColor,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
FrameID: frameRef,
|
||||||
|
Roundness: &Roundness{Type: 2},
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
|
||||||
|
})
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: textID,
|
||||||
|
Type: "text",
|
||||||
|
X: m.X,
|
||||||
|
Y: m.Y + 7,
|
||||||
|
Width: 30,
|
||||||
|
Height: 16,
|
||||||
|
StrokeColor: powerColor,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
FrameID: frameRef,
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
Text: m.Label,
|
||||||
|
OriginalText: m.Label,
|
||||||
|
FontSize: 11,
|
||||||
|
FontFamily: 1,
|
||||||
|
TextAlign: "center",
|
||||||
|
VerticalAlign: "middle",
|
||||||
|
ContainerID: &elID,
|
||||||
|
LineHeight: 1.25,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cables — arrows with startBinding/endBinding to the port / device /
|
||||||
|
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
|
||||||
|
// "to" points) come from the same anchor logic the canvas uses.
|
||||||
|
for _, c := range snap.Cables {
|
||||||
|
elID := idFor(c.ExcalidrawID)
|
||||||
|
a.Cables[c.ID] = elID
|
||||||
|
fromAnchor, fromRef := exportAnchor(c.FromPortID, c.FromDeviceID, c.FromIOID,
|
||||||
|
snap, deviceElID, portElID, ioElID)
|
||||||
|
toAnchor, toRef := exportAnchor(c.ToPortID, c.ToDeviceID, c.ToIOID,
|
||||||
|
snap, deviceElID, portElID, ioElID)
|
||||||
|
// fromRef/toRef are nil when the endpoint row vanished (manual
|
||||||
|
// cable referencing a deleted port, say). Skip rather than emit
|
||||||
|
// a half-bound arrow.
|
||||||
|
if fromRef == nil || toRef == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
color := cableTypeColor[c.TypeID]
|
||||||
|
if color == "" {
|
||||||
|
color = "#1e1e1e"
|
||||||
|
}
|
||||||
|
startArr := ""
|
||||||
|
endArr := "arrow"
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: elID,
|
||||||
|
Type: "arrow",
|
||||||
|
X: fromAnchor[0],
|
||||||
|
Y: fromAnchor[1],
|
||||||
|
Width: toAnchor[0] - fromAnchor[0],
|
||||||
|
Height: toAnchor[1] - fromAnchor[1],
|
||||||
|
StrokeColor: color,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 2,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
|
||||||
|
StartArrowhead: &startArr,
|
||||||
|
EndArrowhead: &endArr,
|
||||||
|
StartBinding: bindingPtr(fromRef),
|
||||||
|
EndBinding: bindingPtr(toRef),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend in the top-left of the first frame (or at 20,20 if there
|
||||||
|
// are no frames). One text row per cable_type, stacked vertically.
|
||||||
|
legendX, legendY := 20.0, 20.0
|
||||||
|
if len(snap.Frames) > 0 {
|
||||||
|
legendX = snap.Frames[0].X + 10
|
||||||
|
legendY = snap.Frames[0].Y + 10
|
||||||
|
}
|
||||||
|
for i, t := range snap.CableTypes {
|
||||||
|
els = append(els, Element{
|
||||||
|
ID: genID(),
|
||||||
|
Type: "text",
|
||||||
|
X: legendX,
|
||||||
|
Y: legendY + float64(i*18),
|
||||||
|
Width: 80,
|
||||||
|
Height: 16,
|
||||||
|
StrokeColor: t.Color,
|
||||||
|
BackgroundColor: "transparent",
|
||||||
|
FillStyle: "solid",
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeStyle: "solid",
|
||||||
|
Roughness: 0,
|
||||||
|
Opacity: 100,
|
||||||
|
GroupIDs: []string{},
|
||||||
|
Seed: randInt(),
|
||||||
|
Version: 1,
|
||||||
|
VersionNonce: randInt(),
|
||||||
|
Updated: nowMilli,
|
||||||
|
Text: t.Name,
|
||||||
|
OriginalText: t.Name,
|
||||||
|
FontSize: 16,
|
||||||
|
FontFamily: 1,
|
||||||
|
TextAlign: "left",
|
||||||
|
VerticalAlign: "top",
|
||||||
|
LineHeight: 1.25,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scene := &Scene{
|
||||||
|
Type: "excalidraw",
|
||||||
|
Version: 2,
|
||||||
|
Source: "mcables",
|
||||||
|
Elements: els,
|
||||||
|
AppState: AppState{
|
||||||
|
GridSize: nil,
|
||||||
|
ViewBackground: "#ffffff",
|
||||||
|
},
|
||||||
|
Files: Files{},
|
||||||
|
}
|
||||||
|
return scene, a
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindingPtr(b *Binding) *Binding {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportAnchor returns (x,y) + a Binding for the endpoint kind passed in.
|
||||||
|
func exportAnchor(portID, deviceID, ioID *int64, snap *db.Snapshot,
|
||||||
|
devElID, portElID, ioElID map[int64]string,
|
||||||
|
) ([2]float64, *Binding) {
|
||||||
|
if portID != nil {
|
||||||
|
// Find the port + its parent device.
|
||||||
|
for _, p := range snap.Ports {
|
||||||
|
if p.ID != *portID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range snap.Devices {
|
||||||
|
if d.ID == p.DeviceID {
|
||||||
|
id := portElID[p.ID]
|
||||||
|
return [2]float64{d.X + p.XOffset, d.Y + p.YOffset}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deviceID != nil {
|
||||||
|
for _, d := range snap.Devices {
|
||||||
|
if d.ID != *deviceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := devElID[d.ID]
|
||||||
|
return [2]float64{d.X + d.Width/2, d.Y + d.Height/2}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ioID != nil {
|
||||||
|
for _, m := range snap.IOMarkers {
|
||||||
|
if m.ID != *ioID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := ioElID[m.ID]
|
||||||
|
return [2]float64{m.X + 15, m.Y + 15}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [2]float64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate21 mints a 21-char base62 identifier, the shape Excalidraw
|
||||||
|
// uses for element ids (nanoid-style). crypto/rand source.
|
||||||
|
func Generate21() string {
|
||||||
|
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
buf := make([]byte, 21)
|
||||||
|
max := big.NewInt(int64(len(alphabet)))
|
||||||
|
for i := range buf {
|
||||||
|
n, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
// crypto/rand failure is unrecoverable in practice; fall back
|
||||||
|
// to a deterministic alphabet position so callers see a panic-
|
||||||
|
// adjacent symptom rather than a half-initialised id.
|
||||||
|
return fmt.Sprintf("crypto-rand-failed-%d", i)
|
||||||
|
}
|
||||||
|
buf[i] = alphabet[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// randInt returns a non-negative int64 derived from crypto/rand for
|
||||||
|
// Excalidraw's `seed` / `versionNonce`. Excalidraw treats these as
|
||||||
|
// noise — only the IDs and the structural fields matter.
|
||||||
|
func randInt() int64 {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalScene returns the scene as Excalidraw-flavoured JSON.
|
||||||
|
func MarshalScene(s *Scene) ([]byte, error) {
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
165
internal/exporter/exporter_test.go
Normal file
165
internal/exporter/exporter_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deterministic id generator for tests
|
||||||
|
func newSeq() func() string {
|
||||||
|
i := 0
|
||||||
|
return func() string {
|
||||||
|
i++
|
||||||
|
return "id" + strings.Repeat("0", 19-len(itoa(i))) + itoa(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
buf := [20]byte{}
|
||||||
|
pos := len(buf)
|
||||||
|
for i > 0 {
|
||||||
|
pos--
|
||||||
|
buf[pos] = byte('0' + i%10)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
return string(buf[pos:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleSnapshot() *db.Snapshot {
|
||||||
|
pid := int64(1)
|
||||||
|
devID := int64(10)
|
||||||
|
devID2 := int64(11)
|
||||||
|
portID := int64(100)
|
||||||
|
portID2 := int64(101)
|
||||||
|
ioID := int64(200)
|
||||||
|
|
||||||
|
return &db.Snapshot{
|
||||||
|
Project: db.Project{ID: pid, Name: "LOFT", DrawingName: "LOFT.excalidraw"},
|
||||||
|
Frames: []db.Frame{
|
||||||
|
{ID: 1, ProjectID: pid, Name: "desk", X: 100, Y: 100, Width: 800, Height: 500},
|
||||||
|
},
|
||||||
|
Devices: []db.Device{
|
||||||
|
{ID: devID, ProjectID: pid, Name: "NAS", Color: "#1e1e1e", X: 200, Y: 200, Width: 100, Height: 35, FrameID: ptr(int64(1))},
|
||||||
|
{ID: devID2, ProjectID: pid, Name: "Switch", Color: "#1e1e1e", X: 400, Y: 200, Width: 100, Height: 35},
|
||||||
|
},
|
||||||
|
Ports: []db.Port{
|
||||||
|
{ID: portID, ProjectID: pid, DeviceID: devID, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||||
|
{ID: portID2, ProjectID: pid, DeviceID: devID2, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||||
|
},
|
||||||
|
IOMarkers: []db.IOMarker{
|
||||||
|
{ID: ioID, ProjectID: pid, Label: "Wall A", X: 50, Y: 50},
|
||||||
|
},
|
||||||
|
Cables: []db.Cable{
|
||||||
|
{ID: 1000, ProjectID: pid, TypeID: 5,
|
||||||
|
FromPortID: &portID, ToPortID: &portID2, Auto: false},
|
||||||
|
},
|
||||||
|
CableTypes: []db.CableType{
|
||||||
|
{ID: 1, Name: "Power", Color: "#e03131"},
|
||||||
|
{ID: 2, Name: "USB", Color: "#2f9e44"},
|
||||||
|
{ID: 3, Name: "HDMI", Color: "#1971c2"},
|
||||||
|
{ID: 4, Name: "DP", Color: "#9c36b5"},
|
||||||
|
{ID: 5, Name: "RJ45", Color: "#ffd500"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestBuildScene_BasicShape(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
|
||||||
|
if scene.Type != "excalidraw" || scene.Version != 2 {
|
||||||
|
t.Errorf("bad header: %+v", scene)
|
||||||
|
}
|
||||||
|
// frame(1) + device-rect+text(2 each) + ports(2) + io+text(2) +
|
||||||
|
// cable(1) + legend(5) = 1 + 4 + 2 + 2 + 1 + 5 = 15.
|
||||||
|
if len(scene.Elements) < 15 {
|
||||||
|
t.Errorf("element count = %d, want ≥15", len(scene.Elements))
|
||||||
|
}
|
||||||
|
if len(ids.Frames) != 1 || len(ids.Devices) != 2 || len(ids.Ports) != 2 ||
|
||||||
|
len(ids.IOMarkers) != 1 || len(ids.Cables) != 1 {
|
||||||
|
t.Errorf("id assignment shape wrong: %+v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScene_ReusesExistingExcalidrawIDs(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
// Pre-assign an excalidraw_id on the first device.
|
||||||
|
preset := "preset0000000000000NAS"[:21]
|
||||||
|
snap.Devices[0].ExcalidrawID = &preset
|
||||||
|
_, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
if ids.Devices[snap.Devices[0].ID] != preset {
|
||||||
|
t.Errorf("preset id not reused: got %q, want %q", ids.Devices[snap.Devices[0].ID], preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScene_ArrowsBindToPorts(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
// The arrow's startBinding should reference the from-port's element id.
|
||||||
|
fromPortElID := ids.Ports[100]
|
||||||
|
toPortElID := ids.Ports[101]
|
||||||
|
var found *Element
|
||||||
|
for i := range scene.Elements {
|
||||||
|
if scene.Elements[i].Type == "arrow" {
|
||||||
|
found = &scene.Elements[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("no arrow in scene")
|
||||||
|
}
|
||||||
|
if found.StartBinding == nil || found.StartBinding.ElementID != fromPortElID {
|
||||||
|
t.Errorf("start binding wrong: %+v", found.StartBinding)
|
||||||
|
}
|
||||||
|
if found.EndBinding == nil || found.EndBinding.ElementID != toPortElID {
|
||||||
|
t.Errorf("end binding wrong: %+v", found.EndBinding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScene_BundlesIgnored(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
// Snapshot.Bundles is unused in the exporter for v0 per design §4.1.
|
||||||
|
// Add some and confirm no bundle elements appear in the scene.
|
||||||
|
snap.Bundles = []db.Bundle{{ID: 1, Name: "trunk", CableIDs: []int64{1000}}}
|
||||||
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
for _, e := range scene.Elements {
|
||||||
|
if strings.Contains(e.Type, "bundle") {
|
||||||
|
t.Errorf("bundle element leaked into scene: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalScene_IsJSON(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
b, err := MarshalScene(scene)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var roundtrip map[string]any
|
||||||
|
if err := json.Unmarshal(b, &roundtrip); err != nil {
|
||||||
|
t.Fatalf("roundtrip: %v", err)
|
||||||
|
}
|
||||||
|
if roundtrip["type"] != "excalidraw" {
|
||||||
|
t.Errorf("type field = %v, want excalidraw", roundtrip["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate21(t *testing.T) {
|
||||||
|
a := Generate21()
|
||||||
|
b := Generate21()
|
||||||
|
if len(a) != 21 || len(b) != 21 {
|
||||||
|
t.Errorf("len wrong: %d / %d", len(a), len(b))
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("ids collide: %q == %q", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
225
internal/server/cables.go
Normal file
225
internal/server/cables.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cableEndpointBody struct {
|
||||||
|
PortID *int64 `json:"port_id,omitempty"`
|
||||||
|
DeviceID *int64 `json:"device_id,omitempty"`
|
||||||
|
IOID *int64 `json:"io_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cableCreate struct {
|
||||||
|
TypeID int64 `json:"type_id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
From cableEndpointBody `json:"from"`
|
||||||
|
To cableEndpointBody `json:"to"`
|
||||||
|
Auto bool `json:"auto,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cablePatch struct {
|
||||||
|
TypeID *int64 `json:"type_id,omitempty"`
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
From *cableEndpointBody `json:"from,omitempty"`
|
||||||
|
To *cableEndpointBody `json:"to,omitempty"`
|
||||||
|
Auto *bool `json:"auto,omitempty"`
|
||||||
|
// Promote=true asks the server to set auto=false when an auto cable
|
||||||
|
// is being PATCHed (slice 6 §5b.3 — explicit promote-to-manual).
|
||||||
|
Promote bool `json:"promote,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCableEndpoint(b cableEndpointBody) db.CableEndpoint {
|
||||||
|
return db.CableEndpoint{PortID: b.PortID, DeviceID: b.DeviceID, IOID: b.IOID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) listCables(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs, err := h.store.ListCables(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createCable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body cableCreate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := h.store.CreateCable(pid, db.CableCreate{
|
||||||
|
TypeID: body.TypeID, Label: body.Label,
|
||||||
|
From: toCableEndpoint(body.From), To: toCableEndpoint(body.To),
|
||||||
|
Auto: body.Auto,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) patchCable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body cablePatch
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := db.CableUpdate{
|
||||||
|
TypeID: body.TypeID, Label: body.Label, Auto: body.Auto,
|
||||||
|
}
|
||||||
|
if body.From != nil {
|
||||||
|
ep := toCableEndpoint(*body.From)
|
||||||
|
u.From = &ep
|
||||||
|
}
|
||||||
|
if body.To != nil {
|
||||||
|
ep := toCableEndpoint(*body.To)
|
||||||
|
u.To = &ep
|
||||||
|
}
|
||||||
|
// Promote semantics: explicit promote=true OR (PATCH touched
|
||||||
|
// type/from/to AND the current cable is auto) → set auto=false.
|
||||||
|
if body.Promote {
|
||||||
|
f := false
|
||||||
|
u.Auto = &f
|
||||||
|
}
|
||||||
|
c, err := h.store.UpdateCable(pid, id, u)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deleteCable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteCable(pid, id); err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- bundles
|
||||||
|
|
||||||
|
type bundleCreate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
CableIDs []int64 `json:"cable_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bundlePatch struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
CableIDs *[]int64 `json:"cable_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) listBundles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bs, err := h.store.ListBundles(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bundleCreate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := h.store.CreateBundle(pid, db.BundleCreate{
|
||||||
|
Name: body.Name, CableIDs: body.CableIDs, Auto: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) patchBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bundlePatch
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := h.store.UpdateBundle(pid, id, db.BundleUpdate{
|
||||||
|
Name: body.Name, CableIDs: body.CableIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deleteBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteBundle(pid, id); err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
115
internal/server/connection_requirements.go
Normal file
115
internal/server/connection_requirements.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type connReqCreate struct {
|
||||||
|
FromDeviceID int64 `json:"from_device_id"`
|
||||||
|
ToDeviceID int64 `json:"to_device_id"`
|
||||||
|
PreferredCableTypeID *int64 `json:"preferred_cable_type_id,omitempty"`
|
||||||
|
MustConnect *bool `json:"must_connect,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// connReqPatch uses RawMessage for preferred_cable_type_id so the wire
|
||||||
|
// tri-state ({} / null / int) is preserved.
|
||||||
|
type connReqPatch struct {
|
||||||
|
PreferredCableTypeID json.RawMessage `json:"preferred_cable_type_id,omitempty"`
|
||||||
|
MustConnect *bool `json:"must_connect,omitempty"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) listConnectionRequirements(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rs, err := h.store.ListConnectionRequirements(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createConnectionRequirement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body connReqCreate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cr, err := h.store.CreateConnectionRequirement(pid, db.ConnectionRequirementCreate{
|
||||||
|
FromDeviceID: body.FromDeviceID,
|
||||||
|
ToDeviceID: body.ToDeviceID,
|
||||||
|
PreferredCableTypeID: body.PreferredCableTypeID,
|
||||||
|
MustConnect: body.MustConnect,
|
||||||
|
Notes: body.Notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, cr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) patchConnectionRequirement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body connReqPatch
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctRef, err := parseFrameRef(body.PreferredCableTypeID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), "preferred_cable_type_id must be an integer or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cr, err := h.store.UpdateConnectionRequirement(pid, id, db.ConnectionRequirementUpdate{
|
||||||
|
PreferredCableTypeID: ctRef,
|
||||||
|
MustConnect: body.MustConnect,
|
||||||
|
Notes: body.Notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deleteConnectionRequirement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteConnectionRequirement(pid, id); err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
147
internal/server/device_types.go
Normal file
147
internal/server/device_types.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceTypePortBody struct {
|
||||||
|
CableTypeID int64 `json:"cable_type_id"`
|
||||||
|
LabelPrefix string `json:"label_prefix,omitempty"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Edge string `json:"edge,omitempty"`
|
||||||
|
SortOrder int `json:"sort_order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceTypeCreate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Ports []deviceTypePortBody `json:"ports,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceTypePatch struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Kind *string `json:"kind,omitempty"`
|
||||||
|
Icon *string `json:"icon,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Ports *[]deviceTypePortBody `json:"ports,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func portsToStore(body []deviceTypePortBody) []db.DeviceTypePortCreate {
|
||||||
|
out := make([]db.DeviceTypePortCreate, len(body))
|
||||||
|
for i, p := range body {
|
||||||
|
c := p.Count
|
||||||
|
if c <= 0 {
|
||||||
|
c = 1
|
||||||
|
}
|
||||||
|
out[i] = db.DeviceTypePortCreate{
|
||||||
|
CableTypeID: p.CableTypeID,
|
||||||
|
LabelPrefix: p.LabelPrefix,
|
||||||
|
Count: c,
|
||||||
|
Edge: p.Edge,
|
||||||
|
SortOrder: p.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/device-types — built-in catalog only, read-only.
|
||||||
|
func (h *handlers) listBuiltInDeviceTypes(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
dts, err := h.store.ListBuiltInDeviceTypes()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/projects/:pid/device-types — built-ins + project-custom merged.
|
||||||
|
func (h *handlers) listDeviceTypes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dts, err := h.store.ListDeviceTypesForProject(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createDeviceType(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body deviceTypeCreate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dt, err := h.store.CreateDeviceType(pid, db.DeviceTypeCreate{
|
||||||
|
Name: body.Name, Kind: body.Kind, Icon: body.Icon,
|
||||||
|
Description: body.Description, Ports: portsToStore(body.Ports),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) patchDeviceType(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body deviceTypePatch
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := db.DeviceTypeUpdate{
|
||||||
|
Name: body.Name, Kind: body.Kind, Icon: body.Icon, Description: body.Description,
|
||||||
|
}
|
||||||
|
if body.Ports != nil {
|
||||||
|
converted := portsToStore(*body.Ports)
|
||||||
|
u.Ports = &converted
|
||||||
|
}
|
||||||
|
dt, err := h.store.UpdateDeviceType(pid, id, u)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deleteDeviceType(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteDeviceType(pid, id); err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
122
internal/server/export.go
Normal file
122
internal/server/export.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
"mgit.msbls.de/m/mcables/internal/exporter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncExport runs the project's snapshot through the exporter, persists
|
||||||
|
// the assigned excalidraw_ids, then PUTs the scene to mxdrw.msbls.de.
|
||||||
|
func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := os.Getenv("MEXDRAW_BASE_URL")
|
||||||
|
if base == "" {
|
||||||
|
base = "https://mxdrw.msbls.de"
|
||||||
|
}
|
||||||
|
user := os.Getenv("MEXDRAW_USER")
|
||||||
|
pass := os.Getenv("MEXDRAW_PASS")
|
||||||
|
if user == "" || pass == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorBody{
|
||||||
|
Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
|
||||||
|
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := h.store.Snapshot(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
scene, ids := exporter.BuildScene(snap, now, exporter.Generate21)
|
||||||
|
|
||||||
|
// Persist the freshly-assigned ids so the next export reuses them.
|
||||||
|
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
|
||||||
|
// only updates rows whose excalidraw_id is still NULL).
|
||||||
|
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
|
||||||
|
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := exporter.MarshalScene(scene)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, fmt.Errorf("marshal scene: %w", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drawingName := snap.Project.DrawingName
|
||||||
|
if !strings.HasSuffix(drawingName, ".excalidraw") {
|
||||||
|
drawingName += ".excalidraw"
|
||||||
|
}
|
||||||
|
url := strings.TrimSuffix(base, "/") + "/api/drawings/" + drawingName
|
||||||
|
|
||||||
|
// Sane network timeout; mxdrw is on the LAN so this should be quick.
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, fmt.Errorf("build PUT: %w", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||||
|
Error: "mxdrw unreachable",
|
||||||
|
Details: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||||
|
Error: fmt.Sprintf("mxdrw rejected upload (%d)", resp.StatusCode),
|
||||||
|
Details: map[string]any{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"body": string(body),
|
||||||
|
"url": url,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort parse — mxdrw returns whatever it returns; we surface
|
||||||
|
// the public viewer URL no matter what.
|
||||||
|
var serverEcho any
|
||||||
|
_ = json.Unmarshal(body, &serverEcho)
|
||||||
|
|
||||||
|
viewerURL := strings.TrimSuffix(base, "/") + "/draw/" + strings.TrimSuffix(drawingName, ".excalidraw") + ".excalidraw"
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"drawing_name": drawingName,
|
||||||
|
"url": viewerURL,
|
||||||
|
"element_count": len(scene.Elements),
|
||||||
|
"mxdrw_response": serverEcho,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noLeak prevents unused-import errors if errors-pkg ever becomes unused
|
||||||
|
// after a refactor — keeps the import light.
|
||||||
|
var _ = errors.New
|
||||||
@@ -110,6 +110,7 @@ func (h *handlers) deleteFrame(w http.ResponseWriter, r *http.Request) {
|
|||||||
type deviceCreate struct {
|
type deviceCreate struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
FrameID *int64 `json:"frame_id,omitempty"`
|
FrameID *int64 `json:"frame_id,omitempty"`
|
||||||
|
TypeID *int64 `json:"type_id,omitempty"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
@@ -117,13 +118,14 @@ type deviceCreate struct {
|
|||||||
Height float64 `json:"height"`
|
Height float64 `json:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// devicePatch uses a raw `json.RawMessage` for frame_id so we can tell
|
// devicePatch uses a raw `json.RawMessage` for frame_id + type_id so we
|
||||||
// "key absent" (leave alone) from "key present and null" (set to NULL)
|
// can tell "key absent" (leave alone) from "key present and null"
|
||||||
// from "key present with an int" (move to that frame). Standard encoding
|
// (set to NULL) from "key present with an int" (move to that target).
|
||||||
// of nullable fields in JSON PATCH.
|
// Standard encoding of nullable fields in JSON PATCH.
|
||||||
type devicePatch struct {
|
type devicePatch struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||||
|
TypeID json.RawMessage `json:"type_id,omitempty"`
|
||||||
Color *string `json:"color,omitempty"`
|
Color *string `json:"color,omitempty"`
|
||||||
X *float64 `json:"x,omitempty"`
|
X *float64 `json:"x,omitempty"`
|
||||||
Y *float64 `json:"y,omitempty"`
|
Y *float64 `json:"y,omitempty"`
|
||||||
@@ -173,7 +175,8 @@ func (h *handlers) createDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
|
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
|
||||||
Name: body.Name, FrameID: body.FrameID, Color: body.Color,
|
Name: body.Name, FrameID: body.FrameID, TypeID: body.TypeID,
|
||||||
|
Color: body.Color,
|
||||||
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,8 +207,13 @@ 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")
|
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
|
||||||
return
|
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{
|
d, err := h.store.UpdateDevice(pid, id, db.DeviceUpdate{
|
||||||
Name: body.Name, FrameID: ref, Color: body.Color,
|
Name: body.Name, FrameID: ref, TypeID: typeRef, Color: body.Color,
|
||||||
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ func writeError(w http.ResponseWriter, err error, details any) {
|
|||||||
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
|
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
|
||||||
case errors.Is(err, db.ErrInvalidInput):
|
case errors.Is(err, db.ErrInvalidInput):
|
||||||
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
|
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:
|
default:
|
||||||
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
|
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
|
||||||
}
|
}
|
||||||
|
|||||||
109
internal/server/io_markers.go
Normal file
109
internal/server/io_markers.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ioMarkerCreate struct {
|
||||||
|
FrameID *int64 `json:"frame_id,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioMarkerPatch mirrors devicePatch's frame_id tri-state — see
|
||||||
|
// devicePatch + parseFrameRef in frames_devices.go for the wire format.
|
||||||
|
type ioMarkerPatch struct {
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||||
|
X *float64 `json:"x,omitempty"`
|
||||||
|
Y *float64 `json:"y,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) listIOMarkers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ms, err := h.store.ListIOMarkers(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createIOMarker(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body ioMarkerCreate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := h.store.CreateIOMarker(pid, db.IOMarkerCreate{
|
||||||
|
FrameID: body.FrameID, Label: body.Label, X: body.X, Y: body.Y,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) patchIOMarker(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body ioMarkerPatch
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ref, err := parseFrameRef(body.FrameID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := h.store.UpdateIOMarker(pid, id, db.IOMarkerUpdate{
|
||||||
|
Label: body.Label, FrameID: ref, X: body.X, Y: body.Y,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deleteIOMarker(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteIOMarker(pid, id); err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
114
internal/server/ports.go
Normal file
114
internal/server/ports.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type portCreate struct {
|
||||||
|
TypeID int64 `json:"type_id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
XOffset float64 `json:"x_offset"`
|
||||||
|
YOffset float64 `json:"y_offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type portPatch struct {
|
||||||
|
TypeID *int64 `json:"type_id,omitempty"`
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
XOffset *float64 `json:"x_offset,omitempty"`
|
||||||
|
YOffset *float64 `json:"y_offset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) listPortsForDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ps, err := h.store.ListPortsForDevice(pid, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createPort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body portCreate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.store.CreatePort(pid, id, db.PortCreate{
|
||||||
|
TypeID: body.TypeID, Label: body.Label,
|
||||||
|
XOffset: body.XOffset, YOffset: body.YOffset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) patchPort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body portPatch
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.store.UpdatePort(pid, id, db.PortUpdate{
|
||||||
|
TypeID: body.TypeID, Label: body.Label,
|
||||||
|
XOffset: body.XOffset, YOffset: body.YOffset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deletePort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeletePort(pid, id); err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -45,6 +45,54 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
|||||||
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
|
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
|
||||||
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
|
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.
|
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||||
// the file server already emits — without this, browsers cache aggressively
|
// the file server already emits — without this, browsers cache aggressively
|
||||||
|
|||||||
149
internal/server/solver.go
Normal file
149
internal/server/solver.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview := r.URL.Query().Get("preview") == "1"
|
||||||
|
res, err := h.store.Solve(pid, preview)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ports-and-resolve combo: POST a new port to a device + re-run solve in
|
||||||
|
// the same request. Used by the inspector quick-fix.
|
||||||
|
type portsAndResolveBody struct {
|
||||||
|
TypeID int64 `json:"type_id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
XOffset float64 `json:"x_offset,omitempty"`
|
||||||
|
YOffset float64 `json:"y_offset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) portsAndResolve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseInt64Path(r, "id")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body portsAndResolveBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.store.PortsAndResolve(pid, id, body.TypeID, body.Label, body.XOffset, body.YOffset)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------- setup templates
|
||||||
|
|
||||||
|
func (h *handlers) listSetupTemplates(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ts, err := h.store.ListSetupTemplates()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
type applyTemplateBody struct {
|
||||||
|
TemplateID int64 `json:"template_id"`
|
||||||
|
NameOverrides map[string]string `json:"name_overrides,omitempty"`
|
||||||
|
SkipDevices []int64 `json:"skip_devices,omitempty"`
|
||||||
|
OriginX float64 `json:"origin_x,omitempty"`
|
||||||
|
OriginY float64 `json:"origin_y,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) applyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body applyTemplateBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts := db.ApplyTemplateOptions{
|
||||||
|
NameOverrides: map[int64]string{},
|
||||||
|
SkipDevices: map[int64]bool{},
|
||||||
|
OriginX: body.OriginX,
|
||||||
|
OriginY: body.OriginY,
|
||||||
|
}
|
||||||
|
// JSON keys are strings; parse to int64.
|
||||||
|
for k, v := range body.NameOverrides {
|
||||||
|
var tid int64
|
||||||
|
_, _ = fmtSscan(k, &tid)
|
||||||
|
if tid > 0 {
|
||||||
|
opts.NameOverrides[tid] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tid := range body.SkipDevices {
|
||||||
|
opts.SkipDevices[tid] = true
|
||||||
|
}
|
||||||
|
res, err := h.store.ApplyTemplate(pid, body.TemplateID, opts)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-solve by default. ?solve=0 opts out for power users who want
|
||||||
|
// to inspect the seeded devices/requirements before the solver runs.
|
||||||
|
// This is THE fix for the v6 UX hole: m hit Apply, saw an empty
|
||||||
|
// canvas because nothing reloaded *and* nothing solved. With the
|
||||||
|
// frontend re-snapshotting after the POST returns and the response
|
||||||
|
// already carrying solver output, m sees the wired diagram in one click.
|
||||||
|
skipSolve := r.URL.Query().Get("solve") == "0"
|
||||||
|
combined := map[string]any{"template_apply": res}
|
||||||
|
if !skipSolve {
|
||||||
|
solveRes, err := h.store.Solve(pid, false)
|
||||||
|
if err != nil {
|
||||||
|
// Apply succeeded but Solve failed — don't 500 the whole
|
||||||
|
// call. Return template_apply with the solve error inline so
|
||||||
|
// the UI can recover (devices are there; m can re-solve).
|
||||||
|
combined["solve_error"] = err.Error()
|
||||||
|
} else {
|
||||||
|
combined["solve"] = solveRes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtSscan parses a base-10 int from a string, returning (n, nil) on success.
|
||||||
|
// Inline so handlers don't pull in strconv just for one call site.
|
||||||
|
func fmtSscan(s string, out *int64) (int, error) {
|
||||||
|
var v int64
|
||||||
|
read := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
v = v*10 + int64(c-'0')
|
||||||
|
read++
|
||||||
|
}
|
||||||
|
*out = v
|
||||||
|
return read, nil
|
||||||
|
}
|
||||||
@@ -20,9 +20,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-spacer"></div>
|
<div class="topbar-spacer"></div>
|
||||||
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
|
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
|
||||||
Export
|
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
|
||||||
</button>
|
<button type="button" id="btn-export" class="btn">Export</button>
|
||||||
|
<span id="toast" class="toast" hidden></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
@@ -32,13 +33,19 @@
|
|||||||
<ul id="legend-list" class="legend-list"></ul>
|
<ul id="legend-list" class="legend-list"></ul>
|
||||||
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
|
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="requirements">
|
||||||
|
<h2 class="sidebar-heading">Requirements</h2>
|
||||||
|
<ul id="requirement-list" class="requirement-list"></ul>
|
||||||
|
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
|
||||||
|
</section>
|
||||||
<section class="tools">
|
<section class="tools">
|
||||||
<h2 class="sidebar-heading">Tools</h2>
|
<h2 class="sidebar-heading">Tools</h2>
|
||||||
<ul class="tool-list">
|
<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-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
|
||||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
|
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
|
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
|
||||||
|
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -113,6 +120,91 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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 -->
|
<!-- Delete Project confirm -->
|
||||||
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
||||||
<form method="dialog" id="form-delete-project">
|
<form method="dialog" id="form-delete-project">
|
||||||
|
|||||||
1537
web/static/main.js
1537
web/static/main.js
File diff suppressed because it is too large
Load Diff
@@ -211,7 +211,224 @@ body {
|
|||||||
.canvas-wrap.tool-frame #canvas,
|
.canvas-wrap.tool-frame #canvas,
|
||||||
.canvas-wrap.tool-frame #canvas *,
|
.canvas-wrap.tool-frame #canvas *,
|
||||||
.canvas-wrap.tool-device #canvas,
|
.canvas-wrap.tool-device #canvas,
|
||||||
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
|
.canvas-wrap.tool-device #canvas *,
|
||||||
|
.canvas-wrap.tool-io #canvas,
|
||||||
|
.canvas-wrap.tool-io #canvas *,
|
||||||
|
.canvas-wrap.tool-port #canvas,
|
||||||
|
.canvas-wrap.tool-port #canvas *,
|
||||||
|
.canvas-wrap.tool-cable #canvas,
|
||||||
|
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn-link:hover { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Highlight a port that's been picked as the cable-draw source. */
|
||||||
|
.port-circle.cable-from {
|
||||||
|
stroke-width: 3;
|
||||||
|
filter: drop-shadow(0 0 4px var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header toast — slice 8 export feedback */
|
||||||
|
.toast {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 420px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toast.ok { background: #e8f5e9; color: #1b5e20; }
|
||||||
|
.toast.error { background: #fdecea; color: #911313; }
|
||||||
|
.toast a { color: inherit; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* IO markers — diamonds. Power-by-convention, so the default fill is
|
||||||
|
the Power cable_type colour (#e03131). Rotated 45° rect is the
|
||||||
|
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 0;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Solve preview-diff modal */
|
||||||
|
.modal-wide { width: 560px; }
|
||||||
|
.sv-body { font-size: 13px; }
|
||||||
|
.sv-body h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
.sv-body ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.sv-body li {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.sv-body li.added { border-left: 3px solid #2f9e44; }
|
||||||
|
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
|
||||||
|
.sv-body li.unmet { border-left: 3px solid #f59f00; }
|
||||||
|
.sv-body li.unmet .quickfix {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-preview {
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.tp-preview h4 {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
}
|
||||||
|
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.tp-preview li { padding: 2px 0; }
|
||||||
|
.tp-preview .skip {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.rubber-band {
|
.rubber-band {
|
||||||
fill: rgba(25, 113, 194, 0.08);
|
fill: rgba(25, 113, 194, 0.08);
|
||||||
|
|||||||
Reference in New Issue
Block a user