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:
co-authored by
Claude Fable 5
parent
e90a53fb6c
commit
10f99e23f5
@@ -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
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user