Compare commits
63 Commits
mai/picass
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7331f334a8 | |||
| 1c234f3f46 | |||
| cff897978f | |||
| 55f8a06560 | |||
| 79e17a5cb1 | |||
| c206a331ec | |||
| 2b4c574508 | |||
| 2933bb8662 | |||
| 98fe040364 | |||
| 813d59b068 | |||
| 2cbefd3146 | |||
| a1de1246e5 | |||
| fee9bc5d26 | |||
| 04e7e86a52 | |||
| 6af076a5e0 | |||
| ae59dfc894 | |||
| 4202d0465f | |||
| 8df5de193a | |||
| a675c499c3 | |||
| 78bce498b4 | |||
| 359ed892ac | |||
| 0ecd9c8b4a | |||
| fca9fb0a0f | |||
| 40ab3d2630 | |||
| 17e6b5e91c | |||
| 9107a9f7b2 | |||
| 89686d0c1f | |||
| 57a9154f18 | |||
| 6c31802522 | |||
| 46e8474c2b | |||
| 9aa395854d | |||
| f08c48e9b5 | |||
| 6cd5925f4c | |||
| 9773063008 | |||
| 61bc1dcf43 | |||
| 056777f1c1 | |||
| 2aff5eb04d | |||
| 5c11bf33cb | |||
| 86264d1284 | |||
| b28fc0c565 | |||
| 491db730eb | |||
| 90157dfd14 | |||
| f1af2820e1 | |||
| 3276cfeb17 | |||
| 82cf5a3052 | |||
| 5d055ad521 | |||
| 93b276875e | |||
| 205e9eab26 | |||
| fe6f86593e | |||
| a7835468a1 | |||
| 8a6e8c8406 | |||
| 275cb5a55a | |||
| a81dbe2f8c | |||
| 2cd981d3ae | |||
| 0c7d165ed6 | |||
| 9625d97efc | |||
| f9c245fbcc | |||
| c61bff7cf2 | |||
| 1d226844d1 | |||
| c681b01aff | |||
| c8bda7a222 | |||
| b93c42a6e0 | |||
| 75b826c583 |
@@ -15,7 +15,7 @@ data
|
||||
|
||||
# Build artefacts
|
||||
bin
|
||||
mcables
|
||||
/cablegui
|
||||
|
||||
# Editor cruft
|
||||
.vscode
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,7 +8,7 @@ data/*.db-shm
|
||||
|
||||
# Build artefacts
|
||||
bin/
|
||||
mcables
|
||||
/cablegui
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -1,11 +1,11 @@
|
||||
# mCables — Project Instructions
|
||||
# CableGUI — Project Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cable-management **framework + solver** for m's setup. m declares his
|
||||
**devices** and the **connection requirements** between them ("NAS must
|
||||
connect to Switch via RJ45"). mCables runs a solver that emits the cable
|
||||
plan + bundle recommendations. mCables is a **schematic**, not a
|
||||
connect to Switch via RJ45"). CableGUI runs a solver that emits the cable
|
||||
plan + bundle recommendations. CableGUI is a **schematic**, not a
|
||||
physical-routing tool — cables are straight lines between endpoints; the
|
||||
"maximum bundling" objective is satisfied by the endpoint-pair rule
|
||||
(when two or more cables share the same A↔B endpoint pair, group them
|
||||
@@ -13,16 +13,17 @@ 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
|
||||
**CableGUI project**, and each project is backed by exactly one Excalidraw
|
||||
drawing. The framework provides a visual web interface backed by a Go
|
||||
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
|
||||
files via mExDraw.
|
||||
|
||||
**Memory group_id:** `mcables`
|
||||
**Memory group_id:** `mcables` (kept historical — all prior memories live
|
||||
under this id; renaming would orphan them)
|
||||
|
||||
**No CLI.** Frontend-first — every interaction is through the visual
|
||||
interface. The backend serves the UI and the API; there is no
|
||||
`mcables` shell binary intended for humans.
|
||||
`cablegui` shell binary intended for humans.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -53,7 +54,7 @@ interface. The backend serves the UI and the API; there is no
|
||||
|
||||
| Layer | Tech | Notes |
|
||||
|---|---|---|
|
||||
| DB | SQLite | `./data/mcables.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
|
||||
| DB | SQLite | `./data/cablegui.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
|
||||
| Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. |
|
||||
| Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. |
|
||||
| Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) |
|
||||
@@ -112,14 +113,14 @@ interface. The backend serves the UI and the API; there is no
|
||||
|
||||
## Deployment — mDock, raw docker (NOT Dokploy)
|
||||
|
||||
mCables runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
|
||||
CableGUI runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
|
||||
as a **plain docker-compose service**. Dokploy is for public mlake/mRiver
|
||||
stuff; mDock uses raw `docker compose` per the conventions of the existing
|
||||
mDock services (mgreen, mgeo, msports-garmin, paperless, …).
|
||||
|
||||
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`,
|
||||
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`.
|
||||
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea
|
||||
- Repo layout on mDock: `/home/m/stacks/cablegui/` with `docker-compose.yml`,
|
||||
`data/` bind-mount, secrets in `/home/m/secrets/cablegui/.env`.
|
||||
- Image: `mgit.msbls.de/m/cablegui:latest` (built and pushed by a Gitea
|
||||
Actions workflow on push to `main`, runs on the self-hosted runner on
|
||||
mDock with label `self-hosted:host`).
|
||||
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
|
||||
@@ -127,12 +128,12 @@ mDock services (mgreen, mgeo, msports-garmin, paperless, …).
|
||||
- LAN URL: `http://mdock:7777`.
|
||||
- No auth — LAN-trusted.
|
||||
|
||||
Local dev (no Docker): `go run ./cmd/mcables` against `./data/mcables.db`.
|
||||
Local dev (no Docker): `go run ./cmd/cablegui` against `./data/cablegui.db`.
|
||||
|
||||
## Seed drawing — visual grammar reference, **not** a runtime importer
|
||||
|
||||
`mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material
|
||||
only**. mCables does **not** auto-ingest it. m will rebuild LOFT and OFFICE
|
||||
only**. CableGUI does **not** auto-ingest it. m will rebuild LOFT and OFFICE
|
||||
from scratch inside the tool — the seed exists so the **exporter** mimics
|
||||
its visual grammar:
|
||||
|
||||
@@ -163,13 +164,13 @@ Legend colours (global, seeded once by migration 001):
|
||||
|
||||
## Out of scope (v0)
|
||||
|
||||
- Multi-user. mCables is m-only.
|
||||
- Multi-user. CableGUI is m-only.
|
||||
- Auth / sharing — LAN-trusted on mDock.
|
||||
- Mobile / responsive — desktop browser only.
|
||||
- Cable inventory beyond visual structure (no length, no purchase history,
|
||||
no SKU). Strictly visual structure for v0.
|
||||
- Import from `.excalidraw` at runtime. If a one-shot migration is ever
|
||||
needed, a separate `mcables-migrate` CLI tool is the right shape, not a
|
||||
needed, a separate `cablegui-migrate` CLI tool is the right shape, not a
|
||||
hot API endpoint.
|
||||
|
||||
## Worker Preferences
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# mCables — single-stage build → distroless runtime image.
|
||||
# CableGUI — single-stage build → distroless runtime image.
|
||||
# go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0
|
||||
# and a distroless/static runtime is all we need.
|
||||
|
||||
@@ -17,20 +17,20 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/mcables \
|
||||
./cmd/mcables
|
||||
-o /out/cablegui \
|
||||
./cmd/cablegui
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/mcables /app/mcables
|
||||
COPY --from=build /out/cablegui /app/cablegui
|
||||
|
||||
ENV MCABLES_ADDR=0.0.0.0:7777 \
|
||||
MCABLES_DB=/app/data/mcables.db
|
||||
ENV CABLEGUI_ADDR=0.0.0.0:7777 \
|
||||
CABLEGUI_DB=/app/data/cablegui.db
|
||||
|
||||
EXPOSE 7777
|
||||
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
|
||||
# /home/m/stacks/mcables/data is owned by m:m, so the container can write
|
||||
# /home/m/stacks/cablegui/data is owned by m:m, so the container can write
|
||||
# to it without chowning the host dir. distroless/static-debian12 accepts
|
||||
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
|
||||
USER 1000:1000
|
||||
ENTRYPOINT ["/app/mcables"]
|
||||
ENTRYPOINT ["/app/cablegui"]
|
||||
|
||||
6
Makefile
6
Makefile
@@ -1,14 +1,14 @@
|
||||
.PHONY: build run test typecheck fmt clean
|
||||
|
||||
BIN := bin/mcables
|
||||
BIN := bin/cablegui
|
||||
PKG := ./...
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables
|
||||
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/cablegui
|
||||
|
||||
run:
|
||||
go run ./cmd/mcables
|
||||
go run ./cmd/cablegui
|
||||
|
||||
test:
|
||||
go test -race $(PKG)
|
||||
|
||||
51
README.md
51
README.md
@@ -1,9 +1,9 @@
|
||||
# mCables
|
||||
# CableGUI
|
||||
|
||||
Cable-management **framework** for m's setup — visual web editor backed by
|
||||
a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.
|
||||
|
||||
Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables
|
||||
Each cable-managed environment (LOFT, OFFICE, …) is a separate CableGUI
|
||||
*project*; each project is backed by exactly one `.excalidraw` drawing on
|
||||
mxdrw.msbls.de.
|
||||
|
||||
@@ -23,7 +23,7 @@ end-to-end; the SVG canvas is intentionally empty until slice 2.
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
go run ./cmd/mcables
|
||||
go run ./cmd/cablegui
|
||||
# open http://localhost:7777
|
||||
```
|
||||
|
||||
@@ -31,20 +31,21 @@ Or built:
|
||||
|
||||
```sh
|
||||
make build
|
||||
./bin/mcables
|
||||
./bin/cablegui
|
||||
```
|
||||
|
||||
The binary serves the frontend from an embedded `web/static/` and the
|
||||
JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
|
||||
JSON API under `/api/`. SQLite lives at `./data/cablegui.db` by default.
|
||||
|
||||
### Environment
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
|
||||
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
|
||||
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
|
||||
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
|
||||
| `CABLEGUI_ADDR` | `0.0.0.0:7777` | Listen address. |
|
||||
| `CABLEGUI_DB` | `./data/cablegui.db` | SQLite path. Parent dir is created on boot. |
|
||||
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
|
||||
| `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
|
||||
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -77,37 +78,35 @@ DELETE /api/cable-types/:id ← 409 in_use if any cable references
|
||||
|
||||
## Deploy to mDock
|
||||
|
||||
mCables runs on **mDock** at `http://mdock:7777` as a docker-compose
|
||||
service under `/home/m/stacks/mcables/`. Pattern matches the other
|
||||
CableGUI runs on **mDock** at `http://mdock:7777` as a docker-compose
|
||||
service under `/home/m/stacks/cablegui/`. Pattern matches the other
|
||||
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
|
||||
no reverse proxy, LAN-trusted.
|
||||
|
||||
### Manual deploy (first roll)
|
||||
### Manual deploy
|
||||
|
||||
1. **Build + push the image** (from any host with docker; today the
|
||||
image lives in mAi's Gitea namespace because mAi doesn't have write
|
||||
access to `m/`):
|
||||
1. **Build + push the image** (image now lives under `m/` in Gitea):
|
||||
|
||||
```sh
|
||||
docker build -t mgit.msbls.de/mai/mcables:latest .
|
||||
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \
|
||||
| docker login mgit.msbls.de -u mAi --password-stdin
|
||||
docker push mgit.msbls.de/mai/mcables:latest
|
||||
docker build -t mgit.msbls.de/m/cablegui:latest .
|
||||
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc \
|
||||
| docker login mgit.msbls.de -u m --password-stdin
|
||||
docker push mgit.msbls.de/m/cablegui:latest
|
||||
```
|
||||
|
||||
2. **Prepare directories on mDock** (one-time):
|
||||
|
||||
```sh
|
||||
ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \
|
||||
&& touch /home/m/secrets/mcables/.env \
|
||||
&& chmod 0600 /home/m/secrets/mcables/.env'
|
||||
scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml
|
||||
ssh mdock 'mkdir -p /home/m/stacks/cablegui/data /home/m/secrets/cablegui \
|
||||
&& touch /home/m/secrets/cablegui/.env \
|
||||
&& chmod 0600 /home/m/secrets/cablegui/.env'
|
||||
scp docker-compose.yml mdock:/home/m/stacks/cablegui/docker-compose.yml
|
||||
```
|
||||
|
||||
3. **Pull + start**:
|
||||
|
||||
```sh
|
||||
ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'
|
||||
ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'
|
||||
```
|
||||
|
||||
4. **Verify** from any LAN host:
|
||||
@@ -118,11 +117,11 @@ no reverse proxy, LAN-trusted.
|
||||
```
|
||||
|
||||
To **update** to a new build: rebuild + push the image, then
|
||||
`ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'`.
|
||||
`ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'`.
|
||||
|
||||
### Persistence
|
||||
|
||||
SQLite lives at `/home/m/stacks/mcables/data/mcables.db` on the host
|
||||
SQLite lives at `/home/m/stacks/cablegui/data/cablegui.db` on the host
|
||||
(bind-mounted into the container at `/app/data`). Container runs as
|
||||
UID 1000:1000 to align with `m:m` ownership on mDock — DB files end
|
||||
up owned by `m`, the host user.
|
||||
|
||||
64
cmd/cablegui/main.go
Normal file
64
cmd/cablegui/main.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/server"
|
||||
"mgit.msbls.de/m/cablegui/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := envOr("CABLEGUI_ADDR", "0.0.0.0:7777")
|
||||
dbPath := envOr("CABLEGUI_DB", "./data/cablegui.db")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
log.Fatalf("mkdir data dir: %v", err)
|
||||
}
|
||||
|
||||
store, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := db.Migrate(store.DB()); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: server.New(store, web.Static()),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("cablegui listening on %s (db=%s)", addr, dbPath)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
# mCables — production compose for mDock.
|
||||
# Lives at /home/m/stacks/mcables/docker-compose.yml on mDock.
|
||||
# CableGUI — production compose for mDock.
|
||||
# Lives at /home/m/stacks/cablegui/docker-compose.yml on mDock.
|
||||
# Matches the existing mDock service patterns (mgreen, mgeo, …).
|
||||
|
||||
services:
|
||||
mcables:
|
||||
image: mgit.msbls.de/m/mcables:latest
|
||||
container_name: mcables
|
||||
cablegui:
|
||||
image: mgit.msbls.de/m/cablegui:latest
|
||||
container_name: cablegui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7777:7777"
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- MCABLES_ADDR=0.0.0.0:7777
|
||||
- MCABLES_DB=/app/data/mcables.db
|
||||
- CABLEGUI_ADDR=0.0.0.0:7777
|
||||
- CABLEGUI_DB=/app/data/cablegui.db
|
||||
env_file:
|
||||
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
|
||||
- /home/m/secrets/mcables/.env
|
||||
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
|
||||
- /home/m/secrets/cablegui/.env
|
||||
volumes:
|
||||
- /home/m/stacks/mcables/data:/app/data
|
||||
- /home/m/stacks/cablegui/data:/app/data
|
||||
|
||||
346
docs/design.md
346
docs/design.md
@@ -1,4 +1,4 @@
|
||||
# mCables — Design v4.1
|
||||
# CableGUI — Design v4.1
|
||||
|
||||
Cable-management **framework + solver** for m's setup. Inventor shift 1
|
||||
design, revised through v2 (rescope to multi-project framework), v3
|
||||
@@ -6,7 +6,7 @@ design, revised through v2 (rescope to multi-project framework), v3
|
||||
**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
|
||||
> 1. **CableGUI 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
|
||||
@@ -33,13 +33,13 @@ design, revised through v2 (rescope to multi-project framework), v3
|
||||
|
||||
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
|
||||
the *visual-grammar reference*, not a bootstrap import target),
|
||||
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy
|
||||
`mai-memory` (`cablegui`, `m`), and the live mDock services for deploy
|
||||
conventions (§10). v4 driven by m's product-vision clarification:
|
||||
|
||||
> "we provide a cable manager — I say what devices we have, the app tells
|
||||
> me how to bundle cables and how the most efficient connection looks like"
|
||||
|
||||
mCables shifts from a manual draw-and-click editor to a **solver** that
|
||||
CableGUI shifts from a manual draw-and-click editor to a **solver** that
|
||||
takes a list of devices + the connections m needs and emits the cable
|
||||
plan + bundle recommendations. The manual editor stays (it's the only way
|
||||
to inspect + tweak the plan) but is no longer the primary surface.
|
||||
@@ -60,7 +60,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
|
||||
> 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,
|
||||
> bundle. No path or trunk geometry — CableGUI 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
|
||||
@@ -72,7 +72,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
|
||||
> bundle-rendering polish.
|
||||
>
|
||||
> **What carried over from v3 (unchanged in v4)**
|
||||
> - mCables is a framework: top-level `projects`, each backed by one
|
||||
> - CableGUI is a framework: top-level `projects`, each backed by one
|
||||
> `.excalidraw` drawing. `UNIQUE(projects.name)`.
|
||||
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
|
||||
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
|
||||
@@ -81,8 +81,8 @@ to inspect + tweak the plan) but is no longer the primary surface.
|
||||
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
|
||||
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
|
||||
> - No cable inventory metadata; visual + connectivity structure only.
|
||||
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
|
||||
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose.
|
||||
> - DB at `./data/cablegui.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
|
||||
> - Deploy on mDock under `/home/m/stacks/cablegui/`, raw docker-compose.
|
||||
>
|
||||
> **What's superseded in v4**
|
||||
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
|
||||
@@ -97,7 +97,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
|
||||
|
||||
`Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at
|
||||
runtime. It is the visual-grammar reference we lock the export onto so that
|
||||
when m rebuilds LOFT and OFFICE inside mCables, the exported `.excalidraw`
|
||||
when m rebuilds LOFT and OFFICE inside CableGUI, the exported `.excalidraw`
|
||||
looks like the seed.
|
||||
|
||||
Concrete numbers from the live file (180 elements):
|
||||
@@ -128,7 +128,7 @@ Three observations about the seed's visual grammar — these constrain the
|
||||
1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by
|
||||
cable type. They are not children of the device in the Excalidraw sense
|
||||
(no `containerId`/`boundElements` link) — purely positional. When we
|
||||
export from mCables we mimic that: port ellipse at `(device.x +
|
||||
export from CableGUI we mimic that: port ellipse at `(device.x +
|
||||
port.x_offset, device.y + port.y_offset)`, stroke colour = type colour.
|
||||
2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses
|
||||
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
|
||||
@@ -161,7 +161,7 @@ painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
|
||||
|
||||
## 2. SQLite schema
|
||||
|
||||
`./data/mcables.db` (project-local, gitignored). WAL mode, FKs on.
|
||||
`./data/cablegui.db` (project-local, gitignored). WAL mode, FKs on.
|
||||
Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple
|
||||
Dockerfile).
|
||||
|
||||
@@ -453,18 +453,26 @@ Office setup template:
|
||||
| fritz | network | Power × 1; RJ45 × 4 |
|
||||
| ChromeCast | display | Power × 1; HDMI × 1 |
|
||||
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
|
||||
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) |
|
||||
| IOx-6 | hub | Power × 1; USB × 6 |
|
||||
| IOx-8 | hub | Power × 1; USB × 8 |
|
||||
| IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
|
||||
| IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
|
||||
| IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
|
||||
| **Screen** | display | Power × 1; HDMI × 1 |
|
||||
| **Keyboard** | accessory | USB × 1 |
|
||||
| **Mouse** | accessory | USB × 1 |
|
||||
| **Multi-plug 3** | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
|
||||
| **Multi-plug 4** | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
|
||||
| **Multi-plug 5** | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
|
||||
| **Multi-plug 6** | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
|
||||
| **Wifi-plug** | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
|
||||
|
||||
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing
|
||||
shows them in red because most carry Power, but they also hub USB). v0
|
||||
seeds them as USB hubs; m overrides per-instance. The catalog is editable
|
||||
in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3
|
||||
profile once and not re-override every instance.
|
||||
v5 (migration 005) added the Multi-plug 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`.
|
||||
@@ -601,8 +609,8 @@ cascade does **not** touch `cable_types` (no FK to projects).
|
||||
|
||||
## 3. Go HTTP API
|
||||
|
||||
Single binary `cmd/mcables`, `net/http`, no router framework. Listens on
|
||||
`0.0.0.0:7777` by default (overridable via `MCABLES_ADDR`). Static frontend
|
||||
Single binary `cmd/cablegui`, `net/http`, no router framework. Listens on
|
||||
`0.0.0.0:7777` by default (overridable via `CABLEGUI_ADDR`). Static frontend
|
||||
from `embed.FS` at `/`, JSON API under `/api/`.
|
||||
|
||||
```
|
||||
@@ -772,7 +780,7 @@ generated scene JSON.
|
||||
|
||||
## 4. Export — DB → Excalidraw (visual-grammar conformance)
|
||||
|
||||
mCables generates a `.excalidraw` scene from a project's rows. The seed
|
||||
CableGUI generates a `.excalidraw` scene from a project's rows. The seed
|
||||
drawing's grammar is the contract.
|
||||
|
||||
### 4.1 Element mapping
|
||||
@@ -790,7 +798,7 @@ drawing's grammar is the contract.
|
||||
|
||||
### 4.2 Element IDs are stable across exports
|
||||
|
||||
Every mCables row carries `excalidraw_id` (TEXT, generated on first export
|
||||
Every CableGUI row carries `excalidraw_id` (TEXT, generated on first export
|
||||
via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row
|
||||
reuses the same ID. This means:
|
||||
|
||||
@@ -858,7 +866,7 @@ 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
|
||||
CableGUI 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:
|
||||
@@ -980,7 +988,7 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ mCables DB (truth) │
|
||||
│ CableGUI DB (truth) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
export ▼
|
||||
@@ -990,11 +998,11 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
- mCables UI → DB: synchronous (every drag/add/remove persists immediately).
|
||||
- CableGUI UI → DB: synchronous (every drag/add/remove persists immediately).
|
||||
- DB → Excalidraw: **manual** button "Export to Excalidraw" in the header,
|
||||
per project. Calls `POST /api/projects/:pid/sync/export`.
|
||||
- Excalidraw → DB: **not implemented** in v0. Anything m draws in
|
||||
Excalidraw stays in Excalidraw until he redraws it in mCables.
|
||||
Excalidraw stays in Excalidraw until he redraws it in CableGUI.
|
||||
|
||||
This keeps the v0 scope tight: no conflict resolution, no element-diff
|
||||
import, no auto-debounce. mExDraw keeps its own version history (git
|
||||
@@ -1004,7 +1012,7 @@ When mxdrw is unreachable: the export button shows a tooltip and disables;
|
||||
the editor keeps working against the local DB.
|
||||
|
||||
Post-MVP, import returns as a one-shot migration tool (separate
|
||||
`mcables-migrate` CLI tool, not part of the running server) for seeding
|
||||
`cablegui-migrate` CLI tool, not part of the running server) for seeding
|
||||
new projects from existing `.excalidraw` files.
|
||||
|
||||
---
|
||||
@@ -1015,7 +1023,7 @@ The editor lives at `/`. Layout:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
|
||||
│ CableGUI [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
|
||||
├────────┬───────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Legend │ │
|
||||
@@ -1246,7 +1254,7 @@ Slices 9+ (not promised for the first coder shift):
|
||||
- Cable inventory metadata (length/SKU) if m later wants it.
|
||||
- Dark mode.
|
||||
|
||||
Out of scope, period (would change mCables's mental model): path
|
||||
Out of scope, period (would change CableGUI'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.
|
||||
|
||||
@@ -1256,7 +1264,7 @@ routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
|
||||
|
||||
The six v4 questions are now answered. Locked answers:
|
||||
|
||||
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.**
|
||||
1. **Where do paths come from?** → **Nowhere — CableGUI is a schematic.**
|
||||
Cables are straight lines between endpoints. The solver does not
|
||||
route, the renderer does not route, and "maximum bundling" reduces to
|
||||
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
|
||||
@@ -1299,25 +1307,25 @@ before writing this:
|
||||
- Host port mappings: deliberately collision-free across the host. Existing
|
||||
high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878
|
||||
(radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr).
|
||||
**Port 7777 is free** — taking it for mCables.
|
||||
**Port 7777 is free** — taking it for CableGUI.
|
||||
- Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical
|
||||
pattern (mgreen). For project-local data we put `data/` *next to* the
|
||||
compose file so a `git pull && docker compose up -d` is the whole deploy:
|
||||
`/home/m/stacks/mcables/data:/app/data`.
|
||||
`/home/m/stacks/cablegui/data:/app/data`.
|
||||
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
|
||||
pattern). mCables only needs `MEXDRAW_TOKEN` for export.
|
||||
pattern). CableGUI only needs `MEXDRAW_TOKEN` for export.
|
||||
- No reverse proxy on mDock. Services expose ports directly on the LAN
|
||||
(mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via
|
||||
mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only).
|
||||
mlake/Dokploy + Caddy when needed — out of scope for CableGUI (LAN-only).
|
||||
- Auto-deploy via the Gitea Actions self-hosted runner already installed
|
||||
on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to
|
||||
`main` → workflow on mDock → `docker compose up --build -d`.
|
||||
|
||||
### Repo layout for mCables
|
||||
### Repo layout for CableGUI
|
||||
|
||||
```
|
||||
mCables/
|
||||
├── cmd/mcables/main.go # Go binary
|
||||
CableGUI/
|
||||
├── cmd/cablegui/main.go # Go binary
|
||||
├── internal/
|
||||
│ ├── db/ # migrations + store
|
||||
│ ├── importer/ # post-MVP only (not in MVP)
|
||||
@@ -1328,7 +1336,7 @@ mCables/
|
||||
│ ├── main.js # ES module entry
|
||||
│ ├── style.css
|
||||
│ └── lib/... # SVG helpers, store, components
|
||||
├── data/ # mCables runtime DB lives here (gitignored)
|
||||
├── data/ # CableGUI runtime DB lives here (gitignored)
|
||||
│ └── .gitkeep
|
||||
├── docs/design.md # this file
|
||||
├── Dockerfile
|
||||
@@ -1353,37 +1361,37 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
|
||||
-o /out/mcables ./cmd/mcables
|
||||
-o /out/cablegui ./cmd/cablegui
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/mcables /app/mcables
|
||||
ENV MCABLES_ADDR=0.0.0.0:7777
|
||||
ENV MCABLES_DB=/app/data/mcables.db
|
||||
COPY --from=build /out/cablegui /app/cablegui
|
||||
ENV CABLEGUI_ADDR=0.0.0.0:7777
|
||||
ENV CABLEGUI_DB=/app/data/cablegui.db
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 7777
|
||||
ENTRYPOINT ["/app/mcables"]
|
||||
ENTRYPOINT ["/app/cablegui"]
|
||||
```
|
||||
|
||||
### docker-compose.yml (on mDock at `/home/m/stacks/mcables/`)
|
||||
### docker-compose.yml (on mDock at `/home/m/stacks/cablegui/`)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcables:
|
||||
image: mgit.msbls.de/m/mcables:latest
|
||||
container_name: mcables
|
||||
cablegui:
|
||||
image: mgit.msbls.de/m/cablegui:latest
|
||||
container_name: cablegui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7777:7777"
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- MCABLES_ADDR=0.0.0.0:7777
|
||||
- MCABLES_DB=/app/data/mcables.db
|
||||
- CABLEGUI_ADDR=0.0.0.0:7777
|
||||
- CABLEGUI_DB=/app/data/cablegui.db
|
||||
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
|
||||
env_file:
|
||||
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN
|
||||
- /home/m/secrets/cablegui/.env # contains MEXDRAW_TOKEN
|
||||
volumes:
|
||||
- /home/m/stacks/mcables/data:/app/data
|
||||
- /home/m/stacks/cablegui/data:/app/data
|
||||
```
|
||||
|
||||
LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`).
|
||||
@@ -1404,15 +1412,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build image
|
||||
run: docker build -t mgit.msbls.de/m/mcables:latest .
|
||||
run: docker build -t mgit.msbls.de/m/cablegui:latest .
|
||||
- name: Push image
|
||||
run: |
|
||||
echo "${{ secrets.GITEA_TOKEN }}" | \
|
||||
docker login mgit.msbls.de -u mAi --password-stdin
|
||||
docker push mgit.msbls.de/m/mcables:latest
|
||||
docker push mgit.msbls.de/m/cablegui:latest
|
||||
- name: Up
|
||||
run: |
|
||||
cd /home/m/stacks/mcables
|
||||
cd /home/m/stacks/cablegui
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -1420,7 +1428,7 @@ jobs:
|
||||
### Local-development run (no Docker)
|
||||
|
||||
```
|
||||
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db
|
||||
make run # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
|
||||
make typecheck # tsc --noEmit on web/
|
||||
make test # go test ./...
|
||||
```
|
||||
@@ -1430,4 +1438,228 @@ gitignored.
|
||||
|
||||
---
|
||||
|
||||
DESIGN v4.1 READY FOR REVIEW
|
||||
## 11. v5 — Cable routing via clamps
|
||||
|
||||
m's bundling primitive: a **clamp** is a physical anchor on the canvas
|
||||
(think cable tie / clip). A cable routes from its `from` endpoint,
|
||||
through zero or more clamps **in order**, to its `to` endpoint. Two
|
||||
cables that share an ordered pair of consecutive clamps are visibly
|
||||
bundled along that segment — no detection pass, no inference: the
|
||||
overlap *is* the bundle.
|
||||
|
||||
This replaces the abandoned waypoints + segment-detection approach.
|
||||
v0's straight-line schematic stays as the empty-clamps case
|
||||
(`cable_clamps` is empty for a fresh solver-emitted cable).
|
||||
|
||||
### 11.1 Schema (migration 007)
|
||||
|
||||
```sql
|
||||
CREATE TABLE clamps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX clamps_project_idx ON clamps(project_id);
|
||||
|
||||
CREATE TABLE cable_clamps (
|
||||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||||
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
|
||||
ord INTEGER NOT NULL, -- 1..N along from→to
|
||||
PRIMARY KEY (cable_id, ord),
|
||||
UNIQUE (cable_id, clamp_id) -- a cable can't visit the same clamp twice
|
||||
);
|
||||
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
|
||||
```
|
||||
|
||||
`frame_id` on clamps mirrors devices + IO markers — m can put a clamp
|
||||
inside a frame and the frame-drag carries it.
|
||||
|
||||
`UNIQUE (cable_id, clamp_id)` blocks loops. `ord` is a small int, 1-based;
|
||||
nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5
|
||||
during edits and the renderer is fine with that), but the UI keeps them
|
||||
contiguous on every mutation for sanity.
|
||||
|
||||
### 11.2 Cable rendering model
|
||||
|
||||
Each cable resolves to a polyline `[from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor]`
|
||||
where:
|
||||
- `from-anchor` / `to-anchor` come from the existing `anchorForEndpoint`
|
||||
resolver (port / device / IO).
|
||||
- clamp anchors are `(clamp.x, clamp.y)` directly — clamps don't have a
|
||||
width/height to centre.
|
||||
|
||||
For N=0 clamps the result is the v0 straight line. For N≥1 we render
|
||||
a `<polyline>` instead of a `<line>`.
|
||||
|
||||
The endpoint-replug handles from §10 (cable-replug) stay on the **first
|
||||
and last** vertices. Mid-polyline vertices get their own clamp-handle —
|
||||
small grab points only on the selected cable, which behave like
|
||||
clamp-detach when dragged onto empty canvas (drop a clamp off the
|
||||
cable's path).
|
||||
|
||||
### 11.3 Bundle visualisation — derived from shared segments
|
||||
|
||||
A **segment** is a directed pair `(A, B)` where A and B are consecutive
|
||||
nodes of a cable's polyline. Two cables share a segment when their
|
||||
polyline contains the same A→B (or B→A — segment matching is
|
||||
undirected).
|
||||
|
||||
For each segment, compute `cables[]` — the cables that traverse it.
|
||||
If `len(cables) ≥ 2`, render the segment as a single thick line on top
|
||||
of the individual ones:
|
||||
|
||||
- **Width**: `2 + N` px (N = cable count). Caps at ~12 px.
|
||||
- **Colour**: a striped pattern, one stripe per distinct cable type in
|
||||
the bundle, ordered by cable_type.id. SVG `<linearGradient>` with
|
||||
hard stops produces the stripe band cheaply; render it on a sibling
|
||||
`<polyline>` over the individual lines.
|
||||
- **Tooltip**: `<title>` child listing the cables ("Power · USB · HDMI").
|
||||
|
||||
At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square)
|
||||
shows a small count badge (`×N`) when N > 1. At fan-out points
|
||||
(endpoint with no clamp before it on the polyline) the individual
|
||||
coloured lines re-emerge, so m sees which port each strand goes to.
|
||||
|
||||
Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average
|
||||
polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per
|
||||
cable) this is trivial. We rebuild the segment map on every renderCanvas
|
||||
— no caching layer.
|
||||
|
||||
### 11.4 UI gestures
|
||||
|
||||
**+ Clamp tool (`C` shortcut, also a sidebar button):**
|
||||
- Click empty canvas → place a clamp at the cursor (POST `/clamps`).
|
||||
Standalone clamp — not on any cable yet.
|
||||
- Click a cable line → insert this clamp into that cable. The new clamp
|
||||
sits at the click position (snapped to the nearest point on the
|
||||
cable's polyline) and its `ord` is computed so it falls between the
|
||||
two existing vertices it lies between.
|
||||
|
||||
**Drag a cable's mid-segment:**
|
||||
- Pointerdown on a cable line (not on an endpoint handle) and drag.
|
||||
Live preview shows a bend at the cursor. Pointerup:
|
||||
- If the cursor is within snap-radius (~16 px) of an existing clamp:
|
||||
insert that clamp into the cable's polyline at the right `ord`.
|
||||
- Otherwise: create a fresh clamp at the release point and insert it.
|
||||
|
||||
**Clamp inspector** (selecting a clamp on the canvas):
|
||||
- Position (x, y editable + label)
|
||||
- "Cables through this clamp": list with each cable's two endpoints,
|
||||
click → select that cable
|
||||
- "Remove from this cable" (per row) → DELETE the matching cable_clamps
|
||||
row; cable's polyline collapses around the gap.
|
||||
- "Delete clamp" → cascade-removes from every cable_clamps row.
|
||||
|
||||
**Right-click on a clamp icon ON a cable** → "Remove from this cable"
|
||||
inline.
|
||||
|
||||
**Frame drag** carries clamps the same way it carries devices + IO
|
||||
markers (clamp.frame_id mirrors the existing pattern, drag handler
|
||||
already iterates frame-contained items).
|
||||
|
||||
### 11.5 Relationship to the existing `bundles` table
|
||||
|
||||
**Recommendation: keep `bundles` and `bundle_cables`, repurpose them.**
|
||||
|
||||
- Implicit/auto bundles → derived live from shared clamp segments. No
|
||||
DB rows. The §5 `GET /bundles/suggestions` endpoint stays useful as a
|
||||
"you might want to route these through the same clamps" hint.
|
||||
- Explicit named bundles → still in the `bundles` table. m names a
|
||||
group ("desk → wall trunk"), the UI offers "route all members through
|
||||
these clamps" as a one-click action. Useful for the case where m
|
||||
wants a stable label on a logical bundle that isn't yet routed.
|
||||
|
||||
Migration 007 leaves `bundles` + `bundle_cables` untouched. A v6 cleanup
|
||||
can drop them if m decides the explicit-named path isn't worth keeping.
|
||||
|
||||
### 11.6 Solver coupling
|
||||
|
||||
The v0 solver still emits **straight cables** — no clamp rows. m
|
||||
hand-routes after Solve. The solver's preview-diff is unaffected
|
||||
(solver compares endpoint pairs; clamp routing is independent of the
|
||||
endpoint identity).
|
||||
|
||||
Future v5.1: solver-suggested clamps based on shared paths between
|
||||
endpoint pairs. Out of scope here.
|
||||
|
||||
### 11.7 Export to mxdrw
|
||||
|
||||
Clamps map to small diamond elements (separate from IO markers — IO
|
||||
diamonds are red wall-outlets; clamps are grey routing points).
|
||||
`excalidraw_id` is stable across re-exports per the existing pattern.
|
||||
|
||||
Cable arrows become Excalidraw `arrow` elements with mid-points (the
|
||||
clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows
|
||||
via the `points` array. Each `startBinding` / `endBinding` resolves to
|
||||
the from/to anchor's excalidraw_id; mid-vertices are unbound.
|
||||
|
||||
Bundle visualisation (thick striped lines on shared segments) is **not
|
||||
exported** in v0 — Excalidraw doesn't natively support gradient strokes,
|
||||
and the mxdrw round-trip would lose them. We export each cable as its
|
||||
own polyline; bundling is a viewer-only concept.
|
||||
|
||||
### 11.8 API additions
|
||||
|
||||
```
|
||||
POST /api/projects/:pid/clamps { x, y, label?, frame_id? } → Clamp
|
||||
PATCH /api/projects/:pid/clamps/:id { x?, y?, label?, frame_id? } → Clamp
|
||||
DELETE /api/projects/:pid/clamps/:id
|
||||
|
||||
POST /api/projects/:pid/cables/:cid/clamps { clamp_id, ord? } → CableClamp
|
||||
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid
|
||||
|
||||
# Convenience: re-order clamps on a cable in one call
|
||||
PUT /api/projects/:pid/cables/:cid/clamps { clamp_ids: [int, int, …] }
|
||||
```
|
||||
|
||||
Snapshot endpoint grows two arrays:
|
||||
- `clamps: []Clamp`
|
||||
- `cable_clamps: []{ cable_id, clamp_id, ord }`
|
||||
|
||||
### 11.9 Open questions for m
|
||||
|
||||
1. **Clamp icon shape.** Diamond (overlaps visually with IO markers
|
||||
when zoomed out), small filled circle (overlaps with port circles),
|
||||
or rounded square `▢` 10×10? Recommend rounded square — distinct from
|
||||
everything else on the canvas today.
|
||||
2. **Snap radius when inserting onto a cable.** ~16 px world-units feels
|
||||
right at 1× zoom. Should it scale with zoom (visual constant) or stay
|
||||
world-constant (gesture stays the same regardless of zoom)? Recommend
|
||||
visual constant — divide by current zoom.
|
||||
3. **Clamp deletion when shared.** If a clamp is used by 4 cables and m
|
||||
clicks "Delete clamp", do we (a) refuse with a "still in use" prompt,
|
||||
(b) cascade-remove from all 4 cables, or (c) cascade silently? Current
|
||||
draft says cascade silently. Worth a confirmation?
|
||||
4. **Bundle stripe order.** Cable-type id is stable but arbitrary; visual
|
||||
order on a thick line affects readability. Order by stripe-count
|
||||
(Power first if 3 Power + 1 USB), or by cable-type-id (deterministic
|
||||
but unrelated to importance)? Recommend by-count, ties broken by id.
|
||||
5. **Solver respect for existing routing.** When m re-runs Solve after
|
||||
hand-routing, should the solver preserve existing clamp routing on
|
||||
user-owned (`auto=0`) cables? Auto cables are wiped + rebuilt, so
|
||||
their clamps disappear with them — that's expected. But manual cables
|
||||
with clamps should clearly keep them. Confirm.
|
||||
|
||||
### 11.10 Slice plan (post-design)
|
||||
|
||||
1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp,
|
||||
AttachClampToCable, DetachClampFromCable, ReorderClamps).
|
||||
2. HTTP endpoints + snapshot extension.
|
||||
3. Frontend: clamp render + + Clamp tool + canvas placement (no
|
||||
cable attach yet).
|
||||
4. Cable polyline render via clamps, mid-segment drag-to-clamp,
|
||||
clamp inspector.
|
||||
5. Shared-segment bundle visualisation (gradient stripe + count badge).
|
||||
6. Export pipeline extension — mxdrw arrows with mid-points + clamp
|
||||
diamonds. Bundle viz stays viewer-only.
|
||||
|
||||
---
|
||||
|
||||
DESIGN v5 READY FOR REVIEW
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module mgit.msbls.de/m/mcables
|
||||
module mgit.msbls.de/m/cablegui
|
||||
|
||||
go 1.25.5
|
||||
|
||||
|
||||
222
internal/db/bundles.go
Normal file
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
|
||||
}
|
||||
351
internal/db/clamps.go
Normal file
351
internal/db/clamps.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClampCreate is the create-shape for a new clamp.
|
||||
type ClampCreate struct {
|
||||
X float64
|
||||
Y float64
|
||||
Label string
|
||||
FrameID *int64
|
||||
}
|
||||
|
||||
// ClampUpdate is the partial-update shape.
|
||||
type ClampUpdate struct {
|
||||
X *float64
|
||||
Y *float64
|
||||
Label *string
|
||||
// FrameID tri-state: nil = leave alone; non-nil pointer to nil ptr
|
||||
// would be ambiguous, so we use FrameRef like devices.
|
||||
FrameID FrameRef
|
||||
}
|
||||
|
||||
// CreateClamp inserts a new clamp inside a project.
|
||||
func (s *Store) CreateClamp(projectID int64, c ClampCreate) (*Clamp, error) {
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.FrameID != nil {
|
||||
if _, err := s.GetFrame(projectID, *c.FrameID); err != nil {
|
||||
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO clamps (project_id, x, y, label, frame_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
projectID, c.X, c.Y, strings.TrimSpace(c.Label), nullableInt64(c.FrameID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetClamp(projectID, id)
|
||||
}
|
||||
|
||||
// GetClamp returns a single clamp scoped to the project.
|
||||
func (s *Store) GetClamp(projectID, id int64) (*Clamp, error) {
|
||||
var c Clamp
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
|
||||
FROM clamps WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
c.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
c.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListClamps returns every clamp in a project, ordered by id.
|
||||
func (s *Store) ListClamps(projectID int64) ([]Clamp, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
|
||||
FROM clamps WHERE project_id = ? ORDER BY id`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Clamp{}
|
||||
for rows.Next() {
|
||||
var c Clamp
|
||||
var frame sql.NullInt64
|
||||
var ex sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
c.FrameID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
c.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateClamp applies a partial update.
|
||||
func (s *Store) UpdateClamp(projectID, id int64, u ClampUpdate) (*Clamp, error) {
|
||||
cur, err := s.GetClamp(projectID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.X != nil {
|
||||
cur.X = *u.X
|
||||
}
|
||||
if u.Y != nil {
|
||||
cur.Y = *u.Y
|
||||
}
|
||||
if u.Label != nil {
|
||||
cur.Label = strings.TrimSpace(*u.Label)
|
||||
}
|
||||
if u.FrameID.Set {
|
||||
if u.FrameID.ID == nil {
|
||||
cur.FrameID = nil
|
||||
} else {
|
||||
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
||||
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
|
||||
}
|
||||
id := *u.FrameID.ID
|
||||
cur.FrameID = &id
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE clamps SET x = ?, y = ?, label = ?, frame_id = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
cur.X, cur.Y, cur.Label, nullableInt64(cur.FrameID), id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetClamp(projectID, id)
|
||||
}
|
||||
|
||||
// DeleteClamp removes a clamp. cable_clamps rows cascade.
|
||||
func (s *Store) DeleteClamp(projectID, id int64) error {
|
||||
res, err := s.db.Exec(`DELETE FROM clamps WHERE id = ? AND project_id = ?`, id, projectID)
|
||||
if err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCableClamps returns every (cable_id, clamp_id, ord) row in a
|
||||
// project, joined through cables to scope by project_id.
|
||||
func (s *Store) ListCableClamps(projectID int64) ([]CableClamp, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT cc.cable_id, cc.clamp_id, cc.ord
|
||||
FROM cable_clamps cc
|
||||
JOIN cables c ON c.id = cc.cable_id
|
||||
WHERE c.project_id = ?
|
||||
ORDER BY cc.cable_id, cc.ord`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CableClamp{}
|
||||
for rows.Next() {
|
||||
var cc CableClamp
|
||||
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, cc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListClampsForCable returns the clamps on a cable in ord sequence.
|
||||
func (s *Store) ListClampsForCable(projectID, cableID int64) ([]CableClamp, error) {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT cable_id, clamp_id, ord
|
||||
FROM cable_clamps WHERE cable_id = ? ORDER BY ord`, cableID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CableClamp{}
|
||||
for rows.Next() {
|
||||
var cc CableClamp
|
||||
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, cc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AttachClampToCable inserts a (cable, clamp) row. If `ord` is 0, the
|
||||
// clamp is appended at the end. Otherwise existing rows at or after
|
||||
// `ord` shift up by 1 to make room.
|
||||
func (s *Store) AttachClampToCable(projectID, cableID, clampID int64, ord int) (*CableClamp, error) {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.GetClamp(projectID, clampID); err != nil {
|
||||
return nil, fmt.Errorf("%w: clamp not found", ErrInvalidInput)
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Refuse loops — UNIQUE (cable_id, clamp_id) enforces this, but a
|
||||
// pre-check gives a clearer error.
|
||||
var exists int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COUNT(*) FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
||||
cableID, clampID,
|
||||
).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil, fmt.Errorf("%w: clamp %d already on cable %d", ErrConflict, clampID, cableID)
|
||||
}
|
||||
var maxOrd sql.NullInt64
|
||||
if err := tx.QueryRow(
|
||||
`SELECT MAX(ord) FROM cable_clamps WHERE cable_id = ?`, cableID,
|
||||
).Scan(&maxOrd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := 0
|
||||
if maxOrd.Valid {
|
||||
current = int(maxOrd.Int64)
|
||||
}
|
||||
if ord <= 0 || ord > current+1 {
|
||||
ord = current + 1
|
||||
} else if ord <= current {
|
||||
// Shift existing rows at ord..current up by 1 to free the slot.
|
||||
// SQLite UPDATE doesn't support ORDER BY (no UPDATE-with-temp
|
||||
// trick available), so a single `ord = ord + 1` would collide
|
||||
// with the UNIQUE (cable_id, ord) constraint during the bulk
|
||||
// update. Two-pass avoids the conflict: bump to a high offset
|
||||
// first, then settle back to ord+1.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cable_clamps SET ord = ord + 10000
|
||||
WHERE cable_id = ? AND ord >= ?`, cableID, ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cable_clamps SET ord = ord - 10000 + 1
|
||||
WHERE cable_id = ? AND ord >= ?`, cableID, 10000+ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
|
||||
cableID, clampID, ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CableClamp{CableID: cableID, ClampID: clampID, Ord: ord}, nil
|
||||
}
|
||||
|
||||
// DetachClampFromCable removes a clamp from a cable's polyline. The
|
||||
// trailing rows close up to keep `ord` contiguous.
|
||||
func (s *Store) DetachClampFromCable(projectID, cableID, clampID int64) error {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var removed sql.NullInt64
|
||||
if err := tx.QueryRow(
|
||||
`SELECT ord FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
||||
cableID, clampID,
|
||||
).Scan(&removed); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
||||
cableID, clampID,
|
||||
); err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
// Close the gap: anyone with ord > removed slides down by 1.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cable_clamps SET ord = ord - 1
|
||||
WHERE cable_id = ? AND ord > ?`, cableID, removed.Int64,
|
||||
); err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ReorderCableClamps replaces the whole clamp sequence on a cable with
|
||||
// the given clamp IDs, in order. Every member of clampIDs must already
|
||||
// be a valid clamp in the same project; duplicates → ErrConflict.
|
||||
func (s *Store) ReorderCableClamps(projectID, cableID int64, clampIDs []int64) ([]CableClamp, error) {
|
||||
if _, err := s.GetCable(projectID, cableID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, cid := range clampIDs {
|
||||
if seen[cid] {
|
||||
return nil, fmt.Errorf("%w: duplicate clamp %d", ErrConflict, cid)
|
||||
}
|
||||
seen[cid] = true
|
||||
if _, err := s.GetClamp(projectID, cid); err != nil {
|
||||
return nil, fmt.Errorf("%w: clamp %d not in project", ErrInvalidInput, cid)
|
||||
}
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM cable_clamps WHERE cable_id = ?`, cableID); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
out := make([]CableClamp, 0, len(clampIDs))
|
||||
for i, cid := range clampIDs {
|
||||
ord := i + 1
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
|
||||
cableID, cid, ord,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
out = append(out, CableClamp{CableID: cableID, ClampID: cid, Ord: ord})
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
188
internal/db/clamps_test.go
Normal file
188
internal/db/clamps_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateClamp_Basic(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
c, err := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 200, Label: "trunk-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if c.X != 100 || c.Y != 200 || c.Label != "trunk-1" {
|
||||
t.Errorf("bad shape: %+v", c)
|
||||
}
|
||||
if c.ProjectID != p.ID {
|
||||
t.Errorf("project_id mismatch: got %d, want %d", c.ProjectID, p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClamp_PositionAndLabel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
c, _ := s.CreateClamp(p.ID, ClampCreate{X: 0, Y: 0})
|
||||
nx, ny := 50.0, 75.0
|
||||
lbl := "renamed"
|
||||
upd, err := s.UpdateClamp(p.ID, c.ID, ClampUpdate{X: &nx, Y: &ny, Label: &lbl})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if upd.X != 50 || upd.Y != 75 || upd.Label != "renamed" {
|
||||
t.Errorf("update didn't take: %+v", upd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteClamp_CascadesToCableClamps(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
cl, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 50})
|
||||
if _, err := s.AttachClampToCable(p.ID, cab.ID, cl.ID, 0); err != nil {
|
||||
t.Fatalf("attach: %v", err)
|
||||
}
|
||||
if err := s.DeleteClamp(p.ID, cl.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
rows, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("cable_clamps not cleared: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachClampToCable_AppendsAndOrders(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
cc1, _ := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
|
||||
cc2, _ := s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
|
||||
cc3, _ := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
|
||||
if cc1.Ord != 1 || cc2.Ord != 2 || cc3.Ord != 3 {
|
||||
t.Errorf("ord sequence wrong: %d, %d, %d", cc1.Ord, cc2.Ord, cc3.Ord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachClampToCable_InsertShiftsExisting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0) // ord=1
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0) // ord=2
|
||||
// Insert c3 between c1 and c2 → c3 gets ord=2, old c2 bumps to 3.
|
||||
if _, err := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 2); err != nil {
|
||||
t.Fatalf("attach mid: %v", err)
|
||||
}
|
||||
got, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3: %+v", len(got), got)
|
||||
}
|
||||
want := []struct{ id int64; ord int }{
|
||||
{c1.ID, 1}, {c3.ID, 2}, {c2.ID, 3},
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].ClampID != w.id || got[i].Ord != w.ord {
|
||||
t.Errorf("[%d] got %+v, want clamp=%d ord=%d", i, got[i], w.id, w.ord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachClampToCable_DuplicateRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
|
||||
if _, err := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("duplicate err = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachClampFromCable_ClosesGap(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
|
||||
_, _ = s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
|
||||
if err := s.DetachClampFromCable(p.ID, cab.ID, c2.ID); err != nil {
|
||||
t.Fatalf("detach: %v", err)
|
||||
}
|
||||
got, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].ClampID != c1.ID || got[0].Ord != 1 {
|
||||
t.Errorf("[0] = %+v", got[0])
|
||||
}
|
||||
if got[1].ClampID != c3.ID || got[1].Ord != 2 {
|
||||
t.Errorf("[1] = %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderCableClamps_FullReplace(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
|
||||
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
|
||||
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
|
||||
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
|
||||
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
|
||||
if _, err := s.ReorderCableClamps(p.ID, cab.ID, []int64{c3.ID, c1.ID, c2.ID}); err != nil {
|
||||
t.Fatalf("reorder: %v", err)
|
||||
}
|
||||
got, _ := s.ListClampsForCable(p.ID, cab.ID)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ClampID != c3.ID || got[1].ClampID != c1.ID || got[2].ClampID != c2.ID {
|
||||
t.Errorf("order wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_IncludesClamps(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 10, Y: 20})
|
||||
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 30, Y: 40})
|
||||
snap, err := s.Snapshot(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if len(snap.Clamps) != 2 {
|
||||
t.Errorf("clamps in snapshot = %d, want 2", len(snap.Clamps))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package db owns SQLite access for mCables: migrations runner + the
|
||||
// Package db owns SQLite access for CableGUI: migrations runner + the
|
||||
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests
|
||||
// and the HTTP layer take a *Store, never a raw *sql.DB.
|
||||
package db
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestSeed_BuiltInDeviceTypes(t *testing.T) {
|
||||
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
|
||||
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
|
||||
"Screen", "Keyboard", "Mouse",
|
||||
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
|
||||
@@ -54,12 +55,17 @@ func TestSeed_PortProfiles(t *testing.T) {
|
||||
"fritz": {5}, // Power 1 + RJ45 4
|
||||
"ChromeCast": {2}, // Power 1 + HDMI 1
|
||||
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
|
||||
"IOx-3": {4}, // Power 1 + USB 3
|
||||
"IOx-6": {7}, // Power 1 + USB 6
|
||||
"IOx-8": {9}, // Power 1 + USB 8
|
||||
"Screen": {2}, // Power 1 + HDMI 1
|
||||
"Keyboard": {1}, // USB 1
|
||||
"Mouse": {1}, // USB 1
|
||||
"IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
|
||||
"IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
|
||||
"IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
|
||||
"Screen": {2}, // Power 1 + HDMI 1
|
||||
"Keyboard": {1}, // USB 1
|
||||
"Mouse": {1}, // USB 1
|
||||
"Multi-plug 3": {4}, // Power In 1 + Power Out 3 (after v6)
|
||||
"Multi-plug 4": {5}, // Power In 1 + Power Out 4 (after v6)
|
||||
"Multi-plug 5": {6}, // Power In 1 + Power Out 5 (after v6)
|
||||
"Multi-plug 6": {7}, // Power In 1 + Power Out 6 (after v6)
|
||||
"Wifi-plug": {2}, // Power In 1 + Power Out 1 (after v6)
|
||||
}
|
||||
for name, want := range cases {
|
||||
dt, ok := byName[name]
|
||||
@@ -77,6 +83,80 @@ func TestSeed_PortProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeed_PowerHubs locks down the post-migration-006 port profile for
|
||||
// every power-distribution device type: IOx-3/6/8, Multi-plug 3/4/5/6,
|
||||
// and Wifi-plug. Each carries exactly two profile rows — a single
|
||||
// "Power In" port on the top (back) edge and N "Power Out" ports on the
|
||||
// bottom (front) edge, where N is the device-specific output count.
|
||||
//
|
||||
// This test covers the v5 catalog identity (kind, icon, built-in) for
|
||||
// the 5 power-distribution types and the v6 port-profile fix for all
|
||||
// 8 hubs in one table.
|
||||
func TestSeed_PowerHubs(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
all, err := s.ListBuiltInDeviceTypes()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 21 {
|
||||
t.Errorf("built-in count = %d, want 21 (16 from v4 + 5 from v5)", len(all))
|
||||
}
|
||||
byName := map[string]DeviceType{}
|
||||
for _, d := range all {
|
||||
byName[d.Name] = d
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
// kind/icon are only set for the 5 v5-power types; empty means
|
||||
// "don't check" (the IOx-* keep their v4-seeded kind=hub icon=nil).
|
||||
kind string
|
||||
icon string
|
||||
outCount int // N — number of "Power Out" outlets on the bottom edge
|
||||
}{
|
||||
// v5 catalog (kind+icon checked)
|
||||
{name: "Multi-plug 3", kind: "hub", icon: "🔌", outCount: 3},
|
||||
{name: "Multi-plug 4", kind: "hub", icon: "🔌", outCount: 4},
|
||||
{name: "Multi-plug 5", kind: "hub", icon: "🔌", outCount: 5},
|
||||
{name: "Multi-plug 6", kind: "hub", icon: "🔌", outCount: 6},
|
||||
{name: "Wifi-plug", kind: "accessory", icon: "📶", outCount: 1},
|
||||
// v4 hubs re-shaped by v6 (kind/icon left blank → not checked)
|
||||
{name: "IOx-3", outCount: 3},
|
||||
{name: "IOx-6", outCount: 6},
|
||||
{name: "IOx-8", outCount: 8},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dt, ok := byName[c.name]
|
||||
if !ok {
|
||||
t.Errorf("missing %q", c.name)
|
||||
continue
|
||||
}
|
||||
if !dt.BuiltIn {
|
||||
t.Errorf("%s: built_in should be true", c.name)
|
||||
}
|
||||
if dt.ProjectID != nil {
|
||||
t.Errorf("%s: project_id should be nil", c.name)
|
||||
}
|
||||
if c.kind != "" && dt.Kind != c.kind {
|
||||
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
|
||||
}
|
||||
if c.icon != "" && (dt.Icon == nil || *dt.Icon != c.icon) {
|
||||
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
|
||||
}
|
||||
if len(dt.Ports) != 2 {
|
||||
t.Errorf("%s: expected 2 port-profile rows, got %d", c.name, len(dt.Ports))
|
||||
continue
|
||||
}
|
||||
in := dt.Ports[0]
|
||||
out := dt.Ports[1]
|
||||
if in.CableTypeID != 1 || in.Count != 1 || in.Edge != "top" || in.LabelPrefix != "Power In" {
|
||||
t.Errorf("%s: Power In row mismatch: %+v", c.name, in)
|
||||
}
|
||||
if out.CableTypeID != 1 || out.Count != c.outCount || out.Edge != "bottom" || out.LabelPrefix != "Power Out" {
|
||||
t.Errorf("%s: Power Out row mismatch: %+v (want count=%d)", c.name, out, c.outCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- CRUD (custom rows)
|
||||
|
||||
func TestCreateDeviceType_CustomBasic(t *testing.T) {
|
||||
|
||||
63
internal/db/excalidraw_ids.go
Normal file
63
internal/db/excalidraw_ids.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// PersistExcalidrawIDs writes the assignments returned by the exporter
|
||||
// back onto the corresponding rows. Idempotent: only updates rows whose
|
||||
// excalidraw_id is currently NULL (the first export "owns" the id; later
|
||||
// exports reuse it so mxdrw's collab cursors / undo history survive).
|
||||
//
|
||||
// Caller passes one map per kind; keys are the in-project row ids,
|
||||
// values are the 21-char Excalidraw element ids the exporter minted.
|
||||
func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||
frames, devices, ports, ios, cables, clamps map[int64]string,
|
||||
) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := updateExIDs(tx, "frames", projectID, frames); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "devices", projectID, devices); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "ports", projectID, ports); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "io_markers", projectID, ios); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "clamps", projectID, clamps); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func updateExIDs(tx *sql.Tx, table string, projectID int64, m map[int64]string) error {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
stmt, err := tx.Prepare(
|
||||
`UPDATE ` + table + `
|
||||
SET excalidraw_id = ?
|
||||
WHERE id = ? AND project_id = ? AND excalidraw_id IS NULL`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for id, exID := range m {
|
||||
if _, err := stmt.Exec(exID, id, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
-- mCables v3 initial schema. See docs/design.md §2.
|
||||
-- CableGUI v3 initial schema. See docs/design.md §2.
|
||||
|
||||
-- A project IS a drawing. LOFT and OFFICE are separate projects.
|
||||
-- One project ↔ one .excalidraw file in mExDraw.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- mCables v4 device-type catalog. See docs/design.md §2.1 + §2.2.
|
||||
-- CableGUI v4 device-type catalog. See docs/design.md §2.1 + §2.2.
|
||||
|
||||
-- v4 — device-type catalog. Built-in types live globally (project_id NULL).
|
||||
-- Per-project custom types use project_id = X.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- mCables v4.1 connection requirements + solver-owned cable flag.
|
||||
-- CableGUI v4.1 connection requirements + solver-owned cable flag.
|
||||
-- See docs/design.md §2.1 + §2 connection_requirements + §5b.3.
|
||||
|
||||
-- The solver's input: "device A must connect to device B via cable type T".
|
||||
|
||||
157
internal/db/migrations/004_setup_templates.sql
Normal file
157
internal/db/migrations/004_setup_templates.sql
Normal file
@@ -0,0 +1,157 @@
|
||||
-- CableGUI v4.1 setup templates. See docs/design.md §2.4.
|
||||
--
|
||||
-- A template is a named recipe of (device_types + requirements) that
|
||||
-- bootstraps a project from blank to solver-ready in one apply call.
|
||||
|
||||
CREATE TABLE setup_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
built_in INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE setup_template_devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
|
||||
suggested_name TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
|
||||
|
||||
CREATE TABLE setup_template_requirements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||||
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||||
CHECK (from_template_device_id != to_template_device_id)
|
||||
);
|
||||
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
|
||||
|
||||
-- ---------------------------------------------------------------- Living Room
|
||||
INSERT INTO setup_templates (name, description, built_in)
|
||||
VALUES ('Living Room', 'TV + Soundbar + ChromeCast, HDMI between them.', 1);
|
||||
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM device_types WHERE name='TV' AND project_id IS NULL),
|
||||
'TV', 0;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM device_types WHERE name='Soundbar' AND project_id IS NULL),
|
||||
'Soundbar', 1;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM device_types WHERE name='ChromeCast' AND project_id IS NULL),
|
||||
'ChromeCast', 2;
|
||||
|
||||
-- TV ↔ Soundbar (HDMI, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='Soundbar'),
|
||||
3, 1;
|
||||
-- TV ↔ ChromeCast (HDMI, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='ChromeCast'),
|
||||
3, 1;
|
||||
|
||||
-- ---------------------------------------------------------------- Home Office
|
||||
INSERT INTO setup_templates (name, description, built_in)
|
||||
VALUES ('Home Office', 'PC + Screen + Keyboard + Mouse. HDMI + USB.', 1);
|
||||
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='PC' AND project_id IS NULL),
|
||||
'PC', 0;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='Screen' AND project_id IS NULL),
|
||||
'Screen', 1;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='Keyboard' AND project_id IS NULL),
|
||||
'Keyboard', 2;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='Mouse' AND project_id IS NULL),
|
||||
'Mouse', 3;
|
||||
|
||||
-- PC ↔ Screen (HDMI, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Screen'),
|
||||
3, 1;
|
||||
-- PC ↔ Keyboard (USB, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Keyboard'),
|
||||
2, 1;
|
||||
-- PC ↔ Mouse (USB, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Mouse'),
|
||||
2, 1;
|
||||
|
||||
-- ---------------------------------------------------------------- Server Rack
|
||||
INSERT INTO setup_templates (name, description, built_in)
|
||||
VALUES ('Server Rack', 'NAS + Switch + fritz. Ethernet trunk + power.', 1);
|
||||
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM device_types WHERE name='NAS' AND project_id IS NULL),
|
||||
'NAS', 0;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM device_types WHERE name='Switch' AND project_id IS NULL),
|
||||
'Switch', 1;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM device_types WHERE name='fritz' AND project_id IS NULL),
|
||||
'fritz', 2;
|
||||
|
||||
-- NAS ↔ Switch (RJ45, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='NAS'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
|
||||
5, 1;
|
||||
-- Switch ↔ fritz (RJ45, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='fritz'),
|
||||
5, 1;
|
||||
32
internal/db/migrations/005_catalog_power.sql
Normal file
32
internal/db/migrations/005_catalog_power.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- CableGUI v5 — catalog: power-distribution devices.
|
||||
-- Adds 5 built-in device_types (project_id NULL, built_in=1).
|
||||
--
|
||||
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The
|
||||
-- solver treats every Power port identically regardless of in/out
|
||||
-- direction; m knows which end is which from the physical setup.
|
||||
--
|
||||
-- Wifi-plug is a pass-through outlet (Power × 2: one in, one out).
|
||||
|
||||
INSERT INTO device_types (name, kind, icon, built_in, description) VALUES
|
||||
('Multi-plug 3', 'hub', '🔌', 1, '3-way power strip (1 in + 3 out)'),
|
||||
('Multi-plug 4', 'hub', '🔌', 1, '4-way power strip (1 in + 4 out)'),
|
||||
('Multi-plug 5', 'hub', '🔌', 1, '5-way power strip (1 in + 5 out)'),
|
||||
('Multi-plug 6', 'hub', '🔌', 1, '6-way power strip (1 in + 6 out)'),
|
||||
('Wifi-plug', 'accessory', '📶', 1, 'WiFi-controllable pass-through outlet');
|
||||
|
||||
-- Port profiles. cable_types id 1 = Power (seeded in 001).
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 4, 'bottom', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 5, 'bottom', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 6, 'bottom', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 7, 'bottom', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 2, 'bottom', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||
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 @@
|
||||
-- CableGUI v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
|
||||
--
|
||||
-- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N),
|
||||
-- but m's physical IOx-* devices are power strips (1 power input on
|
||||
-- the back, N power outputs on the front). v5's Multi-plug 3/4/5/6
|
||||
-- profiles also lumped every Power port on the bottom edge without
|
||||
-- distinguishing the input from the outputs.
|
||||
--
|
||||
-- This migration replaces the port profile for the 8 power-distribution
|
||||
-- types with the canonical "1 in (top/back) + N out (bottom/front)"
|
||||
-- layout. Convention: top=back, bottom=front.
|
||||
--
|
||||
-- N for each type:
|
||||
-- IOx-3 / Multi-plug 3 → 3 outputs
|
||||
-- IOx-6 → 6 outputs
|
||||
-- IOx-8 → 8 outputs
|
||||
-- Multi-plug 4 → 4 outputs
|
||||
-- Multi-plug 5 → 5 outputs
|
||||
-- Multi-plug 6 → 6 outputs
|
||||
-- Wifi-plug → 1 output (it's a pass-through outlet)
|
||||
--
|
||||
-- Existing devices m may have created with the old profile keep their
|
||||
-- already-seeded ports — per design §2.3, ports are instance-owned. To
|
||||
-- get the new layout on an existing instance, delete it and re-create.
|
||||
--
|
||||
-- cable_types id 1 = Power (seeded in 001).
|
||||
|
||||
-- 1) Drop the existing port-profile rows for each affected type.
|
||||
DELETE FROM device_type_ports
|
||||
WHERE device_type_id IN (
|
||||
SELECT id FROM device_types
|
||||
WHERE project_id IS NULL
|
||||
AND name IN (
|
||||
'IOx-3', 'IOx-6', 'IOx-8',
|
||||
'Multi-plug 3', 'Multi-plug 4', 'Multi-plug 5', 'Multi-plug 6',
|
||||
'Wifi-plug'
|
||||
)
|
||||
);
|
||||
|
||||
-- 2) Insert the canonical (1 in on top, N out on bottom) profile.
|
||||
-- IOx-3 — 1 in + 3 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
|
||||
|
||||
-- IOx-6 — 1 in + 6 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
|
||||
|
||||
-- IOx-8 — 1 in + 8 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
|
||||
|
||||
-- Multi-plug 3 — 1 in + 3 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||
|
||||
-- Multi-plug 4 — 1 in + 4 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 4, 'bottom', 1 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||
|
||||
-- Multi-plug 5 — 1 in + 5 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 5, 'bottom', 1 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||
|
||||
-- Multi-plug 6 — 1 in + 6 out
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||
|
||||
-- Wifi-plug — 1 in + 1 out (pass-through outlet)
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power Out', 1, 'bottom', 1 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||
31
internal/db/migrations/007_clamps.sql
Normal file
31
internal/db/migrations/007_clamps.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- CableGUI v5 — cable routing via clamps. See docs/design.md §11.
|
||||
--
|
||||
-- A clamp is a physical anchor placed on the canvas. A cable's polyline
|
||||
-- runs from its `from` endpoint → its clamps in `ord` sequence → its
|
||||
-- `to` endpoint. Cables that share an ordered pair of consecutive
|
||||
-- clamps are visibly bundled along that segment (computed live by the
|
||||
-- frontend; no detection pass).
|
||||
|
||||
CREATE TABLE clamps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX clamps_project_idx ON clamps(project_id);
|
||||
CREATE INDEX clamps_frame_idx ON clamps(frame_id);
|
||||
|
||||
CREATE TABLE cable_clamps (
|
||||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||||
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
|
||||
ord INTEGER NOT NULL, -- 1-based along from→to
|
||||
PRIMARY KEY (cable_id, ord),
|
||||
UNIQUE (cable_id, clamp_id)
|
||||
);
|
||||
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
|
||||
@@ -111,6 +111,102 @@ type ConnectionRequirement struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Cable is a typed connection. Each endpoint is exactly one of
|
||||
// (port, device, io-marker). Auto=true means the solver placed it.
|
||||
type Cable struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label *string `json:"label"`
|
||||
FromPortID *int64 `json:"from_port_id"`
|
||||
FromDeviceID *int64 `json:"from_device_id"`
|
||||
FromIOID *int64 `json:"from_io_id"`
|
||||
ToPortID *int64 `json:"to_port_id"`
|
||||
ToDeviceID *int64 `json:"to_device_id"`
|
||||
ToIOID *int64 `json:"to_io_id"`
|
||||
Auto bool `json:"auto"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Bundle is a named group of cables that physically run together.
|
||||
type Bundle struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Auto bool `json:"auto"`
|
||||
CableIDs []int64 `json:"cable_ids"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SetupTemplate is a named recipe of device-types + requirements.
|
||||
type SetupTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Devices []SetupTemplateDevice `json:"devices"`
|
||||
Requirements []SetupTemplateRequirement `json:"requirements"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SetupTemplateDevice struct {
|
||||
ID int64 `json:"id"`
|
||||
TemplateID int64 `json:"template_id"`
|
||||
DeviceTypeID int64 `json:"device_type_id"`
|
||||
DeviceType *DeviceType `json:"device_type,omitempty"`
|
||||
SuggestedName *string `json:"suggested_name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type SetupTemplateRequirement struct {
|
||||
ID int64 `json:"id"`
|
||||
TemplateID int64 `json:"template_id"`
|
||||
FromTemplateDeviceID int64 `json:"from_template_device_id"`
|
||||
ToTemplateDeviceID int64 `json:"to_template_device_id"`
|
||||
PreferredCableTypeID *int64 `json:"preferred_cable_type_id"`
|
||||
MustConnect bool `json:"must_connect"`
|
||||
}
|
||||
|
||||
// SolveResult is the response shape from POST /api/projects/:pid/solve.
|
||||
type SolveResult struct {
|
||||
CablesAdded []Cable `json:"cables_added"`
|
||||
CablesKept []int64 `json:"cables_kept"`
|
||||
CablesRemoved []int64 `json:"cables_removed"`
|
||||
BundlesAdded []Bundle `json:"bundles_added"`
|
||||
BundlesRemoved []int64 `json:"bundles_removed"`
|
||||
Unsatisfied []UnsatisfiedReq `json:"unsatisfied"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
type UnsatisfiedReq struct {
|
||||
RequirementID int64 `json:"requirement_id"`
|
||||
Reason string `json:"reason"`
|
||||
WhichSide string `json:"which_side,omitempty"` // "from" | "to" | "" when both/neither
|
||||
CableType string `json:"cable_type,omitempty"` // when known
|
||||
}
|
||||
|
||||
// ApplyTemplateResult is the response from POST /apply-template.
|
||||
type ApplyTemplateResult struct {
|
||||
FramesAdded []Frame `json:"frames_added"`
|
||||
DevicesAdded []Device `json:"devices_added"`
|
||||
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
|
||||
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
|
||||
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
|
||||
}
|
||||
|
||||
type SkippedTemplateDevice struct {
|
||||
TemplateDeviceID int64 `json:"template_device_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
type SkippedTemplateReq struct {
|
||||
TemplateRequirementID int64 `json:"template_requirement_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Snapshot is the editor's one-shot loader payload for a single project.
|
||||
// Arrays for collections still gated by future slices stay non-nil [] so
|
||||
// JSON encodes as [] not null.
|
||||
@@ -119,9 +215,34 @@ type Snapshot struct {
|
||||
Frames []Frame `json:"frames"`
|
||||
Devices []Device `json:"devices"`
|
||||
Ports []Port `json:"ports"`
|
||||
Cables []any `json:"cables"`
|
||||
Cables []Cable `json:"cables"`
|
||||
IOMarkers []IOMarker `json:"io_markers"`
|
||||
Bundles []any `json:"bundles"`
|
||||
Bundles []Bundle `json:"bundles"`
|
||||
CableTypes []CableType `json:"cable_types"`
|
||||
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
|
||||
Clamps []Clamp `json:"clamps"`
|
||||
CableClamps []CableClamp `json:"cable_clamps"`
|
||||
}
|
||||
|
||||
// Clamp is a routing anchor on the canvas. Cables route through clamps
|
||||
// in `ord` sequence (see cable_clamps), giving m a physical handle on
|
||||
// where bundles converge.
|
||||
type Clamp struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Label string `json:"label"`
|
||||
FrameID *int64 `json:"frame_id"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CableClamp is one (cable, clamp, ord) row. Ord is 1-based along the
|
||||
// cable's from→to direction.
|
||||
type CableClamp struct {
|
||||
CableID int64 `json:"cable_id"`
|
||||
ClampID int64 `json:"clamp_id"`
|
||||
Ord int `json:"ord"`
|
||||
}
|
||||
|
||||
@@ -2,8 +2,187 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports.
|
||||
type PortCreate struct {
|
||||
TypeID int64
|
||||
Label string
|
||||
XOffset float64
|
||||
YOffset float64
|
||||
}
|
||||
|
||||
// PortUpdate is the partial-update shape.
|
||||
type PortUpdate struct {
|
||||
TypeID *int64
|
||||
Label *string
|
||||
XOffset *float64
|
||||
YOffset *float64
|
||||
}
|
||||
|
||||
// CreatePort inserts a port on a device. The device must exist in the
|
||||
// project; the cable type must exist globally.
|
||||
func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) {
|
||||
if _, err := s.GetDevice(projectID, deviceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.GetCableType(p.TypeID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
label := strings.TrimSpace(p.Label)
|
||||
var labelArg any
|
||||
if label != "" {
|
||||
labelArg = label
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetPort(projectID, id)
|
||||
}
|
||||
|
||||
// GetPort loads a port by id, project-scoped.
|
||||
func (s *Store) GetPort(projectID, id int64) (*Port, error) {
|
||||
var p Port
|
||||
var label, ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||
excalidraw_id, created_at, updated_at
|
||||
FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
||||
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if label.Valid {
|
||||
v := label.String
|
||||
p.Label = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
p.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpdatePort applies a partial update.
|
||||
func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) {
|
||||
cur, err := s.GetPort(projectID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.TypeID != nil {
|
||||
if _, err := s.GetCableType(*u.TypeID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cur.TypeID = *u.TypeID
|
||||
}
|
||||
if u.Label != nil {
|
||||
v := strings.TrimSpace(*u.Label)
|
||||
if v == "" {
|
||||
cur.Label = nil
|
||||
} else {
|
||||
cur.Label = &v
|
||||
}
|
||||
}
|
||||
if u.XOffset != nil {
|
||||
cur.XOffset = *u.XOffset
|
||||
}
|
||||
if u.YOffset != nil {
|
||||
cur.YOffset = *u.YOffset
|
||||
}
|
||||
var labelArg any
|
||||
if cur.Label != nil {
|
||||
labelArg = *cur.Label
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE ports
|
||||
SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
return s.GetPort(projectID, id)
|
||||
}
|
||||
|
||||
// DeletePort removes a port from a device. The schema's
|
||||
// ON DELETE SET NULL on cables.from_port_id / to_port_id collides with
|
||||
// the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so
|
||||
// we instead cascade-delete any cables that referenced the port on
|
||||
// either side — same effect from m's POV: the cable is gone, m can
|
||||
// re-draw if he still wants it.
|
||||
func (s *Store) DeletePort(projectID, id int64) error {
|
||||
if _, err := s.GetPort(projectID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`,
|
||||
projectID, id, id,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListPortsForDevice returns every port on one device, project-scoped.
|
||||
func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||
excalidraw_id, created_at, updated_at
|
||||
FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`,
|
||||
projectID, deviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Port{}
|
||||
for rows.Next() {
|
||||
var p Port
|
||||
var label, ex sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
||||
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if label.Valid {
|
||||
v := label.String
|
||||
p.Label = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
p.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListPortsForProject returns every port in a project, ordered by
|
||||
// device_id + id so callers can group cheaply.
|
||||
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {
|
||||
|
||||
465
internal/db/setup_templates.go
Normal file
465
internal/db/setup_templates.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListSetupTemplates returns every template with its devices +
|
||||
// requirements hydrated.
|
||||
func (s *Store) ListSetupTemplates() ([]SetupTemplate, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, description, built_in, created_at, updated_at
|
||||
FROM setup_templates ORDER BY id`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SetupTemplate{}
|
||||
for rows.Next() {
|
||||
var t SetupTemplate
|
||||
var built int
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &built,
|
||||
&t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.BuiltIn = built != 0
|
||||
out = append(out, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
devs, err := s.listTemplateDevices(out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].Devices = devs
|
||||
reqs, err := s.listTemplateRequirements(out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].Requirements = reqs
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSetupTemplate is a one-template variant of List.
|
||||
func (s *Store) GetSetupTemplate(id int64) (*SetupTemplate, error) {
|
||||
var t SetupTemplate
|
||||
var built int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, description, built_in, created_at, updated_at
|
||||
FROM setup_templates WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Name, &t.Description, &built, &t.CreatedAt, &t.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.BuiltIn = built != 0
|
||||
t.Devices, err = s.listTemplateDevices(t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Requirements, err = s.listTemplateRequirements(t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTemplateDevices(templateID int64) ([]SetupTemplateDevice, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, template_id, device_type_id, suggested_name, sort_order
|
||||
FROM setup_template_devices WHERE template_id = ? ORDER BY sort_order, id`,
|
||||
templateID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SetupTemplateDevice{}
|
||||
for rows.Next() {
|
||||
var d SetupTemplateDevice
|
||||
var sn sql.NullString
|
||||
if err := rows.Scan(&d.ID, &d.TemplateID, &d.DeviceTypeID, &sn, &d.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sn.Valid {
|
||||
v := sn.String
|
||||
d.SuggestedName = &v
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hydrate the device_type for the UI's optgroup labels.
|
||||
for i := range out {
|
||||
dt, err := s.GetDeviceType(out[i].DeviceTypeID)
|
||||
if err == nil {
|
||||
out[i].DeviceType = dt
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTemplateRequirements(templateID int64) ([]SetupTemplateRequirement, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, template_id, from_template_device_id, to_template_device_id,
|
||||
preferred_cable_type_id, must_connect
|
||||
FROM setup_template_requirements WHERE template_id = ? ORDER BY id`,
|
||||
templateID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SetupTemplateRequirement{}
|
||||
for rows.Next() {
|
||||
var r SetupTemplateRequirement
|
||||
var pct sql.NullInt64
|
||||
var must int
|
||||
if err := rows.Scan(&r.ID, &r.TemplateID, &r.FromTemplateDeviceID, &r.ToTemplateDeviceID,
|
||||
&pct, &must); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pct.Valid {
|
||||
v := pct.Int64
|
||||
r.PreferredCableTypeID = &v
|
||||
}
|
||||
r.MustConnect = must != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ApplyTemplateOptions controls per-device name overrides + opt-outs.
|
||||
type ApplyTemplateOptions struct {
|
||||
NameOverrides map[int64]string // template_device_id → custom name
|
||||
SkipDevices map[int64]bool // template_device_id → skip
|
||||
// Layout: where to place the first device in the cluster on the canvas.
|
||||
OriginX, OriginY float64
|
||||
}
|
||||
|
||||
// ApplyTemplate seeds devices + requirements from the template into
|
||||
// projectID in a single transaction. Name collisions skip the device
|
||||
// (recorded in skipped_devices); requirements whose endpoints both fail
|
||||
// to land are also skipped.
|
||||
func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOptions) (*ApplyTemplateResult, error) {
|
||||
tmpl, err := s.GetSetupTemplate(templateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &ApplyTemplateResult{
|
||||
FramesAdded: []Frame{},
|
||||
DevicesAdded: []Device{},
|
||||
RequirementsAdded: []ConnectionRequirement{},
|
||||
SkippedDevices: []SkippedTemplateDevice{},
|
||||
RequirementsSkipped: []SkippedTemplateReq{},
|
||||
}
|
||||
|
||||
if opts.OriginX == 0 && opts.OriginY == 0 {
|
||||
opts.OriginX, opts.OriginY = 200, 200
|
||||
}
|
||||
|
||||
// Pull existing device + frame names in the project so we can
|
||||
// pre-check collisions without aborting the whole transaction.
|
||||
existing, err := s.ListDevices(projectID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nameTaken := map[string]bool{}
|
||||
for _, d := range existing {
|
||||
nameTaken[d.Name] = true
|
||||
}
|
||||
existingFrames, err := s.ListFrames(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frameNameTaken := map[string]bool{}
|
||||
for _, f := range existingFrames {
|
||||
frameNameTaken[f.Name] = true
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Plan a uniform grid for the template's devices inside a new frame
|
||||
// named after the template. The grid drives both frame size and
|
||||
// per-device (x, y). Devices that get skipped (name collision /
|
||||
// SkipDevices) leave their grid cell empty.
|
||||
const (
|
||||
devW, devH = 100.0, 35.0
|
||||
gapX, gapY = 30.0, 50.0
|
||||
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
|
||||
)
|
||||
n := len(tmpl.Devices)
|
||||
cols := 1
|
||||
if n > 0 {
|
||||
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
|
||||
}
|
||||
rows := 1
|
||||
if n > 0 {
|
||||
rows = (n + cols - 1) / cols
|
||||
}
|
||||
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
|
||||
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
|
||||
frameName := pickFrameName(tmpl.Name, frameNameTaken)
|
||||
|
||||
frame, err := createFrameTx(tx, projectID, FrameCreate{
|
||||
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
|
||||
Width: frameW, Height: frameH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
|
||||
}
|
||||
out.FramesAdded = append(out.FramesAdded, *frame)
|
||||
|
||||
// Map: template_device_id → newly-created device_id (or 0 if skipped).
|
||||
tmplToDevice := map[int64]int64{}
|
||||
|
||||
for i, td := range tmpl.Devices {
|
||||
if opts.SkipDevices[td.ID] {
|
||||
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
||||
TemplateDeviceID: td.ID, Reason: "skip requested",
|
||||
})
|
||||
tmplToDevice[td.ID] = 0
|
||||
continue
|
||||
}
|
||||
name := opts.NameOverrides[td.ID]
|
||||
if name == "" && td.SuggestedName != nil {
|
||||
name = *td.SuggestedName
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Device %d", td.ID)
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if nameTaken[name] {
|
||||
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
||||
TemplateDeviceID: td.ID,
|
||||
Reason: fmt.Sprintf("name %q already used in project", name),
|
||||
})
|
||||
tmplToDevice[td.ID] = 0
|
||||
continue
|
||||
}
|
||||
// Grid cell (col, row) within the frame. Cell anchor is the
|
||||
// top-left of the device rect; offsets are added to the frame's
|
||||
// own (x, y) so the device sits inside the frame.
|
||||
col := i % cols
|
||||
row := i / cols
|
||||
x := frame.X + padX + float64(col)*(devW+gapX)
|
||||
y := frame.Y + padY + float64(row)*(devH+gapY)
|
||||
// Use createDeviceTx so port-seeding shares the same transaction.
|
||||
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
|
||||
Name: name,
|
||||
TypeID: &td.DeviceTypeID,
|
||||
FrameID: &frame.ID,
|
||||
X: x,
|
||||
Y: y,
|
||||
Width: devW,
|
||||
Height: devH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed %s: %w", name, err)
|
||||
}
|
||||
nameTaken[name] = true
|
||||
tmplToDevice[td.ID] = d.ID
|
||||
out.DevicesAdded = append(out.DevicesAdded, *d)
|
||||
}
|
||||
|
||||
for _, tr := range tmpl.Requirements {
|
||||
fromID := tmplToDevice[tr.FromTemplateDeviceID]
|
||||
toID := tmplToDevice[tr.ToTemplateDeviceID]
|
||||
if fromID == 0 || toID == 0 {
|
||||
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
||||
TemplateRequirementID: tr.ID,
|
||||
Reason: "one or both endpoint devices were skipped",
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Normalise pair_lo/pair_hi, mirror what CreateConnectionRequirement does.
|
||||
lo, hi := fromID, toID
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
must := 0
|
||||
if tr.MustConnect {
|
||||
must = 1
|
||||
}
|
||||
var ctArg any
|
||||
if tr.PreferredCableTypeID != nil {
|
||||
ctArg = *tr.PreferredCableTypeID
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO connection_requirements
|
||||
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
||||
must_connect, notes, pair_lo, pair_hi)
|
||||
VALUES (?, ?, ?, ?, ?, '', ?, ?)`,
|
||||
projectID, fromID, toID, ctArg, must, lo, hi,
|
||||
)
|
||||
if err != nil {
|
||||
// A UNIQUE collision (project already has the same requirement)
|
||||
// is non-fatal — record as skipped, continue.
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
||||
TemplateRequirementID: tr.ID,
|
||||
Reason: "requirement already exists in project",
|
||||
})
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rid, _ := res.LastInsertId()
|
||||
out.RequirementsAdded = append(out.RequirementsAdded, ConnectionRequirement{
|
||||
ID: rid,
|
||||
ProjectID: projectID,
|
||||
FromDeviceID: fromID,
|
||||
ToDeviceID: toID,
|
||||
PreferredCableTypeID: tr.PreferredCableTypeID,
|
||||
MustConnect: tr.MustConnect,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pickFrameName returns a frame name that doesn't collide with anything
|
||||
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
|
||||
// and so on.
|
||||
func pickFrameName(base string, taken map[string]bool) string {
|
||||
if !taken[base] {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s %d", base, i)
|
||||
if !taken[candidate] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
|
||||
// the validation in CreateFrame (name + positive size) but avoids the
|
||||
// s.db.Exec call so ApplyTemplate can keep everything on the same
|
||||
// connection under MaxOpenConns(1).
|
||||
func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) {
|
||||
name := strings.TrimSpace(f.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if f.Width <= 0 || f.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO frames (project_id, name, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
projectID, name, f.X, f.Y, f.Width, f.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
var out Frame
|
||||
var ex sql.NullString
|
||||
err = tx.QueryRow(
|
||||
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
|
||||
&ex, &out.CreatedAt, &out.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ex.Valid {
|
||||
out.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// createDeviceTx is a tx-aware variant of CreateDevice used by
|
||||
// ApplyTemplate so seeding the template's devices + their ports stays
|
||||
// inside one atomic apply.
|
||||
//
|
||||
// Validation is intentionally lighter than CreateDevice: callers (only
|
||||
// ApplyTemplate today) hold a tx on the single SQLite connection, so
|
||||
// any "validate by reading from s.db" call would deadlock. The template's
|
||||
// device_type_id + frame_id come from already-validated template rows,
|
||||
// and SQLite FK constraints catch any genuine corruption on INSERT
|
||||
// (mapped to ErrInvalidInput by mapWriteErr).
|
||||
func (s *Store) createDeviceTx(tx *sql.Tx, projectID int64, d DeviceCreate) (*Device, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if d.Width <= 0 || d.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||
}
|
||||
color := strings.TrimSpace(d.Color)
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
||||
name, color, d.X, d.Y, d.Width, d.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
deviceID, _ := res.LastInsertId()
|
||||
if d.TypeID != nil {
|
||||
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Read back via the public store path is fine — the row exists in
|
||||
// the in-flight tx and SQLite sees its own writes within the tx.
|
||||
// Use a sub-helper that takes the tx executor for clean isolation.
|
||||
return s.readDeviceTx(tx, projectID, deviceID)
|
||||
}
|
||||
|
||||
func (s *Store) readDeviceTx(ex execer, projectID, id int64) (*Device, error) {
|
||||
var d Device
|
||||
var frame, typeID sql.NullInt64
|
||||
var ex2 sql.NullString
|
||||
err := ex.QueryRow(
|
||||
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
&ex2, &d.CreatedAt, &d.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
d.FrameID = &v
|
||||
}
|
||||
if typeID.Valid {
|
||||
v := typeID.Int64
|
||||
d.TypeID = &v
|
||||
}
|
||||
if ex2.Valid {
|
||||
d.ExcalidrawID = &ex2.String
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
509
internal/db/solver.go
Normal file
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
|
||||
}
|
||||
329
internal/db/solver_test.go
Normal file
329
internal/db/solver_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// builtInTypeID returns the id of the named built-in device type.
|
||||
func builtInTypeID(t *testing.T, s *Store, name string) int64 {
|
||||
t.Helper()
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
for _, dt := range all {
|
||||
if dt.Name == name {
|
||||
return dt.ID
|
||||
}
|
||||
}
|
||||
t.Fatalf("built-in %q not found", name)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ basic solver wins
|
||||
|
||||
func TestSolve_BasicNAStoSwitch(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
nasT := builtInTypeID(t, s, "NAS")
|
||||
swT := builtInTypeID(t, s, "Switch")
|
||||
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
rj45 := int64(5)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
||||
})
|
||||
res, err := s.Solve(p.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("solve: %v", err)
|
||||
}
|
||||
if len(res.CablesAdded) != 1 {
|
||||
t.Fatalf("cables_added len = %d, want 1", len(res.CablesAdded))
|
||||
}
|
||||
if res.CablesAdded[0].TypeID != rj45 {
|
||||
t.Errorf("cable type = %d, want %d (RJ45)", res.CablesAdded[0].TypeID, rj45)
|
||||
}
|
||||
if !res.CablesAdded[0].Auto {
|
||||
t.Errorf("cable.auto should be true")
|
||||
}
|
||||
if len(res.Unsatisfied) != 0 {
|
||||
t.Errorf("unsatisfied should be empty; got %+v", res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_AmbiguousType_RequirementUnsatisfied(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
// Both PCs have Power + USB + HDMI + RJ45 → multiple types match.
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: a.ID, ToDeviceID: b.ID, // no PreferredCableTypeID
|
||||
})
|
||||
res, _ := s.Solve(p.ID, true)
|
||||
if len(res.CablesAdded) != 0 {
|
||||
t.Errorf("ambiguous: should not add cables, got %d", len(res.CablesAdded))
|
||||
}
|
||||
if len(res.Unsatisfied) != 1 || res.Unsatisfied[0].Reason == "" {
|
||||
t.Errorf("expected 1 unsatisfied req with non-empty reason; got %+v", res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_NoFreePort_RequirementUnsatisfied(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
// Mouse only has 1 USB port. Two USB requirements against it should
|
||||
// leave one unsatisfied.
|
||||
mouseT := builtInTypeID(t, s, "Mouse")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
pc1, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC1", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
pc2, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC2", TypeID: &pcT, X: 400, Y: 0, Width: 100, Height: 35})
|
||||
usb := int64(2)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: mouse.ID, ToDeviceID: pc1.ID, PreferredCableTypeID: &usb,
|
||||
})
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: mouse.ID, ToDeviceID: pc2.ID, PreferredCableTypeID: &usb,
|
||||
})
|
||||
res, _ := s.Solve(p.ID, true)
|
||||
if len(res.CablesAdded) != 1 {
|
||||
t.Errorf("expected 1 cable to land (one mouse USB), got %d", len(res.CablesAdded))
|
||||
}
|
||||
if len(res.Unsatisfied) != 1 {
|
||||
t.Errorf("expected 1 unsatisfied; got %d (%+v)", len(res.Unsatisfied), res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------- preview vs apply semantics
|
||||
|
||||
func TestSolve_PreviewDoesNotWrite(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
nasT := builtInTypeID(t, s, "NAS")
|
||||
swT := builtInTypeID(t, s, "Switch")
|
||||
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
rj45 := int64(5)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
||||
})
|
||||
_, _ = s.Solve(p.ID, true) // preview
|
||||
cables, _ := s.ListCables(p.ID)
|
||||
if len(cables) != 0 {
|
||||
t.Errorf("preview wrote %d cables; want 0", len(cables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_ApplyThenIdempotent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
nasT := builtInTypeID(t, s, "NAS")
|
||||
swT := builtInTypeID(t, s, "Switch")
|
||||
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
rj45 := int64(5)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
||||
})
|
||||
r1, _ := s.Solve(p.ID, false)
|
||||
if len(r1.CablesAdded) != 1 {
|
||||
t.Fatalf("first apply: cables_added=%d, want 1", len(r1.CablesAdded))
|
||||
}
|
||||
r2, _ := s.Solve(p.ID, false)
|
||||
if len(r2.CablesAdded) != 0 {
|
||||
t.Errorf("second apply: cables_added=%d, want 0 (idempotent)", len(r2.CablesAdded))
|
||||
}
|
||||
if len(r2.CablesKept) != 1 {
|
||||
t.Errorf("second apply: cables_kept=%d, want 1", len(r2.CablesKept))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_ManualCableReservesPort(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
mouseT := builtInTypeID(t, s, "Mouse")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
pc, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
|
||||
// Manual cable USB Mouse↔PC: claims the only mouse USB port.
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
var mouseUSB, pcUSB int64
|
||||
for _, prt := range ports {
|
||||
if prt.DeviceID == mouse.ID && prt.TypeID == 2 {
|
||||
mouseUSB = prt.ID
|
||||
}
|
||||
if prt.DeviceID == pc.ID && prt.TypeID == 2 {
|
||||
pcUSB = prt.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
usb := int64(2)
|
||||
_, _ = s.CreateCable(p.ID, CableCreate{
|
||||
TypeID: usb,
|
||||
From: CableEndpoint{PortID: &mouseUSB},
|
||||
To: CableEndpoint{PortID: &pcUSB},
|
||||
Auto: false,
|
||||
})
|
||||
|
||||
// Now add a requirement that also wants USB on the mouse → no free port.
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: mouse.ID, ToDeviceID: pc.ID, PreferredCableTypeID: &usb,
|
||||
})
|
||||
res, _ := s.Solve(p.ID, true)
|
||||
if len(res.Unsatisfied) == 0 {
|
||||
t.Errorf("expected unsatisfied req (manual cable should reserve the only mouse USB port)")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- setup templates
|
||||
|
||||
func TestApplyTemplate_LivingRoom(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var lr SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Living Room" {
|
||||
lr = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
if lr.ID == 0 {
|
||||
t.Fatal("Living Room template not seeded")
|
||||
}
|
||||
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if len(res.DevicesAdded) != 3 {
|
||||
t.Errorf("devices added = %d, want 3 (TV, Soundbar, ChromeCast)", len(res.DevicesAdded))
|
||||
}
|
||||
if len(res.RequirementsAdded) != 2 {
|
||||
t.Errorf("requirements added = %d, want 2 (TV↔Soundbar, TV↔ChromeCast)", len(res.RequirementsAdded))
|
||||
}
|
||||
// Ports were seeded as part of the device creation.
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
if len(ports) < 6 { // TV(3) + Soundbar(2) + ChromeCast(2) = 7
|
||||
t.Errorf("ports after template apply = %d, expected ≥6", len(ports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var ho SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Home Office" {
|
||||
ho = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
res, err := s.Solve(p.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("solve: %v", err)
|
||||
}
|
||||
if len(res.CablesAdded) != 3 {
|
||||
t.Errorf("Home Office should solve to 3 cables (PC↔Screen, PC↔Keyboard, PC↔Mouse); got %d", len(res.CablesAdded))
|
||||
}
|
||||
if len(res.Unsatisfied) != 0 {
|
||||
t.Errorf("unsatisfied = %+v, want []", res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var lr SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Living Room" {
|
||||
lr = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if len(res.FramesAdded) != 1 {
|
||||
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
|
||||
}
|
||||
frame := res.FramesAdded[0]
|
||||
if frame.Name != "Living Room" {
|
||||
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
|
||||
}
|
||||
for _, d := range res.DevicesAdded {
|
||||
if d.FrameID == nil || *d.FrameID != frame.ID {
|
||||
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
|
||||
}
|
||||
// Device top-left should be inside the frame rect.
|
||||
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
|
||||
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
|
||||
}
|
||||
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
|
||||
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
|
||||
}
|
||||
}
|
||||
// No two devices share the same (X, Y) — the grid layout spreads them out.
|
||||
seen := map[[2]float64]string{}
|
||||
for _, d := range res.DevicesAdded {
|
||||
key := [2]float64{d.X, d.Y}
|
||||
if prev, ok := seen[key]; ok {
|
||||
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
|
||||
}
|
||||
seen[key] = d.Name
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
// Pre-create a frame called "Living Room" so the template's frame name collides.
|
||||
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var lr SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Living Room" {
|
||||
lr = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if len(res.FramesAdded) != 1 {
|
||||
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
|
||||
}
|
||||
if res.FramesAdded[0].Name != "Living Room 2" {
|
||||
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
// Pre-create a device called "PC" so the Home Office template's PC collides.
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var ho SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Home Office" {
|
||||
ho = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
res, _ := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{})
|
||||
if len(res.SkippedDevices) == 0 {
|
||||
t.Errorf("expected at least one skipped device for name collision; got %+v", res.SkippedDevices)
|
||||
}
|
||||
if len(res.RequirementsSkipped) == 0 {
|
||||
t.Errorf("PC requirements should be skipped when PC device skipped; got %+v", res.RequirementsSkipped)
|
||||
}
|
||||
}
|
||||
@@ -179,16 +179,34 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cables, err := s.ListCables(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundles, err := s.ListBundles(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clamps, err := s.ListClamps(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cableClamps, err := s.ListCableClamps(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Snapshot{
|
||||
Project: *p,
|
||||
Frames: frames,
|
||||
Devices: devices,
|
||||
Ports: ports,
|
||||
Cables: []any{},
|
||||
Cables: cables,
|
||||
IOMarkers: ios,
|
||||
Bundles: []any{},
|
||||
Bundles: bundles,
|
||||
CableTypes: types,
|
||||
ConnectionRequirements: reqs,
|
||||
Clamps: clamps,
|
||||
CableClamps: cableClamps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
630
internal/exporter/exporter.go
Normal file
630
internal/exporter/exporter.go
Normal file
@@ -0,0 +1,630 @@
|
||||
// Package exporter builds an Excalidraw scene JSON from a project
|
||||
// snapshot per docs/design.md §4 ("Export — DB → Excalidraw").
|
||||
//
|
||||
// The exporter is a pure function on a *db.Snapshot — no DB access, no
|
||||
// IO — so it's trivial to unit-test against fixtures and gives the
|
||||
// caller (the HTTP handler) a clean handoff: build scene → upload.
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
// Scene is the top-level Excalidraw file format. Keys mirror what the
|
||||
// official Excalidraw JSON contains (we only emit the keys mxdrw cares
|
||||
// about for rendering — `appState`, `files`, `libraryItems` etc. can be
|
||||
// added later if m needs them).
|
||||
type Scene struct {
|
||||
Type string `json:"type"`
|
||||
Version int `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Elements []Element `json:"elements"`
|
||||
AppState AppState `json:"appState"`
|
||||
Files Files `json:"files"`
|
||||
}
|
||||
|
||||
type AppState struct {
|
||||
GridSize *int `json:"gridSize"`
|
||||
ViewBackground string `json:"viewBackgroundColor"`
|
||||
}
|
||||
|
||||
type Files struct{}
|
||||
|
||||
// Element is one node in the scene. Excalidraw's wire format has a lot
|
||||
// of optional fields; we only emit the ones that matter for the shapes
|
||||
// we draw. Extra null/zero fields are fine in Excalidraw (it merges
|
||||
// defaults). Pointer fields stay nil-omitted via omitempty so the
|
||||
// payload stays clean.
|
||||
type Element struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Angle float64 `json:"angle"`
|
||||
StrokeColor string `json:"strokeColor"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
FillStyle string `json:"fillStyle"`
|
||||
StrokeWidth int `json:"strokeWidth"`
|
||||
StrokeStyle string `json:"strokeStyle"`
|
||||
Roughness int `json:"roughness"`
|
||||
Opacity int `json:"opacity"`
|
||||
GroupIDs []string `json:"groupIds"`
|
||||
FrameID *string `json:"frameId"`
|
||||
Roundness *Roundness `json:"roundness"`
|
||||
Seed int64 `json:"seed"`
|
||||
Version int `json:"version"`
|
||||
VersionNonce int64 `json:"versionNonce"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
BoundElements []BoundRef `json:"boundElements,omitempty"`
|
||||
Updated int64 `json:"updated"`
|
||||
Link *string `json:"link"`
|
||||
Locked bool `json:"locked"`
|
||||
|
||||
// Element-type-specific extras
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Text-element fields
|
||||
Text string `json:"text,omitempty"`
|
||||
FontSize int `json:"fontSize,omitempty"`
|
||||
FontFamily int `json:"fontFamily,omitempty"`
|
||||
TextAlign string `json:"textAlign,omitempty"`
|
||||
VerticalAlign string `json:"verticalAlign,omitempty"`
|
||||
ContainerID *string `json:"containerId,omitempty"`
|
||||
OriginalText string `json:"originalText,omitempty"`
|
||||
LineHeight float64 `json:"lineHeight,omitempty"`
|
||||
|
||||
// Arrow-element fields
|
||||
Points [][2]float64 `json:"points,omitempty"`
|
||||
StartBinding *Binding `json:"startBinding,omitempty"`
|
||||
EndBinding *Binding `json:"endBinding,omitempty"`
|
||||
StartArrowhead *string `json:"startArrowhead,omitempty"`
|
||||
EndArrowhead *string `json:"endArrowhead,omitempty"`
|
||||
LastCommittedPoint *[2]float64 `json:"lastCommittedPoint,omitempty"`
|
||||
}
|
||||
|
||||
type Roundness struct {
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
type BoundRef struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
ElementID string `json:"elementId"`
|
||||
Focus float64 `json:"focus"`
|
||||
Gap float64 `json:"gap"`
|
||||
}
|
||||
|
||||
// IDAssignment is the result of running BuildScene: the scene to upload
|
||||
// + the per-row excalidraw_id assignments that the caller should
|
||||
// persist so the next export reuses the same ids (Excalidraw collab
|
||||
// cursors / comments / undo history survive that way; design §4.2).
|
||||
type IDAssignment struct {
|
||||
Frames map[int64]string `json:"frames"`
|
||||
Devices map[int64]string `json:"devices"`
|
||||
Ports map[int64]string `json:"ports"`
|
||||
IOMarkers map[int64]string `json:"io_markers"`
|
||||
Cables map[int64]string `json:"cables"`
|
||||
Clamps map[int64]string `json:"clamps"`
|
||||
}
|
||||
|
||||
// BuildScene transforms a project snapshot into an Excalidraw Scene +
|
||||
// the id-assignment side-table.
|
||||
//
|
||||
// nowMilli is the Updated timestamp (one millisecond stamp for every
|
||||
// element keeps re-exports consistent — mxdrw treats wildly-different
|
||||
// updateds as edit-noise).
|
||||
//
|
||||
// genID is a 21-char ID factory. Tests pass a deterministic generator
|
||||
// to lock element ids down across asserts. Production uses Generate21.
|
||||
func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene, *IDAssignment) {
|
||||
a := &IDAssignment{
|
||||
Frames: map[int64]string{},
|
||||
Devices: map[int64]string{},
|
||||
Ports: map[int64]string{},
|
||||
IOMarkers: map[int64]string{},
|
||||
Cables: map[int64]string{},
|
||||
Clamps: map[int64]string{},
|
||||
}
|
||||
// idFor: reuse the existing excalidraw_id if present, else mint one.
|
||||
idFor := func(existing *string) string {
|
||||
if existing != nil && *existing != "" {
|
||||
return *existing
|
||||
}
|
||||
return genID()
|
||||
}
|
||||
|
||||
cableTypeColor := map[int64]string{}
|
||||
for _, t := range snap.CableTypes {
|
||||
cableTypeColor[t.ID] = t.Color
|
||||
}
|
||||
|
||||
// We'll need: device-id → element-id, port-id → element-id, io-id → element-id
|
||||
// for binding arrows.
|
||||
deviceElID := map[int64]string{}
|
||||
portElID := map[int64]string{}
|
||||
ioElID := map[int64]string{}
|
||||
frameElID := map[int64]string{}
|
||||
|
||||
var els []Element
|
||||
|
||||
// Frames first (Excalidraw renders later elements on top; frames are
|
||||
// containers that go on the bottom).
|
||||
for _, f := range snap.Frames {
|
||||
elID := idFor(f.ExcalidrawID)
|
||||
a.Frames[f.ID] = elID
|
||||
frameElID[f.ID] = elID
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "frame",
|
||||
X: f.X,
|
||||
Y: f.Y,
|
||||
Width: f.Width,
|
||||
Height: f.Height,
|
||||
StrokeColor: "#bbbbbb",
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Name: f.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// Devices: rectangle + bound text with the device's name. Excalidraw
|
||||
// uses a `containerId` pointer on the text to bind it to the rect,
|
||||
// and `boundElements` on the rect to point back at the text.
|
||||
for _, d := range snap.Devices {
|
||||
rectID := idFor(d.ExcalidrawID)
|
||||
a.Devices[d.ID] = rectID
|
||||
deviceElID[d.ID] = rectID
|
||||
textID := genID()
|
||||
var frameRef *string
|
||||
if d.FrameID != nil {
|
||||
if v, ok := frameElID[*d.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
// Rect
|
||||
els = append(els, Element{
|
||||
ID: rectID,
|
||||
Type: "rectangle",
|
||||
X: d.X,
|
||||
Y: d.Y,
|
||||
Width: d.Width,
|
||||
Height: d.Height,
|
||||
StrokeColor: d.Color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 3},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
|
||||
})
|
||||
// Bound text — name centered on the rect.
|
||||
els = append(els, Element{
|
||||
ID: textID,
|
||||
Type: "text",
|
||||
X: d.X,
|
||||
Y: d.Y + d.Height/2 - 8,
|
||||
Width: d.Width,
|
||||
Height: 16,
|
||||
StrokeColor: d.Color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Text: d.Name,
|
||||
OriginalText: d.Name,
|
||||
FontSize: 16,
|
||||
FontFamily: 1,
|
||||
TextAlign: "center",
|
||||
VerticalAlign: "middle",
|
||||
ContainerID: &rectID,
|
||||
LineHeight: 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
// Ports — small ellipses at device.x + port.x_offset (positional,
|
||||
// not containerId-bound per the seed drawing's grammar; design §4.1).
|
||||
for _, p := range snap.Ports {
|
||||
elID := idFor(p.ExcalidrawID)
|
||||
a.Ports[p.ID] = elID
|
||||
portElID[p.ID] = elID
|
||||
// Locate the parent device for absolute pos + frame ref.
|
||||
var dev *db.Device
|
||||
for i := range snap.Devices {
|
||||
if snap.Devices[i].ID == p.DeviceID {
|
||||
dev = &snap.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if dev == nil {
|
||||
continue
|
||||
}
|
||||
var frameRef *string
|
||||
if dev.FrameID != nil {
|
||||
if v, ok := frameElID[*dev.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
color := cableTypeColor[p.TypeID]
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "ellipse",
|
||||
X: dev.X + p.XOffset - 6,
|
||||
Y: dev.Y + p.YOffset - 4,
|
||||
Width: 12,
|
||||
Height: 9,
|
||||
StrokeColor: color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 2},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
})
|
||||
}
|
||||
|
||||
// IO markers — diamonds with bound "IO" (or m's label) text.
|
||||
powerColor := ""
|
||||
for _, t := range snap.CableTypes {
|
||||
if t.Name == "Power" {
|
||||
powerColor = t.Color
|
||||
break
|
||||
}
|
||||
}
|
||||
if powerColor == "" {
|
||||
powerColor = "#e03131"
|
||||
}
|
||||
for _, m := range snap.IOMarkers {
|
||||
elID := idFor(m.ExcalidrawID)
|
||||
a.IOMarkers[m.ID] = elID
|
||||
ioElID[m.ID] = elID
|
||||
textID := genID()
|
||||
var frameRef *string
|
||||
if m.FrameID != nil {
|
||||
if v, ok := frameElID[*m.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "diamond",
|
||||
X: m.X,
|
||||
Y: m.Y,
|
||||
Width: 30,
|
||||
Height: 30,
|
||||
StrokeColor: powerColor,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 2},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
|
||||
})
|
||||
els = append(els, Element{
|
||||
ID: textID,
|
||||
Type: "text",
|
||||
X: m.X,
|
||||
Y: m.Y + 7,
|
||||
Width: 30,
|
||||
Height: 16,
|
||||
StrokeColor: powerColor,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Text: m.Label,
|
||||
OriginalText: m.Label,
|
||||
FontSize: 11,
|
||||
FontFamily: 1,
|
||||
TextAlign: "center",
|
||||
VerticalAlign: "middle",
|
||||
ContainerID: &elID,
|
||||
LineHeight: 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
// Clamps — small grey rounded squares (v5 §11.7). Distinct from the
|
||||
// red IO marker diamonds so m can tell routing anchors from wall
|
||||
// outlets at a glance.
|
||||
const clampSize = 12.0
|
||||
for _, cl := range snap.Clamps {
|
||||
elID := idFor(cl.ExcalidrawID)
|
||||
a.Clamps[cl.ID] = elID
|
||||
var frameRef *string
|
||||
if cl.FrameID != nil {
|
||||
if v, ok := frameElID[*cl.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "rectangle",
|
||||
X: cl.X - clampSize/2,
|
||||
Y: cl.Y - clampSize/2,
|
||||
Width: clampSize,
|
||||
Height: clampSize,
|
||||
StrokeColor: "#555555",
|
||||
BackgroundColor: "#888888",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 1,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 3},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-group cable_clamps by cable for the arrow mid-points pass.
|
||||
clampsByCable := map[int64][]db.CableClamp{}
|
||||
for _, cc := range snap.CableClamps {
|
||||
clampsByCable[cc.CableID] = append(clampsByCable[cc.CableID], cc)
|
||||
}
|
||||
for _, arr := range clampsByCable {
|
||||
// Already sorted by ListCableClamps (ORDER BY cable_id, ord),
|
||||
// but defend against unsorted inputs.
|
||||
sort.Slice(arr, func(i, j int) bool { return arr[i].Ord < arr[j].Ord })
|
||||
}
|
||||
clampPos := map[int64][2]float64{}
|
||||
for _, cl := range snap.Clamps {
|
||||
clampPos[cl.ID] = [2]float64{cl.X, cl.Y}
|
||||
}
|
||||
|
||||
// Cables — arrows with startBinding/endBinding to the port / device /
|
||||
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
|
||||
// "to" points) come from the same anchor logic the canvas uses.
|
||||
for _, c := range snap.Cables {
|
||||
elID := idFor(c.ExcalidrawID)
|
||||
a.Cables[c.ID] = elID
|
||||
fromAnchor, fromRef := exportAnchor(c.FromPortID, c.FromDeviceID, c.FromIOID,
|
||||
snap, deviceElID, portElID, ioElID)
|
||||
toAnchor, toRef := exportAnchor(c.ToPortID, c.ToDeviceID, c.ToIOID,
|
||||
snap, deviceElID, portElID, ioElID)
|
||||
// fromRef/toRef are nil when the endpoint row vanished (manual
|
||||
// cable referencing a deleted port, say). Skip rather than emit
|
||||
// a half-bound arrow.
|
||||
if fromRef == nil || toRef == nil {
|
||||
continue
|
||||
}
|
||||
color := cableTypeColor[c.TypeID]
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
startArr := ""
|
||||
endArr := "arrow"
|
||||
// Excalidraw arrow `points` is relative to (X, Y). We anchor at
|
||||
// the from-point, so vertex 0 is always (0, 0). Mid-vertices
|
||||
// (clamps) and the final to-vertex are offsets from there.
|
||||
pts := [][2]float64{{0, 0}}
|
||||
for _, cc := range clampsByCable[c.ID] {
|
||||
pos, ok := clampPos[cc.ClampID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pts = append(pts, [2]float64{pos[0] - fromAnchor[0], pos[1] - fromAnchor[1]})
|
||||
}
|
||||
pts = append(pts, [2]float64{toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]})
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "arrow",
|
||||
X: fromAnchor[0],
|
||||
Y: fromAnchor[1],
|
||||
Width: toAnchor[0] - fromAnchor[0],
|
||||
Height: toAnchor[1] - fromAnchor[1],
|
||||
StrokeColor: color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Points: pts,
|
||||
StartArrowhead: &startArr,
|
||||
EndArrowhead: &endArr,
|
||||
StartBinding: bindingPtr(fromRef),
|
||||
EndBinding: bindingPtr(toRef),
|
||||
})
|
||||
}
|
||||
|
||||
// Legend in the top-left of the first frame (or at 20,20 if there
|
||||
// are no frames). One text row per cable_type, stacked vertically.
|
||||
legendX, legendY := 20.0, 20.0
|
||||
if len(snap.Frames) > 0 {
|
||||
legendX = snap.Frames[0].X + 10
|
||||
legendY = snap.Frames[0].Y + 10
|
||||
}
|
||||
for i, t := range snap.CableTypes {
|
||||
els = append(els, Element{
|
||||
ID: genID(),
|
||||
Type: "text",
|
||||
X: legendX,
|
||||
Y: legendY + float64(i*18),
|
||||
Width: 80,
|
||||
Height: 16,
|
||||
StrokeColor: t.Color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 1,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Text: t.Name,
|
||||
OriginalText: t.Name,
|
||||
FontSize: 16,
|
||||
FontFamily: 1,
|
||||
TextAlign: "left",
|
||||
VerticalAlign: "top",
|
||||
LineHeight: 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
scene := &Scene{
|
||||
Type: "excalidraw",
|
||||
Version: 2,
|
||||
Source: "cablegui",
|
||||
Elements: els,
|
||||
AppState: AppState{
|
||||
GridSize: nil,
|
||||
ViewBackground: "#ffffff",
|
||||
},
|
||||
Files: Files{},
|
||||
}
|
||||
return scene, a
|
||||
}
|
||||
|
||||
func bindingPtr(b *Binding) *Binding {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// exportAnchor returns (x,y) + a Binding for the endpoint kind passed in.
|
||||
func exportAnchor(portID, deviceID, ioID *int64, snap *db.Snapshot,
|
||||
devElID, portElID, ioElID map[int64]string,
|
||||
) ([2]float64, *Binding) {
|
||||
if portID != nil {
|
||||
// Find the port + its parent device.
|
||||
for _, p := range snap.Ports {
|
||||
if p.ID != *portID {
|
||||
continue
|
||||
}
|
||||
for _, d := range snap.Devices {
|
||||
if d.ID == p.DeviceID {
|
||||
id := portElID[p.ID]
|
||||
return [2]float64{d.X + p.XOffset, d.Y + p.YOffset}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if deviceID != nil {
|
||||
for _, d := range snap.Devices {
|
||||
if d.ID != *deviceID {
|
||||
continue
|
||||
}
|
||||
id := devElID[d.ID]
|
||||
return [2]float64{d.X + d.Width/2, d.Y + d.Height/2}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||
}
|
||||
}
|
||||
if ioID != nil {
|
||||
for _, m := range snap.IOMarkers {
|
||||
if m.ID != *ioID {
|
||||
continue
|
||||
}
|
||||
id := ioElID[m.ID]
|
||||
return [2]float64{m.X + 15, m.Y + 15}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||
}
|
||||
}
|
||||
return [2]float64{}, nil
|
||||
}
|
||||
|
||||
// Generate21 mints a 21-char base62 identifier, the shape Excalidraw
|
||||
// uses for element ids (nanoid-style). crypto/rand source.
|
||||
func Generate21() string {
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
buf := make([]byte, 21)
|
||||
max := big.NewInt(int64(len(alphabet)))
|
||||
for i := range buf {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
// crypto/rand failure is unrecoverable in practice; fall back
|
||||
// to a deterministic alphabet position so callers see a panic-
|
||||
// adjacent symptom rather than a half-initialised id.
|
||||
return fmt.Sprintf("crypto-rand-failed-%d", i)
|
||||
}
|
||||
buf[i] = alphabet[n.Int64()]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// randInt returns a non-negative int64 derived from crypto/rand for
|
||||
// Excalidraw's `seed` / `versionNonce`. Excalidraw treats these as
|
||||
// noise — only the IDs and the structural fields matter.
|
||||
func randInt() int64 {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n.Int64()
|
||||
}
|
||||
|
||||
// MarshalScene returns the scene as Excalidraw-flavoured JSON.
|
||||
func MarshalScene(s *Scene) ([]byte, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
225
internal/exporter/exporter_test.go
Normal file
225
internal/exporter/exporter_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
// deterministic id generator for tests
|
||||
func newSeq() func() string {
|
||||
i := 0
|
||||
return func() string {
|
||||
i++
|
||||
return "id" + strings.Repeat("0", 19-len(itoa(i))) + itoa(i)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
buf := [20]byte{}
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func sampleSnapshot() *db.Snapshot {
|
||||
pid := int64(1)
|
||||
devID := int64(10)
|
||||
devID2 := int64(11)
|
||||
portID := int64(100)
|
||||
portID2 := int64(101)
|
||||
ioID := int64(200)
|
||||
|
||||
return &db.Snapshot{
|
||||
Project: db.Project{ID: pid, Name: "LOFT", DrawingName: "LOFT.excalidraw"},
|
||||
Frames: []db.Frame{
|
||||
{ID: 1, ProjectID: pid, Name: "desk", X: 100, Y: 100, Width: 800, Height: 500},
|
||||
},
|
||||
Devices: []db.Device{
|
||||
{ID: devID, ProjectID: pid, Name: "NAS", Color: "#1e1e1e", X: 200, Y: 200, Width: 100, Height: 35, FrameID: ptr(int64(1))},
|
||||
{ID: devID2, ProjectID: pid, Name: "Switch", Color: "#1e1e1e", X: 400, Y: 200, Width: 100, Height: 35},
|
||||
},
|
||||
Ports: []db.Port{
|
||||
{ID: portID, ProjectID: pid, DeviceID: devID, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||
{ID: portID2, ProjectID: pid, DeviceID: devID2, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||
},
|
||||
IOMarkers: []db.IOMarker{
|
||||
{ID: ioID, ProjectID: pid, Label: "Wall A", X: 50, Y: 50},
|
||||
},
|
||||
Cables: []db.Cable{
|
||||
{ID: 1000, ProjectID: pid, TypeID: 5,
|
||||
FromPortID: &portID, ToPortID: &portID2, Auto: false},
|
||||
},
|
||||
CableTypes: []db.CableType{
|
||||
{ID: 1, Name: "Power", Color: "#e03131"},
|
||||
{ID: 2, Name: "USB", Color: "#2f9e44"},
|
||||
{ID: 3, Name: "HDMI", Color: "#1971c2"},
|
||||
{ID: 4, Name: "DP", Color: "#9c36b5"},
|
||||
{ID: 5, Name: "RJ45", Color: "#ffd500"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestBuildScene_BasicShape(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
|
||||
if scene.Type != "excalidraw" || scene.Version != 2 {
|
||||
t.Errorf("bad header: %+v", scene)
|
||||
}
|
||||
// frame(1) + device-rect+text(2 each) + ports(2) + io+text(2) +
|
||||
// cable(1) + legend(5) = 1 + 4 + 2 + 2 + 1 + 5 = 15.
|
||||
if len(scene.Elements) < 15 {
|
||||
t.Errorf("element count = %d, want ≥15", len(scene.Elements))
|
||||
}
|
||||
if len(ids.Frames) != 1 || len(ids.Devices) != 2 || len(ids.Ports) != 2 ||
|
||||
len(ids.IOMarkers) != 1 || len(ids.Cables) != 1 {
|
||||
t.Errorf("id assignment shape wrong: %+v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ReusesExistingExcalidrawIDs(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
// Pre-assign an excalidraw_id on the first device.
|
||||
preset := "preset0000000000000NAS"[:21]
|
||||
snap.Devices[0].ExcalidrawID = &preset
|
||||
_, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
if ids.Devices[snap.Devices[0].ID] != preset {
|
||||
t.Errorf("preset id not reused: got %q, want %q", ids.Devices[snap.Devices[0].ID], preset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ArrowsBindToPorts(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
// The arrow's startBinding should reference the from-port's element id.
|
||||
fromPortElID := ids.Ports[100]
|
||||
toPortElID := ids.Ports[101]
|
||||
var found *Element
|
||||
for i := range scene.Elements {
|
||||
if scene.Elements[i].Type == "arrow" {
|
||||
found = &scene.Elements[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("no arrow in scene")
|
||||
}
|
||||
if found.StartBinding == nil || found.StartBinding.ElementID != fromPortElID {
|
||||
t.Errorf("start binding wrong: %+v", found.StartBinding)
|
||||
}
|
||||
if found.EndBinding == nil || found.EndBinding.ElementID != toPortElID {
|
||||
t.Errorf("end binding wrong: %+v", found.EndBinding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_BundlesIgnored(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
// Snapshot.Bundles is unused in the exporter for v0 per design §4.1.
|
||||
// Add some and confirm no bundle elements appear in the scene.
|
||||
snap.Bundles = []db.Bundle{{ID: 1, Name: "trunk", CableIDs: []int64{1000}}}
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
for _, e := range scene.Elements {
|
||||
if strings.Contains(e.Type, "bundle") {
|
||||
t.Errorf("bundle element leaked into scene: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ClampsRenderAsRectangles(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
snap.Clamps = []db.Clamp{
|
||||
{ID: 1, ProjectID: 1, X: 500, Y: 300},
|
||||
{ID: 2, ProjectID: 1, X: 550, Y: 320},
|
||||
}
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
if len(ids.Clamps) != 2 {
|
||||
t.Errorf("clamp ids = %d, want 2", len(ids.Clamps))
|
||||
}
|
||||
clampElIDs := map[string]bool{}
|
||||
for _, id := range ids.Clamps {
|
||||
clampElIDs[id] = true
|
||||
}
|
||||
got := 0
|
||||
for _, e := range scene.Elements {
|
||||
if clampElIDs[e.ID] && e.Type == "rectangle" {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != 2 {
|
||||
t.Errorf("clamp rectangle elements = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ArrowPointsIncludeClamps(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
snap.Clamps = []db.Clamp{
|
||||
{ID: 10, ProjectID: 1, X: 350, Y: 250},
|
||||
}
|
||||
snap.CableClamps = []db.CableClamp{
|
||||
{CableID: 1000, ClampID: 10, Ord: 1},
|
||||
}
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
var arrow *Element
|
||||
for i := range scene.Elements {
|
||||
if scene.Elements[i].Type == "arrow" {
|
||||
arrow = &scene.Elements[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if arrow == nil {
|
||||
t.Fatal("no arrow in scene")
|
||||
}
|
||||
if len(arrow.Points) != 3 {
|
||||
t.Errorf("arrow points = %d, want 3 (from + clamp + to): %+v", len(arrow.Points), arrow.Points)
|
||||
}
|
||||
// First point is always (0, 0) by convention; middle point should
|
||||
// equal the clamp's position relative to the arrow's anchor.
|
||||
if arrow.Points[0][0] != 0 || arrow.Points[0][1] != 0 {
|
||||
t.Errorf("first point = %v, want [0,0]", arrow.Points[0])
|
||||
}
|
||||
// Middle vertex = clamp.x - fromAnchor.x, clamp.y - fromAnchor.y.
|
||||
// fromAnchor for port 100 = (200 + 50, 200 + 35) = (250, 235).
|
||||
wantX, wantY := 350.0-250.0, 250.0-235.0
|
||||
if arrow.Points[1][0] != wantX || arrow.Points[1][1] != wantY {
|
||||
t.Errorf("mid point = %v, want [%v, %v]", arrow.Points[1], wantX, wantY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalScene_IsJSON(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
b, err := MarshalScene(scene)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var roundtrip map[string]any
|
||||
if err := json.Unmarshal(b, &roundtrip); err != nil {
|
||||
t.Fatalf("roundtrip: %v", err)
|
||||
}
|
||||
if roundtrip["type"] != "excalidraw" {
|
||||
t.Errorf("type field = %v, want excalidraw", roundtrip["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate21(t *testing.T) {
|
||||
a := Generate21()
|
||||
b := Generate21()
|
||||
if len(a) != 21 || len(b) != 21 {
|
||||
t.Errorf("len wrong: %d / %d", len(a), len(b))
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("ids collide: %q == %q", a, b)
|
||||
}
|
||||
}
|
||||
225
internal/server/cables.go
Normal file
225
internal/server/cables.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type cableEndpointBody struct {
|
||||
PortID *int64 `json:"port_id,omitempty"`
|
||||
DeviceID *int64 `json:"device_id,omitempty"`
|
||||
IOID *int64 `json:"io_id,omitempty"`
|
||||
}
|
||||
|
||||
type cableCreate struct {
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
From cableEndpointBody `json:"from"`
|
||||
To cableEndpointBody `json:"to"`
|
||||
Auto bool `json:"auto,omitempty"`
|
||||
}
|
||||
|
||||
type cablePatch struct {
|
||||
TypeID *int64 `json:"type_id,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
From *cableEndpointBody `json:"from,omitempty"`
|
||||
To *cableEndpointBody `json:"to,omitempty"`
|
||||
Auto *bool `json:"auto,omitempty"`
|
||||
// Promote=true asks the server to set auto=false when an auto cable
|
||||
// is being PATCHed (slice 6 §5b.3 — explicit promote-to-manual).
|
||||
Promote bool `json:"promote,omitempty"`
|
||||
}
|
||||
|
||||
func toCableEndpoint(b cableEndpointBody) db.CableEndpoint {
|
||||
return db.CableEndpoint{PortID: b.PortID, DeviceID: b.DeviceID, IOID: b.IOID}
|
||||
}
|
||||
|
||||
func (h *handlers) listCables(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cs, err := h.store.ListCables(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
}
|
||||
|
||||
func (h *handlers) createCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
c, err := h.store.CreateCable(pid, db.CableCreate{
|
||||
TypeID: body.TypeID, Label: body.Label,
|
||||
From: toCableEndpoint(body.From), To: toCableEndpoint(body.To),
|
||||
Auto: body.Auto,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (h *handlers) patchCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cablePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
u := db.CableUpdate{
|
||||
TypeID: body.TypeID, Label: body.Label, Auto: body.Auto,
|
||||
}
|
||||
if body.From != nil {
|
||||
ep := toCableEndpoint(*body.From)
|
||||
u.From = &ep
|
||||
}
|
||||
if body.To != nil {
|
||||
ep := toCableEndpoint(*body.To)
|
||||
u.To = &ep
|
||||
}
|
||||
// Promote semantics: explicit promote=true OR (PATCH touched
|
||||
// type/from/to AND the current cable is auto) → set auto=false.
|
||||
if body.Promote {
|
||||
f := false
|
||||
u.Auto = &f
|
||||
}
|
||||
c, err := h.store.UpdateCable(pid, id, u)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteCable(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- bundles
|
||||
|
||||
type bundleCreate struct {
|
||||
Name string `json:"name"`
|
||||
CableIDs []int64 `json:"cable_ids"`
|
||||
}
|
||||
|
||||
type bundlePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
CableIDs *[]int64 `json:"cable_ids,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listBundles(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
bs, err := h.store.ListBundles(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bs)
|
||||
}
|
||||
|
||||
func (h *handlers) createBundle(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body bundleCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
b, err := h.store.CreateBundle(pid, db.BundleCreate{
|
||||
Name: body.Name, CableIDs: body.CableIDs, Auto: false,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, b)
|
||||
}
|
||||
|
||||
func (h *handlers) patchBundle(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body bundlePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
b, err := h.store.UpdateBundle(pid, id, db.BundleUpdate{
|
||||
Name: body.Name, CableIDs: body.CableIDs,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteBundle(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteBundle(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
195
internal/server/clamps.go
Normal file
195
internal/server/clamps.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type clampCreate struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Label string `json:"label,omitempty"`
|
||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||
}
|
||||
|
||||
type clampPatch struct {
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
FrameID json.RawMessage `json:"frame_id,omitempty"`
|
||||
}
|
||||
|
||||
type cableClampAttach struct {
|
||||
ClampID int64 `json:"clamp_id"`
|
||||
Ord int `json:"ord,omitempty"`
|
||||
}
|
||||
|
||||
type cableClampReorder struct {
|
||||
ClampIDs []int64 `json:"clamp_ids"`
|
||||
}
|
||||
|
||||
func (h *handlers) listClamps(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cs, err := h.store.ListClamps(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
}
|
||||
|
||||
func (h *handlers) createClamp(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body clampCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
ref, err := parseFrameRef(body.FrameID)
|
||||
if err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
c, err := h.store.CreateClamp(pid, db.ClampCreate{
|
||||
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref.ID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (h *handlers) patchClamp(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body clampPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
ref, err := parseFrameRef(body.FrameID)
|
||||
if err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
c, err := h.store.UpdateClamp(pid, id, db.ClampUpdate{
|
||||
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteClamp(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteClamp(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/projects/:pid/cables/:cid/clamps — attach a clamp to a cable.
|
||||
func (h *handlers) attachClampToCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cid, ok := parseInt64Path(r, "cid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableClampAttach
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
cc, err := h.store.AttachClampToCable(pid, cid, body.ClampID, body.Ord)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, cc)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/:pid/cables/:cid/clamps/:cmid — detach a clamp.
|
||||
func (h *handlers) detachClampFromCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cid, ok := parseInt64Path(r, "cid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cmid, ok := parseInt64Path(r, "cmid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cmid must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DetachClampFromCable(pid, cid, cmid); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PUT /api/projects/:pid/cables/:cid/clamps — replace clamp sequence.
|
||||
func (h *handlers) reorderCableClamps(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cid, ok := parseInt64Path(r, "cid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableClampReorder
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
out, err := h.store.ReorderCableClamps(pid, cid, body.ClampIDs)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type connReqCreate struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type deviceTypePortBody struct {
|
||||
|
||||
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/cablegui/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/exporter"
|
||||
)
|
||||
|
||||
// syncExport runs the project's snapshot through the exporter, persists
|
||||
// the assigned excalidraw_ids, then PUTs the scene to mxdrw.msbls.de.
|
||||
func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
base := os.Getenv("MEXDRAW_BASE_URL")
|
||||
if base == "" {
|
||||
base = "https://mxdrw.msbls.de"
|
||||
}
|
||||
user := os.Getenv("MEXDRAW_USER")
|
||||
pass := os.Getenv("MEXDRAW_PASS")
|
||||
if user == "" || pass == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody{
|
||||
Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
|
||||
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/cablegui/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
snap, err := h.store.Snapshot(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
scene, ids := exporter.BuildScene(snap, now, exporter.Generate21)
|
||||
|
||||
// Persist the freshly-assigned ids so the next export reuses them.
|
||||
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
|
||||
// only updates rows whose excalidraw_id is still NULL).
|
||||
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables, ids.Clamps); err != nil {
|
||||
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := exporter.MarshalScene(scene)
|
||||
if err != nil {
|
||||
writeError(w, fmt.Errorf("marshal scene: %w", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
drawingName := snap.Project.DrawingName
|
||||
if !strings.HasSuffix(drawingName, ".excalidraw") {
|
||||
drawingName += ".excalidraw"
|
||||
}
|
||||
url := strings.TrimSuffix(base, "/") + "/api/drawings/" + drawingName
|
||||
|
||||
// Sane network timeout; mxdrw is on the LAN so this should be quick.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
writeError(w, fmt.Errorf("build PUT: %w", err), nil)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(user, pass)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||
Error: "mxdrw unreachable",
|
||||
Details: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||
Error: fmt.Sprintf("mxdrw rejected upload (%d)", resp.StatusCode),
|
||||
Details: map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(body),
|
||||
"url": url,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort parse — mxdrw returns whatever it returns; we surface
|
||||
// the public viewer URL no matter what.
|
||||
var serverEcho any
|
||||
_ = json.Unmarshal(body, &serverEcho)
|
||||
|
||||
viewerURL := strings.TrimSuffix(base, "/") + "/draw/" + strings.TrimSuffix(drawingName, ".excalidraw") + ".excalidraw"
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"drawing_name": drawingName,
|
||||
"url": viewerURL,
|
||||
"element_count": len(scene.Elements),
|
||||
"mxdrw_response": serverEcho,
|
||||
})
|
||||
}
|
||||
|
||||
// noLeak prevents unused-import errors if errors-pkg ever becomes unused
|
||||
// after a refactor — keeps the import light.
|
||||
var _ = errors.New
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- frames
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type ioMarkerCreate struct {
|
||||
|
||||
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/cablegui/internal/db"
|
||||
)
|
||||
|
||||
type portCreate struct {
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
XOffset float64 `json:"x_offset"`
|
||||
YOffset float64 `json:"y_offset"`
|
||||
}
|
||||
|
||||
type portPatch struct {
|
||||
TypeID *int64 `json:"type_id,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
XOffset *float64 `json:"x_offset,omitempty"`
|
||||
YOffset *float64 `json:"y_offset,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listPortsForDevice(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
ps, err := h.store.ListPortsForDevice(pid, id)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ps)
|
||||
}
|
||||
|
||||
func (h *handlers) createPort(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body portCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
p, err := h.store.CreatePort(pid, id, db.PortCreate{
|
||||
TypeID: body.TypeID, Label: body.Label,
|
||||
XOffset: body.XOffset, YOffset: body.YOffset,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (h *handlers) patchPort(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body portPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
p, err := h.store.UpdatePort(pid, id, db.PortUpdate{
|
||||
TypeID: body.TypeID, Label: body.Label,
|
||||
XOffset: body.XOffset, YOffset: body.YOffset,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *handlers) deletePort(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeletePort(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
// New returns an http.Handler serving the mCables API at /api/ and the
|
||||
// New returns an http.Handler serving the CableGUI API at /api/ and the
|
||||
// embedded frontend at /. The frontend FS should be rooted such that
|
||||
// "index.html" is at its root.
|
||||
func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
@@ -51,6 +51,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
|
||||
|
||||
// Ports — slice 7 lets m add/edit/remove instance ports on a device.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/devices/{id}/ports", h.listPortsForDevice)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports", h.createPort)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/ports/{id}", h.patchPort)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/ports/{id}", h.deletePort)
|
||||
|
||||
// Device-type catalog. Built-ins are read-only; project-custom rows
|
||||
// support full CRUD scoped to the project.
|
||||
mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes)
|
||||
@@ -65,6 +71,37 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/connection-requirements/{id}", h.patchConnectionRequirement)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/connection-requirements/{id}", h.deleteConnectionRequirement)
|
||||
|
||||
// Cables — slice 6: solver writes here with auto=1; slice 7 lets m
|
||||
// hand-draw with auto=0. PATCH supports `promote: true` to flip auto→0.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/cables", h.listCables)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/cables", h.createCable)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/cables/{id}", h.patchCable)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{id}", h.deleteCable)
|
||||
|
||||
// Bundles — manual + auto.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/bundles", h.listBundles)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/bundles", h.createBundle)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/bundles/{id}", h.patchBundle)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/bundles/{id}", h.deleteBundle)
|
||||
|
||||
// Solver + quick-fix combo + setup templates.
|
||||
mux.HandleFunc("POST /api/projects/{pid}/solve", h.solve)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports-and-resolve", h.portsAndResolve)
|
||||
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate)
|
||||
|
||||
// Slice 8 — export to mxdrw.msbls.de
|
||||
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
|
||||
|
||||
// v5 — clamps + cable routing.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/clamps", h.listClamps)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/clamps", h.createClamp)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/clamps/{id}", h.patchClamp)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/clamps/{id}", h.deleteClamp)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/cables/{cid}/clamps", h.attachClampToCable)
|
||||
mux.HandleFunc("PUT /api/projects/{pid}/cables/{cid}/clamps", h.reorderCableClamps)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{cid}/clamps/{cmid}", h.detachClampFromCable)
|
||||
|
||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||
// the file server already emits — without this, browsers cache aggressively
|
||||
|
||||
149
internal/server/solver.go
Normal file
149
internal/server/solver.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/cablegui/internal/db"
|
||||
)
|
||||
|
||||
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
preview := r.URL.Query().Get("preview") == "1"
|
||||
res, err := h.store.Solve(pid, preview)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ports-and-resolve combo: POST a new port to a device + re-run solve in
|
||||
// the same request. Used by the inspector quick-fix.
|
||||
type portsAndResolveBody struct {
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
XOffset float64 `json:"x_offset,omitempty"`
|
||||
YOffset float64 `json:"y_offset,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) portsAndResolve(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body portsAndResolveBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
res, err := h.store.PortsAndResolve(pid, id, body.TypeID, body.Label, body.XOffset, body.YOffset)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- setup templates
|
||||
|
||||
func (h *handlers) listSetupTemplates(w http.ResponseWriter, _ *http.Request) {
|
||||
ts, err := h.store.ListSetupTemplates()
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ts)
|
||||
}
|
||||
|
||||
type applyTemplateBody struct {
|
||||
TemplateID int64 `json:"template_id"`
|
||||
NameOverrides map[string]string `json:"name_overrides,omitempty"`
|
||||
SkipDevices []int64 `json:"skip_devices,omitempty"`
|
||||
OriginX float64 `json:"origin_x,omitempty"`
|
||||
OriginY float64 `json:"origin_y,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) applyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body applyTemplateBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
opts := db.ApplyTemplateOptions{
|
||||
NameOverrides: map[int64]string{},
|
||||
SkipDevices: map[int64]bool{},
|
||||
OriginX: body.OriginX,
|
||||
OriginY: body.OriginY,
|
||||
}
|
||||
// JSON keys are strings; parse to int64.
|
||||
for k, v := range body.NameOverrides {
|
||||
var tid int64
|
||||
_, _ = fmtSscan(k, &tid)
|
||||
if tid > 0 {
|
||||
opts.NameOverrides[tid] = v
|
||||
}
|
||||
}
|
||||
for _, tid := range body.SkipDevices {
|
||||
opts.SkipDevices[tid] = true
|
||||
}
|
||||
res, err := h.store.ApplyTemplate(pid, body.TemplateID, opts)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-solve by default. ?solve=0 opts out for power users who want
|
||||
// to inspect the seeded devices/requirements before the solver runs.
|
||||
// This is THE fix for the v6 UX hole: m hit Apply, saw an empty
|
||||
// canvas because nothing reloaded *and* nothing solved. With the
|
||||
// frontend re-snapshotting after the POST returns and the response
|
||||
// already carrying solver output, m sees the wired diagram in one click.
|
||||
skipSolve := r.URL.Query().Get("solve") == "0"
|
||||
combined := map[string]any{"template_apply": res}
|
||||
if !skipSolve {
|
||||
solveRes, err := h.store.Solve(pid, false)
|
||||
if err != nil {
|
||||
// Apply succeeded but Solve failed — don't 500 the whole
|
||||
// call. Return template_apply with the solve error inline so
|
||||
// the UI can recover (devices are there; m can re-solve).
|
||||
combined["solve_error"] = err.Error()
|
||||
} else {
|
||||
combined["solve"] = solveRes
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, combined)
|
||||
}
|
||||
|
||||
// fmtSscan parses a base-10 int from a string, returning (n, nil) on success.
|
||||
// Inline so handlers don't pull in strconv just for one call site.
|
||||
func fmtSscan(s string, out *int64) (int, error) {
|
||||
var v int64
|
||||
read := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
v = v*10 + int64(c-'0')
|
||||
read++
|
||||
}
|
||||
*out = v
|
||||
return read, nil
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>mCables</title>
|
||||
<title>CableGUI</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">mCables</span>
|
||||
<span class="brand">CableGUI</span>
|
||||
<div class="project-picker">
|
||||
<label for="project-select" class="sr-only">Project</label>
|
||||
<select id="project-select" aria-label="Active project">
|
||||
@@ -20,9 +20,15 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
|
||||
Export
|
||||
</button>
|
||||
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
|
||||
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
|
||||
<button type="button" id="btn-export" class="btn">Export</button>
|
||||
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
|
||||
<span class="zoom-cluster">
|
||||
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
|
||||
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
|
||||
</span>
|
||||
<span id="toast" class="toast" hidden></span>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
@@ -30,12 +36,6 @@
|
||||
<section class="legend">
|
||||
<h2 class="sidebar-heading">Cable types</h2>
|
||||
<ul id="legend-list" class="legend-list"></ul>
|
||||
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
|
||||
</section>
|
||||
<section class="requirements">
|
||||
<h2 class="sidebar-heading">Requirements</h2>
|
||||
<ul id="requirement-list" class="requirement-list"></ul>
|
||||
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
|
||||
</section>
|
||||
<section class="tools">
|
||||
<h2 class="sidebar-heading">Tools</h2>
|
||||
@@ -43,18 +43,22 @@
|
||||
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
|
||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||
<li><button type="button" id="tool-clamp" class="btn btn-tiny" data-tool="clamp" title="Click canvas to drop a clamp. Cables can then route through it.">+ Clamp</button></li>
|
||||
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
|
||||
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-wrap" aria-label="Diagram">
|
||||
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
|
||||
<defs id="canvas-defs"></defs>
|
||||
<g id="canvas-frames"></g>
|
||||
<g id="canvas-devices"></g>
|
||||
<g id="canvas-ports"></g>
|
||||
<g id="canvas-cables"></g>
|
||||
<g id="canvas-bundles"></g>
|
||||
<g id="canvas-clamps"></g>
|
||||
<g id="canvas-io"></g>
|
||||
</svg>
|
||||
<p id="empty-hint" class="empty-hint">
|
||||
@@ -175,6 +179,35 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Solve preview-diff (slice 6) -->
|
||||
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
|
||||
<div style="padding: 16px;">
|
||||
<h2 id="sv-title">Solve preview</h2>
|
||||
<div id="sv-body" class="sv-body"></div>
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Apply template (slice 6) -->
|
||||
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
|
||||
<form method="dialog" id="form-template">
|
||||
<h2 id="tp-title">Apply setup template</h2>
|
||||
<label class="field">
|
||||
<span>Template</span>
|
||||
<select id="tp-select" required></select>
|
||||
</label>
|
||||
<div id="tp-preview" class="tp-preview"></div>
|
||||
<p class="form-error" id="tp-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Apply</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Project confirm -->
|
||||
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
||||
<form method="dialog" id="form-delete-project">
|
||||
@@ -194,6 +227,24 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Admin: projects + cable types + device types + setup templates -->
|
||||
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
|
||||
<div class="admin-shell">
|
||||
<header class="admin-header">
|
||||
<h2 id="adm-title">Admin</h2>
|
||||
<button type="button" class="btn btn-link admin-close" data-close>✕</button>
|
||||
</header>
|
||||
<nav class="admin-tabs" role="tablist">
|
||||
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="requirements" role="tab">Requirements</button>
|
||||
</nav>
|
||||
<section class="admin-body" id="admin-body" role="tabpanel"></section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2437
web/static/main.js
2437
web/static/main.js
File diff suppressed because it is too large
Load Diff
@@ -180,17 +180,43 @@ body {
|
||||
fill: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* Frame bottom-right resize affordance. Mirrors .device-resize-handle
|
||||
but uses the accent-on-frame palette so it reads as part of the frame
|
||||
chrome rather than the device. */
|
||||
.frame-resize-handle {
|
||||
fill: rgba(0, 0, 0, 0.15);
|
||||
stroke: rgba(0, 0, 0, 0.25);
|
||||
stroke-width: 1;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.frame-resize-handle:hover {
|
||||
fill: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Stroke + fill come from the device's user-set colour, written as
|
||||
inline style in renderCanvas — leaving them out of .device-rect so
|
||||
the author CSS doesn't override the inline style. */
|
||||
.device-rect {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.device-rect.selected { stroke-width: 3; }
|
||||
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
|
||||
|
||||
/* Bottom-right resize affordance per device. Subtle grey by default,
|
||||
stronger on hover so m can find it without it dominating the rect. */
|
||||
.device-resize-handle {
|
||||
fill: rgba(120, 120, 120, 0.35);
|
||||
stroke: rgba(60, 60, 60, 0.45);
|
||||
stroke-width: 1;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.device-resize-handle:hover {
|
||||
fill: rgba(60, 60, 60, 0.65);
|
||||
}
|
||||
|
||||
.device-label {
|
||||
fill: var(--text);
|
||||
font-size: 12px;
|
||||
@@ -213,7 +239,100 @@ body {
|
||||
.canvas-wrap.tool-device #canvas,
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
|
||||
.canvas-wrap.tool-io #canvas *,
|
||||
.canvas-wrap.tool-clamp #canvas,
|
||||
.canvas-wrap.tool-clamp #canvas *,
|
||||
.canvas-wrap.tool-cable #canvas,
|
||||
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
|
||||
|
||||
/* Clamps — small grey rounded squares (v5 §11). Cables route through
|
||||
them in `ord` sequence. */
|
||||
.clamp {
|
||||
fill: rgba(120, 120, 120, 0.85);
|
||||
stroke: rgba(40, 40, 40, 0.85);
|
||||
stroke-width: 1.5;
|
||||
cursor: grab;
|
||||
}
|
||||
.clamp.selected {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
.clamp-label {
|
||||
fill: var(--text-muted);
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Shared-segment count badge — m sees ×N next to clamps that route
|
||||
≥ 2 cables. */
|
||||
.clamp-badge {
|
||||
fill: var(--text);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Bundle overlay — thick striped polyline drawn on top of individual
|
||||
cables along shared segments. v5 §11.3. */
|
||||
.bundle-line {
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-link:hover { color: var(--danger); }
|
||||
|
||||
/* Highlight a port that's been picked as the cable-draw source. */
|
||||
.port-circle.cable-from {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
|
||||
/* Zoom cluster — % + Fit button next to Admin. */
|
||||
.zoom-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
#zoom-pct {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.canvas-wrap.panning #canvas,
|
||||
.canvas-wrap.panning #canvas * { cursor: grabbing !important; }
|
||||
.canvas-wrap.space-pan-ready #canvas,
|
||||
.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; }
|
||||
|
||||
/* Header toast — slice 8 export feedback */
|
||||
.toast {
|
||||
display: inline-block;
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toast.ok { background: #e8f5e9; color: #1b5e20; }
|
||||
.toast.error { background: #fdecea; color: #911313; }
|
||||
.toast a { color: inherit; text-decoration: underline; }
|
||||
|
||||
/* IO markers — diamonds. Power-by-convention, so the default fill is
|
||||
the Power cable_type colour (#e03131). Rotated 45° rect is the
|
||||
@@ -238,15 +357,16 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Ports — small circles laid out along the device edge. The fill is
|
||||
white so the port is visible regardless of the underlying device's
|
||||
stroke; the stroke colour comes from the cable_type the port carries
|
||||
(set inline in JS). */
|
||||
/* Ports — small circles laid out along the device edge. Both fill and
|
||||
stroke come from the cable_type the port carries (set inline in JS)
|
||||
so the port reads clearly as a coloured anchor on the device. */
|
||||
.port-circle {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 2;
|
||||
pointer-events: none; /* slice 4 — selection happens at device-level */
|
||||
cursor: crosshair;
|
||||
}
|
||||
.port-circle.selected {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
|
||||
.port-row {
|
||||
@@ -255,13 +375,20 @@ body {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 2px 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.port-row .swatch {
|
||||
.port-row:hover { background: var(--surface-2); }
|
||||
.port-row .swatch,
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.port-row .label { color: var(--text); }
|
||||
.port-row .conn { color: var(--text-muted); font-size: 11px; }
|
||||
@@ -316,6 +443,189 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Cables on the canvas. Stroke colour comes from the cable_type;
|
||||
solver-owned cables (auto=1) render with a slightly dashed pattern
|
||||
so m can tell at a glance which the solver placed. */
|
||||
.cable-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cable-line.auto { stroke-dasharray: 8 3; }
|
||||
.cable-line:hover { stroke-width: 4; }
|
||||
.cable-line.selected { stroke-width: 4; }
|
||||
|
||||
/* Endpoint handles — only rendered for the currently-selected cable.
|
||||
Grab cursor on idle, grabbing while dragging (.replugging on root). */
|
||||
.cable-handle {
|
||||
cursor: grab;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.cable-handle:hover { stroke-width: 3; }
|
||||
.canvas-wrap.replugging .cable-handle,
|
||||
.canvas-wrap.replugging #canvas * { cursor: grabbing !important; }
|
||||
|
||||
/* Solve preview-diff modal */
|
||||
.modal-wide { width: 560px; }
|
||||
|
||||
/* Admin modal — wider, tabbed */
|
||||
.modal-wide.admin-shell-host { width: 760px; }
|
||||
#modal-admin { width: 760px; max-width: 90vw; }
|
||||
.admin-shell { padding: 16px; min-height: 460px; }
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-header h2 { margin: 0; }
|
||||
.admin-close { font-size: 16px; padding: 4px 8px; }
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-tab {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-tab:hover { color: var(--text); }
|
||||
.admin-tab[aria-selected="true"] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.admin-body {
|
||||
font-size: 13px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.admin-row {
|
||||
display: grid;
|
||||
gap: 6px 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-row:last-child { border-bottom: 0; }
|
||||
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
|
||||
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
|
||||
.admin-row .field input,
|
||||
.admin-row .field textarea,
|
||||
.admin-row .field select {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.admin-row.locked { opacity: 0.85; }
|
||||
.admin-row .locked-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.admin-row-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.admin-row-title .swatch { display: inline-block; }
|
||||
.admin-empty { color: var(--text-muted); padding: 16px 0; }
|
||||
.admin-add-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.port-profile-list {
|
||||
margin: 4px 0 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.port-profile-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.tmpl-detail {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
|
||||
|
||||
.sv-body { font-size: 13px; }
|
||||
.sv-body h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.sv-body ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sv-body li {
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.sv-body li.added { border-left: 3px solid #2f9e44; }
|
||||
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
|
||||
.sv-body li.unmet { border-left: 3px solid #f59f00; }
|
||||
.sv-body li.unmet .quickfix {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tp-preview {
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.tp-preview h4 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
|
||||
.tp-preview li { padding: 2px 0; }
|
||||
.tp-preview .skip {
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
stroke: var(--accent);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
|
||||
// via embed.FS so deploying mCables means shipping one file.
|
||||
// via embed.FS so deploying CableGUI means shipping one file.
|
||||
package web
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user