merge: slice 1 — bootstrap + project CRUD + global cable_types

picasso shipped (7 commits @ 905c75c):
- Go module + cmd/mcables binary
- internal/db: migrations runner + 001_init.sql (full v3 schema, 5 cable_types seeded)
- internal/db/store.go: projects + cable_types CRUD with sentinel errors
- internal/server: net/http handlers (Go 1.22 ServeMux)
- web/static: project picker, legend, modals (new project / cable type / delete), ?project= URL state
- 17 store tests green, end-to-end smoke verified

Endpoints live: /api/healthz, /api/projects {GET POST}, /api/projects/:id
{GET PATCH DELETE?confirm=<name>}, /api/cable-types {GET POST}, /api/cable-types/:id {PATCH DELETE}.

Next: slice 2 (frames + devices + drag-to-position) on m's go.
This commit is contained in:
mAi
2026-05-15 16:50:02 +02:00
18 changed files with 2099 additions and 25 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Local DB
data/*.db
data/*.db-wal
data/*.db-shm
# mai worker-local logs (per-worktree, not source)
.m/
# Build artefacts
bin/
mcables
# Editor
.vscode/
.idea/
*.swp

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: build run test typecheck fmt clean
BIN := bin/mcables
PKG := ./...
build:
@mkdir -p bin
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables
run:
go run ./cmd/mcables
test:
go test -race $(PKG)
typecheck:
@if [ -f web/tsconfig.json ]; then \
cd web && tsc --noEmit; \
else \
echo "web/tsconfig.json not present yet — typecheck skipped"; \
fi
fmt:
gofmt -s -w .
clean:
rm -rf bin

106
README.md
View File

@@ -1,37 +1,93 @@
# mCables
Cable management for m's setup — visual interface + SQLite inventory, generating + updating Excalidraw diagrams via mExDraw.
Cable-management **framework** for m's setup — visual web editor backed by
a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.
Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables
*project*; each project is backed by exactly one `.excalidraw` drawing on
mxdrw.msbls.de.
## Status
Bootstrap. Architecture sketch below; implementation pending.
Slice 1 — bootstrap shipped. Projects + global cable types are
end-to-end; the SVG canvas is intentionally empty until slice 2.
## Goal
Track devices, ports, and cables across m's setups (server rack, office, living room). Generate / update Excalidraw diagrams from the inventory. Detect bundles of parallel cables. Visualise cable types by colour (RJ45, DP, HDMI, USB, Power, …).
m's existing drawing is the seed: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw — devices are rectangles, ports are ellipses positioned on the device, cables are arrows from port to port, cable type is encoded via colour with a legend.
## Architecture sketch
| Layer | Tech | Role |
| Slice | What's in it | Status |
|---|---|---|
| Storage | SQLite (`~/.m/mcables.db`) | `devices`, `ports`, `cables`, `cable_types`, `bundles`, `frames` |
| Backend | Go | HTTP API serving the visual frontend, mExDraw integration for diagram I/O |
| Frontend | Visual web UI | Browser-based editor (no CLI). Add/edit devices and cables, see live preview |
| Output | mExDraw via MCP | Render + update Excalidraw drawings |
| Project tracking | mBrian `topic-mcables` | Decisions, status, links to drawings — not the data itself |
| 1 | Project CRUD, global cable types, empty SVG canvas, project picker | ✅ |
| 2 | Frames + devices, drag-to-position | pending |
| 3 | Ports + cables (click-port → click-port) | pending |
| 4 | IO markers + cable-type editing | pending |
| 5 | Export to mxdrw.msbls.de | pending |
## Tech decisions (open)
## Run it
- Frontend stack — vanilla TS + small UI lib, or a framework (Svelte / Preact)?
- Diagram import from the existing `Cable-Management.excalidraw` — one-shot migration script that parses bindings → DB rows.
- Layout algorithm for bundle suggestions — parallel cables along the same path get bundled visually.
```sh
go run ./cmd/mcables
# open http://localhost:7777
```
These get resolved in the first design pass.
Or built:
## Refs
```sh
make build
./bin/mcables
```
- m's seed drawing: https://mxdrw.msbls.de/draw/Cable-Management.excalidraw
- mExDraw MCP: `mcp__mexdraw__*`
- Related: mBrian `topic-msbls` (infrastructure inventory)
The binary serves the frontend from an embedded `web/static/` and the
JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default.
### Environment
| Var | Default | Notes |
|---|---|---|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. |
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. |
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. |
### Tests
```sh
make test # go test -race ./...
```
Store-level tests cover projects + cable-types CRUD, the
`drawing_name` auto-default, the `?confirm=<name>` guardrail on
`DELETE /api/projects/:pid`, and the `ON DELETE RESTRICT` on a
referenced cable type.
## API (slice 1)
```
GET /api/healthz → 200 {"status":"ok"}
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name?, description?}
drawing_name defaults to "<name>.excalidraw"
GET /api/projects/:pid → {project, cable_types, frames, devices, …}
PATCH /api/projects/:pid ← partial
DELETE /api/projects/:pid?confirm=<name> ← confirm must equal current name
GET /api/cable-types → [CableType, …] (global)
POST /api/cable-types ← {name, color}
PATCH /api/cable-types/:id ← partial — affects every project
DELETE /api/cable-types/:id ← 409 in_use if any cable references it
```
## Design + project conventions
- `docs/design.md` — full v3 design (schema, API, importer/export
conventions, slices, mDock deploy notes).
- `CLAUDE.md` — project instructions for mai workers.
## Architecture
| Layer | Tech |
|---|---|
| DB | SQLite via `modernc.org/sqlite` (cgo-free), WAL, FKs on |
| Backend | Go 1.22+ `net/http` ServeMux pattern routing, single binary |
| Frontend | Vanilla ES modules + SVG, no build step, embedded via `embed.FS` |
| Export (slice 5) | mExDraw HTTP API on mxdrw.msbls.de |
LAN-trusted, no auth.

0
data/.gitkeep Normal file
View File

16
go.mod Normal file
View File

@@ -0,0 +1,16 @@
module mgit.msbls.de/m/mcables
go 1.25.5
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1 // indirect
)

21
go.sum Normal file
View File

@@ -0,0 +1,21 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

47
internal/db/db.go Normal file
View File

@@ -0,0 +1,47 @@
// Package db owns SQLite access for mCables: migrations runner + the
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests
// and the HTTP layer take a *Store, never a raw *sql.DB.
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens (or creates) the SQLite file at path and returns a Store
// with WAL + foreign keys + busy_timeout configured.
func Open(path string) (*Store, error) {
// `_pragma` query params are honoured by modernc.org/sqlite for
// connection-time PRAGMA setup. journal_mode WAL is persistent
// across opens; the others apply per-connection.
dsn := fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)",
path,
)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := d.Ping(); err != nil {
_ = d.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
// Single writer keeps things deterministic for a local-LAN tool;
// reads scale fine in WAL.
d.SetMaxOpenConns(1)
return &Store{db: d}, nil
}
// Store is the application's handle on the SQLite database.
type Store struct {
db *sql.DB
}
// DB returns the underlying *sql.DB. Used by Migrate and (sparingly) by
// callers that need a raw query escape hatch.
func (s *Store) DB() *sql.DB { return s.db }
// Close releases the database.
func (s *Store) Close() error { return s.db.Close() }

94
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,94 @@
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
"strings"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Migrate applies any pending SQL files from migrations/*.sql in
// lexicographic order against the given *sql.DB. Applied filenames are
// tracked in schema_migrations so each runs at most once. Idempotent.
func Migrate(d *sql.DB) error {
if _, err := d.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied, err := loadApplied(d)
if err != nil {
return err
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
if applied[name] {
continue
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if err := runMigration(d, name, string(body)); err != nil {
return err
}
}
return nil
}
func loadApplied(d *sql.DB) (map[string]bool, error) {
rows, err := d.Query("SELECT name FROM schema_migrations")
if err != nil {
return nil, fmt.Errorf("load applied: %w", err)
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out[n] = true
}
return out, rows.Err()
}
func runMigration(d *sql.DB, name, body string) error {
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("begin %s: %w", name, err)
}
if _, err := tx.Exec(body); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (name) VALUES (?)", name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,144 @@
-- mCables v3 initial schema. See docs/design.md §2.
-- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw.
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
drawing_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: GLOBAL legend, shared across all projects.
-- Seeded once below with the 5 defaults.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 1200,
height REAL NOT NULL DEFAULT 800,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX frames_project_idx ON frames(project_id);
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
CREATE TABLE ports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
x_offset REAL NOT NULL,
y_offset REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX ports_project_idx ON ports(project_id);
CREATE INDEX ports_device_idx ON ports(device_id);
CREATE INDEX ports_type_idx ON ports(type_id);
CREATE TABLE io_markers (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
label TEXT NOT NULL DEFAULT 'IO',
x REAL NOT NULL,
y REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX io_markers_project_idx ON io_markers(project_id);
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
CREATE TABLE cables (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
),
CHECK (
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX cables_project_idx ON cables(project_id);
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
CREATE INDEX cables_type_idx ON cables(type_id);
CREATE TABLE bundles (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX bundles_project_idx ON bundles(project_id);
CREATE TABLE bundle_cables (
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
-- Seed the 5 default cable types, once.
INSERT INTO cable_types (name, color) VALUES
('Power', '#e03131'),
('USB', '#2f9e44'),
('HDMI', '#1971c2'),
('DP', '#9c36b5'),
('RJ45', '#ffd500');

34
internal/db/models.go Normal file
View File

@@ -0,0 +1,34 @@
package db
// Project is the top-level entity. One project ↔ one .excalidraw drawing.
type Project struct {
ID int64 `json:"id"`
Name string `json:"name"`
DrawingName string `json:"drawing_name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CableType is global. Renaming/recolouring affects every project.
type CableType struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Snapshot is the editor's one-shot loader payload for a single project.
// Slice 1 returns the project + the global cable_types; the other arrays
// are present but empty until later slices ship their CRUD.
type Snapshot struct {
Project Project `json:"project"`
Frames []any `json:"frames"`
Devices []any `json:"devices"`
Ports []any `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []any `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
}

321
internal/db/store.go Normal file
View File

@@ -0,0 +1,321 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// Sentinel errors callers can match against. The server layer maps these
// to HTTP status codes.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict") // UNIQUE violation
ErrInUse = errors.New("in use") // cable_type referenced by a cable
ErrConfirmName = errors.New("confirm name missing or mismatched")
ErrInvalidInput = errors.New("invalid input")
)
// -----------------------------------------------------------------------------
// Projects
// -----------------------------------------------------------------------------
// CreateProject inserts a new project. drawingName, if empty, defaults to
// "<name>.excalidraw". name and drawingName are trimmed; an empty name
// after trimming is rejected.
func (s *Store) CreateProject(name, drawingName, description string) (*Project, error) {
name = strings.TrimSpace(name)
drawingName = strings.TrimSpace(drawingName)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if drawingName == "" {
drawingName = name + ".excalidraw"
}
res, err := s.db.Exec(
`INSERT INTO projects (name, drawing_name, description) VALUES (?, ?, ?)`,
name, drawingName, description,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetProject(id)
}
// GetProject loads a project by ID.
func (s *Store) GetProject(id int64) (*Project, error) {
var p Project
err := s.db.QueryRow(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
// ListProjects returns every project ordered by name.
func (s *Store) ListProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT id, name, drawing_name, description, created_at, updated_at
FROM projects ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.DrawingName, &p.Description, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ProjectUpdate carries partial fields for PATCH. A nil pointer means
// "leave this field untouched".
type ProjectUpdate struct {
Name *string
DrawingName *string
Description *string
}
// UpdateProject applies the partial update. Empty struct = no-op (just
// bumps updated_at). Empty Name (after trim) is rejected; whitespace-only
// DrawingName is treated as "use <name>.excalidraw" — same default as
// CreateProject.
func (s *Store) UpdateProject(id int64, u ProjectUpdate) (*Project, error) {
cur, err := s.GetProject(id)
if err != nil {
return nil, err
}
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.DrawingName != nil {
v := strings.TrimSpace(*u.DrawingName)
if v == "" {
v = cur.Name + ".excalidraw"
}
cur.DrawingName = v
}
if u.Description != nil {
cur.Description = *u.Description
}
if _, err := s.db.Exec(
`UPDATE projects
SET name = ?, drawing_name = ?, description = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.DrawingName, cur.Description, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetProject(id)
}
// DeleteProject removes the project (cascading frames, devices, ports,
// cables, io_markers, bundles, bundle_cables). confirmName must match the
// project's current name; otherwise ErrConfirmName is returned and nothing
// is deleted.
func (s *Store) DeleteProject(id int64, confirmName string) error {
p, err := s.GetProject(id)
if err != nil {
return err
}
if confirmName != p.Name {
return ErrConfirmName
}
if _, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id); err != nil {
return err
}
return nil
}
// Snapshot loads the full editor-init payload for one project. In slice
// 1 the project-scoped collections are still empty.
func (s *Store) Snapshot(id int64) (*Snapshot, error) {
p, err := s.GetProject(id)
if err != nil {
return nil, err
}
types, err := s.ListCableTypes()
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: []any{},
Devices: []any{},
Ports: []any{},
Cables: []any{},
IOMarkers: []any{},
Bundles: []any{},
CableTypes: types,
}, nil
}
// -----------------------------------------------------------------------------
// Cable types (global)
// -----------------------------------------------------------------------------
// CreateCableType inserts a global cable type. name must be globally unique.
func (s *Store) CreateCableType(name, color string) (*CableType, error) {
name = strings.TrimSpace(name)
color = strings.TrimSpace(color)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if color == "" {
return nil, fmt.Errorf("%w: color is required", ErrInvalidInput)
}
res, err := s.db.Exec(
`INSERT INTO cable_types (name, color) VALUES (?, ?)`, name, color,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetCableType(id)
}
// GetCableType loads a cable type by ID.
func (s *Store) GetCableType(id int64) (*CableType, error) {
var t CableType
err := s.db.QueryRow(
`SELECT id, name, color, created_at, updated_at
FROM cable_types WHERE id = ?`, id,
).Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &t, nil
}
// ListCableTypes returns every cable type ordered by id (insertion order,
// so the legend renders in the same order across reloads).
func (s *Store) ListCableTypes() ([]CableType, error) {
rows, err := s.db.Query(
`SELECT id, name, color, created_at, updated_at
FROM cable_types ORDER BY id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableType{}
for rows.Next() {
var t CableType
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// CableTypeUpdate is the partial-update shape for PATCH.
type CableTypeUpdate struct {
Name *string
Color *string
}
// UpdateCableType applies a partial update.
func (s *Store) UpdateCableType(id int64, u CableTypeUpdate) (*CableType, error) {
cur, err := s.GetCableType(id)
if err != nil {
return nil, err
}
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.Color != nil {
v := strings.TrimSpace(*u.Color)
if v == "" {
return nil, fmt.Errorf("%w: color cannot be empty", ErrInvalidInput)
}
cur.Color = v
}
if _, err := s.db.Exec(
`UPDATE cable_types
SET name = ?, color = ?, updated_at = datetime('now')
WHERE id = ?`,
cur.Name, cur.Color, id,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetCableType(id)
}
// DeleteCableType removes a cable type. SQLite enforces ON DELETE RESTRICT
// from cables.type_id and ports.type_id; we surface that as ErrInUse plus
// the count of referencing cables (so the UI can show "blocked by N cables").
func (s *Store) DeleteCableType(id int64) error {
if _, err := s.GetCableType(id); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM cable_types WHERE id = ?`, id); err != nil {
if isForeignKeyConstraint(err) {
return ErrInUse
}
return err
}
return nil
}
// CountCablesUsingType returns how many cables reference this cable_type.
// Used by the server to enrich a 409 InUse response with a helpful number.
func (s *Store) CountCablesUsingType(id int64) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM cables WHERE type_id = ?`, id).Scan(&n)
return n, err
}
// -----------------------------------------------------------------------------
// Error mapping
// -----------------------------------------------------------------------------
// mapWriteErr classifies SQLite write errors into our sentinel errors so
// the handler layer can pick the right HTTP status. Falls through to the
// raw error for anything we don't recognise.
func mapWriteErr(err error) error {
if err == nil {
return nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "UNIQUE constraint failed"):
return fmt.Errorf("%w: %s", ErrConflict, msg)
case strings.Contains(msg, "FOREIGN KEY constraint failed"):
return fmt.Errorf("%w: %s", ErrInUse, msg)
case strings.Contains(msg, "CHECK constraint failed"):
return fmt.Errorf("%w: %s", ErrInvalidInput, msg)
}
return err
}
func isForeignKeyConstraint(err error) bool {
return err != nil && strings.Contains(err.Error(), "FOREIGN KEY constraint failed")
}

281
internal/db/store_test.go Normal file
View File

@@ -0,0 +1,281 @@
package db
import (
"errors"
"path/filepath"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
if err := Migrate(s.DB()); err != nil {
t.Fatalf("migrate: %v", err)
}
return s
}
// --------------------------------------------------------------------- projects
func TestCreateProject_DefaultsDrawingName(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject("LOFT", "", "")
if err != nil {
t.Fatalf("create: %v", err)
}
if p.Name != "LOFT" {
t.Errorf("name = %q, want LOFT", p.Name)
}
if p.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw", p.DrawingName)
}
}
func TestCreateProject_AcceptsExplicitDrawingName(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject("OFFICE", "office-rack.excalidraw", "rack only")
if err != nil {
t.Fatalf("create: %v", err)
}
if p.DrawingName != "office-rack.excalidraw" {
t.Errorf("drawing_name = %q, want office-rack.excalidraw", p.DrawingName)
}
if p.Description != "rack only" {
t.Errorf("description = %q", p.Description)
}
}
func TestCreateProject_EmptyNameRejected(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateProject(" ", "", ""); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
}
func TestCreateProject_DuplicateNameRejected(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateProject("LOFT", "", ""); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := s.CreateProject("LOFT", "", ""); !errors.Is(err, ErrConflict) {
t.Fatalf("second create err = %v, want ErrConflict", err)
}
}
func TestListProjects_OrderedByName(t *testing.T) {
s := newTestStore(t)
for _, name := range []string{"OFFICE", "LOFT", "GARAGE"} {
if _, err := s.CreateProject(name, "", ""); err != nil {
t.Fatalf("create %s: %v", name, err)
}
}
got, err := s.ListProjects()
if err != nil {
t.Fatalf("list: %v", err)
}
want := []string{"GARAGE", "LOFT", "OFFICE"}
for i, p := range got {
if p.Name != want[i] {
t.Errorf("[%d] = %q, want %q", i, p.Name, want[i])
}
}
}
func TestGetProject_NotFound(t *testing.T) {
s := newTestStore(t)
if _, err := s.GetProject(999); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUpdateProject_PartialFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
newName := "LOFT-2"
updated, err := s.UpdateProject(p.ID, ProjectUpdate{Name: &newName})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "LOFT-2" {
t.Errorf("name = %q, want LOFT-2", updated.Name)
}
// drawing_name should not auto-change from a Name update — it's only
// auto-defaulted when drawing_name is explicitly set to empty.
if updated.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw (unchanged)", updated.DrawingName)
}
}
func TestUpdateProject_BlankDrawingNameDefaults(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "old.excalidraw", "")
blank := " "
updated, err := s.UpdateProject(p.ID, ProjectUpdate{DrawingName: &blank})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.DrawingName != "LOFT.excalidraw" {
t.Errorf("drawing_name = %q, want LOFT.excalidraw", updated.DrawingName)
}
}
func TestDeleteProject_ConfirmGuardrail(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Wrong name → no delete.
if err := s.DeleteProject(p.ID, "OFFICE"); !errors.Is(err, ErrConfirmName) {
t.Fatalf("wrong-name err = %v, want ErrConfirmName", err)
}
if _, err := s.GetProject(p.ID); err != nil {
t.Fatalf("project should still exist: %v", err)
}
// Empty confirm → no delete.
if err := s.DeleteProject(p.ID, ""); !errors.Is(err, ErrConfirmName) {
t.Fatalf("empty-confirm err = %v, want ErrConfirmName", err)
}
// Correct name → delete.
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
t.Fatalf("correct-name delete: %v", err)
}
if _, err := s.GetProject(p.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("project should be gone: %v", err)
}
}
func TestSnapshot_IncludesGlobalCableTypes(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if snap.Project.ID != p.ID {
t.Errorf("project.id = %d, want %d", snap.Project.ID, p.ID)
}
if len(snap.CableTypes) != 5 {
t.Errorf("cable_types len = %d, want 5 (the seeded defaults)", len(snap.CableTypes))
}
if snap.Frames == nil || snap.Devices == nil || snap.Ports == nil ||
snap.Cables == nil || snap.IOMarkers == nil || snap.Bundles == nil {
t.Errorf("snapshot collections must be non-nil arrays, not null, for slice-1 JSON output")
}
}
// ------------------------------------------------------------------ cable_types
func TestListCableTypes_SeededFive(t *testing.T) {
s := newTestStore(t)
ts, err := s.ListCableTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
wantNames := []string{"Power", "USB", "HDMI", "DP", "RJ45"}
if len(ts) != 5 {
t.Fatalf("len = %d, want 5", len(ts))
}
for i, want := range wantNames {
if ts[i].Name != want {
t.Errorf("[%d].Name = %q, want %q", i, ts[i].Name, want)
}
if ts[i].Color == "" {
t.Errorf("[%d].Color empty", i)
}
}
}
func TestCreateCableType_GlobalUnique(t *testing.T) {
s := newTestStore(t)
if _, err := s.CreateCableType("Audio", "#ff0000"); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := s.CreateCableType("Audio", "#00ff00"); !errors.Is(err, ErrConflict) {
t.Fatalf("dup err = %v, want ErrConflict", err)
}
}
func TestUpdateCableType_RenameAndRecolour(t *testing.T) {
s := newTestStore(t)
ts, _ := s.ListCableTypes()
hdmi := ts[2] // seed order: Power, USB, HDMI, DP, RJ45
newName := "HDMI-2.1"
newColor := "#000000"
updated, err := s.UpdateCableType(hdmi.ID, CableTypeUpdate{Name: &newName, Color: &newColor})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "HDMI-2.1" || updated.Color != "#000000" {
t.Errorf("got %+v", updated)
}
}
func TestDeleteCableType_BlockedByCable(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Reach the seeded Power cable type.
ts, _ := s.ListCableTypes()
power := ts[0]
// Wire up a minimal cable referencing the Power type via the raw DB
// (the typed device/port API ships in slice 2+). The schema CHECK
// requires exactly one endpoint each side — use device-level binding
// against placeholder rows.
d := s.DB()
res, err := d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderA")
if err != nil {
t.Fatalf("insert device A: %v", err)
}
deviceA, _ := res.LastInsertId()
res, err = d.Exec(`INSERT INTO devices (project_id, name, x, y, width, height)
VALUES (?, ?, 0, 0, 100, 30)`, p.ID, "PlaceholderB")
if err != nil {
t.Fatalf("insert device B: %v", err)
}
deviceB, _ := res.LastInsertId()
if _, err := d.Exec(`INSERT INTO cables
(project_id, type_id, from_device_id, to_device_id)
VALUES (?, ?, ?, ?)`, p.ID, power.ID, deviceA, deviceB); err != nil {
t.Fatalf("insert cable: %v", err)
}
// Now delete → must be blocked.
if err := s.DeleteCableType(power.ID); !errors.Is(err, ErrInUse) {
t.Fatalf("delete err = %v, want ErrInUse", err)
}
n, err := s.CountCablesUsingType(power.ID)
if err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Errorf("count = %d, want 1", n)
}
}
func TestDeleteCableType_UnusedSucceeds(t *testing.T) {
s := newTestStore(t)
t2, _ := s.CreateCableType("Audio", "#000000")
if err := s.DeleteCableType(t2.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}
func TestDeleteProject_DoesNotTouchCableTypes(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteProject(p.ID, "LOFT"); err != nil {
t.Fatalf("delete: %v", err)
}
ts, _ := s.ListCableTypes()
if len(ts) != 5 {
t.Errorf("cable_types should survive project deletion; got %d, want 5", len(ts))
}
}

236
internal/server/handlers.go Normal file
View File

@@ -0,0 +1,236 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"mgit.msbls.de/m/mcables/internal/db"
)
type handlers struct {
store *db.Store
}
// ---------------------------------------------------------------- utility
// writeJSON encodes v as JSON at the given status. Errors during encoding
// are logged-silent (the response has already started) — this is the
// last-resort path; callers should validate inputs early.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error string `json:"error"`
Details any `json:"details,omitempty"`
}
// writeError maps a Store sentinel to an HTTP status + JSON body.
func writeError(w http.ResponseWriter, err error, details any) {
switch {
case errors.Is(err, db.ErrNotFound):
writeJSON(w, http.StatusNotFound, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrConflict):
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInUse):
writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrConfirmName):
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
case errors.Is(err, db.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details})
default:
writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details})
}
}
func parseInt64Path(r *http.Request, key string) (int64, bool) {
raw := r.PathValue(key)
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// ---------------------------------------------------------------- health
func (h *handlers) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// ---------------------------------------------------------------- projects
type projectCreate struct {
Name string `json:"name"`
DrawingName string `json:"drawing_name"`
Description string `json:"description"`
}
type projectPatch struct {
Name *string `json:"name,omitempty"`
DrawingName *string `json:"drawing_name,omitempty"`
Description *string `json:"description,omitempty"`
}
func (h *handlers) listProjects(w http.ResponseWriter, _ *http.Request) {
ps, err := h.store.ListProjects()
if err != nil {
writeError(w, err, nil)
return
}
if ps == nil {
ps = []db.Project{}
}
writeJSON(w, http.StatusOK, ps)
}
func (h *handlers) createProject(w http.ResponseWriter, r *http.Request) {
var body projectCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.CreateProject(body.Name, body.DrawingName, body.Description)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *handlers) getProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
snap, err := h.store.Snapshot(id)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, snap)
}
func (h *handlers) patchProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body projectPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.UpdateProject(id, db.ProjectUpdate{
Name: body.Name,
DrawingName: body.DrawingName,
Description: body.Description,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *handlers) deleteProject(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
confirm := r.URL.Query().Get("confirm")
if confirm == "" {
writeError(w, db.ErrConfirmName,
"DELETE requires ?confirm=<project name> matching the project's current name")
return
}
if err := h.store.DeleteProject(id, confirm); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- cable_types
type cableTypeCreate struct {
Name string `json:"name"`
Color string `json:"color"`
}
type cableTypePatch struct {
Name *string `json:"name,omitempty"`
Color *string `json:"color,omitempty"`
}
func (h *handlers) listCableTypes(w http.ResponseWriter, _ *http.Request) {
ts, err := h.store.ListCableTypes()
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ts)
}
func (h *handlers) createCableType(w http.ResponseWriter, r *http.Request) {
var body cableTypeCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
t, err := h.store.CreateCableType(body.Name, body.Color)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (h *handlers) patchCableType(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body cableTypePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
t, err := h.store.UpdateCableType(id, db.CableTypeUpdate{
Name: body.Name,
Color: body.Color,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *handlers) deleteCableType(w http.ResponseWriter, r *http.Request) {
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteCableType(id); err != nil {
// On ErrInUse, count referencing cables so the client can show
// "blocked by N cables".
if errors.Is(err, db.ErrInUse) {
n, _ := h.store.CountCablesUsingType(id)
writeError(w, err, map[string]int{"in_use_by_cables": n})
return
}
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

40
internal/server/server.go Normal file
View File

@@ -0,0 +1,40 @@
// Package server wires the HTTP API + the embedded frontend onto a
// single net/http handler. Routes use Go 1.22 ServeMux pattern matching
// (no router framework).
package server
import (
"io/fs"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
// New returns an http.Handler serving the mCables API at /api/ and the
// embedded frontend at /. The frontend FS should be rooted such that
// "index.html" is at its root.
func New(store *db.Store, frontend fs.FS) http.Handler {
mux := http.NewServeMux()
h := &handlers{store: store}
// Health
mux.HandleFunc("GET /api/healthz", h.healthz)
// Projects
mux.HandleFunc("GET /api/projects", h.listProjects)
mux.HandleFunc("POST /api/projects", h.createProject)
mux.HandleFunc("GET /api/projects/{pid}", h.getProject)
mux.HandleFunc("PATCH /api/projects/{pid}", h.patchProject)
mux.HandleFunc("DELETE /api/projects/{pid}", h.deleteProject)
// Cable types (global)
mux.HandleFunc("GET /api/cable-types", h.listCableTypes)
mux.HandleFunc("POST /api/cable-types", h.createCableType)
mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType)
mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
mux.Handle("/", http.FileServerFS(frontend))
return mux
}

135
web/static/index.html Normal file
View File

@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<span class="brand">mCables</span>
<div class="project-picker">
<label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project">
<option value="">— no project —</option>
</select>
<button type="button" id="btn-new-project" class="btn">+ Project</button>
<button type="button" id="btn-delete-project" class="btn btn-danger" hidden>
Delete
</button>
</div>
<div class="topbar-spacer"></div>
<button type="button" id="btn-export" class="btn" disabled title="Slice 5">
Export
</button>
</header>
<main class="layout">
<aside class="sidebar" aria-label="Tools">
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>
<ul class="tool-list">
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Frame</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
</ul>
</section>
</aside>
<section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<g id="canvas-frames"></g>
<g id="canvas-devices"></g>
<g id="canvas-ports"></g>
<g id="canvas-cables"></g>
<g id="canvas-io"></g>
</svg>
<p id="empty-hint" class="empty-hint">
Pick or create a project to start drawing.
</p>
</section>
<aside class="inspector" aria-label="Inspector">
<h2 class="sidebar-heading">Inspector</h2>
<p class="muted">Nothing selected.</p>
</aside>
</main>
<!-- New Project modal -->
<dialog id="modal-new-project" class="modal" aria-labelledby="np-title">
<form method="dialog" id="form-new-project">
<h2 id="np-title">New project</h2>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Drawing name</span>
<input type="text" name="drawing_name" autocomplete="off"
placeholder="auto: <name>.excalidraw" />
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="2"></textarea>
</label>
<p class="form-error" id="np-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- New/Edit Cable Type modal -->
<dialog id="modal-cable-type" class="modal" aria-labelledby="ct-title">
<form method="dialog" id="form-cable-type">
<h2 id="ct-title">Cable type</h2>
<p class="banner">
Cable types are shared across all projects. Renaming or recolouring
affects every project.
</p>
<label class="field">
<span>Name</span>
<input type="text" name="name" required autocomplete="off" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" name="color" value="#1971c2" />
</label>
<p class="form-error" id="ct-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<!-- Delete Project confirm -->
<dialog id="modal-delete-project" class="modal" aria-labelledby="dp-title">
<form method="dialog" id="form-delete-project">
<h2 id="dp-title">Delete project</h2>
<p>
This will cascade-delete every frame, device, port, cable, IO marker
and bundle in the project. <strong>Cable types are global and are not affected.</strong>
</p>
<p>Type the project name to confirm:</p>
<input type="text" name="confirm" required autocomplete="off"
id="dp-confirm-input" />
<p class="form-error" id="dp-error" hidden></p>
<div class="actions">
<button type="submit" class="btn btn-danger">Delete</button>
<button type="button" class="btn" data-close>Cancel</button>
</div>
</form>
</dialog>
<script type="module" src="/main.js"></script>
</body>
</html>

335
web/static/main.js Normal file
View File

@@ -0,0 +1,335 @@
// mCables frontend entry — vanilla ES module, no build step.
//
// Slice 1 covers: list/create/delete projects, list/create/edit/delete
// global cable types, and reflect the active project in ?project=<id>.
/**
* @typedef {{ id: number, name: string, drawing_name: string,
* description: string, created_at: string, updated_at: string }} Project
* @typedef {{ id: number, name: string, color: string,
* created_at: string, updated_at: string }} CableType
*/
const API = "/api";
const state = {
/** @type {Project[]} */ projects: [],
/** @type {CableType[]} */ cableTypes: [],
/** @type {Project | null} */ active: null,
/** active cable-type id (used for drawing in later slices) */
activeTypeId: null,
};
// ---------- API client ---------- //
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(json?.error || res.statusText);
err.status = res.status;
err.details = json?.details;
throw err;
}
return json;
}
const listProjects = () => api("GET", "/projects");
const createProject = (body) => api("POST", "/projects", body);
const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
const deleteProject = (id, confirm) =>
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
const getSnapshot = (id) => api("GET", `/projects/${id}`);
const listCableTypes = () => api("GET", "/cable-types");
const createCableType = (body) => api("POST", "/cable-types", body);
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
function setHidden(el, hidden) {
if (hidden) el.setAttribute("hidden", "");
else el.removeAttribute("hidden");
}
// ---------- URL state ---------- //
function activeProjectIdFromURL() {
const raw = new URLSearchParams(location.search).get("project");
const id = raw && Number.parseInt(raw, 10);
return Number.isFinite(id) && id > 0 ? id : null;
}
function setActiveInURL(id) {
const url = new URL(location.href);
if (id == null) url.searchParams.delete("project");
else url.searchParams.set("project", String(id));
history.replaceState(null, "", url.toString());
}
// ---------- render ---------- //
function renderProjectPicker() {
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
const current = state.active?.id ?? "";
sel.innerHTML = "";
const blank = new Option("— pick a project —", "");
sel.append(blank);
for (const p of state.projects) {
const opt = new Option(p.name, String(p.id));
if (p.id === current) opt.selected = true;
sel.append(opt);
}
setHidden($("#btn-delete-project"), !state.active);
}
function renderLegend() {
const ul = $("#legend-list");
ul.innerHTML = "";
for (const t of state.cableTypes) {
const li = document.createElement("li");
li.className = "legend-row";
li.dataset.id = String(t.id);
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
li.innerHTML = `
<span class="legend-swatch" style="background:${t.color}"></span>
<span class="legend-name"></span>
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
`;
li.querySelector(".legend-name").textContent = t.name;
li.addEventListener("click", (e) => {
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
openCableTypeModal(t);
e.stopPropagation();
return;
}
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
renderLegend();
});
ul.append(li);
}
}
function renderEmptyHint() {
const hint = $("#empty-hint");
if (!state.active) {
hint.textContent = state.projects.length
? "Pick a project from the dropdown to start drawing."
: "Create your first project to get started.";
setHidden(hint, false);
} else {
hint.textContent = `${state.active.name} — slice 1: empty canvas. Frames + devices arrive in slice 2.`;
setHidden(hint, false);
}
}
function render() {
renderProjectPicker();
renderLegend();
renderEmptyHint();
}
// ---------- active project ---------- //
async function activateProject(id) {
if (id == null) {
state.active = null;
setActiveInURL(null);
render();
return;
}
try {
const snap = await getSnapshot(id);
state.active = snap.project;
// The snapshot also returns the global cable types — refresh from
// the source of truth so a stale state.cableTypes can never linger.
state.cableTypes = snap.cable_types || [];
setActiveInURL(id);
render();
} catch (err) {
if (err.status === 404) {
// The id in the URL points to a deleted project — clear it.
state.active = null;
setActiveInURL(null);
render();
} else {
alert(`Failed to load project: ${err.message}`);
}
}
}
// ---------- modals ---------- //
function bindCloseButtons(dialog) {
dialog.querySelectorAll("[data-close]").forEach((btn) =>
btn.addEventListener("click", () => dialog.close()),
);
}
function showError(el, msg) {
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
el.textContent = msg;
setHidden(el, false);
}
function openNewProjectModal() {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
const err = $("#np-error");
form.reset();
showError(err, "");
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
drawing_name: String(fd.get("drawing_name") || "").trim(),
description: String(fd.get("description") || ""),
};
if (!body.drawing_name) delete body.drawing_name;
try {
const p = await createProject(body);
state.projects = await listProjects();
dlg.close();
await activateProject(p.id);
} catch (e) {
showError(err, e.message || "Failed to create project");
}
};
}
function openCableTypeModal(existing) {
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
const err = $("#ct-error");
const title = $("#ct-title");
form.reset();
showError(err, "");
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
if (existing) {
form.elements.namedItem("name").value = existing.name;
form.elements.namedItem("color").value = existing.color;
} else {
form.elements.namedItem("color").value = "#1971c2";
}
// Slot in a Delete button when editing an existing type.
const actions = form.querySelector(".actions");
actions.querySelector(".btn-delete-type")?.remove();
if (existing) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn btn-danger btn-delete-type";
del.style.marginRight = "auto";
del.textContent = "Delete";
del.addEventListener("click", async () => {
try {
await deleteCableType(existing.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === existing.id) state.activeTypeId = null;
dlg.close();
render();
} catch (e) {
const n = e.details?.in_use_by_cables;
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
}
});
actions.prepend(del);
}
dlg.showModal();
form.elements.namedItem("name").focus();
form.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
color: String(fd.get("color") || "").trim(),
};
try {
if (existing) await patchCableType(existing.id, body);
else await createCableType(body);
state.cableTypes = await listCableTypes();
dlg.close();
render();
} catch (e) {
showError(err, e.message || "Save failed");
}
};
}
function openDeleteProjectModal() {
if (!state.active) return;
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
const err = $("#dp-error");
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
form.reset();
showError(err, "");
input.placeholder = state.active.name;
dlg.showModal();
input.focus();
form.onsubmit = async (e) => {
e.preventDefault();
const confirm = String(new FormData(form).get("confirm") || "");
try {
await deleteProject(state.active.id, confirm);
state.projects = await listProjects();
dlg.close();
await activateProject(null);
} catch (e) {
showError(err, e.message || "Delete failed");
}
};
}
// ---------- boot ---------- //
async function boot() {
bindCloseButtons($("#modal-new-project"));
bindCloseButtons($("#modal-cable-type"));
bindCloseButtons($("#modal-delete-project"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#project-select").addEventListener("change", (e) => {
const v = /** @type {HTMLSelectElement} */ (e.target).value;
activateProject(v ? Number(v) : null);
});
try {
[state.projects, state.cableTypes] = await Promise.all([
listProjects(),
listCableTypes(),
]);
} catch (e) {
alert(`Failed to load: ${e.message}`);
return;
}
const wanted = activeProjectIdFromURL();
if (wanted && state.projects.some((p) => p.id === wanted)) {
await activateProject(wanted);
} else {
render();
}
}
boot();

