feat(db): device_types store + port seeding on device create
Catalog: 11 built-ins from §2.2 + the v4.1 trio (Screen, Keyboard, Mouse) seeded in migration 002, totalling 16 built-in types. Store layer: - internal/db/device_types.go — CRUD for device_types. Built-ins (project_id NULL) reject PATCH/DELETE with new ErrForbidden sentinel (handler maps to HTTP 403). Project-custom types accept full CRUD; cross-project access returns ErrNotFound. Replacing the port profile on UPDATE is one transaction. - internal/db/ports.go — ListPortsForProject for the snapshot loader + seedPortsFromType(tx, …) used by CreateDevice. Layout is "evenly spaced along the configured edge", per-edge group ordering by sort_order + id. Labels are "<prefix>" for count==1 and "<prefix> N" 1-indexed for count>1. - Device gains a nullable TypeID + tri-state on UpdateDevice. CreateDevice validates the type is built-in or a project-custom row of the same project, then seeds the device's ports in the same transaction. Snapshot now populates Ports from the store; field type tightened to []Port. Tests (15 new, all green with -race): - 16 built-ins seeded with correct names + project_id=NULL + built_in=1 - Port-profile totals match the §2.2 table for every built-in type - Project-custom create + name-collision-with-built-in → 409 (ErrConflict) - Per-project name UNIQUE — same custom name across projects is fine - PATCH/DELETE built-in → ErrForbidden - Cross-project custom PATCH → ErrNotFound - CreateDevice with NAS type → 2 ports along bottom edge, evenly spaced, labels set - CreateDevice with PC type → 5 ports incl. "USB 1" + "USB 2" - CreateDevice without type_id → 0 ports (freeform fallback) - Cross-project custom type on CreateDevice → ErrInvalidInput - Snapshot includes the seeded ports
This commit is contained in:
351
internal/db/device_types.go
Normal file
351
internal/db/device_types.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrForbidden is the sentinel for "you can't mutate this row" — used by
|
||||
// PATCH/DELETE on built-in device_types.
|
||||
var ErrForbidden = errors.New("forbidden")
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// device_types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// DeviceTypeCreate is the shape POSTed under /api/projects/:pid/device-types.
|
||||
// project_id is the URL :pid; the caller never passes it in the body.
|
||||
type DeviceTypeCreate struct {
|
||||
Name string
|
||||
Kind string
|
||||
Icon string
|
||||
Description string
|
||||
Ports []DeviceTypePortCreate
|
||||
}
|
||||
|
||||
// DeviceTypePortCreate is one row in the type's port profile.
|
||||
type DeviceTypePortCreate struct {
|
||||
CableTypeID int64
|
||||
LabelPrefix string
|
||||
Count int
|
||||
Edge string
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// DeviceTypeUpdate is the partial-update shape. Built-in types reject
|
||||
// any PATCH at the store level.
|
||||
type DeviceTypeUpdate struct {
|
||||
Name *string
|
||||
Kind *string
|
||||
Icon *string
|
||||
Description *string
|
||||
// Ports != nil means "replace the port profile with this set".
|
||||
Ports *[]DeviceTypePortCreate
|
||||
}
|
||||
|
||||
// CreateDeviceType inserts a project-custom row + its port profile in
|
||||
// one transaction. projectID must be non-zero (built-ins are seed-only).
|
||||
func (s *Store) CreateDeviceType(projectID int64, dt DeviceTypeCreate) (*DeviceType, error) {
|
||||
name := strings.TrimSpace(dt.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if projectID == 0 {
|
||||
return nil, fmt.Errorf("%w: project_id is required (built-ins are seed-only)", ErrInvalidInput)
|
||||
}
|
||||
if _, err := s.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Forbid name-collisions with built-ins (UNIQUE(project_id,name)
|
||||
// only enforces inside the project; built-ins have project_id IS
|
||||
// NULL so the constraint doesn't catch them).
|
||||
var builtinClash int
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM device_types WHERE project_id IS NULL AND name = ?`, name,
|
||||
).Scan(&builtinClash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if builtinClash > 0 {
|
||||
return nil, fmt.Errorf("%w: name %q clashes with a built-in device type", ErrConflict, name)
|
||||
}
|
||||
|
||||
kind := strings.TrimSpace(dt.Kind)
|
||||
if kind == "" {
|
||||
kind = "generic"
|
||||
}
|
||||
desc := dt.Description
|
||||
var iconPtr any
|
||||
if icon := strings.TrimSpace(dt.Icon); icon != "" {
|
||||
iconPtr = icon
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO device_types (project_id, name, kind, icon, description, built_in)
|
||||
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||
projectID, name, kind, iconPtr, desc,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
|
||||
for _, p := range dt.Ports {
|
||||
if err := insertDeviceTypePort(tx, id, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDeviceType(id)
|
||||
}
|
||||
|
||||
func insertDeviceTypePort(tx *sql.Tx, deviceTypeID int64, p DeviceTypePortCreate) error {
|
||||
if p.CableTypeID <= 0 {
|
||||
return fmt.Errorf("%w: cable_type_id is required on each port row", ErrInvalidInput)
|
||||
}
|
||||
if p.Count <= 0 {
|
||||
p.Count = 1
|
||||
}
|
||||
edge := strings.TrimSpace(p.Edge)
|
||||
if edge == "" {
|
||||
edge = "bottom"
|
||||
}
|
||||
if edge != "top" && edge != "bottom" && edge != "left" && edge != "right" {
|
||||
return fmt.Errorf("%w: edge must be top/bottom/left/right", ErrInvalidInput)
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
`INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
deviceTypeID, p.CableTypeID, p.LabelPrefix, p.Count, edge, p.SortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeviceType loads a single type row (built-in OR project-custom)
|
||||
// with its port profile.
|
||||
func (s *Store) GetDeviceType(id int64) (*DeviceType, error) {
|
||||
dt, err := scanDeviceTypeByID(s.db, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports, err := s.listDeviceTypePorts(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dt.Ports = ports
|
||||
return dt, nil
|
||||
}
|
||||
|
||||
func scanDeviceTypeByID(d *sql.DB, id int64) (*DeviceType, error) {
|
||||
var dt DeviceType
|
||||
var proj sql.NullInt64
|
||||
var icon sql.NullString
|
||||
var built int
|
||||
err := d.QueryRow(
|
||||
`SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at
|
||||
FROM device_types WHERE id = ?`, id,
|
||||
).Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built,
|
||||
&dt.CreatedAt, &dt.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proj.Valid {
|
||||
v := proj.Int64
|
||||
dt.ProjectID = &v
|
||||
}
|
||||
if icon.Valid {
|
||||
dt.Icon = &icon.String
|
||||
}
|
||||
dt.BuiltIn = built != 0
|
||||
return &dt, nil
|
||||
}
|
||||
|
||||
// ListBuiltInDeviceTypes returns every built-in type (project_id IS NULL).
|
||||
func (s *Store) ListBuiltInDeviceTypes() ([]DeviceType, error) {
|
||||
return s.listDeviceTypesWhere(`project_id IS NULL`, nil)
|
||||
}
|
||||
|
||||
// ListDeviceTypesForProject returns built-ins + the project's custom
|
||||
// types, merged. Built-ins come first (insertion order), then custom by
|
||||
// id.
|
||||
func (s *Store) ListDeviceTypesForProject(projectID int64) ([]DeviceType, error) {
|
||||
return s.listDeviceTypesWhere(
|
||||
`project_id IS NULL OR project_id = ?`, []any{projectID},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) listDeviceTypesWhere(where string, args []any) ([]DeviceType, error) {
|
||||
q := `SELECT id, project_id, name, kind, icon, description, built_in, created_at, updated_at
|
||||
FROM device_types WHERE ` + where +
|
||||
` ORDER BY (project_id IS NOT NULL), id`
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []DeviceType{}
|
||||
for rows.Next() {
|
||||
var dt DeviceType
|
||||
var proj sql.NullInt64
|
||||
var icon sql.NullString
|
||||
var built int
|
||||
if err := rows.Scan(&dt.ID, &proj, &dt.Name, &dt.Kind, &icon, &dt.Description, &built,
|
||||
&dt.CreatedAt, &dt.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proj.Valid {
|
||||
v := proj.Int64
|
||||
dt.ProjectID = &v
|
||||
}
|
||||
if icon.Valid {
|
||||
dt.Icon = &icon.String
|
||||
}
|
||||
dt.BuiltIn = built != 0
|
||||
out = append(out, dt)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hydrate ports per row. Two queries per request is fine for the
|
||||
// catalog size; switch to a single JOIN-and-group if it becomes hot.
|
||||
for i := range out {
|
||||
ps, err := s.listDeviceTypePorts(out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].Ports = ps
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) listDeviceTypePorts(deviceTypeID int64) ([]DeviceTypePort, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, device_type_id, cable_type_id, label_prefix, count, edge, sort_order
|
||||
FROM device_type_ports WHERE device_type_id = ? ORDER BY sort_order, id`,
|
||||
deviceTypeID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []DeviceTypePort{}
|
||||
for rows.Next() {
|
||||
var p DeviceTypePort
|
||||
if err := rows.Scan(&p.ID, &p.DeviceTypeID, &p.CableTypeID,
|
||||
&p.LabelPrefix, &p.Count, &p.Edge, &p.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateDeviceType applies a partial update. Built-in rows are rejected
|
||||
// with ErrForbidden. Cross-project rows are rejected with ErrNotFound.
|
||||
// Replacing the port profile (Ports != nil) wipes and re-inserts.
|
||||
func (s *Store) UpdateDeviceType(projectID, id int64, u DeviceTypeUpdate) (*DeviceType, error) {
|
||||
cur, err := s.GetDeviceType(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cur.BuiltIn {
|
||||
return nil, fmt.Errorf("%w: built-in device types are read-only", ErrForbidden)
|
||||
}
|
||||
if cur.ProjectID == nil || *cur.ProjectID != projectID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if u.Name != nil {
|
||||
v := strings.TrimSpace(*u.Name)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
||||
}
|
||||
cur.Name = v
|
||||
}
|
||||
if u.Kind != nil {
|
||||
v := strings.TrimSpace(*u.Kind)
|
||||
if v == "" {
|
||||
v = "generic"
|
||||
}
|
||||
cur.Kind = v
|
||||
}
|
||||
if u.Icon != nil {
|
||||
v := strings.TrimSpace(*u.Icon)
|
||||
if v == "" {
|
||||
cur.Icon = nil
|
||||
} else {
|
||||
cur.Icon = &v
|
||||
}
|
||||
}
|
||||
if u.Description != nil {
|
||||
cur.Description = *u.Description
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var iconArg any
|
||||
if cur.Icon != nil {
|
||||
iconArg = *cur.Icon
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE device_types
|
||||
SET name = ?, kind = ?, icon = ?, description = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
cur.Name, cur.Kind, iconArg, cur.Description, id,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
if u.Ports != nil {
|
||||
if _, err := tx.Exec(`DELETE FROM device_type_ports WHERE device_type_id = ?`, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range *u.Ports {
|
||||
if err := insertDeviceTypePort(tx, id, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDeviceType(id)
|
||||
}
|
||||
|
||||
// DeleteDeviceType removes a project-custom row. Built-ins → ErrForbidden.
|
||||
// Cross-project → ErrNotFound. Cascades to device_type_ports (FK CASCADE)
|
||||
// and SET-NULLs the type_id on any device referencing it.
|
||||
func (s *Store) DeleteDeviceType(projectID, id int64) error {
|
||||
cur, err := s.GetDeviceType(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cur.BuiltIn {
|
||||
return fmt.Errorf("%w: built-in device types are read-only", ErrForbidden)
|
||||
}
|
||||
if cur.ProjectID == nil || *cur.ProjectID != projectID {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM device_types WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
283
internal/db/device_types_test.go
Normal file
283
internal/db/device_types_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------- catalog (seeded)
|
||||
|
||||
func TestSeed_BuiltInDeviceTypes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
got, err := s.ListBuiltInDeviceTypes()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
|
||||
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
|
||||
"Screen", "Keyboard", "Mouse",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("built-in count = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].Name != w {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i].Name, w)
|
||||
}
|
||||
if !got[i].BuiltIn {
|
||||
t.Errorf("[%d] %q should be built_in", i, got[i].Name)
|
||||
}
|
||||
if got[i].ProjectID != nil {
|
||||
t.Errorf("[%d] %q should have project_id=nil", i, got[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeed_PortProfiles(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
byName := map[string]DeviceType{}
|
||||
for _, d := range all {
|
||||
byName[d.Name] = d
|
||||
}
|
||||
cases := map[string]struct {
|
||||
totalPorts int // sum of count across profile rows
|
||||
}{
|
||||
"NAS": {2}, // Power 1 + RJ45 1
|
||||
"PC": {5}, // Power 1 + RJ45 1 + HDMI 1 + USB 2
|
||||
"Mac": {4}, // Power 1 + HDMI 1 + USB 2
|
||||
"Notebook": {3}, // Power 1 + USB 2
|
||||
"TV": {3}, // Power 1 + HDMI 2
|
||||
"Soundbar": {2}, // Power 1 + HDMI 1
|
||||
"Switch": {6}, // Power 1 + RJ45 5
|
||||
"fritz": {5}, // Power 1 + RJ45 4
|
||||
"ChromeCast": {2}, // Power 1 + HDMI 1
|
||||
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
|
||||
"IOx-3": {4}, // Power 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
|
||||
}
|
||||
for name, want := range cases {
|
||||
dt, ok := byName[name]
|
||||
if !ok {
|
||||
t.Errorf("missing built-in %q", name)
|
||||
continue
|
||||
}
|
||||
total := 0
|
||||
for _, p := range dt.Ports {
|
||||
total += p.Count
|
||||
}
|
||||
if total != want.totalPorts {
|
||||
t.Errorf("%s: total ports = %d, want %d", name, total, want.totalPorts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- CRUD (custom rows)
|
||||
|
||||
func TestCreateDeviceType_CustomBasic(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
dt, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{
|
||||
Name: "DigitalCam", Kind: "accessory",
|
||||
Description: "A camera with HDMI out",
|
||||
Ports: []DeviceTypePortCreate{
|
||||
{CableTypeID: 1, LabelPrefix: "Power", Count: 1},
|
||||
{CableTypeID: 3, LabelPrefix: "HDMI", Count: 1, SortOrder: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if dt.BuiltIn {
|
||||
t.Errorf("built_in should be false")
|
||||
}
|
||||
if dt.ProjectID == nil || *dt.ProjectID != p.ID {
|
||||
t.Errorf("project_id mismatch: %+v", dt.ProjectID)
|
||||
}
|
||||
if len(dt.Ports) != 2 {
|
||||
t.Errorf("port profile rows = %d, want 2", len(dt.Ports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDeviceType_NameClashWithBuiltIn(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
_, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "NAS"})
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("err = %v, want ErrConflict (NAS is built-in)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDeviceType_PerProjectUnique(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if _, err := s.CreateDeviceType(p.ID, DeviceTypeCreate{Name: "Foo"}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("dup err = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeviceType_BuiltInForbidden(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
nas := all[0]
|
||||
newName := "renamed"
|
||||
_, err := s.UpdateDeviceType(p.ID, nas.ID, DeviceTypeUpdate{Name: &newName})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("err = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDeviceType_BuiltInForbidden(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
if err := s.DeleteDeviceType(p.ID, all[0].ID); !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("err = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeviceType_CrossProjectIsNotFound(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
dt, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Foo"})
|
||||
newName := "bar"
|
||||
if _, err := s.UpdateDeviceType(p2.ID, dt.ID, DeviceTypeUpdate{Name: &newName}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- device + ports seed
|
||||
|
||||
func TestCreateDevice_SeedsPortsFromBuiltInType(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
var nasID int64
|
||||
for _, dt := range all {
|
||||
if dt.Name == "NAS" {
|
||||
nasID = dt.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if nasID == 0 {
|
||||
t.Fatal("NAS not in catalog")
|
||||
}
|
||||
d, err := s.CreateDevice(p.ID, DeviceCreate{
|
||||
Name: "NAS-Loft", TypeID: &nasID,
|
||||
X: 100, Y: 100, Width: 100, Height: 35,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if d.TypeID == nil || *d.TypeID != nasID {
|
||||
t.Errorf("type_id wrong: %+v", d.TypeID)
|
||||
}
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
if len(ports) != 2 {
|
||||
t.Fatalf("port count = %d, want 2 (Power + RJ45)", len(ports))
|
||||
}
|
||||
for _, prt := range ports {
|
||||
if prt.YOffset != 35 {
|
||||
t.Errorf("port y_offset = %v, want 35 (bottom edge)", prt.YOffset)
|
||||
}
|
||||
if prt.XOffset <= 0 || prt.XOffset >= 100 {
|
||||
t.Errorf("port x_offset = %v, want between 0 and 100", prt.XOffset)
|
||||
}
|
||||
if prt.Label == nil {
|
||||
t.Errorf("port label = nil, want non-nil (label_prefix is set)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDevice_SeedsPortsForPC_FourGroupsFiveTotal(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
var pcID int64
|
||||
for _, dt := range all {
|
||||
if dt.Name == "PC" {
|
||||
pcID = dt.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if pcID == 0 {
|
||||
t.Fatal("PC not in catalog")
|
||||
}
|
||||
if _, err := s.CreateDevice(p.ID, DeviceCreate{
|
||||
Name: "Workstation", TypeID: &pcID,
|
||||
X: 0, Y: 0, Width: 100, Height: 35,
|
||||
}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
if len(ports) != 5 {
|
||||
t.Errorf("port count = %d, want 5 (Power+RJ45+HDMI+USB×2)", len(ports))
|
||||
}
|
||||
// USB×2 must produce two labels "USB 1" and "USB 2".
|
||||
usbLabels := map[string]bool{}
|
||||
for _, prt := range ports {
|
||||
if prt.Label != nil && (*prt.Label == "USB 1" || *prt.Label == "USB 2") {
|
||||
usbLabels[*prt.Label] = true
|
||||
}
|
||||
}
|
||||
if !usbLabels["USB 1"] || !usbLabels["USB 2"] {
|
||||
t.Errorf("USB labels missing: got %v", usbLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDevice_NoTypeID_NoPorts(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
if _, err := s.CreateDevice(p.ID, DeviceCreate{
|
||||
Name: "Freeform", X: 0, Y: 0, Width: 100, Height: 35,
|
||||
}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
ports, _ := s.ListPortsForProject(p.ID)
|
||||
if len(ports) != 0 {
|
||||
t.Errorf("freeform device should have 0 ports, got %d", len(ports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDevice_CrossProjectCustomTypeRejected(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p1, _ := s.CreateProject("LOFT", "", "")
|
||||
p2, _ := s.CreateProject("OFFICE", "", "")
|
||||
custom, _ := s.CreateDeviceType(p1.ID, DeviceTypeCreate{Name: "Exotic"})
|
||||
_, err := s.CreateDevice(p2.ID, DeviceCreate{
|
||||
Name: "Wrong", TypeID: &custom.ID,
|
||||
X: 0, Y: 0, Width: 100, Height: 35,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("err = %v, want ErrInvalidInput (cross-project custom type)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_IncludesPorts(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
all, _ := s.ListBuiltInDeviceTypes()
|
||||
for _, dt := range all {
|
||||
if dt.Name == "Mac" {
|
||||
_, _ = s.CreateDevice(p.ID, DeviceCreate{
|
||||
Name: "M1", TypeID: &dt.ID,
|
||||
X: 0, Y: 0, Width: 100, Height: 35,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
snap, _ := s.Snapshot(p.ID)
|
||||
if len(snap.Ports) != 4 {
|
||||
t.Errorf("snapshot.Ports = %d, want 4 (Mac: Power+HDMI+USB×2)", len(snap.Ports))
|
||||
}
|
||||
}
|
||||
@@ -166,9 +166,14 @@ func (s *Store) DeleteFrame(projectID, id int64) error {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// DeviceCreate is the create-shape. FrameID may be nil ("outside any frame").
|
||||
// TypeID may be nil for a freeform device (no auto-seeded ports). If set,
|
||||
// the type must be either built-in or a project-custom type belonging to
|
||||
// the same project — and CreateDevice seeds the device's ports from the
|
||||
// type's port profile in the same transaction.
|
||||
type DeviceCreate struct {
|
||||
Name string
|
||||
FrameID *int64
|
||||
TypeID *int64
|
||||
Color string
|
||||
X float64
|
||||
Y float64
|
||||
@@ -179,10 +184,11 @@ type DeviceCreate struct {
|
||||
// DeviceUpdate is the partial-update shape. project_id deliberately not
|
||||
// settable. FrameID is *(*int64) so callers can distinguish "leave as-is"
|
||||
// (nil) from "set to NULL" (&nil) — Go syntax: pass a *(*int64) where the
|
||||
// inner pointer is nil to clear.
|
||||
// inner pointer is nil to clear. TypeID uses the same FrameRef tri-state.
|
||||
type DeviceUpdate struct {
|
||||
Name *string
|
||||
FrameID FrameRef // see FrameRef below
|
||||
TypeID FrameRef // tri-state for type_id: same shape as FrameRef
|
||||
Color *string
|
||||
X *float64
|
||||
Y *float64
|
||||
@@ -201,7 +207,11 @@ type FrameRef struct {
|
||||
}
|
||||
|
||||
// CreateDevice inserts a new device. FrameID, if provided, must reference
|
||||
// a frame in the same project.
|
||||
// a frame in the same project. TypeID, if provided, must reference a
|
||||
// built-in or a project-custom device_type in the same project — the
|
||||
// store seeds the device's ports from that type's profile in the same
|
||||
// transaction so a half-created device (row inserted, ports missing)
|
||||
// can never exist.
|
||||
func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" {
|
||||
@@ -221,32 +231,62 @@ func (s *Store) CreateDevice(projectID int64, d DeviceCreate) (*Device, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if d.TypeID != nil {
|
||||
dt, err := s.GetDeviceType(*d.TypeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *d.TypeID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Project-custom types must match the device's project. Built-ins
|
||||
// (project_id NULL) are available to every project.
|
||||
if dt.ProjectID != nil && *dt.ProjectID != projectID {
|
||||
return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *d.TypeID)
|
||||
}
|
||||
}
|
||||
color := strings.TrimSpace(d.Color)
|
||||
if color == "" {
|
||||
color = "#1e1e1e"
|
||||
}
|
||||
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO devices (project_id, frame_id, name, color, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID, nullableInt64(d.FrameID), name, color, d.X, d.Y, d.Width, d.Height,
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
||||
name, color, d.X, d.Y, d.Width, d.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetDevice(projectID, id)
|
||||
deviceID, _ := res.LastInsertId()
|
||||
|
||||
if d.TypeID != nil {
|
||||
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDevice(projectID, deviceID)
|
||||
}
|
||||
|
||||
// GetDevice loads a device, project-scoped.
|
||||
func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
|
||||
var d Device
|
||||
var frame sql.NullInt64
|
||||
var frame, typeID sql.NullInt64
|
||||
var ex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
&ex, &d.CreatedAt, &d.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -258,6 +298,10 @@ func (s *Store) GetDevice(projectID, id int64) (*Device, error) {
|
||||
v := frame.Int64
|
||||
d.FrameID = &v
|
||||
}
|
||||
if typeID.Valid {
|
||||
v := typeID.Int64
|
||||
d.TypeID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
d.ExcalidrawID = &ex.String
|
||||
}
|
||||
@@ -277,13 +321,13 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
||||
)
|
||||
if frameID != nil {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE project_id = ? AND frame_id = ? ORDER BY created_at, id`,
|
||||
projectID, *frameID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, project_id, frame_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM devices WHERE project_id = ? ORDER BY created_at, id`,
|
||||
projectID,
|
||||
)
|
||||
@@ -295,9 +339,9 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
||||
out := []Device{}
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
var frame sql.NullInt64
|
||||
var frame, typeID sql.NullInt64
|
||||
var ex sql.NullString
|
||||
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
if err := rows.Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
||||
&ex, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -305,6 +349,10 @@ func (s *Store) ListDevices(projectID int64, frameID *int64) ([]Device, error) {
|
||||
v := frame.Int64
|
||||
d.FrameID = &v
|
||||
}
|
||||
if typeID.Valid {
|
||||
v := typeID.Int64
|
||||
d.TypeID = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
d.ExcalidrawID = &ex.String
|
||||
}
|
||||
@@ -363,11 +411,27 @@ func (s *Store) UpdateDevice(projectID, id int64, u DeviceUpdate) (*Device, erro
|
||||
}
|
||||
cur.FrameID = u.FrameID.ID
|
||||
}
|
||||
if u.TypeID.Set {
|
||||
if u.TypeID.ID != nil {
|
||||
dt, err := s.GetDeviceType(*u.TypeID.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: device type %d not found", ErrInvalidInput, *u.TypeID.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if dt.ProjectID != nil && *dt.ProjectID != projectID {
|
||||
return nil, fmt.Errorf("%w: device type %d is custom to another project", ErrInvalidInput, *u.TypeID.ID)
|
||||
}
|
||||
}
|
||||
cur.TypeID = u.TypeID.ID
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE devices
|
||||
SET frame_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
||||
SET frame_id = ?, type_id = ?, name = ?, color = ?, x = ?, y = ?, width = ?, height = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND project_id = ?`,
|
||||
nullableInt64(cur.FrameID), cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
||||
nullableInt64(cur.FrameID), nullableInt64(cur.TypeID),
|
||||
cur.Name, cur.Color, cur.X, cur.Y, cur.Width, cur.Height, id, projectID,
|
||||
); err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
|
||||
@@ -34,10 +34,13 @@ type Frame struct {
|
||||
}
|
||||
|
||||
// Device is a hardware item inside a project, optionally inside a frame.
|
||||
// v4: type_id (nullable) lets a device inherit its port profile from a
|
||||
// device_types catalog row.
|
||||
type Device struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
FrameID *int64 `json:"frame_id"` // nullable: device "outside" any frame
|
||||
TypeID *int64 `json:"type_id"` // nullable: freeform device when null
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
X float64 `json:"x"`
|
||||
@@ -49,6 +52,49 @@ type Device struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeviceType is a catalog row. Built-in rows have ProjectID nil and
|
||||
// BuiltIn true. Project-custom rows have ProjectID set.
|
||||
type DeviceType struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID *int64 `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Ports []DeviceTypePort `json:"ports"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeviceTypePort is a row of a type's port profile. The seeder uses
|
||||
// (cable_type_id, count, label_prefix, edge, sort_order) to lay out
|
||||
// concrete ports on a freshly-created device.
|
||||
type DeviceTypePort struct {
|
||||
ID int64 `json:"id"`
|
||||
DeviceTypeID int64 `json:"device_type_id"`
|
||||
CableTypeID int64 `json:"cable_type_id"`
|
||||
LabelPrefix string `json:"label_prefix"`
|
||||
Count int `json:"count"`
|
||||
Edge string `json:"edge"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// Port is a connector on a device. cable_type colour drives the visual
|
||||
// rendering; ports are instance-owned even when seeded from a type.
|
||||
type Port struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
TypeID int64 `json:"type_id"` // cable type
|
||||
Label *string `json:"label"`
|
||||
XOffset float64 `json:"x_offset"`
|
||||
YOffset float64 `json:"y_offset"`
|
||||
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -56,7 +102,7 @@ type Snapshot struct {
|
||||
Project Project `json:"project"`
|
||||
Frames []Frame `json:"frames"`
|
||||
Devices []Device `json:"devices"`
|
||||
Ports []any `json:"ports"`
|
||||
Ports []Port `json:"ports"`
|
||||
Cables []any `json:"cables"`
|
||||
IOMarkers []IOMarker `json:"io_markers"`
|
||||
Bundles []any `json:"bundles"`
|
||||
|
||||
180
internal/db/ports.go
Normal file
180
internal/db/ports.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// ListPortsForProject returns every port in a project, ordered by
|
||||
// device_id + id so callers can group cheaply.
|
||||
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
||||
excalidraw_id, created_at, updated_at
|
||||
FROM ports WHERE project_id = ? ORDER BY device_id, id`, projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Port{}
|
||||
for rows.Next() {
|
||||
var p Port
|
||||
var label, ex sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID,
|
||||
&label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if label.Valid {
|
||||
v := label.String
|
||||
p.Label = &v
|
||||
}
|
||||
if ex.Valid {
|
||||
p.ExcalidrawID = &ex.String
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// seedPortsFromType inserts the ports for a freshly-created device using
|
||||
// the type's `device_type_ports` profile. Port positions are computed by
|
||||
// laying out cable-type groups evenly along the configured edge of the
|
||||
// device, ordered by sort_order. Within a multi-count group the per-port
|
||||
// spacing is also even. Runs inside the same transaction as the device
|
||||
// insert so a failure rolls everything back.
|
||||
//
|
||||
// Layout strategy (v0):
|
||||
// - All ports for a given type sit on the type's configured edge.
|
||||
// - For each edge, compute total port count N (sum of count across
|
||||
// entries on that edge) and distribute as t_i = (i + 1)/(N+1) along
|
||||
// the edge length, so ports don't touch the corners.
|
||||
// - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom).
|
||||
// - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t.
|
||||
// - Labels: '<prefix>' if count==1, '<prefix> N' (1-indexed) if count>1.
|
||||
// Empty prefix → NULL label.
|
||||
func (s *Store) seedPortsFromType(tx *sql.Tx, projectID, deviceID, typeID int64, width, height float64) error {
|
||||
rows, err := tx.Query(
|
||||
`SELECT cable_type_id, label_prefix, count, edge, sort_order
|
||||
FROM device_type_ports
|
||||
WHERE device_type_id = ?
|
||||
ORDER BY edge, sort_order, id`, typeID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type pendingPort struct {
|
||||
cableTypeID int64
|
||||
label *string
|
||||
xOff float64
|
||||
yOff float64
|
||||
}
|
||||
// Group rows by edge first; emit per-port y-or-x slots inside each edge.
|
||||
type groupRow struct {
|
||||
cableTypeID int64
|
||||
labelPrefix string
|
||||
count int
|
||||
}
|
||||
byEdge := map[string][]groupRow{}
|
||||
for rows.Next() {
|
||||
var g groupRow
|
||||
var edge string
|
||||
var sortOrder int
|
||||
if err := rows.Scan(&g.cableTypeID, &g.labelPrefix, &g.count, &edge, &sortOrder); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
byEdge[edge] = append(byEdge[edge], g)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pending []pendingPort
|
||||
for _, edge := range []string{"top", "bottom", "left", "right"} {
|
||||
groups := byEdge[edge]
|
||||
if len(groups) == 0 {
|
||||
continue
|
||||
}
|
||||
total := 0
|
||||
for _, g := range groups {
|
||||
total += g.count
|
||||
}
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
// Emit ports in group + within-group order.
|
||||
idx := 0
|
||||
for _, g := range groups {
|
||||
for k := 0; k < g.count; k++ {
|
||||
t := float64(idx+1) / float64(total+1)
|
||||
var xOff, yOff float64
|
||||
switch edge {
|
||||
case "top":
|
||||
xOff, yOff = width*t, 0
|
||||
case "bottom":
|
||||
xOff, yOff = width*t, height
|
||||
case "left":
|
||||
xOff, yOff = 0, height*t
|
||||
case "right":
|
||||
xOff, yOff = width, height*t
|
||||
}
|
||||
var labelPtr *string
|
||||
if g.labelPrefix != "" {
|
||||
var lbl string
|
||||
if g.count == 1 {
|
||||
lbl = g.labelPrefix
|
||||
} else {
|
||||
lbl = g.labelPrefix + " " + itoa(k+1)
|
||||
}
|
||||
labelPtr = &lbl
|
||||
}
|
||||
pending = append(pending, pendingPort{
|
||||
cableTypeID: g.cableTypeID, label: labelPtr,
|
||||
xOff: xOff, yOff: yOff,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range pending {
|
||||
var labelArg any
|
||||
if p.label != nil {
|
||||
labelArg = *p.label
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
projectID, deviceID, p.cableTypeID, labelArg, p.xOff, p.yOff,
|
||||
); err != nil {
|
||||
return mapWriteErr(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// itoa is a tiny non-allocating int-to-string for port labels.
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
buf := [20]byte{}
|
||||
pos := len(buf)
|
||||
neg := i < 0
|
||||
if neg {
|
||||
i = -i
|
||||
}
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
@@ -171,11 +171,15 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports, err := s.ListPortsForProject(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Snapshot{
|
||||
Project: *p,
|
||||
Frames: frames,
|
||||
Devices: devices,
|
||||
Ports: []any{},
|
||||
Ports: ports,
|
||||
Cables: []any{},
|
||||
IOMarkers: ios,
|
||||
Bundles: []any{},
|
||||
|
||||
Reference in New Issue
Block a user