Compare commits
19 Commits
mai/picass
...
mai/picass
| Author | SHA1 | Date | |
|---|---|---|---|
| 82cf5a3052 | |||
| 5d055ad521 | |||
| 93b276875e | |||
| 205e9eab26 | |||
| fe6f86593e | |||
| a7835468a1 | |||
| 8a6e8c8406 | |||
| 275cb5a55a | |||
| a81dbe2f8c | |||
| 2cd981d3ae | |||
| 0c7d165ed6 | |||
| 9625d97efc | |||
| f9c245fbcc | |||
| c61bff7cf2 | |||
| 1d226844d1 | |||
| c681b01aff | |||
| c8bda7a222 | |||
| b93c42a6e0 | |||
| 75b826c583 |
@@ -43,8 +43,9 @@ JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
|
||||
|---|---|---|
|
||||
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
|
||||
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
|
||||
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
|
||||
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
|
||||
| `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
|
||||
| `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
|
||||
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
|
||||
|
||||
### Tests
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
- MCABLES_ADDR=0.0.0.0:7777
|
||||
- MCABLES_DB=/app/data/mcables.db
|
||||
env_file:
|
||||
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships.
|
||||
# MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
|
||||
- /home/m/secrets/mcables/.env
|
||||
volumes:
|
||||
- /home/m/stacks/mcables/data:/app/data
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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))
|
||||
@@ -57,9 +58,14 @@ func TestSeed_PortProfiles(t *testing.T) {
|
||||
"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
|
||||
"Screen": {2}, // Power 1 + HDMI 1
|
||||
"Keyboard": {1}, // USB 1
|
||||
"Mouse": {1}, // USB 1
|
||||
"Multi-plug 3": {4}, // Power 4
|
||||
"Multi-plug 4": {5}, // Power 5
|
||||
"Multi-plug 5": {6}, // Power 6
|
||||
"Multi-plug 6": {7}, // Power 7
|
||||
"Wifi-plug": {2}, // Power 2
|
||||
}
|
||||
for name, want := range cases {
|
||||
dt, ok := byName[name]
|
||||
@@ -77,6 +83,63 @@ func TestSeed_PortProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeed_PowerCatalog locks down migration 005: the 5 power-distribution
|
||||
// device types are present with the expected kind/icon/port profile, and
|
||||
// the total built-in count rose from 16 to 21.
|
||||
func TestSeed_PowerCatalog(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 string
|
||||
icon string
|
||||
powerPort int // count on the single Power port row
|
||||
}{
|
||||
{"Multi-plug 3", "hub", "🔌", 4},
|
||||
{"Multi-plug 4", "hub", "🔌", 5},
|
||||
{"Multi-plug 5", "hub", "🔌", 6},
|
||||
{"Multi-plug 6", "hub", "🔌", 7},
|
||||
{"Wifi-plug", "accessory", "📶", 2},
|
||||
}
|
||||
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 dt.Kind != c.kind {
|
||||
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
|
||||
}
|
||||
if dt.Icon == nil || *dt.Icon != c.icon {
|
||||
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
|
||||
}
|
||||
if len(dt.Ports) != 1 {
|
||||
t.Errorf("%s: expected 1 port-profile row, got %d", c.name, len(dt.Ports))
|
||||
continue
|
||||
}
|
||||
p := dt.Ports[0]
|
||||
if p.CableTypeID != 1 || p.Count != c.powerPort || p.Edge != "bottom" || p.LabelPrefix != "Power" {
|
||||
t.Errorf("%s: port profile mismatch: %+v", c.name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- CRUD (custom rows)
|
||||
|
||||
func TestCreateDeviceType_CustomBasic(t *testing.T) {
|
||||
|
||||
60
internal/db/excalidraw_ids.go
Normal file
60
internal/db/excalidraw_ids.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// PersistExcalidrawIDs writes the assignments returned by the exporter
|
||||
// back onto the corresponding rows. Idempotent: only updates rows whose
|
||||
// excalidraw_id is currently NULL (the first export "owns" the id; later
|
||||
// exports reuse it so mxdrw's collab cursors / undo history survive).
|
||||
//
|
||||
// Caller passes one map per kind; keys are the in-project row ids,
|
||||
// values are the 21-char Excalidraw element ids the exporter minted.
|
||||
func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||
frames, devices, ports, ios, cables map[int64]string,
|
||||
) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := updateExIDs(tx, "frames", projectID, frames); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "devices", projectID, devices); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "ports", projectID, ports); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "io_markers", projectID, ios); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func updateExIDs(tx *sql.Tx, table string, projectID int64, m map[int64]string) error {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
stmt, err := tx.Prepare(
|
||||
`UPDATE ` + table + `
|
||||
SET excalidraw_id = ?
|
||||
WHERE id = ? AND project_id = ? AND excalidraw_id IS NULL`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for id, exID := range m {
|
||||
if _, err := stmt.Exec(exID, id, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
157
internal/db/migrations/004_setup_templates.sql
Normal file
157
internal/db/migrations/004_setup_templates.sql
Normal file
@@ -0,0 +1,157 @@
|
||||
-- mCables v4.1 setup templates. See docs/design.md §2.4.
|
||||
--
|
||||
-- A template is a named recipe of (device_types + requirements) that
|
||||
-- bootstraps a project from blank to solver-ready in one apply call.
|
||||
|
||||
CREATE TABLE setup_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
built_in INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE setup_template_devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE RESTRICT,
|
||||
suggested_name TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX setup_template_devices_template_idx ON setup_template_devices(template_id);
|
||||
|
||||
CREATE TABLE setup_template_requirements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES setup_templates(id) ON DELETE CASCADE,
|
||||
from_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||
to_template_device_id INTEGER NOT NULL REFERENCES setup_template_devices(id) ON DELETE CASCADE,
|
||||
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
|
||||
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
|
||||
CHECK (from_template_device_id != to_template_device_id)
|
||||
);
|
||||
CREATE INDEX setup_template_reqs_template_idx ON setup_template_requirements(template_id);
|
||||
|
||||
-- ---------------------------------------------------------------- Living Room
|
||||
INSERT INTO setup_templates (name, description, built_in)
|
||||
VALUES ('Living Room', 'TV + Soundbar + ChromeCast, HDMI between them.', 1);
|
||||
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM device_types WHERE name='TV' AND project_id IS NULL),
|
||||
'TV', 0;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM device_types WHERE name='Soundbar' AND project_id IS NULL),
|
||||
'Soundbar', 1;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM device_types WHERE name='ChromeCast' AND project_id IS NULL),
|
||||
'ChromeCast', 2;
|
||||
|
||||
-- TV ↔ Soundbar (HDMI, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='Soundbar'),
|
||||
3, 1;
|
||||
-- TV ↔ ChromeCast (HDMI, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Living Room'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='TV'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Living Room') AND suggested_name='ChromeCast'),
|
||||
3, 1;
|
||||
|
||||
-- ---------------------------------------------------------------- Home Office
|
||||
INSERT INTO setup_templates (name, description, built_in)
|
||||
VALUES ('Home Office', 'PC + Screen + Keyboard + Mouse. HDMI + USB.', 1);
|
||||
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='PC' AND project_id IS NULL),
|
||||
'PC', 0;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='Screen' AND project_id IS NULL),
|
||||
'Screen', 1;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='Keyboard' AND project_id IS NULL),
|
||||
'Keyboard', 2;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM device_types WHERE name='Mouse' AND project_id IS NULL),
|
||||
'Mouse', 3;
|
||||
|
||||
-- PC ↔ Screen (HDMI, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Screen'),
|
||||
3, 1;
|
||||
-- PC ↔ Keyboard (USB, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Keyboard'),
|
||||
2, 1;
|
||||
-- PC ↔ Mouse (USB, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Home Office'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='PC'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Home Office') AND suggested_name='Mouse'),
|
||||
2, 1;
|
||||
|
||||
-- ---------------------------------------------------------------- Server Rack
|
||||
INSERT INTO setup_templates (name, description, built_in)
|
||||
VALUES ('Server Rack', 'NAS + Switch + fritz. Ethernet trunk + power.', 1);
|
||||
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM device_types WHERE name='NAS' AND project_id IS NULL),
|
||||
'NAS', 0;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM device_types WHERE name='Switch' AND project_id IS NULL),
|
||||
'Switch', 1;
|
||||
INSERT INTO setup_template_devices (template_id, device_type_id, suggested_name, sort_order)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM device_types WHERE name='fritz' AND project_id IS NULL),
|
||||
'fritz', 2;
|
||||
|
||||
-- NAS ↔ Switch (RJ45, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='NAS'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
|
||||
5, 1;
|
||||
-- Switch ↔ fritz (RJ45, must)
|
||||
INSERT INTO setup_template_requirements
|
||||
(template_id, from_template_device_id, to_template_device_id, preferred_cable_type_id, must_connect)
|
||||
SELECT
|
||||
(SELECT id FROM setup_templates WHERE name='Server Rack'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='Switch'),
|
||||
(SELECT id FROM setup_template_devices WHERE template_id = (SELECT id FROM setup_templates WHERE name='Server Rack') AND suggested_name='fritz'),
|
||||
5, 1;
|
||||
32
internal/db/migrations/005_catalog_power.sql
Normal file
32
internal/db/migrations/005_catalog_power.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- mCables v5 — catalog: power-distribution devices.
|
||||
-- Adds 5 built-in device_types (project_id NULL, built_in=1).
|
||||
--
|
||||
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The
|
||||
-- solver treats every Power port identically regardless of in/out
|
||||
-- direction; m knows which end is which from the physical setup.
|
||||
--
|
||||
-- Wifi-plug is a pass-through outlet (Power × 2: one in, one out).
|
||||
|
||||
INSERT INTO device_types (name, kind, icon, built_in, description) VALUES
|
||||
('Multi-plug 3', 'hub', '🔌', 1, '3-way power strip (1 in + 3 out)'),
|
||||
('Multi-plug 4', 'hub', '🔌', 1, '4-way power strip (1 in + 4 out)'),
|
||||
('Multi-plug 5', 'hub', '🔌', 1, '5-way power strip (1 in + 5 out)'),
|
||||
('Multi-plug 6', 'hub', '🔌', 1, '6-way power strip (1 in + 6 out)'),
|
||||
('Wifi-plug', 'accessory', '📶', 1, 'WiFi-controllable pass-through outlet');
|
||||
|
||||
-- Port profiles. cable_types id 1 = Power (seeded in 001).
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 4, 'bottom', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 5, 'bottom', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 6, 'bottom', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 7, 'bottom', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
|
||||
|
||||
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
SELECT id, 1, 'Power', 2, 'bottom', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
|
||||
@@ -111,6 +111,101 @@ 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 {
|
||||
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 +214,9 @@ type Snapshot struct {
|
||||
Frames []Frame `json:"frames"`
|
||||
Devices []Device `json:"devices"`
|
||||
Ports []Port `json:"ports"`
|
||||
Cables []any `json:"cables"`
|
||||
Cables []Cable `json:"cables"`
|
||||
IOMarkers []IOMarker `json:"io_markers"`
|
||||
Bundles []any `json:"bundles"`
|
||||
Bundles []Bundle `json:"bundles"`
|
||||
CableTypes []CableType `json:"cable_types"`
|
||||
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
367
internal/db/setup_templates.go
Normal file
367
internal/db/setup_templates.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListSetupTemplates returns every template with its devices +
|
||||
// requirements hydrated.
|
||||
func (s *Store) ListSetupTemplates() ([]SetupTemplate, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, description, built_in, created_at, updated_at
|
||||
FROM setup_templates ORDER BY id`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SetupTemplate{}
|
||||
for rows.Next() {
|
||||
var t SetupTemplate
|
||||
var built int
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &built,
|
||||
&t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.BuiltIn = built != 0
|
||||
out = append(out, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
devs, err := s.listTemplateDevices(out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].Devices = devs
|
||||
reqs, err := s.listTemplateRequirements(out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].Requirements = reqs
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSetupTemplate is a one-template variant of List.
|
||||
func (s *Store) GetSetupTemplate(id int64) (*SetupTemplate, error) {
|
||||
var t SetupTemplate
|
||||
var built int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, description, built_in, created_at, updated_at
|
||||
FROM setup_templates WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Name, &t.Description, &built, &t.CreatedAt, &t.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.BuiltIn = built != 0
|
||||
t.Devices, err = s.listTemplateDevices(t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Requirements, err = s.listTemplateRequirements(t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTemplateDevices(templateID int64) ([]SetupTemplateDevice, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, template_id, device_type_id, suggested_name, sort_order
|
||||
FROM setup_template_devices WHERE template_id = ? ORDER BY sort_order, id`,
|
||||
templateID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SetupTemplateDevice{}
|
||||
for rows.Next() {
|
||||
var d SetupTemplateDevice
|
||||
var sn sql.NullString
|
||||
if err := rows.Scan(&d.ID, &d.TemplateID, &d.DeviceTypeID, &sn, &d.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sn.Valid {
|
||||
v := sn.String
|
||||
d.SuggestedName = &v
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hydrate the device_type for the UI's optgroup labels.
|
||||
for i := range out {
|
||||
dt, err := s.GetDeviceType(out[i].DeviceTypeID)
|
||||
if err == nil {
|
||||
out[i].DeviceType = dt
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTemplateRequirements(templateID int64) ([]SetupTemplateRequirement, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, template_id, from_template_device_id, to_template_device_id,
|
||||
preferred_cable_type_id, must_connect
|
||||
FROM setup_template_requirements WHERE template_id = ? ORDER BY id`,
|
||||
templateID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SetupTemplateRequirement{}
|
||||
for rows.Next() {
|
||||
var r SetupTemplateRequirement
|
||||
var pct sql.NullInt64
|
||||
var must int
|
||||
if err := rows.Scan(&r.ID, &r.TemplateID, &r.FromTemplateDeviceID, &r.ToTemplateDeviceID,
|
||||
&pct, &must); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pct.Valid {
|
||||
v := pct.Int64
|
||||
r.PreferredCableTypeID = &v
|
||||
}
|
||||
r.MustConnect = must != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ApplyTemplateOptions controls per-device name overrides + opt-outs.
|
||||
type ApplyTemplateOptions struct {
|
||||
NameOverrides map[int64]string // template_device_id → custom name
|
||||
SkipDevices map[int64]bool // template_device_id → skip
|
||||
// Layout: where to place the first device in the cluster on the canvas.
|
||||
OriginX, OriginY float64
|
||||
}
|
||||
|
||||
// ApplyTemplate seeds devices + requirements from the template into
|
||||
// projectID in a single transaction. Name collisions skip the device
|
||||
// (recorded in skipped_devices); requirements whose endpoints both fail
|
||||
// to land are also skipped.
|
||||
func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOptions) (*ApplyTemplateResult, error) {
|
||||
tmpl, err := s.GetSetupTemplate(templateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &ApplyTemplateResult{
|
||||
DevicesAdded: []Device{},
|
||||
RequirementsAdded: []ConnectionRequirement{},
|
||||
SkippedDevices: []SkippedTemplateDevice{},
|
||||
RequirementsSkipped: []SkippedTemplateReq{},
|
||||
}
|
||||
|
||||
if opts.OriginX == 0 && opts.OriginY == 0 {
|
||||
opts.OriginX, opts.OriginY = 200, 200
|
||||
}
|
||||
|
||||
// Pull existing device names in the project so we can pre-check
|
||||
// collisions without aborting the whole transaction.
|
||||
existing, err := s.ListDevices(projectID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nameTaken := map[string]bool{}
|
||||
for _, d := range existing {
|
||||
nameTaken[d.Name] = true
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Map: template_device_id → newly-created device_id (or 0 if skipped).
|
||||
tmplToDevice := map[int64]int64{}
|
||||
|
||||
for i, td := range tmpl.Devices {
|
||||
if opts.SkipDevices[td.ID] {
|
||||
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
||||
TemplateDeviceID: td.ID, Reason: "skip requested",
|
||||
})
|
||||
tmplToDevice[td.ID] = 0
|
||||
continue
|
||||
}
|
||||
name := opts.NameOverrides[td.ID]
|
||||
if name == "" && td.SuggestedName != nil {
|
||||
name = *td.SuggestedName
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Device %d", td.ID)
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if nameTaken[name] {
|
||||
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
||||
TemplateDeviceID: td.ID,
|
||||
Reason: fmt.Sprintf("name %q already used in project", name),
|
||||
})
|
||||
tmplToDevice[td.ID] = 0
|
||||
continue
|
||||
}
|
||||
// Lay out devices in a horizontal row near the origin, 150 px apart.
|
||||
x := opts.OriginX + float64(i)*150
|
||||
y := opts.OriginY
|
||||
// Use createDeviceTx so the port-seeding share the same transaction.
|
||||
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
|
||||
Name: name,
|
||||
TypeID: &td.DeviceTypeID,
|
||||
X: x,
|
||||
Y: y,
|
||||
Width: 100,
|
||||
Height: 35,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed %s: %w", name, err)
|
||||
}
|
||||
nameTaken[name] = true
|
||||
tmplToDevice[td.ID] = d.ID
|
||||
out.DevicesAdded = append(out.DevicesAdded, *d)
|
||||
}
|
||||
|
||||
for _, tr := range tmpl.Requirements {
|
||||
fromID := tmplToDevice[tr.FromTemplateDeviceID]
|
||||
toID := tmplToDevice[tr.ToTemplateDeviceID]
|
||||
if fromID == 0 || toID == 0 {
|
||||
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
||||
TemplateRequirementID: tr.ID,
|
||||
Reason: "one or both endpoint devices were skipped",
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Normalise pair_lo/pair_hi, mirror what CreateConnectionRequirement does.
|
||||
lo, hi := fromID, toID
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
must := 0
|
||||
if tr.MustConnect {
|
||||
must = 1
|
||||
}
|
||||
var ctArg any
|
||||
if tr.PreferredCableTypeID != nil {
|
||||
ctArg = *tr.PreferredCableTypeID
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO connection_requirements
|
||||
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
||||
must_connect, notes, pair_lo, pair_hi)
|
||||
VALUES (?, ?, ?, ?, ?, '', ?, ?)`,
|
||||
projectID, fromID, toID, ctArg, must, lo, hi,
|
||||
)
|
||||
if err != nil {
|
||||
// A UNIQUE collision (project already has the same requirement)
|
||||
// is non-fatal — record as skipped, continue.
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
||||
TemplateRequirementID: tr.ID,
|
||||
Reason: "requirement already exists in project",
|
||||
})
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rid, _ := res.LastInsertId()
|
||||
out.RequirementsAdded = append(out.RequirementsAdded, ConnectionRequirement{
|
||||
ID: rid,
|
||||
ProjectID: projectID,
|
||||
FromDeviceID: fromID,
|
||||
ToDeviceID: toID,
|
||||
PreferredCableTypeID: tr.PreferredCableTypeID,
|
||||
MustConnect: tr.MustConnect,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// createDeviceTx is a tx-aware variant of CreateDevice used by
|
||||
// ApplyTemplate so seeding the template's devices + their ports stays
|
||||
// inside one atomic apply.
|
||||
//
|
||||
// Validation is intentionally lighter than CreateDevice: callers (only
|
||||
// ApplyTemplate today) hold a tx on the single SQLite connection, so
|
||||
// any "validate by reading from s.db" call would deadlock. The template's
|
||||
// device_type_id + frame_id come from already-validated template rows,
|
||||
// and SQLite FK constraints catch any genuine corruption on INSERT
|
||||
// (mapped to ErrInvalidInput by mapWriteErr).
|
||||
func (s *Store) createDeviceTx(tx *sql.Tx, projectID int64, d DeviceCreate) (*Device, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if d.Width <= 0 || d.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||
}
|
||||
color := strings.TrimSpace(d.Color)
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
||||
name, color, d.X, d.Y, d.Width, d.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
deviceID, _ := res.LastInsertId()
|
||||
if d.TypeID != nil {
|
||||
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Read back via the public store path is fine — the row exists in
|
||||
// the in-flight tx and SQLite sees its own writes within the tx.
|
||||
// Use a sub-helper that takes the tx executor for clean isolation.
|
||||
return s.readDeviceTx(tx, projectID, deviceID)
|
||||
}
|
||||
|
||||
func (s *Store) readDeviceTx(ex execer, projectID, id int64) (*Device, error) {
|
||||
var d Device
|
||||
var frame, typeID sql.NullInt64
|
||||
var ex2 sql.NullString
|
||||
err := ex.QueryRow(
|
||||
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
&ex2, &d.CreatedAt, &d.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.Valid {
|
||||
v := frame.Int64
|
||||
d.FrameID = &v
|
||||
}
|
||||
if typeID.Valid {
|
||||
v := typeID.Int64
|
||||
d.TypeID = &v
|
||||
}
|
||||
if ex2.Valid {
|
||||
d.ExcalidrawID = &ex2.String
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
509
internal/db/solver.go
Normal file
509
internal/db/solver.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Solve runs the v0 algorithm (design v4.1 §5b.2) against the project.
|
||||
// If preview is true, no DB writes happen — the function returns the
|
||||
// diff it WOULD apply. If preview is false, the diff is applied in a
|
||||
// single transaction.
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Read all auto cables, manual cables, ports, requirements.
|
||||
// 2. Reserve ports used by manual cables (auto=0) so the solver
|
||||
// doesn't reuse them.
|
||||
// 3. For each requirement (must_connect DESC, id ASC):
|
||||
// - Resolve cable type: preferred, or T = port-types(from) ∩
|
||||
// port-types(to). |T|==1 → that. |T|>1 → unsatisfied (ambiguous).
|
||||
// |T|==0 → unsatisfied (no compat type).
|
||||
// - Find lowest-id free port on each side. None → unsatisfied
|
||||
// (no free port). Reserve both.
|
||||
// - Stage an "add cable {from_port, to_port, type, auto=1}".
|
||||
// 4. Endpoint-pair bundle: any pair of device endpoints with ≥ 2
|
||||
// staged cables becomes an auto bundle.
|
||||
// 5. Diff against existing auto cables/bundles: removed = existing
|
||||
// auto rows not in the staged set; kept = those that match by
|
||||
// (from_port, to_port, type); add = remaining staged rows.
|
||||
func (s *Store) Solve(projectID int64, preview bool) (*SolveResult, error) {
|
||||
res := &SolveResult{
|
||||
CablesAdded: []Cable{},
|
||||
CablesKept: []int64{},
|
||||
CablesRemoved: []int64{},
|
||||
BundlesAdded: []Bundle{},
|
||||
BundlesRemoved: []int64{},
|
||||
Unsatisfied: []UnsatisfiedReq{},
|
||||
Warnings: []string{},
|
||||
}
|
||||
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices, err := s.ListDevices(projectID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports, err := s.ListPortsForProject(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cables, err := s.ListCables(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqs, err := s.ListConnectionRequirements(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundles, err := s.ListBundles(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Index ports by (device_id, type_id), sorted by id (deterministic).
|
||||
portsByDevice := map[int64][]Port{}
|
||||
for _, p := range ports {
|
||||
portsByDevice[p.DeviceID] = append(portsByDevice[p.DeviceID], p)
|
||||
}
|
||||
for did := range portsByDevice {
|
||||
sort.SliceStable(portsByDevice[did], func(i, j int) bool {
|
||||
return portsByDevice[did][i].ID < portsByDevice[did][j].ID
|
||||
})
|
||||
}
|
||||
deviceByID := map[int64]Device{}
|
||||
for _, d := range devices {
|
||||
deviceByID[d.ID] = d
|
||||
}
|
||||
|
||||
// Reserve ports used by manual cables.
|
||||
usedPorts := map[int64]bool{}
|
||||
autoCablesByID := map[int64]Cable{}
|
||||
for _, c := range cables {
|
||||
if c.Auto {
|
||||
autoCablesByID[c.ID] = c
|
||||
continue
|
||||
}
|
||||
if c.FromPortID != nil {
|
||||
usedPorts[*c.FromPortID] = true
|
||||
}
|
||||
if c.ToPortID != nil {
|
||||
usedPorts[*c.ToPortID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Sort requirements: must_connect DESC, id ASC.
|
||||
rs := append([]ConnectionRequirement{}, reqs...)
|
||||
sort.SliceStable(rs, func(i, j int) bool {
|
||||
if rs[i].MustConnect != rs[j].MustConnect {
|
||||
return rs[i].MustConnect
|
||||
}
|
||||
return rs[i].ID < rs[j].ID
|
||||
})
|
||||
|
||||
type staged struct {
|
||||
typeID int64
|
||||
fromPortID int64
|
||||
toPortID int64
|
||||
fromDeviceID int64
|
||||
toDeviceID int64
|
||||
}
|
||||
var staging []staged
|
||||
|
||||
for _, r := range rs {
|
||||
_, fromOK := deviceByID[r.FromDeviceID]
|
||||
_, toOK := deviceByID[r.ToDeviceID]
|
||||
if !fromOK || !toOK {
|
||||
// Shouldn't happen (FK CASCADE removes the row when a device
|
||||
// goes), but be defensive.
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve cable type.
|
||||
var typeID int64
|
||||
if r.PreferredCableTypeID != nil {
|
||||
typeID = *r.PreferredCableTypeID
|
||||
} else {
|
||||
fromTypes := map[int64]bool{}
|
||||
for _, p := range portsByDevice[r.FromDeviceID] {
|
||||
fromTypes[p.TypeID] = true
|
||||
}
|
||||
candidates := []int64{}
|
||||
for _, p := range portsByDevice[r.ToDeviceID] {
|
||||
if fromTypes[p.TypeID] {
|
||||
// Add unique.
|
||||
already := false
|
||||
for _, c := range candidates {
|
||||
if c == p.TypeID {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
candidates = append(candidates, p.TypeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
if r.MustConnect {
|
||||
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
||||
RequirementID: r.ID,
|
||||
Reason: "no compatible cable type — devices share no port-type",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
if r.MustConnect {
|
||||
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
||||
RequirementID: r.ID,
|
||||
Reason: "ambiguous cable type — specify preferred_cable_type_id",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
typeID = candidates[0]
|
||||
}
|
||||
|
||||
// Pick lowest-id free port of `typeID` on each side.
|
||||
pickFree := func(deviceID, t int64) *int64 {
|
||||
for _, p := range portsByDevice[deviceID] {
|
||||
if p.TypeID != t {
|
||||
continue
|
||||
}
|
||||
if usedPorts[p.ID] {
|
||||
continue
|
||||
}
|
||||
return &p.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fromPort := pickFree(r.FromDeviceID, typeID)
|
||||
toPort := pickFree(r.ToDeviceID, typeID)
|
||||
if fromPort == nil || toPort == nil {
|
||||
if r.MustConnect {
|
||||
side := ""
|
||||
if fromPort == nil && toPort == nil {
|
||||
side = ""
|
||||
} else if fromPort == nil {
|
||||
side = "from"
|
||||
} else {
|
||||
side = "to"
|
||||
}
|
||||
typeName := ""
|
||||
if ct, err := s.GetCableType(typeID); err == nil {
|
||||
typeName = ct.Name
|
||||
}
|
||||
res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{
|
||||
RequirementID: r.ID,
|
||||
Reason: fmt.Sprintf("no free %s port", typeName),
|
||||
WhichSide: side,
|
||||
CableType: typeName,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
usedPorts[*fromPort] = true
|
||||
usedPorts[*toPort] = true
|
||||
staging = append(staging, staged{
|
||||
typeID: typeID, fromPortID: *fromPort, toPortID: *toPort,
|
||||
fromDeviceID: r.FromDeviceID, toDeviceID: r.ToDeviceID,
|
||||
})
|
||||
}
|
||||
|
||||
// Match staged → existing auto cables by (typeID, fromPortID, toPortID)
|
||||
// or its reverse. Anything matched is "kept"; the rest of auto cables
|
||||
// is "removed". Unmatched staged entries become "added".
|
||||
type sigKey struct{ typeID, a, b int64 }
|
||||
matched := map[int64]bool{} // existing auto cable IDs that match
|
||||
sigToAuto := map[sigKey]int64{}
|
||||
for id, c := range autoCablesByID {
|
||||
if c.FromPortID == nil || c.ToPortID == nil {
|
||||
continue
|
||||
}
|
||||
a, b := *c.FromPortID, *c.ToPortID
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
sigToAuto[sigKey{c.TypeID, a, b}] = id
|
||||
}
|
||||
var toAdd []staged
|
||||
for _, st := range staging {
|
||||
a, b := st.fromPortID, st.toPortID
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
if existingID, ok := sigToAuto[sigKey{st.typeID, a, b}]; ok {
|
||||
matched[existingID] = true
|
||||
res.CablesKept = append(res.CablesKept, existingID)
|
||||
continue
|
||||
}
|
||||
toAdd = append(toAdd, st)
|
||||
}
|
||||
for id := range autoCablesByID {
|
||||
if !matched[id] {
|
||||
res.CablesRemoved = append(res.CablesRemoved, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(res.CablesKept, func(i, j int) bool { return res.CablesKept[i] < res.CablesKept[j] })
|
||||
sort.Slice(res.CablesRemoved, func(i, j int) bool { return res.CablesRemoved[i] < res.CablesRemoved[j] })
|
||||
|
||||
// Endpoint-pair bundling for the final set of auto cables (kept + added).
|
||||
// Group by unordered (deviceA, deviceB). Build the map of port_id → device_id
|
||||
// for fast lookup.
|
||||
portToDevice := map[int64]int64{}
|
||||
for _, p := range ports {
|
||||
portToDevice[p.ID] = p.DeviceID
|
||||
}
|
||||
type pairKey struct{ a, b int64 }
|
||||
pairGroup := map[pairKey][]string{} // staged-or-kept tags (we just count)
|
||||
pairOrder := []pairKey{} // first-seen order
|
||||
|
||||
// We'll need the final list of cables-after-apply (with their IDs) to
|
||||
// build bundles. For preview, kept IDs are real, added IDs are zero;
|
||||
// for apply, we'll re-bundle after inserts.
|
||||
|
||||
if preview {
|
||||
// In preview mode, "kept" IDs are real cables; "added" are
|
||||
// staged. We still compute bundles_added so the UI can show
|
||||
// which cable groups will be bundled. Bundles_added carry
|
||||
// `CableIDs: []` for the staged entries because they don't
|
||||
// have IDs yet — the UI maps by position. cables_kept that
|
||||
// belong to a bundle group also list their existing ids.
|
||||
// In short, slot every staged cable into the same pair bucket
|
||||
// + the kept cables.
|
||||
for _, st := range staging {
|
||||
da, db := st.fromDeviceID, st.toDeviceID
|
||||
if da > db {
|
||||
da, db = db, da
|
||||
}
|
||||
pk := pairKey{da, db}
|
||||
if _, ok := pairGroup[pk]; !ok {
|
||||
pairOrder = append(pairOrder, pk)
|
||||
}
|
||||
pairGroup[pk] = append(pairGroup[pk], "")
|
||||
}
|
||||
// Materialise preview-shape Cable structs for the added rows.
|
||||
for _, st := range toAdd {
|
||||
c := Cable{
|
||||
ProjectID: projectID,
|
||||
TypeID: st.typeID,
|
||||
FromPortID: ptr(st.fromPortID),
|
||||
ToPortID: ptr(st.toPortID),
|
||||
Auto: true,
|
||||
}
|
||||
res.CablesAdded = append(res.CablesAdded, c)
|
||||
}
|
||||
for _, pk := range pairOrder {
|
||||
if len(pairGroup[pk]) < 2 {
|
||||
continue
|
||||
}
|
||||
a := deviceByID[pk.a].Name
|
||||
b := deviceByID[pk.b].Name
|
||||
res.BundlesAdded = append(res.BundlesAdded, Bundle{
|
||||
ProjectID: projectID,
|
||||
Name: a + " ↔ " + b,
|
||||
Auto: true,
|
||||
CableIDs: nil, // post-apply only
|
||||
})
|
||||
}
|
||||
// Existing auto bundles all "would be removed" since we rebuild
|
||||
// from scratch each solve (slice-6 v0 is wholesale-replace).
|
||||
for _, b := range bundles {
|
||||
if b.Auto {
|
||||
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Apply mode: open a transaction, delete removed auto cables + auto
|
||||
// bundles, insert added cables, re-bundle by endpoint pair.
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete obsolete auto bundles (we'll rebuild).
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM bundles WHERE project_id = ? AND auto = 1`, projectID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range bundles {
|
||||
if b.Auto {
|
||||
res.BundlesRemoved = append(res.BundlesRemoved, b.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed auto cables.
|
||||
for _, id := range res.CablesRemoved {
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Insert added cables. Track new ids by their staged signature for
|
||||
// bundle wiring.
|
||||
type addedRow struct {
|
||||
id int64
|
||||
staged staged
|
||||
}
|
||||
addedRows := []addedRow{}
|
||||
for _, st := range toAdd {
|
||||
c, err := s.createCable(tx, projectID, CableCreate{
|
||||
TypeID: st.typeID,
|
||||
From: CableEndpoint{PortID: &st.fromPortID},
|
||||
To: CableEndpoint{PortID: &st.toPortID},
|
||||
Auto: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.CablesAdded = append(res.CablesAdded, *c)
|
||||
addedRows = append(addedRows, addedRow{id: c.ID, staged: st})
|
||||
}
|
||||
|
||||
// Re-bundle: all auto cables (kept + added) grouped by endpoint pair.
|
||||
// First, collect cable IDs per (deviceA, deviceB) — both kept (from
|
||||
// matched map) and added.
|
||||
groups := map[pairKey][]int64{}
|
||||
order := []pairKey{}
|
||||
addToGroup := func(da, db, cid int64) {
|
||||
if da > db {
|
||||
da, db = db, da
|
||||
}
|
||||
pk := pairKey{da, db}
|
||||
if _, ok := groups[pk]; !ok {
|
||||
order = append(order, pk)
|
||||
}
|
||||
groups[pk] = append(groups[pk], cid)
|
||||
}
|
||||
for id, c := range autoCablesByID {
|
||||
if !matched[id] {
|
||||
continue
|
||||
}
|
||||
if c.FromPortID == nil || c.ToPortID == nil {
|
||||
continue
|
||||
}
|
||||
da := portToDevice[*c.FromPortID]
|
||||
db := portToDevice[*c.ToPortID]
|
||||
if da == 0 || db == 0 {
|
||||
continue
|
||||
}
|
||||
addToGroup(da, db, id)
|
||||
}
|
||||
for _, ar := range addedRows {
|
||||
addToGroup(ar.staged.fromDeviceID, ar.staged.toDeviceID, ar.id)
|
||||
}
|
||||
|
||||
for _, pk := range order {
|
||||
ids := groups[pk]
|
||||
if len(ids) < 2 {
|
||||
continue
|
||||
}
|
||||
a := deviceByID[pk.a].Name
|
||||
b := deviceByID[pk.b].Name
|
||||
bundle, err := s.createBundle(tx, projectID, BundleCreate{
|
||||
Name: a + " ↔ " + b,
|
||||
CableIDs: ids,
|
||||
Auto: true,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.BundlesAdded = append(res.BundlesAdded, *bundle)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// PortsAndResolve adds a port to a device + re-runs Solve in one tx.
|
||||
// Used by the inspector's "+ Add <type> port and re-solve" quick-fix.
|
||||
type PortsAndResolveResult struct {
|
||||
Port Port `json:"port"`
|
||||
Solve *SolveResult `json:"solve"`
|
||||
}
|
||||
|
||||
func (s *Store) PortsAndResolve(projectID, deviceID int64, typeID int64, label string, xOff, yOff float64) (*PortsAndResolveResult, error) {
|
||||
d, err := s.GetDevice(projectID, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.GetCableType(typeID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, typeID)
|
||||
}
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Default the new port to the bottom edge at the right-most existing offset.
|
||||
if xOff == 0 && yOff == 0 {
|
||||
xOff = d.Width / 2
|
||||
yOff = d.Height
|
||||
}
|
||||
var labelArg any
|
||||
if label != "" {
|
||||
labelArg = label
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
projectID, deviceID, typeID, labelArg, xOff, yOff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
portID, _ := res.LastInsertId()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Now re-solve outside the tx — Solve manages its own tx for the
|
||||
// apply path. This is a slight relaxation of "single round-trip" — if
|
||||
// the solver run fails the port stays, but that's fine; the port is
|
||||
// what m wanted regardless.
|
||||
solveRes, err := s.Solve(projectID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Re-fetch the port row to return its full shape.
|
||||
port, err := s.getPortByID(portID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PortsAndResolveResult{Port: *port, Solve: solveRes}, nil
|
||||
}
|
||||
|
||||
func (s *Store) getPortByID(id int64) (*Port, error) {
|
||||
var p Port
|
||||
var label, ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||
excalidraw_id, created_at, updated_at
|
||||
FROM ports WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
||||
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if label.Valid {
|
||||
v := label.String
|
||||
p.Label = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
p.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
259
internal/db/solver_test.go
Normal file
259
internal/db/solver_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// builtInTypeID returns the id of the named built-in device type.
|
||||
func builtInTypeID(t *testing.T, s *Store, name string) int64 {
|
||||
t.Helper()
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
for _, dt := range all {
|
||||
if dt.Name == name {
|
||||
return dt.ID
|
||||
}
|
||||
}
|
||||
t.Fatalf("built-in %q not found", name)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ basic solver wins
|
||||
|
||||
func TestSolve_BasicNAStoSwitch(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
nasT := builtInTypeID(t, s, "NAS")
|
||||
swT := builtInTypeID(t, s, "Switch")
|
||||
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
rj45 := int64(5)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
||||
})
|
||||
res, err := s.Solve(p.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("solve: %v", err)
|
||||
}
|
||||
if len(res.CablesAdded) != 1 {
|
||||
t.Fatalf("cables_added len = %d, want 1", len(res.CablesAdded))
|
||||
}
|
||||
if res.CablesAdded[0].TypeID != rj45 {
|
||||
t.Errorf("cable type = %d, want %d (RJ45)", res.CablesAdded[0].TypeID, rj45)
|
||||
}
|
||||
if !res.CablesAdded[0].Auto {
|
||||
t.Errorf("cable.auto should be true")
|
||||
}
|
||||
if len(res.Unsatisfied) != 0 {
|
||||
t.Errorf("unsatisfied should be empty; got %+v", res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_AmbiguousType_RequirementUnsatisfied(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
// Both PCs have Power + USB + HDMI + RJ45 → multiple types match.
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: a.ID, ToDeviceID: b.ID, // no PreferredCableTypeID
|
||||
})
|
||||
res, _ := s.Solve(p.ID, true)
|
||||
if len(res.CablesAdded) != 0 {
|
||||
t.Errorf("ambiguous: should not add cables, got %d", len(res.CablesAdded))
|
||||
}
|
||||
if len(res.Unsatisfied) != 1 || res.Unsatisfied[0].Reason == "" {
|
||||
t.Errorf("expected 1 unsatisfied req with non-empty reason; got %+v", res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_NoFreePort_RequirementUnsatisfied(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
// Mouse only has 1 USB port. Two USB requirements against it should
|
||||
// leave one unsatisfied.
|
||||
mouseT := builtInTypeID(t, s, "Mouse")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
pc1, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC1", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
pc2, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC2", TypeID: &pcT, X: 400, Y: 0, Width: 100, Height: 35})
|
||||
usb := int64(2)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: mouse.ID, ToDeviceID: pc1.ID, PreferredCableTypeID: &usb,
|
||||
})
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: mouse.ID, ToDeviceID: pc2.ID, PreferredCableTypeID: &usb,
|
||||
})
|
||||
res, _ := s.Solve(p.ID, true)
|
||||
if len(res.CablesAdded) != 1 {
|
||||
t.Errorf("expected 1 cable to land (one mouse USB), got %d", len(res.CablesAdded))
|
||||
}
|
||||
if len(res.Unsatisfied) != 1 {
|
||||
t.Errorf("expected 1 unsatisfied; got %d (%+v)", len(res.Unsatisfied), res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------- preview vs apply semantics
|
||||
|
||||
func TestSolve_PreviewDoesNotWrite(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
nasT := builtInTypeID(t, s, "NAS")
|
||||
swT := builtInTypeID(t, s, "Switch")
|
||||
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
rj45 := int64(5)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
||||
})
|
||||
_, _ = s.Solve(p.ID, true) // preview
|
||||
cables, _ := s.ListCables(p.ID)
|
||||
if len(cables) != 0 {
|
||||
t.Errorf("preview wrote %d cables; want 0", len(cables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_ApplyThenIdempotent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
nasT := builtInTypeID(t, s, "NAS")
|
||||
swT := builtInTypeID(t, s, "Switch")
|
||||
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
rj45 := int64(5)
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
||||
})
|
||||
r1, _ := s.Solve(p.ID, false)
|
||||
if len(r1.CablesAdded) != 1 {
|
||||
t.Fatalf("first apply: cables_added=%d, want 1", len(r1.CablesAdded))
|
||||
}
|
||||
r2, _ := s.Solve(p.ID, false)
|
||||
if len(r2.CablesAdded) != 0 {
|
||||
t.Errorf("second apply: cables_added=%d, want 0 (idempotent)", len(r2.CablesAdded))
|
||||
}
|
||||
if len(r2.CablesKept) != 1 {
|
||||
t.Errorf("second apply: cables_kept=%d, want 1", len(r2.CablesKept))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve_ManualCableReservesPort(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
mouseT := builtInTypeID(t, s, "Mouse")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
pc, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
||||
|
||||
// Manual cable USB Mouse↔PC: claims the only mouse USB port.
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
var mouseUSB, pcUSB int64
|
||||
for _, prt := range ports {
|
||||
if prt.DeviceID == mouse.ID && prt.TypeID == 2 {
|
||||
mouseUSB = prt.ID
|
||||
}
|
||||
if prt.DeviceID == pc.ID && prt.TypeID == 2 {
|
||||
pcUSB = prt.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
usb := int64(2)
|
||||
_, _ = s.CreateCable(p.ID, CableCreate{
|
||||
TypeID: usb,
|
||||
From: CableEndpoint{PortID: &mouseUSB},
|
||||
To: CableEndpoint{PortID: &pcUSB},
|
||||
Auto: false,
|
||||
})
|
||||
|
||||
// Now add a requirement that also wants USB on the mouse → no free port.
|
||||
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
||||
FromDeviceID: mouse.ID, ToDeviceID: pc.ID, PreferredCableTypeID: &usb,
|
||||
})
|
||||
res, _ := s.Solve(p.ID, true)
|
||||
if len(res.Unsatisfied) == 0 {
|
||||
t.Errorf("expected unsatisfied req (manual cable should reserve the only mouse USB port)")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- setup templates
|
||||
|
||||
func TestApplyTemplate_LivingRoom(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var lr SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Living Room" {
|
||||
lr = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
if lr.ID == 0 {
|
||||
t.Fatal("Living Room template not seeded")
|
||||
}
|
||||
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if len(res.DevicesAdded) != 3 {
|
||||
t.Errorf("devices added = %d, want 3 (TV, Soundbar, ChromeCast)", len(res.DevicesAdded))
|
||||
}
|
||||
if len(res.RequirementsAdded) != 2 {
|
||||
t.Errorf("requirements added = %d, want 2 (TV↔Soundbar, TV↔ChromeCast)", len(res.RequirementsAdded))
|
||||
}
|
||||
// Ports were seeded as part of the device creation.
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
if len(ports) < 6 { // TV(3) + Soundbar(2) + ChromeCast(2) = 7
|
||||
t.Errorf("ports after template apply = %d, expected ≥6", len(ports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var ho SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Home Office" {
|
||||
ho = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
res, err := s.Solve(p.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("solve: %v", err)
|
||||
}
|
||||
if len(res.CablesAdded) != 3 {
|
||||
t.Errorf("Home Office should solve to 3 cables (PC↔Screen, PC↔Keyboard, PC↔Mouse); got %d", len(res.CablesAdded))
|
||||
}
|
||||
if len(res.Unsatisfied) != 0 {
|
||||
t.Errorf("unsatisfied = %+v, want []", res.Unsatisfied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
pcT := builtInTypeID(t, s, "PC")
|
||||
// Pre-create a device called "PC" so the Home Office template's PC collides.
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
||||
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var ho SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Home Office" {
|
||||
ho = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
res, _ := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{})
|
||||
if len(res.SkippedDevices) == 0 {
|
||||
t.Errorf("expected at least one skipped device for name collision; got %+v", res.SkippedDevices)
|
||||
}
|
||||
if len(res.RequirementsSkipped) == 0 {
|
||||
t.Errorf("PC requirements should be skipped when PC device skipped; got %+v", res.RequirementsSkipped)
|
||||
}
|
||||
}
|
||||
@@ -179,14 +179,22 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cables, err := s.ListCables(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundles, err := s.ListBundles(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Snapshot{
|
||||
Project: *p,
|
||||
Frames: frames,
|
||||
Devices: devices,
|
||||
Ports: ports,
|
||||
Cables: []any{},
|
||||
Cables: cables,
|
||||
IOMarkers: ios,
|
||||
Bundles: []any{},
|
||||
Bundles: bundles,
|
||||
CableTypes: types,
|
||||
ConnectionRequirements: reqs,
|
||||
}, nil
|
||||
|
||||
563
internal/exporter/exporter.go
Normal file
563
internal/exporter/exporter.go
Normal file
@@ -0,0 +1,563 @@
|
||||
// Package exporter builds an Excalidraw scene JSON from a project
|
||||
// snapshot per docs/design.md §4 ("Export — DB → Excalidraw").
|
||||
//
|
||||
// The exporter is a pure function on a *db.Snapshot — no DB access, no
|
||||
// IO — so it's trivial to unit-test against fixtures and gives the
|
||||
// caller (the HTTP handler) a clean handoff: build scene → upload.
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
// Scene is the top-level Excalidraw file format. Keys mirror what the
|
||||
// official Excalidraw JSON contains (we only emit the keys mxdrw cares
|
||||
// about for rendering — `appState`, `files`, `libraryItems` etc. can be
|
||||
// added later if m needs them).
|
||||
type Scene struct {
|
||||
Type string `json:"type"`
|
||||
Version int `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Elements []Element `json:"elements"`
|
||||
AppState AppState `json:"appState"`
|
||||
Files Files `json:"files"`
|
||||
}
|
||||
|
||||
type AppState struct {
|
||||
GridSize *int `json:"gridSize"`
|
||||
ViewBackground string `json:"viewBackgroundColor"`
|
||||
}
|
||||
|
||||
type Files struct{}
|
||||
|
||||
// Element is one node in the scene. Excalidraw's wire format has a lot
|
||||
// of optional fields; we only emit the ones that matter for the shapes
|
||||
// we draw. Extra null/zero fields are fine in Excalidraw (it merges
|
||||
// defaults). Pointer fields stay nil-omitted via omitempty so the
|
||||
// payload stays clean.
|
||||
type Element struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Angle float64 `json:"angle"`
|
||||
StrokeColor string `json:"strokeColor"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
FillStyle string `json:"fillStyle"`
|
||||
StrokeWidth int `json:"strokeWidth"`
|
||||
StrokeStyle string `json:"strokeStyle"`
|
||||
Roughness int `json:"roughness"`
|
||||
Opacity int `json:"opacity"`
|
||||
GroupIDs []string `json:"groupIds"`
|
||||
FrameID *string `json:"frameId"`
|
||||
Roundness *Roundness `json:"roundness"`
|
||||
Seed int64 `json:"seed"`
|
||||
Version int `json:"version"`
|
||||
VersionNonce int64 `json:"versionNonce"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
BoundElements []BoundRef `json:"boundElements,omitempty"`
|
||||
Updated int64 `json:"updated"`
|
||||
Link *string `json:"link"`
|
||||
Locked bool `json:"locked"`
|
||||
|
||||
// Element-type-specific extras
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Text-element fields
|
||||
Text string `json:"text,omitempty"`
|
||||
FontSize int `json:"fontSize,omitempty"`
|
||||
FontFamily int `json:"fontFamily,omitempty"`
|
||||
TextAlign string `json:"textAlign,omitempty"`
|
||||
VerticalAlign string `json:"verticalAlign,omitempty"`
|
||||
ContainerID *string `json:"containerId,omitempty"`
|
||||
OriginalText string `json:"originalText,omitempty"`
|
||||
LineHeight float64 `json:"lineHeight,omitempty"`
|
||||
|
||||
// Arrow-element fields
|
||||
Points [][2]float64 `json:"points,omitempty"`
|
||||
StartBinding *Binding `json:"startBinding,omitempty"`
|
||||
EndBinding *Binding `json:"endBinding,omitempty"`
|
||||
StartArrowhead *string `json:"startArrowhead,omitempty"`
|
||||
EndArrowhead *string `json:"endArrowhead,omitempty"`
|
||||
LastCommittedPoint *[2]float64 `json:"lastCommittedPoint,omitempty"`
|
||||
}
|
||||
|
||||
type Roundness struct {
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
type BoundRef struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
ElementID string `json:"elementId"`
|
||||
Focus float64 `json:"focus"`
|
||||
Gap float64 `json:"gap"`
|
||||
}
|
||||
|
||||
// IDAssignment is the result of running BuildScene: the scene to upload
|
||||
// + the per-row excalidraw_id assignments that the caller should
|
||||
// persist so the next export reuses the same ids (Excalidraw collab
|
||||
// cursors / comments / undo history survive that way; design §4.2).
|
||||
type IDAssignment struct {
|
||||
Frames map[int64]string `json:"frames"`
|
||||
Devices map[int64]string `json:"devices"`
|
||||
Ports map[int64]string `json:"ports"`
|
||||
IOMarkers map[int64]string `json:"io_markers"`
|
||||
Cables map[int64]string `json:"cables"`
|
||||
}
|
||||
|
||||
// BuildScene transforms a project snapshot into an Excalidraw Scene +
|
||||
// the id-assignment side-table.
|
||||
//
|
||||
// nowMilli is the Updated timestamp (one millisecond stamp for every
|
||||
// element keeps re-exports consistent — mxdrw treats wildly-different
|
||||
// updateds as edit-noise).
|
||||
//
|
||||
// genID is a 21-char ID factory. Tests pass a deterministic generator
|
||||
// to lock element ids down across asserts. Production uses Generate21.
|
||||
func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene, *IDAssignment) {
|
||||
a := &IDAssignment{
|
||||
Frames: map[int64]string{},
|
||||
Devices: map[int64]string{},
|
||||
Ports: map[int64]string{},
|
||||
IOMarkers: map[int64]string{},
|
||||
Cables: map[int64]string{},
|
||||
}
|
||||
// idFor: reuse the existing excalidraw_id if present, else mint one.
|
||||
idFor := func(existing *string) string {
|
||||
if existing != nil && *existing != "" {
|
||||
return *existing
|
||||
}
|
||||
return genID()
|
||||
}
|
||||
|
||||
cableTypeColor := map[int64]string{}
|
||||
for _, t := range snap.CableTypes {
|
||||
cableTypeColor[t.ID] = t.Color
|
||||
}
|
||||
|
||||
// We'll need: device-id → element-id, port-id → element-id, io-id → element-id
|
||||
// for binding arrows.
|
||||
deviceElID := map[int64]string{}
|
||||
portElID := map[int64]string{}
|
||||
ioElID := map[int64]string{}
|
||||
frameElID := map[int64]string{}
|
||||
|
||||
var els []Element
|
||||
|
||||
// Frames first (Excalidraw renders later elements on top; frames are
|
||||
// containers that go on the bottom).
|
||||
for _, f := range snap.Frames {
|
||||
elID := idFor(f.ExcalidrawID)
|
||||
a.Frames[f.ID] = elID
|
||||
frameElID[f.ID] = elID
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "frame",
|
||||
X: f.X,
|
||||
Y: f.Y,
|
||||
Width: f.Width,
|
||||
Height: f.Height,
|
||||
StrokeColor: "#bbbbbb",
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Name: f.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// Devices: rectangle + bound text with the device's name. Excalidraw
|
||||
// uses a `containerId` pointer on the text to bind it to the rect,
|
||||
// and `boundElements` on the rect to point back at the text.
|
||||
for _, d := range snap.Devices {
|
||||
rectID := idFor(d.ExcalidrawID)
|
||||
a.Devices[d.ID] = rectID
|
||||
deviceElID[d.ID] = rectID
|
||||
textID := genID()
|
||||
var frameRef *string
|
||||
if d.FrameID != nil {
|
||||
if v, ok := frameElID[*d.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
// Rect
|
||||
els = append(els, Element{
|
||||
ID: rectID,
|
||||
Type: "rectangle",
|
||||
X: d.X,
|
||||
Y: d.Y,
|
||||
Width: d.Width,
|
||||
Height: d.Height,
|
||||
StrokeColor: d.Color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 3},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
|
||||
})
|
||||
// Bound text — name centered on the rect.
|
||||
els = append(els, Element{
|
||||
ID: textID,
|
||||
Type: "text",
|
||||
X: d.X,
|
||||
Y: d.Y + d.Height/2 - 8,
|
||||
Width: d.Width,
|
||||
Height: 16,
|
||||
StrokeColor: d.Color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Text: d.Name,
|
||||
OriginalText: d.Name,
|
||||
FontSize: 16,
|
||||
FontFamily: 1,
|
||||
TextAlign: "center",
|
||||
VerticalAlign: "middle",
|
||||
ContainerID: &rectID,
|
||||
LineHeight: 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
// Ports — small ellipses at device.x + port.x_offset (positional,
|
||||
// not containerId-bound per the seed drawing's grammar; design §4.1).
|
||||
for _, p := range snap.Ports {
|
||||
elID := idFor(p.ExcalidrawID)
|
||||
a.Ports[p.ID] = elID
|
||||
portElID[p.ID] = elID
|
||||
// Locate the parent device for absolute pos + frame ref.
|
||||
var dev *db.Device
|
||||
for i := range snap.Devices {
|
||||
if snap.Devices[i].ID == p.DeviceID {
|
||||
dev = &snap.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if dev == nil {
|
||||
continue
|
||||
}
|
||||
var frameRef *string
|
||||
if dev.FrameID != nil {
|
||||
if v, ok := frameElID[*dev.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
color := cableTypeColor[p.TypeID]
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "ellipse",
|
||||
X: dev.X + p.XOffset - 6,
|
||||
Y: dev.Y + p.YOffset - 4,
|
||||
Width: 12,
|
||||
Height: 9,
|
||||
StrokeColor: color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 2},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
})
|
||||
}
|
||||
|
||||
// IO markers — diamonds with bound "IO" (or m's label) text.
|
||||
powerColor := ""
|
||||
for _, t := range snap.CableTypes {
|
||||
if t.Name == "Power" {
|
||||
powerColor = t.Color
|
||||
break
|
||||
}
|
||||
}
|
||||
if powerColor == "" {
|
||||
powerColor = "#e03131"
|
||||
}
|
||||
for _, m := range snap.IOMarkers {
|
||||
elID := idFor(m.ExcalidrawID)
|
||||
a.IOMarkers[m.ID] = elID
|
||||
ioElID[m.ID] = elID
|
||||
textID := genID()
|
||||
var frameRef *string
|
||||
if m.FrameID != nil {
|
||||
if v, ok := frameElID[*m.FrameID]; ok {
|
||||
frameRef = &v
|
||||
}
|
||||
}
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "diamond",
|
||||
X: m.X,
|
||||
Y: m.Y,
|
||||
Width: 30,
|
||||
Height: 30,
|
||||
StrokeColor: powerColor,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Roundness: &Roundness{Type: 2},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
BoundElements: []BoundRef{{ID: textID, Type: "text"}},
|
||||
})
|
||||
els = append(els, Element{
|
||||
ID: textID,
|
||||
Type: "text",
|
||||
X: m.X,
|
||||
Y: m.Y + 7,
|
||||
Width: 30,
|
||||
Height: 16,
|
||||
StrokeColor: powerColor,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
FrameID: frameRef,
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Text: m.Label,
|
||||
OriginalText: m.Label,
|
||||
FontSize: 11,
|
||||
FontFamily: 1,
|
||||
TextAlign: "center",
|
||||
VerticalAlign: "middle",
|
||||
ContainerID: &elID,
|
||||
LineHeight: 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
// Cables — arrows with startBinding/endBinding to the port / device /
|
||||
// IO marker excalidraw_ids. Endpoint anchors (the visible "from" /
|
||||
// "to" points) come from the same anchor logic the canvas uses.
|
||||
for _, c := range snap.Cables {
|
||||
elID := idFor(c.ExcalidrawID)
|
||||
a.Cables[c.ID] = elID
|
||||
fromAnchor, fromRef := exportAnchor(c.FromPortID, c.FromDeviceID, c.FromIOID,
|
||||
snap, deviceElID, portElID, ioElID)
|
||||
toAnchor, toRef := exportAnchor(c.ToPortID, c.ToDeviceID, c.ToIOID,
|
||||
snap, deviceElID, portElID, ioElID)
|
||||
// fromRef/toRef are nil when the endpoint row vanished (manual
|
||||
// cable referencing a deleted port, say). Skip rather than emit
|
||||
// a half-bound arrow.
|
||||
if fromRef == nil || toRef == nil {
|
||||
continue
|
||||
}
|
||||
color := cableTypeColor[c.TypeID]
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
startArr := ""
|
||||
endArr := "arrow"
|
||||
els = append(els, Element{
|
||||
ID: elID,
|
||||
Type: "arrow",
|
||||
X: fromAnchor[0],
|
||||
Y: fromAnchor[1],
|
||||
Width: toAnchor[0] - fromAnchor[0],
|
||||
Height: toAnchor[1] - fromAnchor[1],
|
||||
StrokeColor: color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 2,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Points: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
|
||||
StartArrowhead: &startArr,
|
||||
EndArrowhead: &endArr,
|
||||
StartBinding: bindingPtr(fromRef),
|
||||
EndBinding: bindingPtr(toRef),
|
||||
})
|
||||
}
|
||||
|
||||
// Legend in the top-left of the first frame (or at 20,20 if there
|
||||
// are no frames). One text row per cable_type, stacked vertically.
|
||||
legendX, legendY := 20.0, 20.0
|
||||
if len(snap.Frames) > 0 {
|
||||
legendX = snap.Frames[0].X + 10
|
||||
legendY = snap.Frames[0].Y + 10
|
||||
}
|
||||
for i, t := range snap.CableTypes {
|
||||
els = append(els, Element{
|
||||
ID: genID(),
|
||||
Type: "text",
|
||||
X: legendX,
|
||||
Y: legendY + float64(i*18),
|
||||
Width: 80,
|
||||
Height: 16,
|
||||
StrokeColor: t.Color,
|
||||
BackgroundColor: "transparent",
|
||||
FillStyle: "solid",
|
||||
StrokeWidth: 1,
|
||||
StrokeStyle: "solid",
|
||||
Roughness: 0,
|
||||
Opacity: 100,
|
||||
GroupIDs: []string{},
|
||||
Seed: randInt(),
|
||||
Version: 1,
|
||||
VersionNonce: randInt(),
|
||||
Updated: nowMilli,
|
||||
Text: t.Name,
|
||||
OriginalText: t.Name,
|
||||
FontSize: 16,
|
||||
FontFamily: 1,
|
||||
TextAlign: "left",
|
||||
VerticalAlign: "top",
|
||||
LineHeight: 1.25,
|
||||
})
|
||||
}
|
||||
|
||||
scene := &Scene{
|
||||
Type: "excalidraw",
|
||||
Version: 2,
|
||||
Source: "mcables",
|
||||
Elements: els,
|
||||
AppState: AppState{
|
||||
GridSize: nil,
|
||||
ViewBackground: "#ffffff",
|
||||
},
|
||||
Files: Files{},
|
||||
}
|
||||
return scene, a
|
||||
}
|
||||
|
||||
func bindingPtr(b *Binding) *Binding {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// exportAnchor returns (x,y) + a Binding for the endpoint kind passed in.
|
||||
func exportAnchor(portID, deviceID, ioID *int64, snap *db.Snapshot,
|
||||
devElID, portElID, ioElID map[int64]string,
|
||||
) ([2]float64, *Binding) {
|
||||
if portID != nil {
|
||||
// Find the port + its parent device.
|
||||
for _, p := range snap.Ports {
|
||||
if p.ID != *portID {
|
||||
continue
|
||||
}
|
||||
for _, d := range snap.Devices {
|
||||
if d.ID == p.DeviceID {
|
||||
id := portElID[p.ID]
|
||||
return [2]float64{d.X + p.XOffset, d.Y + p.YOffset}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if deviceID != nil {
|
||||
for _, d := range snap.Devices {
|
||||
if d.ID != *deviceID {
|
||||
continue
|
||||
}
|
||||
id := devElID[d.ID]
|
||||
return [2]float64{d.X + d.Width/2, d.Y + d.Height/2}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||
}
|
||||
}
|
||||
if ioID != nil {
|
||||
for _, m := range snap.IOMarkers {
|
||||
if m.ID != *ioID {
|
||||
continue
|
||||
}
|
||||
id := ioElID[m.ID]
|
||||
return [2]float64{m.X + 15, m.Y + 15}, &Binding{ElementID: id, Focus: 0, Gap: 1}
|
||||
}
|
||||
}
|
||||
return [2]float64{}, nil
|
||||
}
|
||||
|
||||
// Generate21 mints a 21-char base62 identifier, the shape Excalidraw
|
||||
// uses for element ids (nanoid-style). crypto/rand source.
|
||||
func Generate21() string {
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
buf := make([]byte, 21)
|
||||
max := big.NewInt(int64(len(alphabet)))
|
||||
for i := range buf {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
// crypto/rand failure is unrecoverable in practice; fall back
|
||||
// to a deterministic alphabet position so callers see a panic-
|
||||
// adjacent symptom rather than a half-initialised id.
|
||||
return fmt.Sprintf("crypto-rand-failed-%d", i)
|
||||
}
|
||||
buf[i] = alphabet[n.Int64()]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// randInt returns a non-negative int64 derived from crypto/rand for
|
||||
// Excalidraw's `seed` / `versionNonce`. Excalidraw treats these as
|
||||
// noise — only the IDs and the structural fields matter.
|
||||
func randInt() int64 {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n.Int64()
|
||||
}
|
||||
|
||||
// MarshalScene returns the scene as Excalidraw-flavoured JSON.
|
||||
func MarshalScene(s *Scene) ([]byte, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
165
internal/exporter/exporter_test.go
Normal file
165
internal/exporter/exporter_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
// deterministic id generator for tests
|
||||
func newSeq() func() string {
|
||||
i := 0
|
||||
return func() string {
|
||||
i++
|
||||
return "id" + strings.Repeat("0", 19-len(itoa(i))) + itoa(i)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
buf := [20]byte{}
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func sampleSnapshot() *db.Snapshot {
|
||||
pid := int64(1)
|
||||
devID := int64(10)
|
||||
devID2 := int64(11)
|
||||
portID := int64(100)
|
||||
portID2 := int64(101)
|
||||
ioID := int64(200)
|
||||
|
||||
return &db.Snapshot{
|
||||
Project: db.Project{ID: pid, Name: "LOFT", DrawingName: "LOFT.excalidraw"},
|
||||
Frames: []db.Frame{
|
||||
{ID: 1, ProjectID: pid, Name: "desk", X: 100, Y: 100, Width: 800, Height: 500},
|
||||
},
|
||||
Devices: []db.Device{
|
||||
{ID: devID, ProjectID: pid, Name: "NAS", Color: "#1e1e1e", X: 200, Y: 200, Width: 100, Height: 35, FrameID: ptr(int64(1))},
|
||||
{ID: devID2, ProjectID: pid, Name: "Switch", Color: "#1e1e1e", X: 400, Y: 200, Width: 100, Height: 35},
|
||||
},
|
||||
Ports: []db.Port{
|
||||
{ID: portID, ProjectID: pid, DeviceID: devID, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||
{ID: portID2, ProjectID: pid, DeviceID: devID2, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||
},
|
||||
IOMarkers: []db.IOMarker{
|
||||
{ID: ioID, ProjectID: pid, Label: "Wall A", X: 50, Y: 50},
|
||||
},
|
||||
Cables: []db.Cable{
|
||||
{ID: 1000, ProjectID: pid, TypeID: 5,
|
||||
FromPortID: &portID, ToPortID: &portID2, Auto: false},
|
||||
},
|
||||
CableTypes: []db.CableType{
|
||||
{ID: 1, Name: "Power", Color: "#e03131"},
|
||||
{ID: 2, Name: "USB", Color: "#2f9e44"},
|
||||
{ID: 3, Name: "HDMI", Color: "#1971c2"},
|
||||
{ID: 4, Name: "DP", Color: "#9c36b5"},
|
||||
{ID: 5, Name: "RJ45", Color: "#ffd500"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestBuildScene_BasicShape(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
|
||||
if scene.Type != "excalidraw" || scene.Version != 2 {
|
||||
t.Errorf("bad header: %+v", scene)
|
||||
}
|
||||
// frame(1) + device-rect+text(2 each) + ports(2) + io+text(2) +
|
||||
// cable(1) + legend(5) = 1 + 4 + 2 + 2 + 1 + 5 = 15.
|
||||
if len(scene.Elements) < 15 {
|
||||
t.Errorf("element count = %d, want ≥15", len(scene.Elements))
|
||||
}
|
||||
if len(ids.Frames) != 1 || len(ids.Devices) != 2 || len(ids.Ports) != 2 ||
|
||||
len(ids.IOMarkers) != 1 || len(ids.Cables) != 1 {
|
||||
t.Errorf("id assignment shape wrong: %+v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ReusesExistingExcalidrawIDs(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
// Pre-assign an excalidraw_id on the first device.
|
||||
preset := "preset0000000000000NAS"[:21]
|
||||
snap.Devices[0].ExcalidrawID = &preset
|
||||
_, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
if ids.Devices[snap.Devices[0].ID] != preset {
|
||||
t.Errorf("preset id not reused: got %q, want %q", ids.Devices[snap.Devices[0].ID], preset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_ArrowsBindToPorts(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||
// The arrow's startBinding should reference the from-port's element id.
|
||||
fromPortElID := ids.Ports[100]
|
||||
toPortElID := ids.Ports[101]
|
||||
var found *Element
|
||||
for i := range scene.Elements {
|
||||
if scene.Elements[i].Type == "arrow" {
|
||||
found = &scene.Elements[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("no arrow in scene")
|
||||
}
|
||||
if found.StartBinding == nil || found.StartBinding.ElementID != fromPortElID {
|
||||
t.Errorf("start binding wrong: %+v", found.StartBinding)
|
||||
}
|
||||
if found.EndBinding == nil || found.EndBinding.ElementID != toPortElID {
|
||||
t.Errorf("end binding wrong: %+v", found.EndBinding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScene_BundlesIgnored(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
// Snapshot.Bundles is unused in the exporter for v0 per design §4.1.
|
||||
// Add some and confirm no bundle elements appear in the scene.
|
||||
snap.Bundles = []db.Bundle{{ID: 1, Name: "trunk", CableIDs: []int64{1000}}}
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
for _, e := range scene.Elements {
|
||||
if strings.Contains(e.Type, "bundle") {
|
||||
t.Errorf("bundle element leaked into scene: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalScene_IsJSON(t *testing.T) {
|
||||
snap := sampleSnapshot()
|
||||
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||
b, err := MarshalScene(scene)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var roundtrip map[string]any
|
||||
if err := json.Unmarshal(b, &roundtrip); err != nil {
|
||||
t.Fatalf("roundtrip: %v", err)
|
||||
}
|
||||
if roundtrip["type"] != "excalidraw" {
|
||||
t.Errorf("type field = %v, want excalidraw", roundtrip["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate21(t *testing.T) {
|
||||
a := Generate21()
|
||||
b := Generate21()
|
||||
if len(a) != 21 || len(b) != 21 {
|
||||
t.Errorf("len wrong: %d / %d", len(a), len(b))
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("ids collide: %q == %q", a, b)
|
||||
}
|
||||
}
|
||||
225
internal/server/cables.go
Normal file
225
internal/server/cables.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
type cableEndpointBody struct {
|
||||
PortID *int64 `json:"port_id,omitempty"`
|
||||
DeviceID *int64 `json:"device_id,omitempty"`
|
||||
IOID *int64 `json:"io_id,omitempty"`
|
||||
}
|
||||
|
||||
type cableCreate struct {
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
From cableEndpointBody `json:"from"`
|
||||
To cableEndpointBody `json:"to"`
|
||||
Auto bool `json:"auto,omitempty"`
|
||||
}
|
||||
|
||||
type cablePatch struct {
|
||||
TypeID *int64 `json:"type_id,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
From *cableEndpointBody `json:"from,omitempty"`
|
||||
To *cableEndpointBody `json:"to,omitempty"`
|
||||
Auto *bool `json:"auto,omitempty"`
|
||||
// Promote=true asks the server to set auto=false when an auto cable
|
||||
// is being PATCHed (slice 6 §5b.3 — explicit promote-to-manual).
|
||||
Promote bool `json:"promote,omitempty"`
|
||||
}
|
||||
|
||||
func toCableEndpoint(b cableEndpointBody) db.CableEndpoint {
|
||||
return db.CableEndpoint{PortID: b.PortID, DeviceID: b.DeviceID, IOID: b.IOID}
|
||||
}
|
||||
|
||||
func (h *handlers) listCables(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
cs, err := h.store.ListCables(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
}
|
||||
|
||||
func (h *handlers) createCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cableCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
c, err := h.store.CreateCable(pid, db.CableCreate{
|
||||
TypeID: body.TypeID, Label: body.Label,
|
||||
From: toCableEndpoint(body.From), To: toCableEndpoint(body.To),
|
||||
Auto: body.Auto,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (h *handlers) patchCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body cablePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
u := db.CableUpdate{
|
||||
TypeID: body.TypeID, Label: body.Label, Auto: body.Auto,
|
||||
}
|
||||
if body.From != nil {
|
||||
ep := toCableEndpoint(*body.From)
|
||||
u.From = &ep
|
||||
}
|
||||
if body.To != nil {
|
||||
ep := toCableEndpoint(*body.To)
|
||||
u.To = &ep
|
||||
}
|
||||
// Promote semantics: explicit promote=true OR (PATCH touched
|
||||
// type/from/to AND the current cable is auto) → set auto=false.
|
||||
if body.Promote {
|
||||
f := false
|
||||
u.Auto = &f
|
||||
}
|
||||
c, err := h.store.UpdateCable(pid, id, u)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteCable(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteCable(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- bundles
|
||||
|
||||
type bundleCreate struct {
|
||||
Name string `json:"name"`
|
||||
CableIDs []int64 `json:"cable_ids"`
|
||||
}
|
||||
|
||||
type bundlePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
CableIDs *[]int64 `json:"cable_ids,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listBundles(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
bs, err := h.store.ListBundles(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bs)
|
||||
}
|
||||
|
||||
func (h *handlers) createBundle(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body bundleCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
b, err := h.store.CreateBundle(pid, db.BundleCreate{
|
||||
Name: body.Name, CableIDs: body.CableIDs, Auto: false,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, b)
|
||||
}
|
||||
|
||||
func (h *handlers) patchBundle(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body bundlePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
b, err := h.store.UpdateBundle(pid, id, db.BundleUpdate{
|
||||
Name: body.Name, CableIDs: body.CableIDs,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
func (h *handlers) deleteBundle(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteBundle(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
122
internal/server/export.go
Normal file
122
internal/server/export.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/mcables/internal/exporter"
|
||||
)
|
||||
|
||||
// syncExport runs the project's snapshot through the exporter, persists
|
||||
// the assigned excalidraw_ids, then PUTs the scene to mxdrw.msbls.de.
|
||||
func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
base := os.Getenv("MEXDRAW_BASE_URL")
|
||||
if base == "" {
|
||||
base = "https://mxdrw.msbls.de"
|
||||
}
|
||||
user := os.Getenv("MEXDRAW_USER")
|
||||
pass := os.Getenv("MEXDRAW_PASS")
|
||||
if user == "" || pass == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody{
|
||||
Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
|
||||
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
snap, err := h.store.Snapshot(pid)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
scene, ids := exporter.BuildScene(snap, now, exporter.Generate21)
|
||||
|
||||
// Persist the freshly-assigned ids so the next export reuses them.
|
||||
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
|
||||
// only updates rows whose excalidraw_id is still NULL).
|
||||
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
|
||||
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := exporter.MarshalScene(scene)
|
||||
if err != nil {
|
||||
writeError(w, fmt.Errorf("marshal scene: %w", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
drawingName := snap.Project.DrawingName
|
||||
if !strings.HasSuffix(drawingName, ".excalidraw") {
|
||||
drawingName += ".excalidraw"
|
||||
}
|
||||
url := strings.TrimSuffix(base, "/") + "/api/drawings/" + drawingName
|
||||
|
||||
// Sane network timeout; mxdrw is on the LAN so this should be quick.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
writeError(w, fmt.Errorf("build PUT: %w", err), nil)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth(user, pass)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||
Error: "mxdrw unreachable",
|
||||
Details: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||
Error: fmt.Sprintf("mxdrw rejected upload (%d)", resp.StatusCode),
|
||||
Details: map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(body),
|
||||
"url": url,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort parse — mxdrw returns whatever it returns; we surface
|
||||
// the public viewer URL no matter what.
|
||||
var serverEcho any
|
||||
_ = json.Unmarshal(body, &serverEcho)
|
||||
|
||||
viewerURL := strings.TrimSuffix(base, "/") + "/draw/" + strings.TrimSuffix(drawingName, ".excalidraw") + ".excalidraw"
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"drawing_name": drawingName,
|
||||
"url": viewerURL,
|
||||
"element_count": len(scene.Elements),
|
||||
"mxdrw_response": serverEcho,
|
||||
})
|
||||
}
|
||||
|
||||
// noLeak prevents unused-import errors if errors-pkg ever becomes unused
|
||||
// after a refactor — keeps the import light.
|
||||
var _ = errors.New
|
||||
114
internal/server/ports.go
Normal file
114
internal/server/ports.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
type portCreate struct {
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
XOffset float64 `json:"x_offset"`
|
||||
YOffset float64 `json:"y_offset"`
|
||||
}
|
||||
|
||||
type portPatch struct {
|
||||
TypeID *int64 `json:"type_id,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
XOffset *float64 `json:"x_offset,omitempty"`
|
||||
YOffset *float64 `json:"y_offset,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) listPortsForDevice(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
ps, err := h.store.ListPortsForDevice(pid, id)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ps)
|
||||
}
|
||||
|
||||
func (h *handlers) createPort(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body portCreate
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
p, err := h.store.CreatePort(pid, id, db.PortCreate{
|
||||
TypeID: body.TypeID, Label: body.Label,
|
||||
XOffset: body.XOffset, YOffset: body.YOffset,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (h *handlers) patchPort(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body portPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
p, err := h.store.UpdatePort(pid, id, db.PortUpdate{
|
||||
TypeID: body.TypeID, Label: body.Label,
|
||||
XOffset: body.XOffset, YOffset: body.YOffset,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *handlers) deletePort(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeletePort(pid, id); err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -51,6 +51,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
|
||||
|
||||
// Ports — slice 7 lets m add/edit/remove instance ports on a device.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/devices/{id}/ports", h.listPortsForDevice)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports", h.createPort)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/ports/{id}", h.patchPort)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/ports/{id}", h.deletePort)
|
||||
|
||||
// Device-type catalog. Built-ins are read-only; project-custom rows
|
||||
// support full CRUD scoped to the project.
|
||||
mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes)
|
||||
@@ -65,6 +71,28 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/connection-requirements/{id}", h.patchConnectionRequirement)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/connection-requirements/{id}", h.deleteConnectionRequirement)
|
||||
|
||||
// Cables — slice 6: solver writes here with auto=1; slice 7 lets m
|
||||
// hand-draw with auto=0. PATCH supports `promote: true` to flip auto→0.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/cables", h.listCables)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/cables", h.createCable)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/cables/{id}", h.patchCable)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{id}", h.deleteCable)
|
||||
|
||||
// Bundles — manual + auto.
|
||||
mux.HandleFunc("GET /api/projects/{pid}/bundles", h.listBundles)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/bundles", h.createBundle)
|
||||
mux.HandleFunc("PATCH /api/projects/{pid}/bundles/{id}", h.patchBundle)
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/bundles/{id}", h.deleteBundle)
|
||||
|
||||
// Solver + quick-fix combo + setup templates.
|
||||
mux.HandleFunc("POST /api/projects/{pid}/solve", h.solve)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports-and-resolve", h.portsAndResolve)
|
||||
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
|
||||
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate)
|
||||
|
||||
// Slice 8 — export to mxdrw.msbls.de
|
||||
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
|
||||
|
||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||
// the file server already emits — without this, browsers cache aggressively
|
||||
|
||||
149
internal/server/solver.go
Normal file
149
internal/server/solver.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
)
|
||||
|
||||
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
preview := r.URL.Query().Get("preview") == "1"
|
||||
res, err := h.store.Solve(pid, preview)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ports-and-resolve combo: POST a new port to a device + re-run solve in
|
||||
// the same request. Used by the inspector quick-fix.
|
||||
type portsAndResolveBody struct {
|
||||
TypeID int64 `json:"type_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
XOffset float64 `json:"x_offset,omitempty"`
|
||||
YOffset float64 `json:"y_offset,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) portsAndResolve(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
id, ok := parseInt64Path(r, "id")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body portsAndResolveBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
res, err := h.store.PortsAndResolve(pid, id, body.TypeID, body.Label, body.XOffset, body.YOffset)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- setup templates
|
||||
|
||||
func (h *handlers) listSetupTemplates(w http.ResponseWriter, _ *http.Request) {
|
||||
ts, err := h.store.ListSetupTemplates()
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ts)
|
||||
}
|
||||
|
||||
type applyTemplateBody struct {
|
||||
TemplateID int64 `json:"template_id"`
|
||||
NameOverrides map[string]string `json:"name_overrides,omitempty"`
|
||||
SkipDevices []int64 `json:"skip_devices,omitempty"`
|
||||
OriginX float64 `json:"origin_x,omitempty"`
|
||||
OriginY float64 `json:"origin_y,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handlers) applyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pid, ok := parseInt64Path(r, "pid")
|
||||
if !ok {
|
||||
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||
return
|
||||
}
|
||||
var body applyTemplateBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
|
||||
return
|
||||
}
|
||||
opts := db.ApplyTemplateOptions{
|
||||
NameOverrides: map[int64]string{},
|
||||
SkipDevices: map[int64]bool{},
|
||||
OriginX: body.OriginX,
|
||||
OriginY: body.OriginY,
|
||||
}
|
||||
// JSON keys are strings; parse to int64.
|
||||
for k, v := range body.NameOverrides {
|
||||
var tid int64
|
||||
_, _ = fmtSscan(k, &tid)
|
||||
if tid > 0 {
|
||||
opts.NameOverrides[tid] = v
|
||||
}
|
||||
}
|
||||
for _, tid := range body.SkipDevices {
|
||||
opts.SkipDevices[tid] = true
|
||||
}
|
||||
res, err := h.store.ApplyTemplate(pid, body.TemplateID, opts)
|
||||
if err != nil {
|
||||
writeError(w, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-solve by default. ?solve=0 opts out for power users who want
|
||||
// to inspect the seeded devices/requirements before the solver runs.
|
||||
// This is THE fix for the v6 UX hole: m hit Apply, saw an empty
|
||||
// canvas because nothing reloaded *and* nothing solved. With the
|
||||
// frontend re-snapshotting after the POST returns and the response
|
||||
// already carrying solver output, m sees the wired diagram in one click.
|
||||
skipSolve := r.URL.Query().Get("solve") == "0"
|
||||
combined := map[string]any{"template_apply": res}
|
||||
if !skipSolve {
|
||||
solveRes, err := h.store.Solve(pid, false)
|
||||
if err != nil {
|
||||
// Apply succeeded but Solve failed — don't 500 the whole
|
||||
// call. Return template_apply with the solve error inline so
|
||||
// the UI can recover (devices are there; m can re-solve).
|
||||
combined["solve_error"] = err.Error()
|
||||
} else {
|
||||
combined["solve"] = solveRes
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, combined)
|
||||
}
|
||||
|
||||
// fmtSscan parses a base-10 int from a string, returning (n, nil) on success.
|
||||
// Inline so handlers don't pull in strconv just for one call site.
|
||||
func fmtSscan(s string, out *int64) (int, error) {
|
||||
var v int64
|
||||
read := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
v = v*10 + int64(c-'0')
|
||||
read++
|
||||
}
|
||||
*out = v
|
||||
return read, nil
|
||||
}
|
||||
@@ -20,9 +20,10 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
|
||||
Export
|
||||
</button>
|
||||
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
|
||||
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
|
||||
<button type="button" id="btn-export" class="btn">Export</button>
|
||||
<span id="toast" class="toast" hidden></span>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
@@ -44,7 +45,7 @@
|
||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
|
||||
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -175,6 +176,35 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Solve preview-diff (slice 6) -->
|
||||
<dialog id="modal-solve" class="modal modal-wide" aria-labelledby="sv-title">
|
||||
<div style="padding: 16px;">
|
||||
<h2 id="sv-title">Solve preview</h2>
|
||||
<div id="sv-body" class="sv-body"></div>
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-primary" id="sv-apply">Apply</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Apply template (slice 6) -->
|
||||
<dialog id="modal-template" class="modal modal-wide" aria-labelledby="tp-title">
|
||||
<form method="dialog" id="form-template">
|
||||
<h2 id="tp-title">Apply setup template</h2>
|
||||
<label class="field">
|
||||
<span>Template</span>
|
||||
<select id="tp-select" required></select>
|
||||
</label>
|
||||
<div id="tp-preview" class="tp-preview"></div>
|
||||
<p class="form-error" id="tp-error" hidden></p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Apply</button>
|
||||
<button type="button" class="btn" data-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Project confirm -->
|
||||
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
|
||||
<form method="dialog" id="form-delete-project">
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
* @typedef {{ id: number, project_id: number, from_device_id: number,
|
||||
* to_device_id: number, preferred_cable_type_id: number|null,
|
||||
* must_connect: boolean, notes: string }} ConnectionRequirement
|
||||
* @typedef {{ id: number, project_id: number, type_id: number,
|
||||
* label: string|null, auto: boolean,
|
||||
* from_port_id: number|null, from_device_id: number|null, from_io_id: number|null,
|
||||
* to_port_id: number|null, to_device_id: number|null, to_io_id: number|null }} Cable
|
||||
* @typedef {{ id: number, project_id: number, name: string, auto: boolean,
|
||||
* cable_ids: number[] }} Bundle
|
||||
* @typedef {{ id: number, name: string, description: string, built_in: boolean,
|
||||
* devices: any[], requirements: any[] }} SetupTemplate
|
||||
*/
|
||||
|
||||
const API = "/api";
|
||||
@@ -44,10 +52,18 @@ const state = {
|
||||
/** @type {Port[]} */ ports: [],
|
||||
/** @type {IOMarker[]} */ ioMarkers: [],
|
||||
/** @type {ConnectionRequirement[]} */ requirements: [],
|
||||
/** @type {Cable[]} */ cables: [],
|
||||
/** @type {Bundle[]} */ bundles: [],
|
||||
/** @type {SetupTemplate[]} */ setupTemplates: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | "io" | "req" | null */
|
||||
/** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
|
||||
tool: /** @type {string|null} */ (null),
|
||||
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement", id: number} | null} */ selection: null,
|
||||
/** Slice-7 transient state for the +Port tool. */
|
||||
portToolDevice: /** @type {number|null} */ (null),
|
||||
portToolTypeID: /** @type {number|null} */ (null),
|
||||
/** Slice-7: when the user clicked a source port, this is its id. */
|
||||
cableDrawFromPortID: /** @type {number|null} */ (null),
|
||||
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | null} */ selection: null,
|
||||
};
|
||||
|
||||
// ---------- API client ---------- //
|
||||
@@ -93,12 +109,27 @@ const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers
|
||||
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
|
||||
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
|
||||
|
||||
const createPort = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports`, body);
|
||||
const patchPort = (pid, id, body) => api("PATCH", `/projects/${pid}/ports/${id}`, body);
|
||||
const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
|
||||
|
||||
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
|
||||
|
||||
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
|
||||
|
||||
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
|
||||
const patchRequirement = (pid, id, body) => api("PATCH", `/projects/${pid}/connection-requirements/${id}`, body);
|
||||
const deleteRequirement = (pid, id) => api("DELETE", `/projects/${pid}/connection-requirements/${id}`);
|
||||
|
||||
const patchCable = (pid, id, body) => api("PATCH", `/projects/${pid}/cables/${id}`, body);
|
||||
const deleteCable = (pid, id) => api("DELETE", `/projects/${pid}/cables/${id}`);
|
||||
|
||||
const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${preview ? "?preview=1" : ""}`, {});
|
||||
const portsAndResolve = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports-and-resolve`, body);
|
||||
const listSetupTemplates = () => api("GET", `/setup-templates`);
|
||||
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
|
||||
const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -225,9 +256,11 @@ function renderEmptyHint() {
|
||||
function renderCanvas() {
|
||||
const gFrames = $("#canvas-frames");
|
||||
const gDevices = $("#canvas-devices");
|
||||
const gCables = $("#canvas-cables");
|
||||
const gIO = $("#canvas-io");
|
||||
gFrames.innerHTML = "";
|
||||
gDevices.innerHTML = "";
|
||||
gCables.innerHTML = "";
|
||||
gIO.innerHTML = "";
|
||||
|
||||
for (const f of state.frames) {
|
||||
@@ -277,18 +310,27 @@ function renderCanvas() {
|
||||
g.append(rect, label);
|
||||
|
||||
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
|
||||
// Stroke colour = the cable_type colour the port carries; fill stays white
|
||||
// so the port reads against any device colour.
|
||||
// Both fill and stroke = cable_type colour so the port is obviously coloured
|
||||
// against the device rect.
|
||||
const ports = portsByDevice.get(d.id) || [];
|
||||
for (const prt of ports) {
|
||||
const cx = d.x + prt.x_offset;
|
||||
const cy = d.y + prt.y_offset;
|
||||
const color = cableTypeColor.get(prt.type_id) || "#888";
|
||||
const isCableFrom = state.cableDrawFromPortID === prt.id;
|
||||
const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id;
|
||||
const cls = "port-circle"
|
||||
+ (isCableFrom ? " cable-from" : "")
|
||||
+ (isSelected ? " selected" : "");
|
||||
const c = svgEl("circle", {
|
||||
cx, cy, r: 5,
|
||||
class: "port-circle",
|
||||
class: cls,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
"data-port-id": prt.id,
|
||||
});
|
||||
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
|
||||
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
|
||||
g.append(c);
|
||||
}
|
||||
|
||||
@@ -318,8 +360,67 @@ function renderCanvas() {
|
||||
label.textContent = m.label;
|
||||
g.append(rect, label);
|
||||
gIO.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
|
||||
rect.addEventListener("pointerdown", (e) => {
|
||||
// Slice 7: if a cable draw is in progress, terminate the cable on
|
||||
// this IO marker instead of starting a drag.
|
||||
if (state.cableDrawFromPortID != null) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
finishCableDrawAtIO(m);
|
||||
return;
|
||||
}
|
||||
startDrag(e, "io", m.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Cables — straight lines between resolved endpoint anchors.
|
||||
// Auto-cables render with dashed stroke so m sees which the solver
|
||||
// placed; manual cables are solid.
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
for (const c of state.cables) {
|
||||
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
||||
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
||||
if (!fromAnchor || !toAnchor) continue;
|
||||
const color = cableTypeColor.get(c.type_id) || "#888";
|
||||
const line = svgEl("line", {
|
||||
x1: fromAnchor.x, y1: fromAnchor.y,
|
||||
x2: toAnchor.x, y2: toAnchor.y,
|
||||
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
|
||||
stroke: color,
|
||||
"data-cable-id": c.id,
|
||||
});
|
||||
line.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
});
|
||||
gCables.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a cable endpoint to {x, y} on the canvas. Returns null when
|
||||
* the referenced row has gone missing (rare, but possible mid-edit). */
|
||||
function anchorForEndpoint(portID, deviceID, ioID, portByID, deviceByID, ioByID) {
|
||||
if (portID != null) {
|
||||
const p = portByID.get(portID);
|
||||
if (!p) return null;
|
||||
const d = deviceByID.get(p.device_id);
|
||||
if (!d) return null;
|
||||
return { x: d.x + p.x_offset, y: d.y + p.y_offset };
|
||||
}
|
||||
if (deviceID != null) {
|
||||
const d = deviceByID.get(deviceID);
|
||||
if (!d) return null;
|
||||
return { x: d.x + d.width / 2, y: d.y + d.height / 2 };
|
||||
}
|
||||
if (ioID != null) {
|
||||
const m = ioByID.get(ioID);
|
||||
if (!m) return null;
|
||||
return { x: m.x + IO_SIZE / 2, y: m.y + IO_SIZE / 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
@@ -334,10 +435,114 @@ function renderInspector() {
|
||||
case "io": return renderInspectorIO(body, state.selection.id);
|
||||
case "cable_type": return renderInspectorCableType(body, state.selection.id);
|
||||
case "requirement": return renderInspectorRequirement(body, state.selection.id);
|
||||
case "cable": return renderInspectorCable(body, state.selection.id);
|
||||
case "port": return renderInspectorPort(body, state.selection.id);
|
||||
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspectorCable(body, id) {
|
||||
const c = state.cables.find((x) => x.id === id);
|
||||
if (!c) { body.innerHTML = ""; return; }
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
const ct = state.cableTypes.find((t) => t.id === c.type_id);
|
||||
function endpointLabel(portID, deviceID, ioID) {
|
||||
if (portID != null) {
|
||||
const p = portByID.get(portID);
|
||||
if (!p) return "(missing port)";
|
||||
const d = deviceByID.get(p.device_id);
|
||||
return `${d?.name ?? "?"} · ${p.label ?? "port"}`;
|
||||
}
|
||||
if (deviceID != null) {
|
||||
const d = deviceByID.get(deviceID);
|
||||
return d?.name ?? "(missing device)";
|
||||
}
|
||||
if (ioID != null) {
|
||||
const m = ioByID.get(ioID);
|
||||
return m?.label ?? "(missing IO)";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
const fromLabel = endpointLabel(c.from_port_id, c.from_device_id, c.from_io_id);
|
||||
const toLabel = endpointLabel(c.to_port_id, c.to_device_id, c.to_io_id);
|
||||
|
||||
// Find the driving requirement (auto cable only) — match by
|
||||
// unordered device pair + (cable type or null).
|
||||
let drivingReq = null;
|
||||
if (c.auto) {
|
||||
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
|
||||
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
|
||||
if (fromDev != null && toDev != null) {
|
||||
drivingReq = state.requirements.find((r) => {
|
||||
const same = (r.from_device_id === fromDev && r.to_device_id === toDev)
|
||||
|| (r.from_device_id === toDev && r.to_device_id === fromDev);
|
||||
if (!same) return false;
|
||||
if (r.preferred_cable_type_id == null) return true; // solver-picked match
|
||||
return r.preferred_cable_type_id === c.type_id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Cable ${c.auto ? "(solver)" : "(manual)"}</p>
|
||||
<dl>
|
||||
<dt>type</dt><dd id="cab-type"></dd>
|
||||
<dt>from</dt><dd id="cab-from"></dd>
|
||||
<dt>to</dt><dd id="cab-to"></dd>
|
||||
<dt>driver</dt><dd id="cab-driver"></dd>
|
||||
</dl>
|
||||
<div class="inspector-actions">
|
||||
${c.auto ? `<button type="button" class="btn btn-tiny" id="cab-promote">Promote to manual</button>` : ""}
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="cab-delete">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#cab-type").textContent = ct ? `${ct.name}` : `type #${c.type_id}`;
|
||||
body.querySelector("#cab-from").textContent = fromLabel;
|
||||
body.querySelector("#cab-to").textContent = toLabel;
|
||||
const driverCell = body.querySelector("#cab-driver");
|
||||
if (drivingReq) {
|
||||
const deviceByID2 = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const an = deviceByID2.get(drivingReq.from_device_id)?.name ?? "?";
|
||||
const bn = deviceByID2.get(drivingReq.to_device_id)?.name ?? "?";
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "btn-link";
|
||||
link.style.padding = "0";
|
||||
link.textContent = `${an} ↔ ${bn}`;
|
||||
link.title = "Jump to this requirement";
|
||||
link.addEventListener("click", () => {
|
||||
state.selection = { kind: "requirement", id: drivingReq.id };
|
||||
render();
|
||||
});
|
||||
driverCell.append(link);
|
||||
} else {
|
||||
driverCell.textContent = c.auto ? "(no matching requirement)" : "—";
|
||||
}
|
||||
|
||||
if (c.auto) {
|
||||
body.querySelector("#cab-promote").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
try {
|
||||
const updated = await patchCable(state.active.id, c.id, { promote: true });
|
||||
Object.assign(c, updated);
|
||||
render();
|
||||
} catch (e) { alert(`Promote failed: ${e.message}`); }
|
||||
});
|
||||
}
|
||||
body.querySelector("#cab-delete").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm("Delete this cable?")) return;
|
||||
try {
|
||||
await deleteCable(state.active.id, c.id);
|
||||
state.cables = state.cables.filter((x) => x.id !== c.id);
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (e) { alert(`Delete failed: ${e.message}`); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorFrame(body, id) {
|
||||
const f = state.frames.find((x) => x.id === id);
|
||||
if (!f) { body.innerHTML = ""; return; }
|
||||
@@ -400,10 +605,12 @@ function renderInspectorDevice(body, id) {
|
||||
|
||||
const portsHtml = ports.length
|
||||
? ports.map((p) => `
|
||||
<div class="port-row">
|
||||
<div class="port-row" data-port-id="${p.id}">
|
||||
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
|
||||
<span class="label">${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
|
||||
<span class="conn">unconnected</span>
|
||||
<span class="conn">
|
||||
<button type="button" class="btn-link port-del" data-port-id="${p.id}" title="Delete port">×</button>
|
||||
</span>
|
||||
</div>`).join("")
|
||||
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
|
||||
|
||||
@@ -450,6 +657,9 @@ function renderInspectorDevice(body, id) {
|
||||
</dl>
|
||||
<p class="section-title">Ports</p>
|
||||
<div id="dev-ports">${portsHtml}</div>
|
||||
<div class="inspector-actions" style="margin-top: 4px;">
|
||||
<button type="button" class="btn btn-tiny" id="dev-add-port">+ Port</button>
|
||||
</div>
|
||||
<p class="section-title">Requirements</p>
|
||||
<div id="dev-reqs">${reqsHtml}</div>
|
||||
<div class="inspector-actions">
|
||||
@@ -512,6 +722,35 @@ function renderInspectorDevice(body, id) {
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// +Port — arms the port-placement gesture. Active cable type comes
|
||||
// from the legend selection; if none, defaults to the first cable_type.
|
||||
body.querySelector("#dev-add-port").addEventListener("click", () => {
|
||||
if (!state.active) return;
|
||||
const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
|
||||
if (!typeID) { alert("Pick a cable type in the legend first"); return; }
|
||||
armPortTool(d.id, typeID);
|
||||
});
|
||||
|
||||
// Per-port delete.
|
||||
body.querySelectorAll(".port-del").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!state.active) return;
|
||||
const pid = Number(btn.getAttribute("data-port-id"));
|
||||
if (!pid) return;
|
||||
if (!confirm("Delete this port?")) return;
|
||||
try {
|
||||
await deletePort(state.active.id, pid);
|
||||
state.ports = state.ports.filter((p) => p.id !== pid);
|
||||
// Cables that referenced the port get from_port_id/to_port_id
|
||||
// set to NULL by the schema — refresh from snapshot.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables || [];
|
||||
render();
|
||||
} catch (ex) { alert(`Delete failed: ${ex.message}`); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorRequirement(body, id) {
|
||||
@@ -760,6 +999,104 @@ function renderInspectorIO(body, id) {
|
||||
});
|
||||
}
|
||||
|
||||
// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
|
||||
function renderInspectorPort(body, id) {
|
||||
const prt = state.ports.find((p) => p.id === id);
|
||||
if (!prt) { body.innerHTML = ""; return; }
|
||||
const dev = state.devices.find((d) => d.id === prt.device_id);
|
||||
if (!dev) { body.innerHTML = ""; return; }
|
||||
const ct = state.cableTypes.find((t) => t.id === prt.type_id);
|
||||
const ctColor = ct?.color || "#888";
|
||||
const ctName = ct?.name || "?";
|
||||
const currentEdge = edgeOf(dev, prt);
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Port</p>
|
||||
<dl>
|
||||
<dt>device</dt><dd>${dev.name}</dd>
|
||||
<dt>type</dt>
|
||||
<dd><span class="swatch" style="background:${ctColor}"></span>${ctName}</dd>
|
||||
</dl>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input class="inline-input" id="port-label" value="" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Edge</span>
|
||||
<select id="port-edge">
|
||||
<option value="top">Top</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="bottom">Bottom</option>
|
||||
<option value="left">Left</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#port-label").value = prt.label ?? "";
|
||||
body.querySelector("#port-edge").value = currentEdge;
|
||||
|
||||
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
|
||||
if (!state.active) return;
|
||||
const updated = await patchPort(state.active.id, prt.id, { label });
|
||||
Object.assign(prt, updated);
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
body.querySelector("#port-edge").addEventListener("change", async (e) => {
|
||||
if (!state.active) return;
|
||||
const edge = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||
const { xOff, yOff } = edgeCenter(dev, edge);
|
||||
try {
|
||||
const updated = await patchPort(state.active.id, prt.id, {
|
||||
x_offset: xOff, y_offset: yOff,
|
||||
});
|
||||
Object.assign(prt, updated);
|
||||
renderCanvas();
|
||||
} catch (ex) {
|
||||
alert(`Move port failed: ${ex.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
body.querySelector("#port-delete").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm("Delete this port?")) return;
|
||||
try {
|
||||
await deletePort(state.active.id, prt.id);
|
||||
state.ports = state.ports.filter((p) => p.id !== prt.id);
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables || [];
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (ex) {
|
||||
alert(`Delete failed: ${ex.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Which edge does a port currently sit on? Matches the convention in
|
||||
// snapToDeviceEdge: x_offset = 0 → left, = width → right, y_offset = 0
|
||||
// → top, otherwise bottom (the default).
|
||||
function edgeOf(dev, prt) {
|
||||
if (prt.x_offset <= 0) return "left";
|
||||
if (prt.x_offset >= dev.width) return "right";
|
||||
if (prt.y_offset <= 0) return "top";
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
// Centre of the named edge, expressed as (x_offset, y_offset) relative
|
||||
// to the device origin.
|
||||
function edgeCenter(dev, edge) {
|
||||
switch (edge) {
|
||||
case "top": return { xOff: dev.width / 2, yOff: 0 };
|
||||
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
|
||||
case "bottom": return { xOff: dev.width / 2, yOff: dev.height };
|
||||
case "left": return { xOff: 0, yOff: dev.height / 2 };
|
||||
default: return { xOff: dev.width / 2, yOff: dev.height };
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspectorCableType(body, id) {
|
||||
const t = state.cableTypes.find((x) => x.id === id);
|
||||
if (!t) { body.innerHTML = ""; return; }
|
||||
@@ -906,6 +1243,8 @@ async function activateProject(id) {
|
||||
state.ports = [];
|
||||
state.ioMarkers = [];
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
@@ -918,6 +1257,8 @@ async function activateProject(id) {
|
||||
state.devices = snap.devices || [];
|
||||
state.ioMarkers = snap.io_markers || [];
|
||||
state.ports = snap.ports || [];
|
||||
state.cables = snap.cables || [];
|
||||
state.bundles = snap.bundles || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.selection = null;
|
||||
@@ -941,6 +1282,8 @@ async function activateProject(id) {
|
||||
state.ports = [];
|
||||
state.ioMarkers = [];
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -957,9 +1300,25 @@ function armTool(tool) {
|
||||
const wrap = $(".canvas-wrap");
|
||||
wrap.classList.toggle("tool-frame", tool === "frame");
|
||||
wrap.classList.toggle("tool-device", tool === "device");
|
||||
wrap.classList.toggle("tool-port", tool === "port");
|
||||
wrap.classList.toggle("tool-cable", tool === "cable");
|
||||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||||
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
|
||||
}
|
||||
if (tool !== "port") {
|
||||
state.portToolDevice = null;
|
||||
state.portToolTypeID = null;
|
||||
}
|
||||
if (tool !== "cable") {
|
||||
state.cableDrawFromPortID = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Slice 7: device inspector arms +Port for a specific device + type. */
|
||||
function armPortTool(deviceID, typeID) {
|
||||
state.portToolDevice = deviceID;
|
||||
state.portToolTypeID = typeID;
|
||||
armTool("port");
|
||||
}
|
||||
|
||||
function bindTools() {
|
||||
@@ -971,11 +1330,12 @@ function bindTools() {
|
||||
// Avoid stealing keys while user is typing into an input.
|
||||
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
|
||||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
|
||||
else if (e.key === "f" || e.key === "F") armTool("frame");
|
||||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||||
else if (e.key === "i" || e.key === "I") armTool("io");
|
||||
else if (e.key === "r" || e.key === "R") armTool("req");
|
||||
else if (e.key === "s" || e.key === "S") openSolveModal();
|
||||
});
|
||||
|
||||
// Canvas-level pointerdown handles tool activation + selection clearing.
|
||||
@@ -1012,6 +1372,11 @@ function onCanvasPointerDown(e) {
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "port") {
|
||||
e.preventDefault();
|
||||
placePortAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "io") {
|
||||
e.preventDefault();
|
||||
placeIOMarkerAt(p);
|
||||
@@ -1190,6 +1555,156 @@ function openNewDeviceModal(geom) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Snap (x, y) onto the closest edge of `device`. Returns the (x_off,
|
||||
* y_off) relative to the device's top-left + a debug-friendly edge name. */
|
||||
function snapToDeviceEdge(device, x, y) {
|
||||
// Distance from the point to each of the four edges.
|
||||
const dxLeft = Math.abs(x - device.x);
|
||||
const dxRight = Math.abs((device.x + device.width) - x);
|
||||
const dyTop = Math.abs(y - device.y);
|
||||
const dyBottom = Math.abs((device.y + device.height) - y);
|
||||
const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
|
||||
// Clamp the perpendicular coordinate so the port sits *on* the rect.
|
||||
const localX = Math.max(0, Math.min(device.width, x - device.x));
|
||||
const localY = Math.max(0, Math.min(device.height, y - device.y));
|
||||
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
|
||||
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
|
||||
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
|
||||
return { xOff: localX, yOff: device.height, edge: "bottom" };
|
||||
}
|
||||
|
||||
/** Port-click flow:
|
||||
* - A cable draw is in progress (cableDrawFromPortID set):
|
||||
* same port → cancel; another port → finish the cable.
|
||||
* - Otherwise, no tool armed:
|
||||
* select the port (inspector shows edge picker + label + delete).
|
||||
* - Otherwise, any non-cable tool armed:
|
||||
* bubble so the canvas-level tool handler runs (lets +Port place
|
||||
* a new port even when the click lands on an existing one). */
|
||||
function onPortPointerDown(e, port) {
|
||||
if (!state.active) return;
|
||||
|
||||
// Cable-draw flow takes precedence whenever a source is already picked.
|
||||
if (state.cableDrawFromPortID != null) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (state.cableDrawFromPortID === port.id) {
|
||||
state.cableDrawFromPortID = null;
|
||||
armTool(null);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
finishCableDrawAt(port, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// No cable in progress, no tool: select the port → inspector pane.
|
||||
if (!state.tool) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
state.selection = { kind: "port", id: port.id };
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// The cable tool: start a draw from this port.
|
||||
if (state.tool === "cable") {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
state.cableDrawFromPortID = port.id;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other tool (port / frame / device / io / req): let the click
|
||||
// bubble up so the canvas-level branch fires.
|
||||
}
|
||||
|
||||
async function finishCableDrawAt(targetPort, shiftKey) {
|
||||
if (!state.active) return;
|
||||
const fromPortID = state.cableDrawFromPortID;
|
||||
state.cableDrawFromPortID = null;
|
||||
armTool(null);
|
||||
if (fromPortID == null) return;
|
||||
const sourcePort = state.ports.find((p) => p.id === fromPortID);
|
||||
if (!sourcePort) { render(); return; }
|
||||
|
||||
// Body: shift-click on a port = bind to that port's parent device
|
||||
// (whole-device cable) instead of the port. Plain click = port-to-port.
|
||||
const body = {
|
||||
type_id: sourcePort.type_id,
|
||||
auto: false,
|
||||
from: { port_id: fromPortID },
|
||||
to: shiftKey ? { device_id: targetPort.device_id } : { port_id: targetPort.id },
|
||||
};
|
||||
if (!shiftKey && targetPort.type_id !== sourcePort.type_id) {
|
||||
if (!confirm(`Target port is a different cable type. Connect anyway?`)) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const c = await createCableAPI(state.active.id, body);
|
||||
state.cables.push(c);
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
} catch (e) {
|
||||
alert(`Create cable failed: ${e.message}`);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Click on an IO marker while a cable draw is in progress → terminate
|
||||
* the cable on that IO. Plugged into the IO marker's pointerdown
|
||||
* handler in renderCanvas. */
|
||||
async function finishCableDrawAtIO(ioMarker) {
|
||||
if (!state.active) return;
|
||||
const fromPortID = state.cableDrawFromPortID;
|
||||
state.cableDrawFromPortID = null;
|
||||
armTool(null);
|
||||
if (fromPortID == null) return;
|
||||
const sourcePort = state.ports.find((p) => p.id === fromPortID);
|
||||
if (!sourcePort) { render(); return; }
|
||||
const body = {
|
||||
type_id: sourcePort.type_id,
|
||||
auto: false,
|
||||
from: { port_id: fromPortID },
|
||||
to: { io_id: ioMarker.id },
|
||||
};
|
||||
try {
|
||||
const c = await createCableAPI(state.active.id, body);
|
||||
state.cables.push(c);
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
} catch (e) {
|
||||
alert(`Create cable failed: ${e.message}`);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function placePortAt(p) {
|
||||
if (!state.active) return;
|
||||
const did = state.portToolDevice;
|
||||
const tid = state.portToolTypeID;
|
||||
if (did == null || tid == null) { armTool(null); return; }
|
||||
const dev = state.devices.find((d) => d.id === did);
|
||||
if (!dev) { armTool(null); return; }
|
||||
const snap = snapToDeviceEdge(dev, p.x, p.y);
|
||||
try {
|
||||
const port = await createPort(state.active.id, did, {
|
||||
type_id: tid,
|
||||
x_offset: snap.xOff,
|
||||
y_offset: snap.yOff,
|
||||
});
|
||||
state.ports.push(port);
|
||||
armTool(null);
|
||||
render();
|
||||
} catch (e) {
|
||||
alert(`Add port failed: ${e.message}`);
|
||||
armTool(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeIOMarkerAt(p) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
@@ -1509,6 +2024,253 @@ function openDeleteProjectModal() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- solve flow ---------- //
|
||||
|
||||
function openSolveModal() {
|
||||
if (!state.active) { alert("Pick a project first"); return; }
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-solve"));
|
||||
const body = $("#sv-body");
|
||||
body.innerHTML = `<p class="muted">Computing…</p>`;
|
||||
dlg.showModal();
|
||||
solveProject(state.active.id, true)
|
||||
.then((preview) => renderSolvePreview(body, preview))
|
||||
.catch((e) => { body.innerHTML = `<p class="form-error">${escapeHtml(e.message)}</p>`; });
|
||||
|
||||
$("#sv-apply").onclick = async () => {
|
||||
if (!state.active) return;
|
||||
try {
|
||||
const applied = await solveProject(state.active.id, false);
|
||||
// Refresh from snapshot to pick up new cable ids + bundle assignments.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables || [];
|
||||
state.bundles = snap.bundles || [];
|
||||
state.ports = snap.ports || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
dlg.close();
|
||||
render();
|
||||
// Surface a brief summary as an alert (slice 9+ can replace with a toast).
|
||||
const adds = applied.cables_added?.length ?? 0;
|
||||
const rem = applied.cables_removed?.length ?? 0;
|
||||
const bun = applied.bundles_added?.length ?? 0;
|
||||
const un = applied.unsatisfied?.length ?? 0;
|
||||
const lines = [`Solve applied: +${adds} cables / -${rem} cables / +${bun} bundles`];
|
||||
if (un > 0) lines.push(`${un} requirement${un === 1 ? "" : "s"} unsatisfied`);
|
||||
console.log(lines.join("\n"));
|
||||
} catch (e) {
|
||||
alert(`Apply failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderSolvePreview(body, preview) {
|
||||
const reqByID = new Map(state.requirements.map((r) => [r.id, r]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const cableTypeName = new Map(state.cableTypes.map((t) => [t.id, t.name]));
|
||||
|
||||
const addsHtml = (preview.cables_added || []).map((c) => {
|
||||
const fromDev = c.from_port_id != null ? portByID.get(c.from_port_id)?.device_id : c.from_device_id;
|
||||
const toDev = c.to_port_id != null ? portByID.get(c.to_port_id)?.device_id : c.to_device_id;
|
||||
const a = deviceByID.get(fromDev)?.name ?? "?";
|
||||
const b = deviceByID.get(toDev)?.name ?? "?";
|
||||
return `<li class="added">+ ${escapeHtml(a)} ↔ ${escapeHtml(b)} · ${escapeHtml(cableTypeName.get(c.type_id) ?? "?")}</li>`;
|
||||
}).join("");
|
||||
const remsHtml = (preview.cables_removed || []).map((id) => `<li class="removed">cable #${id}</li>`).join("");
|
||||
const bunsHtml = (preview.bundles_added || []).map((b) => `<li class="added">bundle: ${escapeHtml(b.name)}</li>`).join("");
|
||||
|
||||
const unmetsHtml = (preview.unsatisfied || []).map((u) => {
|
||||
const r = reqByID.get(u.requirement_id);
|
||||
const a = r ? deviceByID.get(r.from_device_id)?.name : "?";
|
||||
const b = r ? deviceByID.get(r.to_device_id)?.name : "?";
|
||||
const reqDesc = `${escapeHtml(a ?? "?")} ↔ ${escapeHtml(b ?? "?")}`;
|
||||
let action = "";
|
||||
// Quick-fix per design v4.1 §5b.4.
|
||||
if ((u.reason || "").startsWith("no free") && u.cable_type && u.which_side) {
|
||||
const side = u.which_side === "from" ? r.from_device_id : r.to_device_id;
|
||||
const sideName = deviceByID.get(side)?.name ?? "?";
|
||||
action = `<span class="quickfix" data-fix="addport" data-device="${side}" data-cable-type="${escapeHtml(u.cable_type)}">+ Add ${escapeHtml(u.cable_type)} port to ${escapeHtml(sideName)} and re-solve</span>`;
|
||||
} else if ((u.reason || "").startsWith("ambiguous") && r) {
|
||||
action = `<span class="quickfix" data-fix="picktype" data-req="${r.id}">Specify cable type…</span>`;
|
||||
} else if ((u.reason || "").startsWith("no compat") && r && r.preferred_cable_type_id != null) {
|
||||
// No common port type for the preferred — offer to add a port on either device.
|
||||
const sideName = deviceByID.get(r.from_device_id)?.name ?? "?";
|
||||
action = `<span class="quickfix" data-fix="addport" data-device="${r.from_device_id}" data-cable-type-id="${r.preferred_cable_type_id}">+ Add port to ${escapeHtml(sideName)} and re-solve</span>`;
|
||||
}
|
||||
return `<li class="unmet">⚠️ ${reqDesc} · ${escapeHtml(u.reason)}${action}</li>`;
|
||||
}).join("");
|
||||
|
||||
body.innerHTML = `
|
||||
${addsHtml ? `<h3>Cables to add</h3><ul>${addsHtml}</ul>` : ""}
|
||||
${remsHtml ? `<h3>Cables to remove</h3><ul>${remsHtml}</ul>` : ""}
|
||||
${bunsHtml ? `<h3>Bundles to add</h3><ul>${bunsHtml}</ul>` : ""}
|
||||
${unmetsHtml ? `<h3>Unsatisfied</h3><ul>${unmetsHtml}</ul>` : ""}
|
||||
${(addsHtml || remsHtml || bunsHtml || unmetsHtml) ? "" : `<p class="muted">No changes — already solved.</p>`}
|
||||
`;
|
||||
|
||||
body.querySelectorAll(".quickfix").forEach((el) => {
|
||||
el.addEventListener("click", async () => {
|
||||
const fix = el.getAttribute("data-fix");
|
||||
if (fix === "addport") {
|
||||
const devID = Number(el.getAttribute("data-device"));
|
||||
let typeID = Number(el.getAttribute("data-cable-type-id"));
|
||||
if (!typeID) {
|
||||
const typeName = el.getAttribute("data-cable-type");
|
||||
const t = state.cableTypes.find((x) => x.name === typeName);
|
||||
typeID = t ? t.id : null;
|
||||
}
|
||||
if (!devID || !typeID) return;
|
||||
try {
|
||||
await portsAndResolve(state.active.id, devID, { type_id: typeID });
|
||||
// Refresh + re-render the preview
|
||||
const refresh = await solveProject(state.active.id, true);
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables; state.bundles = snap.bundles;
|
||||
state.ports = snap.ports; state.requirements = snap.connection_requirements;
|
||||
state.devices = snap.devices;
|
||||
renderSolvePreview(body, refresh);
|
||||
render(); // sidebar updates
|
||||
} catch (e) { alert(`Quick-fix failed: ${e.message}`); }
|
||||
} else if (fix === "picktype") {
|
||||
// Open the requirement modal so m can specify a type.
|
||||
const rid = Number(el.getAttribute("data-req"));
|
||||
const r = state.requirements.find((x) => x.id === rid);
|
||||
if (r) openRequirementModal(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- apply-template flow ---------- //
|
||||
|
||||
async function openApplyTemplateModal() {
|
||||
if (!state.active) { alert("Pick a project first"); return; }
|
||||
if (!state.setupTemplates.length) {
|
||||
state.setupTemplates = await listSetupTemplates();
|
||||
}
|
||||
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-template"));
|
||||
const form = /** @type {HTMLFormElement} */ ($("#form-template"));
|
||||
const sel = /** @type {HTMLSelectElement} */ ($("#tp-select"));
|
||||
const preview = $("#tp-preview");
|
||||
const err = $("#tp-error");
|
||||
showError(err, "");
|
||||
|
||||
sel.innerHTML = "";
|
||||
for (const t of state.setupTemplates) {
|
||||
sel.append(new Option(t.name, String(t.id)));
|
||||
}
|
||||
sel.onchange = () => renderTemplatePreview(preview, sel.value);
|
||||
renderTemplatePreview(preview, sel.value);
|
||||
|
||||
dlg.showModal();
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.active) return;
|
||||
const tid = Number(sel.value);
|
||||
if (!tid) { showError(err, "Pick a template"); return; }
|
||||
// Collect any per-device name overrides (the preview renders inputs).
|
||||
const overrides = {};
|
||||
preview.querySelectorAll("[data-template-device-id]").forEach((row) => {
|
||||
const did = row.getAttribute("data-template-device-id");
|
||||
const input = row.querySelector("input.tp-name");
|
||||
if (input && input.value.trim()) overrides[did] = input.value.trim();
|
||||
});
|
||||
const skip = [];
|
||||
preview.querySelectorAll("input.tp-skip:checked").forEach((cb) => {
|
||||
const did = Number(cb.getAttribute("data-template-device-id"));
|
||||
if (did) skip.push(did);
|
||||
});
|
||||
try {
|
||||
// The server auto-solves by default since v0c7d165 — the response
|
||||
// is {template_apply, solve} (or {template_apply, solve_error}).
|
||||
// We don't need to read the body here; activateProject() below
|
||||
// pulls a fresh snapshot that includes both the seeded devices
|
||||
// and any cables the solver placed.
|
||||
const projID = state.active.id;
|
||||
await applyTemplate(projID, {
|
||||
template_id: tid,
|
||||
name_overrides: overrides,
|
||||
skip_devices: skip,
|
||||
});
|
||||
dlg.close();
|
||||
// Route through the canonical project-load path. That re-hydrates
|
||||
// ALL collections (frames, devices, ports, io_markers, cables,
|
||||
// bundles, requirements, cable_types, device_types) AND clears
|
||||
// the selection — important because m may have had a stale
|
||||
// selection from before the apply. Slice 6's bare re-snapshot
|
||||
// missed the device_types refresh + selection reset.
|
||||
await activateProject(projID);
|
||||
} catch (ex) {
|
||||
showError(err, ex.message || "Apply failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderTemplatePreview(preview, templateIDStr) {
|
||||
if (!templateIDStr) { preview.innerHTML = ""; return; }
|
||||
const t = state.setupTemplates.find((x) => String(x.id) === templateIDStr);
|
||||
if (!t) { preview.innerHTML = ""; return; }
|
||||
const cableTypeName = new Map(state.cableTypes.map((c) => [c.id, c.name]));
|
||||
const devByTplID = new Map(t.devices.map((d) => [d.id, d]));
|
||||
const devsHtml = t.devices.map((d) => {
|
||||
const dtName = d.device_type?.name ?? `type #${d.device_type_id}`;
|
||||
const suggested = d.suggested_name ?? dtName;
|
||||
return `
|
||||
<li data-template-device-id="${d.id}">
|
||||
<input type="checkbox" class="tp-skip" data-template-device-id="${d.id}" title="Skip this device" />
|
||||
<input type="text" class="tp-name inline-input" value="${escapeHtml(suggested)}"
|
||||
style="width: 140px; display: inline-block;" />
|
||||
<span class="muted" style="margin-left: 6px;">${escapeHtml(dtName)}</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
const reqsHtml = t.requirements.map((r) => {
|
||||
const a = devByTplID.get(r.from_template_device_id);
|
||||
const b = devByTplID.get(r.to_template_device_id);
|
||||
const ct = r.preferred_cable_type_id != null ? cableTypeName.get(r.preferred_cable_type_id) : "solver picks";
|
||||
return `<li>${escapeHtml(a?.suggested_name ?? "?")} ↔ ${escapeHtml(b?.suggested_name ?? "?")} · ${escapeHtml(ct ?? "?")}</li>`;
|
||||
}).join("");
|
||||
preview.innerHTML = `
|
||||
<p>${escapeHtml(t.description)}</p>
|
||||
<h4>Devices</h4>
|
||||
<ul>${devsHtml}</ul>
|
||||
<h4>Requirements</h4>
|
||||
<ul>${reqsHtml}</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------- export flow ---------- //
|
||||
|
||||
let toastTimer = null;
|
||||
|
||||
function showToast(kind, html, holdMs = 5000) {
|
||||
const t = $("#toast");
|
||||
t.className = "toast " + (kind || "");
|
||||
t.innerHTML = html;
|
||||
setHidden(t, false);
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { setHidden(t, true); t.innerHTML = ""; }, holdMs);
|
||||
}
|
||||
|
||||
async function exportCurrentProject() {
|
||||
if (!state.active) { alert("Pick a project first"); return; }
|
||||
const btn = $("#btn-export");
|
||||
btn.disabled = true;
|
||||
showToast("", "Exporting…", 30000);
|
||||
try {
|
||||
const res = await syncExport(state.active.id);
|
||||
const url = res.url ?? "(no url)";
|
||||
const count = res.element_count ?? 0;
|
||||
showToast("ok",
|
||||
`Exported ${count} elements → <a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a>`,
|
||||
8000);
|
||||
} catch (e) {
|
||||
// Surface mxdrw unreachability or the upstream error verbatim.
|
||||
const detail = typeof e.details === "object" ? JSON.stringify(e.details) : (e.details ?? "");
|
||||
showToast("error", `Export failed: ${escapeHtml(e.message)}${detail ? ` (${escapeHtml(String(detail))})` : ""}`, 12000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- boot ---------- //
|
||||
|
||||
async function boot() {
|
||||
@@ -1517,6 +2279,8 @@ async function boot() {
|
||||
bindCloseButtons($("#modal-delete-project"));
|
||||
bindCloseButtons($("#modal-new-device"));
|
||||
bindCloseButtons($("#modal-requirement"));
|
||||
bindCloseButtons($("#modal-solve"));
|
||||
bindCloseButtons($("#modal-template"));
|
||||
|
||||
$("#btn-new-project").addEventListener("click", openNewProjectModal);
|
||||
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
|
||||
@@ -1526,6 +2290,9 @@ async function boot() {
|
||||
if (state.devices.length < 2) { alert("Need at least two devices to add a requirement."); return; }
|
||||
openRequirementModal(null);
|
||||
});
|
||||
$("#btn-solve").addEventListener("click", openSolveModal);
|
||||
$("#btn-apply-template").addEventListener("click", openApplyTemplateModal);
|
||||
$("#btn-export").addEventListener("click", exportCurrentProject);
|
||||
|
||||
$("#project-select").addEventListener("change", (e) => {
|
||||
const v = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||
|
||||
@@ -213,7 +213,46 @@ body {
|
||||
.canvas-wrap.tool-device #canvas,
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
|
||||
.canvas-wrap.tool-io #canvas *,
|
||||
.canvas-wrap.tool-port #canvas,
|
||||
.canvas-wrap.tool-port #canvas *,
|
||||
.canvas-wrap.tool-cable #canvas,
|
||||
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-link:hover { color: var(--danger); }
|
||||
|
||||
/* Highlight a port that's been picked as the cable-draw source. */
|
||||
.port-circle.cable-from {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
|
||||
/* Header toast — slice 8 export feedback */
|
||||
.toast {
|
||||
display: inline-block;
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toast.ok { background: #e8f5e9; color: #1b5e20; }
|
||||
.toast.error { background: #fdecea; color: #911313; }
|
||||
.toast a { color: inherit; text-decoration: underline; }
|
||||
|
||||
/* IO markers — diamonds. Power-by-convention, so the default fill is
|
||||
the Power cable_type colour (#e03131). Rotated 45° rect is the
|
||||
@@ -238,15 +277,16 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Ports — small circles laid out along the device edge. The fill is
|
||||
white so the port is visible regardless of the underlying device's
|
||||
stroke; the stroke colour comes from the cable_type the port carries
|
||||
(set inline in JS). */
|
||||
/* Ports — small circles laid out along the device edge. Both fill and
|
||||
stroke come from the cable_type the port carries (set inline in JS)
|
||||
so the port reads clearly as a coloured anchor on the device. */
|
||||
.port-circle {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 2;
|
||||
pointer-events: none; /* slice 4 — selection happens at device-level */
|
||||
cursor: crosshair;
|
||||
}
|
||||
.port-circle.selected {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
|
||||
.port-row {
|
||||
@@ -257,11 +297,15 @@ body {
|
||||
font-size: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.port-row .swatch {
|
||||
.port-row .swatch,
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.port-row .label { color: var(--text); }
|
||||
.port-row .conn { color: var(--text-muted); font-size: 11px; }
|
||||
@@ -316,6 +360,76 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Cables on the canvas. Stroke colour comes from the cable_type;
|
||||
solver-owned cables (auto=1) render with a slightly dashed pattern
|
||||
so m can tell at a glance which the solver placed. */
|
||||
.cable-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cable-line.auto { stroke-dasharray: 8 3; }
|
||||
.cable-line:hover { stroke-width: 4; }
|
||||
.cable-line.selected { stroke-width: 4; }
|
||||
|
||||
/* Solve preview-diff modal */
|
||||
.modal-wide { width: 560px; }
|
||||
.sv-body { font-size: 13px; }
|
||||
.sv-body h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.sv-body ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sv-body li {
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.sv-body li.added { border-left: 3px solid #2f9e44; }
|
||||
.sv-body li.removed { border-left: 3px solid var(--danger); text-decoration: line-through; }
|
||||
.sv-body li.unmet { border-left: 3px solid #f59f00; }
|
||||
.sv-body li.unmet .quickfix {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tp-preview {
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.tp-preview h4 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.tp-preview ul { list-style: none; padding: 0; margin: 0; }
|
||||
.tp-preview li { padding: 2px 0; }
|
||||
.tp-preview .skip {
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
stroke: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user