Duplicate a unit: describe the furniture once, stamp out the rest

Eric's ask. duplicate-unit clones the STRUCTURE — openings with their
sizes, zones, and descriptions, under fresh ids and the first free
"<name> 2"-style name — never the game assignments. And because a
copy immediately wants a real name, units gained rename: opening ids
are stable through it so locations and export URLs never notice, and
a rename-only edit no longer risks wiping the description (the body
field learned the None-means-leave-alone convention). Tests pin the
disjoint ids, the empty copy, the numbering past taken names, and
the wipe-nothing rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-09 15:10:47 -04:00
co-authored by Claude Fable 5
parent e90a53fb6c
commit 10f99e23f5
6 changed files with 224 additions and 5 deletions
+46
View File
@@ -120,6 +120,52 @@
}
],
"description": "Centerpiece of the game wall in the library"
},
{
"name": "Library Right Shelving",
"description": "To the right of the Kallax",
"openings": [
{
"id": "library-right-shelving-a1",
"label": "A1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5
},
{
"id": "library-right-shelving-b1",
"label": "B1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5
},
{
"id": "library-right-shelving-c1",
"label": "C1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5
},
{
"id": "library-right-shelving-d1",
"label": "D1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5
},
{
"id": "library-right-shelving-e1",
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5
}
]
}
]
}
+4 -1
View File
@@ -118,7 +118,10 @@ top row renders as exactly that — a diagram of the wall, not a list. Create
units from presets (IKEA Kallax cube 13.25″ × 13.25″ × 15.4″, Billy shelf,
custom, or a no-size virtual spot like "travel case") as grids of rows ×
columns; add more sections to an existing unit the same way — a later grid
continues the row letters, so 3 × 4 under an A row lands as B1…D4. Openings
continues the row letters, so 3 × 4 under an A row lands as B1…D4. Duplicate a
unit to stamp out identical furniture — the copy takes the structure
(openings, sizes, zones, descriptions) under a fresh auto-numbered name,
never the games — and rename any unit in place. Openings
are editable (dimensions are all-or-none: three numbers or a no-limit
spot), reorderable within their row (moves clamp at row boundaries so the
diagram can't fragment), and deletable. Both units and openings take a
+4
View File
@@ -556,6 +556,10 @@ select {
margin-bottom: .8rem; }
.orow { display: flex; gap: .5rem; }
.formlabel { margin: 1.2rem 0 .35rem; }
.renamerow:not([hidden]) { display: flex; gap: .4rem; margin: .3rem 0 .6rem; }
.renamerow input { font: inherit; font-size: .9rem;
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; flex: 0 1 18rem; }
.unitdesc { margin: -.3rem 0 .7rem; display: flex; gap: .6rem;
align-items: center; flex-wrap: wrap; }
.unitdesc .descform:not([hidden]) { display: flex; gap: .4rem; flex: 1 1 16rem; }
+25 -1
View File
@@ -121,8 +121,16 @@ function unitBlock(u) {
return `<section class="card stack unit">
<div class="unithead"><h2>${esc(u.name)}</h2>
<span class="editactions">
<button class="dupunit" data-unit="${esc(u.name)}"
title="copy this unit's openings — sizes, zones, descriptions — as a new empty unit">
duplicate</button>
<button class="renameunit linkish" data-unit="${esc(u.name)}">rename</button>
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div>
<p class="renamerow" hidden>
<input class="renameinput" value="${esc(u.name)}" aria-label="new unit name">
<button class="primary saverename" data-unit="${esc(u.name)}">save</button>
</p>
<p class="unitdesc">
<span class="meta">${esc(u.description || "")}</span>
<button class="linkish editunitdesc" data-unit="${esc(u.name)}">
@@ -301,7 +309,23 @@ function wire() {
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
post("/api/furniture/delete-unit", {name: b.dataset.unit});
});
document.querySelectorAll(".editunitdesc").forEach(b =>
document.querySelectorAll(".dupunit").forEach(b =>
b.onclick = async () => {
const res = await post("/api/furniture/duplicate-unit", {name: b.dataset.unit});
if (res) showToast(`duplicated — rename the copy and start filling it`);
});
document.querySelectorAll(".renameunit").forEach(b =>
b.onclick = () => {
const row = b.closest(".unithead").nextElementSibling;
row.hidden = !row.hidden;
if (!row.hidden) row.querySelector("input").focus();
});
document.querySelectorAll(".saverename").forEach(b =>
b.onclick = () => post("/api/furniture/edit-unit", {
name: b.dataset.unit,
new_name: b.closest(".renamerow").querySelector("input").value,
}));
document.querySelectorAll(".editunitdesc").forEach(b =
b.onclick = () => {
const form = b.closest(".unitdesc").querySelector(".descform");
form.hidden = !form.hidden;
+42 -3
View File
@@ -227,7 +227,9 @@ class ResearchBody(BaseModel):
class UnitBody(BaseModel):
name: str
description: str = "" # free text: "the wall behind the couch"
# None = leave alone (rename-only edits must not wipe a description)
description: str | None = None
new_name: str = "" # edit-unit only: rename
class OpeningsBody(BaseModel):
@@ -1218,7 +1220,7 @@ def create_app(
units.append(
{
"name": name,
"description": body.description.strip(),
"description": (body.description or "").strip(),
"openings": [],
}
)
@@ -1231,10 +1233,47 @@ def create_app(
unit = next((u for u in units if u["name"] == body.name), None)
if unit is None:
raise HTTPException(404, "no such unit")
unit["description"] = body.description.strip()
if body.new_name.strip() and body.new_name.strip() != body.name:
new = body.new_name.strip()
if any(u["name"] == new for u in units):
raise HTTPException(409, f"a unit named {new!r} already exists")
# opening ids are stable through a rename: locations and
# the export's URLs never notice
unit["name"] = new
if body.description is not None:
unit["description"] = body.description.strip()
return _mutate_furniture(apply)
@app.post("/api/furniture/duplicate-unit")
def api_duplicate_unit(body: UnitBody) -> dict:
"""Clone a unit's STRUCTURE — openings with dims, zones, and
descriptions, under fresh ids — never its game assignments. The
copy takes the first free '<name> 2'-style name."""
def clone(units):
source = next((u for u in units if u["name"] == body.name), None)
if source is None:
raise HTTPException(404, "no such unit")
n = 2
while any(u["name"] == f"{body.name} {n}" for u in units):
n += 1
copy = {
"name": f"{body.name} {n}",
"description": source.get("description", ""),
"openings": [],
}
units.append(copy)
for opening in source.get("openings", []):
copy["openings"].append(
{
**{k: v for k, v in opening.items() if k != "id"},
"id": new_opening_id(units, copy["name"], opening["label"]),
}
)
return _mutate_furniture(clone)
@app.post("/api/furniture/delete-unit")
def api_delete_unit(body: UnitBody) -> dict:
def drop(units):
+103
View File
@@ -875,3 +875,106 @@ def test_unit_descriptions_round_trip(tmp_path):
saved = _json.loads(cfg.furniture_path.read_text())["units"][0]
assert saved["description"] == "now in the study"
def test_duplicate_unit_copies_structure_never_games(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "Catan",
"dims": {
"width_in": 11,
"length_in": 11,
"depth_in": 3,
"source": "version",
},
}
}
web, cfg = _web(tmp_path, games)
web.post(
"/api/furniture/add-unit",
json={"name": "Kallax", "description": "the original"},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Kallax",
"rows": 1,
"cols": 2,
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
first = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
web.post(
"/api/furniture/edit-opening", json={"id": first, "description": "top left"}
)
web.post("/api/locate", json={"key": "1", "opening_id": first})
web.post("/api/furniture/duplicate-unit", json={"name": "Kallax"})
state = web.get("/api/shelves").json()
names = [u["name"] for u in state["units"]]
assert names == ["Kallax", "Kallax 2"]
copy = state["units"][1]
assert copy["description"] == "the original"
assert [o["label"] for o in copy["openings"]] == ["A1", "A2"]
assert copy["openings"][0]["description"] == "top left"
# fresh ids, no residents copied
original_ids = {o["id"] for o in state["units"][0]["openings"]}
copy_ids = {o["id"] for o in copy["openings"]}
assert original_ids.isdisjoint(copy_ids)
assert all(o["games"] == 0 for o in copy["openings"])
# duplicating again numbers past the taken name
web.post("/api/furniture/duplicate-unit", json={"name": "Kallax"})
names = [u["name"] for u in web.get("/api/shelves").json()["units"]]
assert names == ["Kallax", "Kallax 2", "Kallax 3"]
def test_rename_unit_keeps_openings_locations_and_description(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "Catan",
"dims": {
"width_in": 11,
"length_in": 11,
"depth_in": 3,
"source": "version",
},
}
}
web, cfg = _web(tmp_path, games)
web.post(
"/api/furniture/add-unit", json={"name": "Kallax", "description": "keep me"}
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Kallax",
"label": "A1",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
oid = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
web.post("/api/locate", json={"key": "1", "opening_id": oid})
web.post(
"/api/furniture/edit-unit", json={"name": "Kallax", "new_name": "Den wall"}
)
state = web.get("/api/shelves").json()
unit = state["units"][0]
assert unit["name"] == "Den wall"
assert unit["description"] == "keep me" # rename-only edit wipes nothing
assert unit["openings"][0]["games"] == 1 # opening ids stable: game stays
# rename onto a taken name refuses
web.post("/api/furniture/add-unit", json={"name": "Loft"})
assert (
web.post(
"/api/furniture/edit-unit",
json={"name": "Den wall", "new_name": "Loft"},
).status_code
== 409
)