diff --git a/internal/db/ports.go b/internal/db/ports.go index 3da206c..edde1ec 100644 --- a/internal/db/ports.go +++ b/internal/db/ports.go @@ -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) { diff --git a/internal/server/ports.go b/internal/server/ports.go new file mode 100644 index 0000000..057c202 --- /dev/null +++ b/internal/server/ports.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go index be97546..ea41ffa 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/web/static/index.html b/web/static/index.html index d098a6c..3ab32ff 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -46,7 +46,7 @@
- + diff --git a/web/static/main.js b/web/static/main.js index f24387a..57127fe 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -56,8 +56,13 @@ const state = { /** @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), + /** 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", id: number} | null} */ selection: null, }; @@ -104,6 +109,12 @@ 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); @@ -305,11 +316,15 @@ function renderCanvas() { const cx = d.x + prt.x_offset; const cy = d.y + prt.y_offset; const color = cableTypeColor.get(prt.type_id) || "#888"; + const cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : ""); const c = svgEl("circle", { cx, cy, r: 5, - class: "port-circle", + class: cls, stroke: color, + "data-port-id": prt.id, }); + // Slice 7: port-click drives the manual cable-draw flow. + c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt)); g.append(c); } @@ -339,7 +354,17 @@ 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. @@ -469,9 +494,25 @@ function renderInspectorCable(body, id) { body.querySelector("#cab-type").textContent = ct ? `${ct.name}` : `type #${c.type_id}`; body.querySelector("#cab-from").textContent = fromLabel; body.querySelector("#cab-to").textContent = toLabel; - body.querySelector("#cab-driver").textContent = drivingReq - ? `requirement #${drivingReq.id}` - : (c.auto ? "(no matching requirement)" : "—"); + 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 () => { @@ -557,10 +598,12 @@ function renderInspectorDevice(body, id) { const portsHtml = ports.length ? ports.map((p) => ` -No ports yet.
`; @@ -607,6 +650,9 @@ function renderInspectorDevice(body, id) {Ports
Requirements