Before: ApplyTemplate dropped devices in a horizontal row at fixed canvas coords with frame_id NULL — devices appeared anywhere and m had no way to express "these belong together". Now: each apply creates a frame named after the template (suffixed "… 2/3/…" on name collision) and lays the devices out in a uniform grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the new frame's id and grid-cell coords. Schema unchanged. ApplyTemplateResult.frames_added carries the new frame so the frontend can refresh the canvas without a full snapshot reload. Tests: - TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is created with the template's name, every device has frame_id set, every device sits inside the frame rect, no two devices share a grid cell. - TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing "Living Room" frame in the project ⇒ template's frame named "Living Room 2". - Existing tests unchanged.
466 lines
13 KiB
Go
466 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
// ListSetupTemplates returns every template with its devices +
|
|
// requirements hydrated.
|
|
func (s *Store) ListSetupTemplates() ([]SetupTemplate, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, name, description, built_in, created_at, updated_at
|
|
FROM setup_templates ORDER BY id`,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []SetupTemplate{}
|
|
for rows.Next() {
|
|
var t SetupTemplate
|
|
var built int
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &built,
|
|
&t.CreatedAt, &t.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
t.BuiltIn = built != 0
|
|
out = append(out, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
devs, err := s.listTemplateDevices(out[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Devices = devs
|
|
reqs, err := s.listTemplateRequirements(out[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Requirements = reqs
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetSetupTemplate is a one-template variant of List.
|
|
func (s *Store) GetSetupTemplate(id int64) (*SetupTemplate, error) {
|
|
var t SetupTemplate
|
|
var built int
|
|
err := s.db.QueryRow(
|
|
`SELECT id, name, description, built_in, created_at, updated_at
|
|
FROM setup_templates WHERE id = ?`, id,
|
|
).Scan(&t.ID, &t.Name, &t.Description, &built, &t.CreatedAt, &t.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.BuiltIn = built != 0
|
|
t.Devices, err = s.listTemplateDevices(t.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Requirements, err = s.listTemplateRequirements(t.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) listTemplateDevices(templateID int64) ([]SetupTemplateDevice, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, template_id, device_type_id, suggested_name, sort_order
|
|
FROM setup_template_devices WHERE template_id = ? ORDER BY sort_order, id`,
|
|
templateID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []SetupTemplateDevice{}
|
|
for rows.Next() {
|
|
var d SetupTemplateDevice
|
|
var sn sql.NullString
|
|
if err := rows.Scan(&d.ID, &d.TemplateID, &d.DeviceTypeID, &sn, &d.SortOrder); err != nil {
|
|
return nil, err
|
|
}
|
|
if sn.Valid {
|
|
v := sn.String
|
|
d.SuggestedName = &v
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
// Hydrate the device_type for the UI's optgroup labels.
|
|
for i := range out {
|
|
dt, err := s.GetDeviceType(out[i].DeviceTypeID)
|
|
if err == nil {
|
|
out[i].DeviceType = dt
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) listTemplateRequirements(templateID int64) ([]SetupTemplateRequirement, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, template_id, from_template_device_id, to_template_device_id,
|
|
preferred_cable_type_id, must_connect
|
|
FROM setup_template_requirements WHERE template_id = ? ORDER BY id`,
|
|
templateID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []SetupTemplateRequirement{}
|
|
for rows.Next() {
|
|
var r SetupTemplateRequirement
|
|
var pct sql.NullInt64
|
|
var must int
|
|
if err := rows.Scan(&r.ID, &r.TemplateID, &r.FromTemplateDeviceID, &r.ToTemplateDeviceID,
|
|
&pct, &must); err != nil {
|
|
return nil, err
|
|
}
|
|
if pct.Valid {
|
|
v := pct.Int64
|
|
r.PreferredCableTypeID = &v
|
|
}
|
|
r.MustConnect = must != 0
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ApplyTemplateOptions controls per-device name overrides + opt-outs.
|
|
type ApplyTemplateOptions struct {
|
|
NameOverrides map[int64]string // template_device_id → custom name
|
|
SkipDevices map[int64]bool // template_device_id → skip
|
|
// Layout: where to place the first device in the cluster on the canvas.
|
|
OriginX, OriginY float64
|
|
}
|
|
|
|
// ApplyTemplate seeds devices + requirements from the template into
|
|
// projectID in a single transaction. Name collisions skip the device
|
|
// (recorded in skipped_devices); requirements whose endpoints both fail
|
|
// to land are also skipped.
|
|
func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOptions) (*ApplyTemplateResult, error) {
|
|
tmpl, err := s.GetSetupTemplate(templateID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &ApplyTemplateResult{
|
|
FramesAdded: []Frame{},
|
|
DevicesAdded: []Device{},
|
|
RequirementsAdded: []ConnectionRequirement{},
|
|
SkippedDevices: []SkippedTemplateDevice{},
|
|
RequirementsSkipped: []SkippedTemplateReq{},
|
|
}
|
|
|
|
if opts.OriginX == 0 && opts.OriginY == 0 {
|
|
opts.OriginX, opts.OriginY = 200, 200
|
|
}
|
|
|
|
// Pull existing device + frame names in the project so we can
|
|
// pre-check collisions without aborting the whole transaction.
|
|
existing, err := s.ListDevices(projectID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nameTaken := map[string]bool{}
|
|
for _, d := range existing {
|
|
nameTaken[d.Name] = true
|
|
}
|
|
existingFrames, err := s.ListFrames(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
frameNameTaken := map[string]bool{}
|
|
for _, f := range existingFrames {
|
|
frameNameTaken[f.Name] = true
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Plan a uniform grid for the template's devices inside a new frame
|
|
// named after the template. The grid drives both frame size and
|
|
// per-device (x, y). Devices that get skipped (name collision /
|
|
// SkipDevices) leave their grid cell empty.
|
|
const (
|
|
devW, devH = 100.0, 35.0
|
|
gapX, gapY = 30.0, 50.0
|
|
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
|
|
)
|
|
n := len(tmpl.Devices)
|
|
cols := 1
|
|
if n > 0 {
|
|
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
|
|
}
|
|
rows := 1
|
|
if n > 0 {
|
|
rows = (n + cols - 1) / cols
|
|
}
|
|
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
|
|
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
|
|
frameName := pickFrameName(tmpl.Name, frameNameTaken)
|
|
|
|
frame, err := createFrameTx(tx, projectID, FrameCreate{
|
|
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
|
|
Width: frameW, Height: frameH,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
|
|
}
|
|
out.FramesAdded = append(out.FramesAdded, *frame)
|
|
|
|
// Map: template_device_id → newly-created device_id (or 0 if skipped).
|
|
tmplToDevice := map[int64]int64{}
|
|
|
|
for i, td := range tmpl.Devices {
|
|
if opts.SkipDevices[td.ID] {
|
|
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
|
TemplateDeviceID: td.ID, Reason: "skip requested",
|
|
})
|
|
tmplToDevice[td.ID] = 0
|
|
continue
|
|
}
|
|
name := opts.NameOverrides[td.ID]
|
|
if name == "" && td.SuggestedName != nil {
|
|
name = *td.SuggestedName
|
|
}
|
|
if name == "" {
|
|
name = fmt.Sprintf("Device %d", td.ID)
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if nameTaken[name] {
|
|
out.SkippedDevices = append(out.SkippedDevices, SkippedTemplateDevice{
|
|
TemplateDeviceID: td.ID,
|
|
Reason: fmt.Sprintf("name %q already used in project", name),
|
|
})
|
|
tmplToDevice[td.ID] = 0
|
|
continue
|
|
}
|
|
// Grid cell (col, row) within the frame. Cell anchor is the
|
|
// top-left of the device rect; offsets are added to the frame's
|
|
// own (x, y) so the device sits inside the frame.
|
|
col := i % cols
|
|
row := i / cols
|
|
x := frame.X + padX + float64(col)*(devW+gapX)
|
|
y := frame.Y + padY + float64(row)*(devH+gapY)
|
|
// Use createDeviceTx so port-seeding shares the same transaction.
|
|
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
|
|
Name: name,
|
|
TypeID: &td.DeviceTypeID,
|
|
FrameID: &frame.ID,
|
|
X: x,
|
|
Y: y,
|
|
Width: devW,
|
|
Height: devH,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seed %s: %w", name, err)
|
|
}
|
|
nameTaken[name] = true
|
|
tmplToDevice[td.ID] = d.ID
|
|
out.DevicesAdded = append(out.DevicesAdded, *d)
|
|
}
|
|
|
|
for _, tr := range tmpl.Requirements {
|
|
fromID := tmplToDevice[tr.FromTemplateDeviceID]
|
|
toID := tmplToDevice[tr.ToTemplateDeviceID]
|
|
if fromID == 0 || toID == 0 {
|
|
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
|
TemplateRequirementID: tr.ID,
|
|
Reason: "one or both endpoint devices were skipped",
|
|
})
|
|
continue
|
|
}
|
|
// Normalise pair_lo/pair_hi, mirror what CreateConnectionRequirement does.
|
|
lo, hi := fromID, toID
|
|
if lo > hi {
|
|
lo, hi = hi, lo
|
|
}
|
|
must := 0
|
|
if tr.MustConnect {
|
|
must = 1
|
|
}
|
|
var ctArg any
|
|
if tr.PreferredCableTypeID != nil {
|
|
ctArg = *tr.PreferredCableTypeID
|
|
}
|
|
res, err := tx.Exec(
|
|
`INSERT INTO connection_requirements
|
|
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
|
must_connect, notes, pair_lo, pair_hi)
|
|
VALUES (?, ?, ?, ?, ?, '', ?, ?)`,
|
|
projectID, fromID, toID, ctArg, must, lo, hi,
|
|
)
|
|
if err != nil {
|
|
// A UNIQUE collision (project already has the same requirement)
|
|
// is non-fatal — record as skipped, continue.
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
out.RequirementsSkipped = append(out.RequirementsSkipped, SkippedTemplateReq{
|
|
TemplateRequirementID: tr.ID,
|
|
Reason: "requirement already exists in project",
|
|
})
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
rid, _ := res.LastInsertId()
|
|
out.RequirementsAdded = append(out.RequirementsAdded, ConnectionRequirement{
|
|
ID: rid,
|
|
ProjectID: projectID,
|
|
FromDeviceID: fromID,
|
|
ToDeviceID: toID,
|
|
PreferredCableTypeID: tr.PreferredCableTypeID,
|
|
MustConnect: tr.MustConnect,
|
|
})
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// pickFrameName returns a frame name that doesn't collide with anything
|
|
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
|
|
// and so on.
|
|
func pickFrameName(base string, taken map[string]bool) string {
|
|
if !taken[base] {
|
|
return base
|
|
}
|
|
for i := 2; ; i++ {
|
|
candidate := fmt.Sprintf("%s %d", base, i)
|
|
if !taken[candidate] {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
|
|
// the validation in CreateFrame (name + positive size) but avoids the
|
|
// s.db.Exec call so ApplyTemplate can keep everything on the same
|
|
// connection under MaxOpenConns(1).
|
|
func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) {
|
|
name := strings.TrimSpace(f.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if f.Width <= 0 || f.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
|
}
|
|
res, err := tx.Exec(
|
|
`INSERT INTO frames (project_id, name, x, y, width, height)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
projectID, name, f.X, f.Y, f.Width, f.Height,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
var out Frame
|
|
var ex sql.NullString
|
|
err = tx.QueryRow(
|
|
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
|
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
|
|
&ex, &out.CreatedAt, &out.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ex.Valid {
|
|
out.ExcalidrawID = &ex.String
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// createDeviceTx is a tx-aware variant of CreateDevice used by
|
|
// ApplyTemplate so seeding the template's devices + their ports stays
|
|
// inside one atomic apply.
|
|
//
|
|
// Validation is intentionally lighter than CreateDevice: callers (only
|
|
// ApplyTemplate today) hold a tx on the single SQLite connection, so
|
|
// any "validate by reading from s.db" call would deadlock. The template's
|
|
// device_type_id + frame_id come from already-validated template rows,
|
|
// and SQLite FK constraints catch any genuine corruption on INSERT
|
|
// (mapped to ErrInvalidInput by mapWriteErr).
|
|
func (s *Store) createDeviceTx(tx *sql.Tx, projectID int64, d DeviceCreate) (*Device, error) {
|
|
name := strings.TrimSpace(d.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if d.Width <= 0 || d.Height <= 0 {
|
|
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
|
}
|
|
color := strings.TrimSpace(d.Color)
|
|
if color == "" {
|
|
color = "#1e1e1e"
|
|
}
|
|
res, err := tx.Exec(
|
|
`INSERT INTO devices (project_id, frame_id, type_id, name, color, x, y, width, height)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
projectID, nullableInt64(d.FrameID), nullableInt64(d.TypeID),
|
|
name, color, d.X, d.Y, d.Width, d.Height,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
deviceID, _ := res.LastInsertId()
|
|
if d.TypeID != nil {
|
|
if err := s.seedPortsFromType(tx, projectID, deviceID, *d.TypeID, d.Width, d.Height); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Read back via the public store path is fine — the row exists in
|
|
// the in-flight tx and SQLite sees its own writes within the tx.
|
|
// Use a sub-helper that takes the tx executor for clean isolation.
|
|
return s.readDeviceTx(tx, projectID, deviceID)
|
|
}
|
|
|
|
func (s *Store) readDeviceTx(ex execer, projectID, id int64) (*Device, error) {
|
|
var d Device
|
|
var frame, typeID sql.NullInt64
|
|
var ex2 sql.NullString
|
|
err := ex.QueryRow(
|
|
`SELECT id, project_id, frame_id, type_id, name, color, x, y, width, height, excalidraw_id, created_at, updated_at
|
|
FROM devices WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&d.ID, &d.ProjectID, &frame, &typeID, &d.Name, &d.Color, &d.X, &d.Y, &d.Width, &d.Height,
|
|
&ex2, &d.CreatedAt, &d.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
d.FrameID = &v
|
|
}
|
|
if typeID.Valid {
|
|
v := typeID.Int64
|
|
d.TypeID = &v
|
|
}
|
|
if ex2.Valid {
|
|
d.ExcalidrawID = &ex2.String
|
|
}
|
|
return &d, nil
|
|
}
|