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" "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, 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 × 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 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 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 spot), reorderable within their row (moves clamp at row boundaries so the
diagram can't fragment), and deletable. Both units and openings take a diagram can't fragment), and deletable. Both units and openings take a
+4
View File
@@ -556,6 +556,10 @@ select {
margin-bottom: .8rem; } margin-bottom: .8rem; }
.orow { display: flex; gap: .5rem; } .orow { display: flex; gap: .5rem; }
.formlabel { margin: 1.2rem 0 .35rem; } .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; .unitdesc { margin: -.3rem 0 .7rem; display: flex; gap: .6rem;
align-items: center; flex-wrap: wrap; } align-items: center; flex-wrap: wrap; }
.unitdesc .descform:not([hidden]) { display: flex; gap: .4rem; flex: 1 1 16rem; } .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"> return `<section class="card stack unit">
<div class="unithead"><h2>${esc(u.name)}</h2> <div class="unithead"><h2>${esc(u.name)}</h2>
<span class="editactions"> <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> <button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div> </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"> <p class="unitdesc">
<span class="meta">${esc(u.description || "")}</span> <span class="meta">${esc(u.description || "")}</span>
<button class="linkish editunitdesc" data-unit="${esc(u.name)}"> <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.`)) if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
post("/api/furniture/delete-unit", {name: b.dataset.unit}); 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 = () => { b.onclick = () => {
const form = b.closest(".unitdesc").querySelector(".descform"); const form = b.closest(".unitdesc").querySelector(".descform");
form.hidden = !form.hidden; form.hidden = !form.hidden;
+42 -3
View File
@@ -227,7 +227,9 @@ class ResearchBody(BaseModel):
class UnitBody(BaseModel): class UnitBody(BaseModel):
name: str 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): class OpeningsBody(BaseModel):
@@ -1218,7 +1220,7 @@ def create_app(
units.append( units.append(
{ {
"name": name, "name": name,
"description": body.description.strip(), "description": (body.description or "").strip(),
"openings": [], "openings": [],
} }
) )
@@ -1231,10 +1233,47 @@ def create_app(
unit = next((u for u in units if u["name"] == body.name), None) unit = next((u for u in units if u["name"] == body.name), None)
if unit is None: if unit is None:
raise HTTPException(404, "no such unit") 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) 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") @app.post("/api/furniture/delete-unit")
def api_delete_unit(body: UnitBody) -> dict: def api_delete_unit(body: UnitBody) -> dict:
def drop(units): 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] saved = _json.loads(cfg.furniture_path.read_text())["units"][0]
assert saved["description"] == "now in the study" 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
)