Files
Donkie b00c1b875d Add dashboard and label-designer screenshots to the README
The library screenshot shows one of the client's four pages. The two
that are hardest to guess at from a feature list -- spools as draggable
cards grouped by location, and labels built from arbitrary spool fields
-- now sit under it in a two-column table, so they cost one screen's
height between them rather than one each.

They are captured at a narrower viewport than the library shot rather
than scaled down from it: at half width a 1440px page reduces to
unreadable, whereas 900px reflows the dashboard to two columns and the
designer to a tighter grid, so the text survives the downscale. Both are
cropped to a 3:2 aspect and have the nav bar removed -- it is already in
the shot above -- so the pair sits level.

Fixed along the way, since it would otherwise have been immortalised in
the dashboard screenshot: the card separators lost a space where they
met an {#if} boundary, which Svelte trims, rendering "Bambu Lab -Bambu
Green" and "1 kg- Last used 2 days ago". The optional parts are now
built as one expression, which has no block edge to trim.

demo_seed gains a note on why label designs aren't seeded: they're a
blob of a client-side schema, so writing one from Python would duplicate
it. The designer's own "Create your first label" is the honest route.
2026-07-31 08:37:47 +02:00

667 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
"""Populate a Spoolman instance with a small, hand-curated inventory for screenshots.
Unlike ``stress_seed.py`` — which generates a large random dataset to hammer the API —
this builds a plausible *real* inventory: every filament is imported from the external
SpoolmanDB catalog, so names, colors, densities and temperatures all agree with each
other. A spool labelled "Prusa Orange" is actually orange.
The spools on top of those filaments are made up, because that part is genuinely
per-user data: partially used weights, locations, lot numbers, prices, first/last-used
dates, and a few custom fields, so the UI shows a library that has been lived in
rather than a fresh install.
Stdlib only; talks to a running instance over the public API:
python scripts/demo_seed.py --url http://localhost:7912 --purge
Also available as `uv run poe demo-seed <args>`.
Only ever point this at a development instance: --purge deletes *all* vendors, filaments
and spools on the target, not just the ones it created.
Label designs are deliberately not seeded here. They live in the `label_designs` server
setting as a blob of a client-side schema ($lib/labels/types.ts), so writing one from
Python would duplicate that schema and rot the moment it changes. For the label-designer
screenshot, click "Create your first label" in the client instead -- that runs the app's
own newDesign() -- and pick a spool under "Preview with" so the fields resolve. The
setting survives --purge, so a re-seed keeps whatever design you made.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
DEFAULT_URL = "http://localhost:7912"
# ---------------------------------------------------------------------------
# HTTP plumbing
# ---------------------------------------------------------------------------
class ApiError(RuntimeError):
"""A non-2xx response from the Spoolman API."""
def __init__(self, method: str, path: str, status: int, body: str) -> None:
super().__init__(f"{method} {path} -> {status}: {body}")
self.status = status
self.body = body
class Api:
"""Minimal Spoolman API v1 client."""
def __init__(self, base_url: str, timeout: float = 30.0) -> None:
self.base = base_url.rstrip("/") + "/api/v1"
self.timeout = timeout
def _request(self, method: str, path: str, body: Any = None, params: dict | None = None) -> Any:
url = self.base + path
if params:
url += "?" + urllib.parse.urlencode(params)
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method) # noqa: S310
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
raw = resp.read()
except urllib.error.HTTPError as e:
raise ApiError(method, path, e.code, e.read().decode(errors="replace")[:500]) from None
except urllib.error.URLError as e:
raise RuntimeError(f"Could not reach {url}: {e.reason}") from None
if not raw:
return None
return json.loads(raw)
def get(self, path: str, params: dict | None = None) -> Any:
return self._request("GET", path, params=params)
def post(self, path: str, body: Any) -> Any:
return self._request("POST", path, body=body)
def delete(self, path: str) -> Any:
return self._request("DELETE", path)
# ---------------------------------------------------------------------------
# Extra fields - the handful a real user actually defines
# ---------------------------------------------------------------------------
VENDORS_OF_RECORD = ["Prusa Shop", "3DJake", "Amazon", "Local reseller"]
SPOOL_FIELDS: list[tuple[str, dict]] = [
("opened", {"name": "Opened", "order": 1, "field_type": "datetime"}),
("dried", {"name": "Dried", "order": 2, "field_type": "boolean", "default_value": json.dumps(False)}),
(
"bought_from",
{
"name": "Bought from",
"order": 3,
"field_type": "choice",
"choices": VENDORS_OF_RECORD,
"multi_choice": False,
},
),
]
FILAMENT_FIELDS: list[tuple[str, dict]] = [
("drying_temp", {"name": "Drying temperature", "order": 1, "field_type": "integer", "unit": "°C"}),
]
ALL_FIELDS = {"spool": SPOOL_FIELDS, "filament": FILAMENT_FIELDS}
def setup_extra_fields(api: Api) -> None:
print("Creating extra field definitions...")
for entity_type, fields in ALL_FIELDS.items():
for key, params in fields:
api.post(f"/field/{entity_type}/{key}", params)
print(f" {entity_type}: {len(fields)} fields")
def delete_extra_fields(api: Api) -> None:
for entity_type, fields in ALL_FIELDS.items():
existing = {f["key"] for f in api.get(f"/field/{entity_type}")}
for key, _ in fields:
if key in existing:
api.delete(f"/field/{entity_type}/{key}")
# ---------------------------------------------------------------------------
# The inventory
# ---------------------------------------------------------------------------
@dataclass
class SpoolSpec:
"""One physical spool. ``used`` is the fraction of the filament's net weight consumed."""
used: float
#: Hours since this spool was last printed with. None = never opened, still sealed.
last_used_h: float | None = None
#: Hours of use history behind it, i.e. how much earlier `first_used` was.
history_h: float = 24 * 30
location: str | None = None
lot_nr: str | None = None
price: float | None = None
#: Free-form note. Only the inspector shows it, so length is unconstrained.
comment: str | None = None
dried: bool | None = None
bought_from: str | None = None
archived: bool = False
@dataclass
class FilamentSpec:
"""A filament from the external catalog, plus the spools of it on the shelf."""
external_id: str
spools: list[SpoolSpec] = field(default_factory=list)
drying_temp: int | None = None
# Ordered roughly by how recently the filament was used, which is also how the library
# sorts by default, so this list reads top-to-bottom like the screenshot does.
INVENTORY: list[FilamentSpec] = [
FilamentSpec(
"prusament_pla_prusaorange_1000_175_p",
drying_temp=55,
spools=[
SpoolSpec(
used=0.658,
last_used_h=4,
history_h=24 * 47,
location="Prusa MK4S",
lot_nr="24E1707",
price=29.99,
dried=True,
bought_from="Prusa Shop",
comment="House orange - keep one loaded at all times.",
),
SpoolSpec(used=0.931, last_used_h=24 * 12, history_h=24 * 90, location="Shelf B", lot_nr="24C0912"),
SpoolSpec(used=0.0, location="Dry Box 1", lot_nr="25A2244", price=29.99, bought_from="Prusa Shop"),
],
),
FilamentSpec(
"prusament_petg_junglegreen_1000_175_p",
drying_temp=65,
spools=[
SpoolSpec(
used=0.412,
last_used_h=27,
history_h=24 * 20,
location="Voron 2.4",
lot_nr="24F0431",
price=27.49,
dried=True,
bought_from="Prusa Shop",
),
SpoolSpec(used=0.144, last_used_h=24 * 9, history_h=24 * 12, location="Shelf A", lot_nr="24F0431"),
],
),
FilamentSpec(
"bambulab_pla_bambugreen_1000_175_n",
drying_temp=55,
spools=[
SpoolSpec(
used=0.547,
last_used_h=24 * 2,
history_h=24 * 35,
location="Bambu X1C",
lot_nr="B-2411-338",
price=24.99,
bought_from="Amazon",
),
SpoolSpec(used=0.885, last_used_h=24 * 21, history_h=24 * 60, location="Shelf A"),
SpoolSpec(used=0.0, location="Dry Box 2", price=24.99, bought_from="Amazon"),
],
),
FilamentSpec(
"overture_pla_cementgray_1000_175_c",
spools=[
SpoolSpec(
used=0.336,
last_used_h=24 * 3,
history_h=24 * 15,
location="Prusa MK4S",
lot_nr="OV-8842",
price=19.99,
bought_from="Amazon",
comment="Prints fine at 0.28mm draft, good for jigs.",
),
SpoolSpec(used=0.972, last_used_h=24 * 30, history_h=24 * 120, location="Shelf C"),
],
),
FilamentSpec(
"prusament_asa_lipstickred_800_175_p",
drying_temp=80,
spools=[
SpoolSpec(
used=0.288,
last_used_h=24 * 4,
history_h=24 * 26,
location="Voron 2.4",
lot_nr="24D1188",
price=31.99,
dried=True,
bought_from="Prusa Shop",
),
SpoolSpec(used=0.0, location="Dry Box 1", price=31.99, bought_from="Prusa Shop"),
],
),
FilamentSpec(
"ambrosia_pla_silkblue+purple_1000_175_c",
spools=[
SpoolSpec(
used=0.226,
last_used_h=24 * 5,
history_h=24 * 8,
location="Bambu X1C",
price=26.50,
bought_from="3DJake",
comment="Silk finish - slow it down or it strings.",
),
],
),
FilamentSpec(
"fillamentum_asa_extrafilldijonmustard_750_175_n",
drying_temp=80,
spools=[
SpoolSpec(
used=0.618,
last_used_h=24 * 6,
history_h=24 * 70,
location="Shelf B",
lot_nr="FL-2409-7",
price=34.90,
dried=True,
bought_from="3DJake",
),
],
),
FilamentSpec(
"esun_pla_pla-basicaqua_1000_175_c",
spools=[
SpoolSpec(used=0.463, last_used_h=24 * 8, history_h=24 * 40, location="Shelf A", price=17.99),
SpoolSpec(used=0.995, last_used_h=24 * 55, history_h=24 * 150, location="Shelf C", archived=True),
],
),
FilamentSpec(
"bambulab_tpu_blue_1000_175_n",
drying_temp=70,
spools=[
SpoolSpec(
used=0.187,
last_used_h=24 * 10,
history_h=24 * 45,
location="Dry Box 3",
lot_nr="B-2408-102",
price=39.99,
dried=True,
bought_from="Amazon",
comment="Only prints reliably from the dry box.",
),
],
),
FilamentSpec(
"overture_petg_gold_1000_175_c",
spools=[
SpoolSpec(used=0.724, last_used_h=24 * 11, history_h=24 * 85, location="Shelf B", lot_nr="OV-9107"),
SpoolSpec(used=0.0, location="Storage Bin", price=21.99, bought_from="Amazon"),
],
),
FilamentSpec(
"prusament_petg_urbangrey_1000_175_p",
drying_temp=65,
spools=[
SpoolSpec(
used=0.531,
last_used_h=24 * 13,
history_h=24 * 65,
location="Shelf A",
lot_nr="24B0755",
price=27.49,
bought_from="Prusa Shop",
),
SpoolSpec(used=0.089, last_used_h=24 * 41, history_h=24 * 44, location="Shelf A", lot_nr="24B0755"),
],
),
FilamentSpec(
"polymaker_petg_polylitepetgblack_1000_175_c",
drying_temp=65,
spools=[
SpoolSpec(
used=0.612,
last_used_h=24 * 15,
history_h=24 * 95,
location="Voron 2.4",
lot_nr="PM-7731",
price=22.90,
dried=True,
bought_from="3DJake",
),
SpoolSpec(used=0.844, last_used_h=24 * 38, history_h=24 * 110, location="Shelf C", lot_nr="PM-7731"),
SpoolSpec(used=0.0, location="Storage Bin", price=22.90, bought_from="3DJake"),
],
),
FilamentSpec(
"protopasta_petg_candyappleredmetallic_500_175_c",
spools=[
SpoolSpec(
used=0.351,
last_used_h=24 * 17,
history_h=24 * 30,
location="Shelf B",
price=32.00,
bought_from="Local reseller",
comment="Saving the rest for the enclosure badges.",
),
],
),
FilamentSpec(
"fillamentum_pla_extrafillcobaltblue_750_175_n",
spools=[
SpoolSpec(used=0.279, last_used_h=24 * 19, history_h=24 * 50, location="Shelf A", price=28.90),
SpoolSpec(used=0.0, location="Dry Box 2", price=28.90, bought_from="3DJake"),
],
),
FilamentSpec(
"bambulab_pva_clear_500_175_n",
drying_temp=70,
spools=[
SpoolSpec(
used=0.408,
last_used_h=24 * 22,
history_h=24 * 75,
location="Dry Box 3",
lot_nr="B-2407-045",
price=34.99,
dried=True,
bought_from="Amazon",
comment="Support interface only - it drinks moisture.",
),
],
),
FilamentSpec(
"overture_abs_black_1000_175_c",
drying_temp=80,
spools=[
SpoolSpec(used=0.667, last_used_h=24 * 24, history_h=24 * 130, location="Shelf C", lot_nr="OV-6620"),
SpoolSpec(used=0.0, location="Storage Bin", price=20.99, bought_from="Amazon"),
],
),
FilamentSpec(
"prusament_pc-cf_pcblendcarbonfiberblack_800_175_p",
drying_temp=100,
spools=[
SpoolSpec(
used=0.144,
last_used_h=24 * 27,
history_h=24 * 60,
location="Dry Box 3",
lot_nr="24A0219",
price=54.99,
dried=True,
bought_from="Prusa Shop",
comment="Hardened nozzle only - it eats brass.",
),
],
),
FilamentSpec(
"ambrosia_pla_galacticglowgreen_1000_175_c",
spools=[
SpoolSpec(
used=0.092,
last_used_h=24 * 33,
history_h=24 * 35,
location="Shelf B",
price=27.50,
bought_from="3DJake",
),
],
),
FilamentSpec(
"prusament_pla_galaxyblack_1000_175_p",
drying_temp=55,
spools=[
SpoolSpec(
used=0.774,
last_used_h=24 * 36,
history_h=24 * 140,
location="Shelf A",
lot_nr="23K1902",
price=29.99,
bought_from="Prusa Shop",
),
SpoolSpec(used=0.993, last_used_h=24 * 88, history_h=24 * 200, location="Shelf C", archived=True),
SpoolSpec(used=0.0, location="Dry Box 1", price=29.99, bought_from="Prusa Shop"),
],
),
FilamentSpec(
"bambulab_petg_bluegray_1000_175_n",
drying_temp=65,
spools=[
SpoolSpec(
used=0.238,
last_used_h=24 * 44,
history_h=24 * 55,
location="Shelf A",
lot_nr="B-2405-771",
price=25.99,
bought_from="Amazon",
),
],
),
]
# ---------------------------------------------------------------------------
# Creation
# ---------------------------------------------------------------------------
def iso(dt: datetime) -> str:
return dt.isoformat().replace("+00:00", "Z")
def import_external_filament(api: Api, ext: dict, drying_temp: int | None) -> dict:
"""Import a catalog entry the same way the client's "Add spools" flow does."""
existing = api.get("/filament", {"external_id": ext["id"]})
if existing:
return existing[0]
vendors = api.get("/vendor", {"external_id": ext["manufacturer"]})
if vendors:
vendor_id = vendors[0]["id"]
else:
vendor_id = api.post("/vendor", {"name": ext["manufacturer"], "external_id": ext["manufacturer"]})["id"]
body: dict[str, Any] = {
"name": ext["name"],
"material": ext["material"],
"vendor_id": vendor_id,
"density": ext["density"],
"diameter": ext["diameter"],
"weight": ext["weight"],
"external_id": ext["id"],
}
for key, source in (
("spool_weight", "spool_weight"),
("settings_extruder_temp", "extruder_temp"),
("settings_bed_temp", "bed_temp"),
):
if ext.get(source) is not None:
body[key] = ext[source]
if ext.get("color_hexes"):
body["multi_color_hexes"] = ",".join(h.lstrip("#") for h in ext["color_hexes"])
if ext.get("multi_color_direction"):
body["multi_color_direction"] = ext["multi_color_direction"]
elif ext.get("color_hex"):
body["color_hex"] = ext["color_hex"].lstrip("#")
if drying_temp is not None:
body["extra"] = {"drying_temp": json.dumps(drying_temp)}
return api.post("/filament", body)
def create_spool(api: Api, filament: dict, spec: SpoolSpec, now: datetime, index: int) -> dict:
# Every timestamp is derived from a single `now`, so without this they would all
# share its minute-of-the-hour and read as generated. `index` is the spool's
# position in the inventory, which makes the jitter stable across re-seeds.
jitter = timedelta(minutes=(index * 37) % 60, seconds=(index * 23) % 60)
# Consume a whole number of grams, so the list shows "342 g" rather than a
# decimal that wraps onto a second line in the narrow weight column.
net = filament["weight"]
remaining = round(net * (1 - spec.used))
body: dict[str, Any] = {
"filament_id": filament["id"],
"used_weight": net - remaining,
"archived": spec.archived,
}
for key in ("location", "lot_nr", "price", "comment"):
value = getattr(spec, key)
if value is not None:
body[key] = value
if spec.last_used_h is not None:
last = now - timedelta(hours=spec.last_used_h) - jitter
first = last - timedelta(hours=spec.history_h) + jitter
body["last_used"] = iso(last)
body["first_used"] = iso(first)
extra: dict[str, str] = {}
if spec.last_used_h is not None:
# A spool is opened a little before it is first printed with.
extra["opened"] = json.dumps(iso(first - timedelta(hours=26) - jitter))
if spec.dried is not None:
extra["dried"] = json.dumps(spec.dried)
if spec.bought_from is not None:
extra["bought_from"] = json.dumps(spec.bought_from)
if extra:
body["extra"] = extra
return api.post("/spool", body)
def seed(api: Api) -> None:
catalog = api.get("/external/filament")
if not catalog:
raise RuntimeError(
"The external filament catalog is empty. It syncs shortly after startup - "
"wait for it, or check SPOOLMAN_EXTERNAL_DB_URL / network access."
)
by_id = {entry["id"]: entry for entry in catalog}
print(f"External catalog has {len(catalog)} entries\n")
missing = [spec.external_id for spec in INVENTORY if spec.external_id not in by_id]
if missing:
listing = "\n ".join(missing)
raise RuntimeError(
"These curated filaments are no longer in the external catalog, so the "
"screenshot inventory can't be rebuilt as-is. Pick replacements in "
f"scripts/demo_seed.py:\n {listing}",
)
print(f"Importing {len(INVENTORY)} filaments from the external catalog...")
now = datetime.now(timezone.utc)
spool_count = 0
for spec in INVENTORY:
ext = by_id[spec.external_id]
filament = import_external_filament(api, ext, spec.drying_temp)
for spool_spec in spec.spools:
create_spool(api, filament, spool_spec, now, spool_count)
spool_count += 1
print(f" {ext['manufacturer']} {ext['name']} ({ext['material']}) - {len(spec.spools)} spools")
vendors = api.get("/vendor")
print(f"\nDone: {len(vendors)} vendors, {len(INVENTORY)} filaments, {spool_count} spools")
# ---------------------------------------------------------------------------
# Purge
# ---------------------------------------------------------------------------
def _list_all(api: Api, path: str, page: int = 500) -> list[dict]:
out: list[dict] = []
offset = 0
# GET /spool hides archived spools unless asked; without this a purge silently
# leaves every archived spool behind (and then the filament deletes fail).
extra = {"allow_archived": "true"} if path == "/spool" else {}
while True:
batch = api.get(path, {"limit": page, "offset": offset, **extra})
if isinstance(batch, dict):
batch = batch.get("items", [])
if not batch:
break
out.extend(batch)
offset += len(batch)
if len(batch) < page:
break
return out
def purge(api: Api) -> None:
"""Delete every spool, filament, vendor and extra field. Order matters: spools reference filaments."""
for path, label in (("/spool", "spools"), ("/filament", "filaments"), ("/vendor", "vendors")):
items = _list_all(api, path)
for item in items:
api.delete(f"{path}/{item['id']}")
print(f" deleted {len(items)} {label}")
delete_extra_fields(api)
print(" deleted extra field definitions")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--url", default=DEFAULT_URL, help=f"Spoolman base URL (default: {DEFAULT_URL})")
parser.add_argument("--purge", action="store_true", help="Delete all existing data before seeding")
parser.add_argument("--purge-only", action="store_true", help="Delete all data and exit")
args = parser.parse_args()
api = Api(args.url)
try:
info = api.get("/info")
except RuntimeError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"Connected to Spoolman {info['version']} ({info['db_type']}) at {args.url}\n")
if args.purge or args.purge_only:
print("Purging existing data...")
purge(api)
print()
if args.purge_only:
return 0
setup_extra_fields(api)
print()
try:
seed(api)
except RuntimeError as e:
print(f"error: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())