commit 38e62fcf004fd95853159418bd83892bbe9d27f2
parent dfc9b0e0d9bc0ffdd81191b418a9164ac45d6163
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Tue, 3 Sep 2024 16:58:41 -0700
Add furniture
The furniture is split into definitions (say, a specific table in
the house) and placements or maps of those definitions in a given
layout. In this way you can define all the furniture you want to
use once and switch between multiple layouts using that defined
furniture.
Diffstat:
4 files changed, 443 insertions(+), 12 deletions(-)
diff --git a/cmd/api/floorplans.go b/cmd/api/floorplans.go
@@ -19,6 +19,15 @@ type Point struct {
Y *int `json:"y" binding:"required"`
}
+func (e *Env) FurnitureTypes(c *gin.Context) {
+ types, err := e.backend.FurnitureTypes(nil)
+ if err != nil {
+ RespondError(c, 500, "%s: Unable to get types", err.Error())
+ } else {
+ Respond(c, 200, types)
+ }
+}
+
func (e *Env) CreateFloorplan(c *gin.Context) {
var req SettableFloorplan
diff --git a/cmd/api/main.go b/cmd/api/main.go
@@ -89,6 +89,7 @@ func setRoutes(env *Env, r *gin.RouterGroup) {
r.GET("/tokens", env.Auth.RefreshHandler)
r.GET("/settings", env.GetSettings)
r.GET("/services", env.GetServices)
+ r.GET("/furniture", env.FurnitureTypes)
users := r.Group("/users")
users.GET("/:user", env.GetUser)
diff --git a/cmd/api/migration/2024-09-02T02:12:53.sql b/cmd/api/migration/2024-09-02T02:12:53.sql
@@ -0,0 +1,97 @@
+BEGIN;
+
+CREATE FUNCTION spaceplanner.in (int)
+ RETURNS bigint
+ LANGUAGE sql
+ STABLE
+ RETURNS NULL ON NULL INPUT
+ RETURN (SELECT $1 * 96);
+
+CREATE FUNCTION spaceplanner.ft (int)
+ RETURNS bigint
+ LANGUAGE sql
+ STABLE
+ RETURNS NULL ON NULL INPUT
+ RETURN (SELECT spaceplanner.in($1) * 12);
+
+-- NOTE: A lot of these tables put a floorplan id in them
+-- to provide some check of correctness, but it's not enough.
+-- What needs to happen is first, for example with pointmaps,
+-- both a and b need to be verified as the same floorplan,
+-- and also it would be nice to eliminate floorplan ids in these
+-- tables that reference others (such as point or furniture maps)
+-- and use their references to points/furniture_defs to specify
+-- their floorplan
+
+-- Types of furniture
+-- (I don't intend to allow users to create types, who knows)
+-- There will probably be a varieties table with specs for
+-- certain varieties of furniture, such as a king bed
+-- Guess I'd have to consider localization too though.
+CREATE TABLE spaceplanner.furniture_types (
+ name varchar PRIMARY KEY
+);
+
+-- Add some basic types
+INSERT INTO spaceplanner.furniture_types (name)
+ VALUES ('bed'), ('table');
+
+CREATE TABLE spaceplanner.furniture_varieties (
+ name varchar PRIMARY KEY,
+ type varchar
+ REFERENCES spaceplanner.furniture_types(name)
+ NOT NULL,
+ width int NOT NULL,
+ depth int NOT NULL,
+ CONSTRAINT varieties_unique_name_within_type UNIQUE (name, type)
+);
+
+-- Taken from <https://en.wikipedia.org/wiki/Bed_size#North_America>
+INSERT INTO spaceplanner.furniture_varieties (name, type, width, depth)
+VALUES (
+ 'King', 'bed', spaceplanner.in(76), spaceplanner.in(80)
+), (
+ 'Queen', 'bed', spaceplanner.in(60), spaceplanner.in(80)
+), (
+ 'Double', 'bed', spaceplanner.in(54), spaceplanner.in(75)
+), (
+ 'Twin', 'bed', spaceplanner.in(39), spaceplanner.in(75)
+);
+
+CREATE TABLE spaceplanner.furniture (
+ id bigserial PRIMARY KEY,
+ floorplan bigint
+ REFERENCES spaceplanner.floorplans(id)
+ ON DELETE CASCADE
+ NOT NULL,
+ -- This DOES NOT cascade
+ type varchar REFERENCES spaceplanner.furniture_types(name)
+ NOT NULL,
+ name varchar,
+ width int NOT NULL,
+ -- Was going to use 'height', but thought it might be confusing
+ depth int NOT NULL,
+ CONSTRAINT unique_furniture_name_within_floorplan UNIQUE(floorplan, name)
+);
+
+-- Furnature placements, etc.
+CREATE TABLE spaceplanner.furniture_maps (
+ id bigserial PRIMARY KEY,
+ floorplan bigint
+ REFERENCES spaceplanner.floorplans(id)
+ ON DELETE CASCADE
+ NOT NULL,
+ furniture_id bigint
+ REFERENCES spaceplanner.furniture(id)
+ ON DELETE CASCADE
+ NOT NULL,
+ layout varchar NOT NULL DEFAULT '1',
+ -- Probably degree's since I'm simple, but maybe radians?
+ x int NOT NULL,
+ y int NOT NULL,
+ angle int NOT NULL,
+ CONSTRAINT angle_is_valid_degree CHECK (angle >= 0 AND angle < 360),
+ CONSTRAINT no_furniture_clone UNIQUE(furniture_id, layout)
+);
+
+COMMIT;
diff --git a/internal/backend/floorplan_data.go b/internal/backend/floorplan_data.go
@@ -1,14 +1,33 @@
package backend
import (
+ "database/sql"
+ "encoding/json"
"errors"
"fmt"
"strconv"
"strings"
- "database/sql"
- "encoding/json"
)
+import "log"
+
+type FloorplanData struct {
+ Points map[int64]Point `json:"points"`
+ Pointmaps map[int64]PointMap `json:"pointmaps"`
+
+ // I would like to have this in a FurnitureData struct,
+ // but don't want to rework the client to accept that
+ // at the moment.
+ Furniture map[int64]Furniture `json:"furniture"`
+ FurnitureMaps map[int64]FurnitureMap `json:"furniture_maps"`
+}
+
+/*
+ * NOTE: The id fields are not sent in response because it is encoded
+ * in the data structure. That is, they're in a map keyed by their ID
+ * (as seen above.) It's done this way so that the client can update
+ * individual objects with JSON Patch.
+ */
type Point struct {
id int64 `json:"id"`
OldID *int64 `json:"old_id,omitempty"`
@@ -24,9 +43,26 @@ type PointMap struct {
B int64 `json:"b" binding:"required"`
}
-type FloorplanData struct {
- Points map[int64]Point `json:"points"`
- Pointmaps map[int64]PointMap `json:"pointmaps"`
+// NOTE: I'd like to allow every value to be omitted so
+// long as it doesn't violate database rules, but without
+// setting to null.
+type Furniture struct {
+ id int64
+ OldID *int64 `json:"old_id,omitempty"`
+ Type string `json:"type" binding:"required"`
+ Name *string `json:"name"`
+ Width int `json:"width" binding:"required"`
+ Depth int `json:"depth" binding:"required"`
+}
+
+type FurnitureMap struct {
+ id int64
+ OldID *int64 `json:"old_id,omitempty"`
+ FurnitureID int64 `json:"furniture_id" binding:"required"`
+ Layout string `json:"layout" binding:"required"`
+ X int `json:"x" binding:"required"`
+ Y int `json:"y" binding:"required"`
+ Angle int `json:"angle" binding:"required"`
}
type rowReference struct {
@@ -55,10 +91,77 @@ type DBObject interface {
Delete(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error)
}
+type FurnitureType struct {
+ Varieties map[string]FurnitureVariety `json:"varieties,omit_empty"`
+}
+
+type FurnitureVariety struct {
+ Width int `json:"width"`
+ Depth int `json:"depth"`
+}
+
type Mappable interface {
Key() any
}
+func (e *Env) FurnitureTypes(tx *sql.Tx) (map[string]FurnitureType, error) {
+ types, err := e.CacheTxStmt(tx, "furn_types",
+ `SELECT name from spaceplanner.furniture_types`)
+ if err != nil {
+ return nil, err
+ }
+ vars, err := e.CacheTxStmt(tx, "furn_vars",
+ `SELECT type, name, width, depth from spaceplanner.furniture_varieties`)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := vars.Query()
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ data := make(map[string]FurnitureType)
+ for rows.Next() {
+ var tname, vname string
+ var v FurnitureVariety
+ if err := rows.Scan(&tname, &vname, &v.Width, &v.Depth); err != nil {
+ return nil, err
+ }
+
+ if t, exists := data[tname]; exists {
+ t.Varieties[vname] = v
+ } else {
+ t := FurnitureType{}
+ t.Varieties = make(map[string]FurnitureVariety)
+ t.Varieties[vname] = v
+ data[tname] = t
+ }
+ }
+ rows.Close()
+
+ rows, err = types.Query()
+ if err != nil {
+ return nil, err
+ }
+ // Is this evaluating the expression at execution or now?
+ // that would change whether this should be run.
+ defer rows.Close()
+
+ for rows.Next() {
+ var key string
+ if err := rows.Scan(&key); err != nil {
+ return nil, err
+ }
+ if _, exists := data[key]; !exists {
+ data[key] = FurnitureType{}
+ }
+ }
+
+ return data, nil
+}
+
func (e *Env) GetFloorplanData(tx *sql.Tx, user string, floorplan string) (FloorplanData, error) {
var data FloorplanData
var err error
@@ -73,6 +176,16 @@ func (e *Env) GetFloorplanData(tx *sql.Tx, user string, floorplan string) (Floor
return data, err
}
+ data.Furniture, err = e.getFloorplanFurnitureDefs(tx, user, floorplan)
+ if err != nil {
+ return data, err
+ }
+
+ data.FurnitureMaps, err = e.getFloorplanFurnitureMaps(tx, user, floorplan)
+ if err != nil {
+ return data, err
+ }
+
return data, nil
}
@@ -117,6 +230,56 @@ func (e *Env) getFloorplanPointMaps(tx *sql.Tx, user string, floorplan string) (
return mapArray(pointmaps, mapPointMap)
}
+func (e *Env) getFloorplanFurnitureDefs(tx *sql.Tx, user string, floorplan string) (map[int64]Furniture, error) {
+ defsStmt, err := e.CacheTxStmt(tx, "furniture_defs",
+ `SELECT id, type, name, width, depth
+ FROM spaceplanner.furniture
+ WHERE floorplan = floorplan_id($1, $2)`)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := defsStmt.Query(user, floorplan)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ defs, err := collectRows(rows, scanFurniture)
+ if err != nil {
+ return nil, err
+ }
+
+ return mapArray(defs, mapFurniture)
+
+}
+
+func (e *Env) getFloorplanFurnitureMaps(tx *sql.Tx, user string, floorplan string) (map[int64]FurnitureMap, error) {
+ mapsStmt, err := e.CacheTxStmt(tx, "furniture_maps",
+ `SELECT id, furniture_id, layout, x, y, angle
+ FROM spaceplanner.furniture_maps
+ WHERE furniture_id IN (
+ SELECT id
+ FROM spaceplanner.furniture
+ WHERE floorplan = floorplan_id($1, $2)
+ )`)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := mapsStmt.Query(user, floorplan)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ maps, err := collectRows(rows, scanFurnitureMap)
+ if err != nil {
+ return nil, err
+ }
+ return mapArray(maps, mapFurnitureMap)
+}
+
func (e *Env) ReplaceFloorplanData(tx *sql.Tx, user string, floorplan string, data *FloorplanData) (FloorplanData, error) {
mytx := false
if (tx == nil) {
@@ -192,6 +355,8 @@ func (e *Env) PatchFloorplanData(tx *sql.Tx, user string, floorplan string, patc
data := FloorplanData{}
data.Points = make(map[int64]Point)
data.Pointmaps = make(map[int64]PointMap)
+ data.Furniture = make(map[int64]Furniture)
+ data.FurnitureMaps = make(map[int64]FurnitureMap)
// Allowed operations are new, replace, and delete
// (new is an extention that ensures path's don't exist before creation)
@@ -221,6 +386,22 @@ func (e *Env) PatchFloorplanData(tx *sql.Tx, user string, floorplan string, patc
if patch.Op != "delete" {
data.Pointmaps[pointmap.id] = pointmap
}
+ } else if (ref.table == "furniture") {
+ def, err := applyPatch[Furniture](e, tx, user, floorplan, &patch, ref, newRefs)
+ if err != nil {
+ return data, ref.Error("", err)
+ }
+ if patch.Op != "delete" {
+ data.Furniture[def.id] = def
+ }
+ } else if (ref.table == "furniture_maps") {
+ fm, err := applyPatch[FurnitureMap](e, tx, user, floorplan, &patch, ref, newRefs)
+ if err != nil {
+ return data, ref.Error("", err)
+ }
+ if patch.Op != "delete" {
+ data.FurnitureMaps[fm.id] = fm
+ }
} else {
return data, ref.Error("Path does not exist", nil)
}
@@ -256,6 +437,7 @@ func applyPatch[T DBObject](e *Env, tx *sql.Tx, user, floorplan string, patch *P
// can't figure it out right now. I'll come back to it later
// but for now a few type assertions
thing = thing.updateRefs(ref, newRefs).(T)
+ log.Print(patch, thing)
var err error
var dbo DBObject
@@ -264,14 +446,14 @@ func applyPatch[T DBObject](e *Env, tx *sql.Tx, user, floorplan string, patch *P
dbo, err = thing.Create(e, tx, user, floorplan)
case "replace":
dbo, err = thing.Update(e, tx, user, floorplan)
- case "delete":
+ case "remove":
dbo, err = thing.Delete(e, tx, user, floorplan)
default:
return thing, inputRef.Error("Unsupported operation", nil)
}
if err != nil {
- return thing, inputRef.Error("Unable to perform operation", err)
+ return thing, inputRef.Error("Unable to perform " + patch.Op + " operation", err)
}
thing = dbo.(T)
@@ -372,6 +554,123 @@ func (e *Env) DeletePointMap(tx *sql.Tx, user string, floorplan string, id int64
return scanPointMap(stmt.QueryRow(user, floorplan, id))
}
+func (f Furniture) Create(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) {
+ ins, err := e.CacheTxStmt(tx, "add_furn",
+ `INSERT INTO spaceplanner.furniture (floorplan, type, name, width, depth)
+ VALUES (floorplan_id($1, $2), $3, $4, $5, $6)
+ RETURNING id, type, name, width, depth`)
+ if err != nil {
+ return f, err
+ }
+
+ return scanFurniture(ins.QueryRow(user, floorplan, f.Type, f.Name, f.Width, f.Depth))
+}
+
+func (f Furniture) Update(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) {
+ update, err := e.CacheTxStmt(tx, "update_furn",
+ `UPDATE spaceplanner.furniture SET (type, name, width, depth) =
+ ($4, $5, $6, $7) WHERE floorplan = floorplan_id($1, $2) AND id = $3
+ RETURNING id, type, name, width, depth`)
+ if err != nil {
+ return f, err
+ }
+
+ return scanFurniture(update.QueryRow(user, floorplan, f.id, f.Type, f.Name, f.Width, f.Depth))
+}
+
+func (f Furniture) Delete(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) {
+ del, err := e.CacheTxStmt(tx, "dele_furn",
+ `DELETE FROM spaceplanner.furniture
+ WHERE floorplan = floorplan_id($1, $2) AND id = $3
+ RETURNING id, type, name, width, depth`)
+ if err != nil {
+ return f, err
+ }
+
+ return scanFurniture(del.QueryRow(user, floorplan, f.id))
+}
+
+func (f FurnitureMap) Create(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) {
+ ins, err := e.CacheTxStmt(tx, "add_furnmap",
+ `INSERT INTO spaceplanner.furniture_maps (floorplan, furniture_id, layout, x, y, angle)
+ VALUES (floorplan_id($1, $2), $3, $4, $5, $6, $7)
+ RETURNING id, furniture_id, layout, x, y, angle`)
+ if err != nil {
+ return f, err
+ }
+
+ return scanFurnitureMap(ins.QueryRow(user, floorplan, f.FurnitureID, f.Layout, f.X, f.Y, f.Angle))
+}
+
+func (fm FurnitureMap) Update(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) {
+ update, err := e.CacheTxStmt(tx, "update_furnmap",
+ `UPDATE spaceplanner.furniture_maps SET (furniture_id, layout, x, y, angle) =
+ ($4, $5, $6, $7, $8) WHERE floorplan = floorplan_id($1, $2) AND id = $3
+ RETURNING id, furniture_id, layout, x, y, angle`)
+ if err != nil {
+ return fm, err
+ }
+
+ return scanFurnitureMap(update.QueryRow(user, floorplan, fm.id, fm.Layout, fm.X, fm.Y, fm.Angle))
+}
+
+func (f FurnitureMap) Delete(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) {
+ del, err := e.CacheTxStmt(tx, "dele_furnmap",
+ `DELETE FROM spaceplanner.furniture_maps
+ WHERE floorplan = floorplan_id($1, $2) AND id = $3
+ RETURNING id, furniture_id, layout, x, y, angle`)
+ if err != nil {
+ return f, err
+ }
+
+ log.Printf("Delete map %d from %s/%s", f.id, user,floorplan)
+ return scanFurnitureMap(del.QueryRow(user, floorplan, f.id))
+}
+
+func (f Furniture) Ref() rowReference {
+ return newRef("furniture", f.id)
+}
+
+func (f Furniture) updateRefs(ref rowReference, newRefs map[rowReference]rowReference) DBObject {
+ if n, exists := newRefs[ref]; exists {
+ f.id = n.id
+ } else {
+ f.id = ref.id
+ }
+ return f
+}
+
+func (f Furniture) SetOldID(id int64) DBObject {
+ if id != f.id {
+ f.OldID = &id
+ }
+ return f
+}
+
+func (fm FurnitureMap) Ref() rowReference {
+ return newRef("furniture_maps", fm.id)
+}
+
+func (fm FurnitureMap) updateRefs(ref rowReference, newRefs map[rowReference]rowReference) DBObject {
+ if n, exists := newRefs[ref]; exists {
+ fm.id = n.id
+ } else {
+ fm.id = ref.id
+ }
+ if n, exists := newRefs[newRef("furniture", fm.FurnitureID)]; exists {
+ log.Printf("Furniture Map Furniture ID remapped from %d to %d", fm.id, n.id)
+ fm.FurnitureID = n.id
+ }
+ return fm
+}
+
+func (fm FurnitureMap) SetOldID(id int64) DBObject {
+ if id != fm.id {
+ fm.OldID = &id
+ }
+ return fm
+}
+
func (p Point) Ref() rowReference {
return newRef("points", p.id)
}
@@ -426,16 +725,19 @@ func parseRowReference(ref string) (rowReference, error) {
// Ref should look like this: /type/id so three
// segments (including before initial slash)
parts := strings.Split(ref, "/")
- if (len(parts) != 3) {
- return rowReference{}, errors.New(ref + ": Invalid reference")
+ if len(parts) < 3 {
+ return rowReference{}, errors.New(ref + ": Requires table")
+ }
+ if parts[0] != "" {
+ return rowReference{}, errors.New(ref + ": Must start with /")
}
- id, err := strconv.ParseInt(parts[2], 10, 64)
+ id, err := strconv.ParseInt(parts[len(parts) - 1], 10, 64)
if err != nil {
return rowReference{}, errors.New(ref + ": Invalid id")
}
-
- return newRef(parts[1], id), nil
+ parts = parts[1:len(parts) - 1]
+ return newRef(strings.Join(parts, "/"), id), nil
}
func scanPoint(s Scanner) (Point, error) {
@@ -452,6 +754,20 @@ func scanPointMap(s Scanner) (PointMap, error) {
return pm, err
}
+func scanFurniture(row Scanner) (Furniture, error) {
+ var f Furniture
+
+ err := row.Scan(&f.id, &f.Type, &f.Name, &f.Width, &f.Depth)
+ return f, err
+}
+
+func scanFurnitureMap(row Scanner) (FurnitureMap, error) {
+ var m FurnitureMap
+
+ err := row.Scan(&m.id, &m.FurnitureID, &m.Layout, &m.X, &m.Y, &m.Angle)
+ return m, err
+}
+
func mapArray[K comparable, V any](array []V, mapper func(V) (K, error)) (map[K]V, error) {
m := make(map[K]V, len(array))
@@ -473,6 +789,14 @@ func mapPointMap(pm PointMap) (int64, error) {
return pm.id, nil
}
+func mapFurniture(f Furniture) (int64, error) {
+ return f.id, nil
+}
+
+func mapFurnitureMap(m FurnitureMap) (int64, error) {
+ return m.id, nil
+}
+
func (ref rowReference) String() string {
return fmt.Sprintf("/%s/%d", ref.table, ref.id)
}