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) => ` -
    +
    ${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")} - unconnected + + +
    `).join("") : `

    No ports yet.

    `; @@ -607,6 +650,9 @@ function renderInspectorDevice(body, id) {

    Ports

    ${portsHtml}
    +
    + +

    Requirements

    ${reqsHtml}
    @@ -669,6 +715,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) { @@ -1120,9 +1195,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() { @@ -1134,7 +1225,7 @@ 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"); @@ -1176,6 +1267,11 @@ function onCanvasPointerDown(e) { placeDeviceAt(p); return; } + if (state.tool === "port") { + e.preventDefault(); + placePortAt(p); + return; + } if (state.tool === "io") { e.preventDefault(); placeIOMarkerAt(p); @@ -1354,6 +1450,136 @@ 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: + * 1) No source picked yet → this port becomes the source. Highlight it. + * 2) Source already picked → this port is the target. POST a cable + * with `from_port_id` / `to_port_id`, type from the source port, + * auto=0. Shift-click flips the target to "bind to whole device" + * (uses `to_device_id` instead). */ +function onPortPointerDown(e, port) { + if (!state.active) return; + if (state.tool && state.tool !== "cable") return; // other tool wins + e.stopPropagation(); + e.preventDefault(); + if (state.cableDrawFromPortID == null) { + state.cableDrawFromPortID = port.id; + armTool("cable"); // get the crosshair cursor + visual cue + render(); + return; + } + if (state.cableDrawFromPortID === port.id) { + // Cancel — clicked the same port again. + state.cableDrawFromPortID = null; + armTool(null); + render(); + return; + } + finishCableDrawAt(port, e.shiftKey); +} + +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); diff --git a/web/static/style.css b/web/static/style.css index 2b9661d..54c7501 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -213,7 +213,28 @@ 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)); +} /* IO markers — diamonds. Power-by-convention, so the default fill is the Power cable_type colour (#e03131). Rotated 45° rect is the @@ -246,7 +267,7 @@ body { fill: #fff; stroke: var(--text); stroke-width: 2; - pointer-events: none; /* slice 4 — selection happens at device-level */ + cursor: crosshair; } .port-row {