package db import ( "database/sql" "errors" "fmt" "strings" ) // PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports. type PortCreate struct { TypeID int64 Label string XOffset float64 YOffset float64 } // PortUpdate is the partial-update shape. type PortUpdate struct { TypeID *int64 Label *string XOffset *float64 YOffset *float64 } // CreatePort inserts a port on a device. The device must exist in the // project; the cable type must exist globally. func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) { if _, err := s.GetDevice(projectID, deviceID); err != nil { return nil, err } if _, err := s.GetCableType(p.TypeID); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID) } return nil, err } label := strings.TrimSpace(p.Label) var labelArg any if label != "" { labelArg = label } res, err := s.db.Exec( `INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset) VALUES (?, ?, ?, ?, ?, ?)`, projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset, ) if err != nil { return nil, mapWriteErr(err) } id, _ := res.LastInsertId() return s.GetPort(projectID, id) } // GetPort loads a port by id, project-scoped. func (s *Store) GetPort(projectID, id int64) (*Port, error) { var p Port var label, ex sql.NullString err := s.db.QueryRow( `SELECT id, project_id, device_id, type_id, label, x_offset, y_offset, excalidraw_id, created_at, updated_at FROM ports WHERE id = ? AND project_id = ?`, id, projectID, ).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if label.Valid { v := label.String p.Label = &v } if ex.Valid { p.ExcalidrawID = &ex.String } return &p, nil } // UpdatePort applies a partial update. func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) { cur, err := s.GetPort(projectID, id) if err != nil { return nil, err } if u.TypeID != nil { if _, err := s.GetCableType(*u.TypeID); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID) } return nil, err } cur.TypeID = *u.TypeID } if u.Label != nil { v := strings.TrimSpace(*u.Label) if v == "" { cur.Label = nil } else { cur.Label = &v } } if u.XOffset != nil { cur.XOffset = *u.XOffset } if u.YOffset != nil { cur.YOffset = *u.YOffset } var labelArg any if cur.Label != nil { labelArg = *cur.Label } if _, err := s.db.Exec( `UPDATE ports SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now') WHERE id = ? AND project_id = ?`, cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID, ); err != nil { return nil, mapWriteErr(err) } return s.GetPort(projectID, id) } // DeletePort removes a port from a device. The schema's // ON DELETE SET NULL on cables.from_port_id / to_port_id collides with // the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so // we instead cascade-delete any cables that referenced the port on // either side — same effect from m's POV: the cable is gone, m can // re-draw if he still wants it. func (s *Store) DeletePort(projectID, id int64) error { if _, err := s.GetPort(projectID, id); err != nil { return err } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec( `DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`, projectID, id, id, ); err != nil { return err } if _, err := tx.Exec( `DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID, ); err != nil { return err } return tx.Commit() } // ListPortsForDevice returns every port on one device, project-scoped. func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) { rows, err := s.db.Query( `SELECT id, project_id, device_id, type_id, label, x_offset, y_offset, excalidraw_id, created_at, updated_at FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`, projectID, deviceID, ) if err != nil { return nil, err } defer rows.Close() out := []Port{} for rows.Next() { var p Port var label, ex sql.NullString if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, err } if label.Valid { v := label.String p.Label = &v } if ex.Valid { p.ExcalidrawID = &ex.String } out = append(out, p) } return out, rows.Err() } // ListPortsForProject returns every port in a project, ordered by // device_id + id so callers can group cheaply. func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) { rows, err := s.db.Query( `SELECT id, project_id, device_id, type_id, label, x_offset, y_offset, excalidraw_id, created_at, updated_at FROM ports WHERE project_id = ? ORDER BY device_id, id`, projectID, ) if err != nil { return nil, err } defer rows.Close() out := []Port{} for rows.Next() { var p Port var label, ex sql.NullString if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, err } if label.Valid { v := label.String p.Label = &v } if ex.Valid { p.ExcalidrawID = &ex.String } out = append(out, p) } return out, rows.Err() } // seedPortsFromType inserts the ports for a freshly-created device using // the type's `device_type_ports` profile. Port positions are computed by // laying out cable-type groups evenly along the configured edge of the // device, ordered by sort_order. Within a multi-count group the per-port // spacing is also even. Runs inside the same transaction as the device // insert so a failure rolls everything back. // // Layout strategy (v0): // - All ports for a given type sit on the type's configured edge. // - For each edge, compute total port count N (sum of count across // entries on that edge) and distribute as t_i = (i + 1)/(N+1) along // the edge length, so ports don't touch the corners. // - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom). // - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t. // - Labels: '' if count==1, ' 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:]) }