248
web/static/style.css Normal file
View File

@@ -0,0 +1,248 @@
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f4f4f5;
--border: #d4d4d8;
--text: #18181b;
--text-muted: #71717a;
--accent: #1971c2;
--danger: #e03131;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
--radius: 4px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ---------- topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 600;
font-size: 15px;
}
.project-picker {
display: flex;
align-items: center;
gap: 6px;
}
.topbar-spacer { flex: 1; }
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: 220px 1fr 280px;
flex: 1;
min-height: 0;
}
.sidebar,
.inspector {
background: var(--surface);
padding: 12px;
overflow-y: auto;
}
.sidebar { border-right: 1px solid var(--border); }
.inspector { border-left: 1px solid var(--border); }
.sidebar-heading {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.tool-list,
.legend-list {
list-style: none;
padding: 0;
margin: 0 0 8px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: var(--radius);
cursor: pointer;
}
.legend-row:hover { background: var(--surface-2); }
.legend-row[aria-current="true"] {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(0, 0, 0, 0.15);
flex-shrink: 0;
}
.legend-name { flex: 1; }
.legend-edit {
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-muted);
padding: 2px 4px;
border-radius: 2px;
font-size: 12px;
}
.legend-edit:hover { color: var(--text); background: var(--surface-2); }
/* ---------- canvas ---------- */
.canvas-wrap {
position: relative;
overflow: hidden;
background: #f7f7f7;
background-image:
linear-gradient(to right, rgba(0,0,0,0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.04) 1px, transparent 1px);
background-size: 50px 50px;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
.empty-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
background: rgba(255, 255, 255, 0.85);
padding: 8px 14px;
border-radius: var(--radius);
}
.muted { color: var(--text-muted); }
/* ---------- buttons ---------- */
.btn {
font: inherit;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: var(--radius);
cursor: pointer;
box-shadow: var(--shadow);
}
.btn:hover { background: var(--surface-2); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
.btn-tiny { padding: 2px 8px; font-size: 12px; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: #155da3; }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: #b02828; }
/* ---------- dialog ---------- */
.modal {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0;
width: 380px;
max-width: calc(100vw - 32px);
background: var(--surface);
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
}
.modal::backdrop { background: rgba(0,0,0,0.3); }
.modal form { padding: 16px; }
.modal h2 { margin: 0 0 12px 0; font-size: 16px; }
.modal .banner {
background: #fff8e1;
border: 1px solid #f5d76e;
color: #5b4500;
padding: 8px 10px;
border-radius: var(--radius);
font-size: 13px;
margin: 0 0 12px 0;
}
.modal .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.modal .form-error {
color: var(--danger);
font-size: 13px;
margin: 6px 0 0 0;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 0 10px 0;
}
.field span {
font-size: 12px;
color: var(--text-muted);
}
.field input,
.field textarea {
font: inherit;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
width: 100%;
}
.field input:focus,
.field textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}

23
web/web.go Normal file
View File

@@ -0,0 +1,23 @@
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary
// via embed.FS so deploying mCables means shipping one file.
package web
import (
"embed"
"io/fs"
)
//go:embed all:static
var assets embed.FS
// Static returns the frontend filesystem rooted at the package's static/
// dir so callers see index.html at "/".
func Static() fs.FS {
sub, err := fs.Sub(assets, "static")
if err != nil {
// embed sub-rooting can only fail if "static" doesn't exist,
// which is a build-time error. Panic is the right shape.
panic(err)
}
return sub
}