Restore the upstream ancestry that PR #5's squash merge discarded - #9
Merged
Conversation
Tasks 1-3 of the backend security audit all need to answer "is this browser
origin someone we trust". Build it once, driven entirely by the existing
SPOOLMAN_CORS_ORIGIN rather than a new env var.
- Unset means same-origin only, which is right for nearly every deployment.
Non-browser clients (Moonraker, OctoPrint) send no Origin and are unaffected.
- A list means those origins plus our own.
- "*" and debug mode mean "I do not want origin checks" and are honoured as
such, with a loud startup warning. An operator who writes "*" is opting out;
reinterpreting it as something narrower would leave them no way to say so.
Also accept X-Forwarded-Host, so a reverse proxy that rewrites Host (nginx
without proxy_set_header Host $host, Apache without ProxyPreserveHost On) does
not see the guard reject its own web UI. A malicious page cannot set that
header on any of the vectors the guard exists to stop: forms cannot set request
headers, nor can the browser WebSocket API, and a fetch that adds one needs a
CORS preflight it will not get.
get_cors_origin() now normalizes its entries -- it was a bare split(","), so
"https://a, https://b" yielded " https://b", which no Origin header can match.
CORS itself benefits from the same fix. The raw value is kept in the startup
log so a typo stays visible.
Adds tests/ for backend unit tests, runnable with `poe test` and wired into the
lefthook ci group. Nothing enforces this helper yet; that is tasks 1-3.
…ting bodies
Closes the two confirmed CSRF findings and the cross-site websocket hijack
from the backend security audit (todo.md tasks 1 and 3).
POST /setting/{key} took a bare `str` body, and FastAPI only JSON-parses
application/*json -- anything else arrived as raw bytes that lax-mode Pydantic
coerced. A text/plain body was therefore accepted, which is exactly what
<form enctype="text/plain"> sends, so any website the user visited could
rewrite base_url, label_designs, locations or extra_fields_* through their
browser. Now requires a JSON content type.
The audit suggested Body(media_type="application/json") for this, but that only
annotates the OpenAPI schema and is not enforced at runtime -- a text/plain post
to a pinned endpoint still returns 200. Hence an explicit dependency. Both
clients already send application/json, and a charset parameter is accepted, so
this is wire-compatible.
TrustedOriginMiddleware refuses state-changing HTTP methods and every websocket
handshake from an untrusted origin, using the helper added in the previous
commit. A middleware rather than per-endpoint dependencies so that none of the
nine websocket endpoints can be missed, and one added later cannot forget to
opt in. Reads are left alone -- the same-origin policy already covers them --
but every websocket is guarded, since websockets are exempt from CORS and
otherwise leak the whole inventory to any origin that opens one.
It is ordered inside the CORS middleware so a legitimate cross-origin client
can read the 403 body instead of an opaque CORS error.
Writing a malformed extra_fields_* array through the settings endpoint bypassed
validate_extra_field and left GET /field/{entity} permanently returning 500;
that array is now validated, and the registry cache is invalidated on write so
the /setting and /field paths no longer disagree until restart.
Verified against a real uvicorn server by replaying the audit's exploit: the
text/plain write is refused, a bodyless form post that would have reset a
setting is refused, a websocket from a foreign origin gets HTTP 403 on the
handshake, and clients that send no Origin at all -- Moonraker, OctoPrint,
Home Assistant, curl -- are unaffected on both HTTP and websockets.
Rotating deletes the oldest restore point and shifts the rest down, so enough calls in quick succession leave nothing but snapshots of whatever state the database is in now. POST /backup takes no parameters, so a bodyless cross-origin form post reached it; the audit destroyed a full six-deep history that way (todo.md task 2). The origin guard from the previous commit already refuses that request, but the rotation itself should not be this easy to abuse, so two further changes: Rotation is skipped when the database has not actually changed. Not by comparing the live database file, which the audit suggested: under WAL the live file does not yet contain recent commits, so comparing it would skip backups that were genuinely needed. Instead the snapshot is written to a pending file first and compared against the newest backup -- sqlite's backup API reads through the connection, so it sees WAL content. Identical means the pending file is discarded and the history is left alone. This is fail-safe: if the comparison ever came out unequal for an unchanged database, the result is the old behaviour of rotating, never a missed backup. Rotation is also rate-limited to one per five minutes, returning the newest existing backup instead. Never applied when there is no backup to fall back on. BackupResponse gained a `created` field so a client can tell the difference. Additive, so v1-compatible, but load-bearing: telling a user they have a fresh restore point when they do not is exactly the wrong failure mode here. Verified against a real server -- six rapid calls now leave exactly one backup and never rotate.
… model allow_origins=["*"] with allow_credentials=True makes Starlette echo the caller's origin back and set Access-Control-Allow-Credentials, which tells the browser it may send cookies to whichever site asked. Spoolman has no cookies of its own, so this looked harmless, but plenty of instances sit behind a reverse proxy that does the authentication with one -- and there it let any website the user visited make authenticated requests as them. Now credentials are refused whenever the resolved origin list contains "*" (todo.md task 5). Adds a README Security section. The README has no configuration reference at all -- that lives in the Wiki -- so rather than starting one, this documents the things a user has to know before deciding where to run Spoolman: that there is no authentication by design, that it belongs behind an authenticating reverse proxy if it is reachable beyond the LAN, what SPOOLMAN_CORS_ORIGIN is for, why "*" is a bad idea on a shared network, and that a reverse proxy needs to forward the original Host. Verified with SPOOLMAN_CORS_ORIGIN=* against a real server: the response now carries `access-control-allow-origin: *` and no credentials header, where the audit saw the caller's origin echoed with credentials allowed.
Two lower-severity findings from the audit (todo.md tasks 6 and 7). A spreadsheet executes a cell that begins with =, +, -, @, tab or CR, so a vendor named `=cmd|' /C calc'!A0` runs when someone opens the CSV export (CWE-1236). Such cells are now prefixed with a single quote, which spreadsheets read as "this is literal text" and do not display. Only strings are escaped -- numbers reach the writer as numeric types, so a negative number cannot be mistaken for a formula and must not grow a stray quote. Header names need no escaping: they are fixed attribute names or extra-field keys, which are already constrained to ^[a-z0-9_]+$. Exports also gained Content-Disposition, so they download under a sensible name rather than rendering in the browser. Extra-field values had no size limit and the columns are Text(), so a single request could store megabytes: unbounded database growth, and the resulting websocket event overflowed the 1 MiB default frame limit, which killed live updates for every connected client. Values are now capped at the same 65535 characters as settings. The number of extra fields per entity is capped too -- the per-entity key count was already bounded, since unknown keys are rejected, but the registry itself could be grown without limit, and it is cached in memory and embedded in every entity response. Finally, a setting larger than the column limit returned 500; the ValueError is now caught and returned as a 400. Verified end-to-end: a 2 MB extra value is refused on both create and update, an oversized setting returns 400 and leaves the setting unset, normal values still work, and a vendor named `=cmd|' /C calc'!A0` exports as `'=cmd|' /C calc'!A0` while the JSON export keeps the raw name.
Each list endpoint parsed `sort` inline with a bare split(":"), so `?sort=name`
raised "not enough values to unpack" and `?sort=name:sideways` a KeyError from
SortOrder[...]. Both surfaced as a 500 (todo.md task 8).
parse_nested_field also gated on hasattr, so any attribute that merely existed
passed through to order_by() -- `?sort=metadata:asc` got as far as calling
.asc() on the MetaData object. It now checks the SQLAlchemy mapper's columns,
so only real columns are accepted; relationship traversal through filament. and
vendor. still works.
Sort parsing moves into a shared parse_sort() used by all four call sites, which
tolerates surrounding whitespace and rejects a missing colon, an empty field
name or an unknown direction with a message saying what was expected.
database/vendor.py turned out not to use parse_nested_field at all -- it did a
bare getattr(models.Vendor, fieldstr) -- so /vendor kept returning 500 even
after the helper was fixed. It now uses the shared helper like filament.py.
All three endpoints verified end-to-end: 400 for each malformed form, 200 for
valid single and multi-field sorts, and no unhandled exceptions in the log.
reconnectAllNow() skips only OPEN/CONNECTING sockets, so a wake that fires while a socket is still CLOSING opens a replacement. The old socket's onclose then unconditionally cleared sock.pingTimer and nulled sock.ws, killing the replacement's keepalive and leaving a live socket the code believes is down. Guard the handlers with an identity check: onopen/onclose only touch the shared ResourceSocket state while they are still the current socket, and each socket now clears its own keepalive interval so a superseded one can't leak it.
AddSpoolModal closes on any Escape reaching <svelte:window>, but Combobox and DateTimeField let their own Escape keep propagating. Dismissing a location, vendor or material dropdown — or the date popover — inside the modal therefore discarded the whole form. Regression from bf3896b, which moved the modal's keyboard close onto the window. Both now stopPropagation() after closing their own popup. DateTimeField's listener is on document, which is still ahead of window in the bubble path.
Spool.from_db reports remaining_weight as max(initial - used, 0), but find_groups' remaining_expr omitted the clamp. A spool used past empty therefore contributed a negative number to total_remaining_weight, so a group's total no longer matched the sum of the spools it contains — two 500 g spools, one with 200 g used and one with 800 g, reported 0 g instead of 300 g. Mirror the clamp with CASE rather than func.greatest, which is not portable across all four supported databases. Covered by a new integration test.
filament.update re-broadcast every spool of the filament (archived included) after commit, so a filament shared by hundreds of spools turned a single PATCH into hundreds of ORM loads and websocket frames inside one request. This is the unbounded fan-out flagged in review; the same reasoning is why no equivalent was ever added for vendor.update. Subscribers now treat a filament event as invalidating the spools that reference it. In client_v2 the cache is already normalized -- spools hold a filamentId and resolve their filament through filamentById -- so nearly everything a spool displays was live regardless. The exception is a spool with no initial_weight of its own, whose initial/remaining weights the server derived from filament.weight. The cache now recomputes exactly those, mirroring Spool.from_db's formula including its clamp at 0, which needs used_weight and the initial_weight override carried on the cached spool. Reverts the behavior added in 6ade405 (unreleased). Its integration test is repurposed to pin the decision from both sides: no spool event reaches a /spool subscriber, and the filament event subscribers rely on is still emitted.
Two gaps in the new /search endpoint, neither yet released: Archived spools were always included, unlike GET /spool. /search now takes allow_archived (default false) and applies the same false-or-null clause spool.find uses, so the two endpoints agree on what "archived" hides. The exact-id shortcut honours it too -- otherwise searching an archived spool's id surfaced it regardless. Candidate selection ordered by id ASC before LIMIT 200, so on a database with more than 200 matches for a term only the 200 lowest ids were ever ranked and a newly added spool could not be found at all. Ordering descending makes the newest rows the ones that get ranked, which is the useful bias for an inventory that only grows. Ranking still re-sorts the survivors by match quality, so this changes which rows are considered, not how results are ordered. Integration tests cover both defaults and both opt-ins.
The var is the operator's rollback path if the new client misbehaves, but it was only discoverable by reading env.py or the Dockerfile. Nothing else documents Spoolman's env vars, so .env.example is the place.
client_v2 had no unit tests and no test script; the only spec was the Playwright a11y audit, which is a separate run. Adds vitest (`npm test`) configured for src/**/*.test.ts, and starts with the two files the review called out. labels/migrateV1.ts is the priority: it rewrites label templates and print presets users authored in the v1 client, runs unattended at first load, and its output is what they see afterwards -- a silent mistranslation quietly damages work they can't recover. Covers the whole path-rewriting table, the bare/wrapped/extra-field token forms, that a path with no v2 equivalent is left alone rather than mangled, and the guards that keep a hand-edited preset from yielding a zero-size canvas. utils/format.ts is second: it formats every weight, length and percentage on screen. Pins the deliberate rounding rules -- notably that weightAuto floors kg so a displayed "1.1 kg" never overstates what is on the spool -- and checks lengthMeters against the known ~335 m for 1 kg of 1.75 mm PLA. 40 tests.
Comments only. 2924850 removed the filament->spool fan-out, but neither comment named remaining_length, which is the field 6ade405 added the fan-out for. That omission is how someone re-adds it. Spell out that remaining_length is derived from density and diameter and is not cached client-side -- SpoolInspector computes it from the $derived filament, so the filament event alone refreshes it -- while the stored initial/remaining weights are the part that genuinely needs reweighSpoolsOfFilament.
load_and_rewrite_fallback_base_path does blind string replacement on 200.html to insert the operator's base path into SvelteKit's absolute asset references and its hardcoded `base: ""`. str.replace on a pattern that isn't there is a silent no-op, so a SvelteKit upgrade that changes any of those three emission shapes would ship a fallback document whose assets 404 and whose client-side router builds URLs without the base path. That failure is invisible in development and in CI, both of which run at the root path where this code doesn't execute at all. It would surface only on an operator's reverse-proxied instance, as a blank page on exactly the routes the rewrite exists to serve -- direct loads of /spool/show/<id>, the target of printed QR labels. Check each pattern is present before rewriting and raise naming the ones that are not. A build/server mismatch is a packaging error, so refusing to start with an actionable message beats serving a broken page.
_load_filaments memoizes the parsed catalog on the cache file's st_mtime alone. Two writes landing in the same mtime tick therefore look identical to the cache and the second one is never picked up -- the process keeps serving the stale parse until some later sync happens to change the stamp. The window is small on Linux, where st_mtime is nanosecond-resolution, but it is not theoretical: Spoolman's data directory is routinely a bind mount, and filesystems with 1-second mtime granularity are common there. A catalog refresh triggered close after another write would be silently dropped. Including st_size makes the key sensitive to any rewrite that changes length, which is the realistic shape of a catalog update.
5eb660f turned svelte/no-navigation-without-resolve off globally because every hit at the time was a false positive: the href had already been resolved by a params.ts helper or handed in as a pre-resolved prop. Switching the rule off repo-wide also removed the only automated guard against the real bug it exists to catch, and that bug is one nobody developing at the root path can see -- a missing resolve() works perfectly until an operator deploys under SPOOLMAN_BASE_PATH. The rule is on again. The eleven known-good sites are silenced where they live: - Ten .svelte files carry a file-level disable inside their <script>, with the reason attached via the `--` description syntax. Per-line disables don't work for most of them -- the rule anchors on the href attribute, which prettier puts on its own line inside a multi-line element, where an HTML comment can't go. - params.ts's two goto() calls take eslint-disable-next-line each. Both targets are base-path-independent already: a bare `?query` resolves against the current URL, and the other is built from resolve('/'). A new file, or a new component that builds an href itself, is caught again. Verified by adding a raw <a href="/spool/show/1"> and watching the rule fire.
18214ee fixed a real WCAG AA failure by brightening --text-muted/--text-dim/ --text-faint (dark) and darkening them (light), but it moved all three to nearly the same place: 0xa0/0x97/0x90 in dark and 0x5f/0x66/0x6d in light, about 2.6 L* apart. Three tokens exist to express three levels of emphasis; at that spacing they render as one gray and the hierarchy they encode is gone. The squeeze came from anchoring the whole ramp above the AA floor rather than on it. #959595 is the darkest gray that clears 4.5:1 on the lightest dark surface (--surface-raised #2e2e2e), so faint now sits exactly there and muted/dim step up from it in even ~7 L* increments -- the same perceptual spacing the pre-AA ramp had. Light mirrors it from the #676767 ceiling over --surface-sunken #e8e6e2, with tighter ~4.5 L* steps because --text-2 #444444 caps the range. Contrast against the worst-case surface in each theme is 7.0/5.6/4.5 (dark) and 6.3/5.4/4.5 (light). Every one of the six values moved further from its background than 18214ee left it, so this cannot regress the audit it was fixing. The comments now record where the floor comes from, so the next person retuning these knows which end is fixed and why.
UnusedRow's sameWeight check only asks whether the collapsed spools agree, so a group where they all agree on nothing takes the "Y g each" branch. mapSpool falls back to 0 when a spool has no initial_weight and its filament has no weight either, which makes that branch print "0 g each" and the divider "N unused - 0 g each" for spools that are in fact full. Treat a weight of 0 as unknown and fall through to the mixed variants, which state the count and location and simply omit the quantity -- the same thing already done when the spools genuinely differ.
The list coalesces websocket events because one revision bump refetches the group page and every visible GroupRow, so a burst -- a bulk import, a printer streaming use updates, the initial sync -- would otherwise fire that whole fan-out per event. It did so with a trailing debounce, which charges the 300 ms to every event rather than to bursts. The overwhelmingly common case is a single change: one edit in another tab, one printer reporting one spool. Each of those waited out the full window before anything moved, which is exactly the latency a live view should not have, and the burst it was paying for never arrived. Same window, leading edge instead: the first event refetches immediately and opens a cooldown, and anything arriving during it is folded into one catch-up refetch when the cooldown ends. An isolated change is now instant; a burst still costs one fan-out per window rather than one per event. The magic number is named COALESCE_MS and the comment says what the window is trading off.
The a11y audit reports 14 `label` and 2 `select-name` violations on the inspector
drawer, both critical, in both themes. Every editable field in the filament, spool
and vendor inspectors is affected: a screen reader announces the whole panel as a
column of unnamed edit boxes, so it is unusable there.
The cause is structural. AddSpoolModal wraps each control in a <label>, which names
it implicitly -- that surface has no violations. The inspectors instead use
FieldGrid, a two-column grid where <Field> emits the label as one cell and the
control as its sibling. Nothing connects them.
<Field> now publishes the id of its label text through context, and the generic
controls that can sit in a value cell -- EditableField, NumberInput, Combobox --
point aria-labelledby at it. Outside a <Field> the context is undefined and the
attribute is omitted, so the same components keep their implicit labels in
AddSpoolModal. aria-labelledby rather than <label for>: a value cell can hold two
controls (a range's min/max) or no labelable one at all (ColorEditor), where a
`for` would have to pick exactly one and would dangle when there isn't one. The id
goes on an inner span around just the label text, because the ⓘ help toggle is a
sibling and would otherwise fold "Help" into every name.
ExtraFieldInput names its inputs from field.name directly, since it already knows
it; range inputs get "<field> min"/"<field> max" so the pair is distinguishable.
This also fixes its boolean Toggle, which announced the type ("Boolean") rather
than the field, so every boolean extra field had the same name.
Audit is clean on all six surfaces in both themes. Verified the names resolve to
the visible labels rather than merely existing -- Name, Material, Diameter,
Density, ..., Integer range min, Integer range max, Single choice.
White on the dark theme's --accent (#be682f) is 4.03:1, so every solid accent button failed WCAG AA -- the a11y audit flagged .btn.primary and .seg-btn.active as serious. No foreground fixes it: on #be682f the best any color reaches is 5.21:1 (pure black), and every readable one scores worse than white. So the fill becomes its own token rather than the accent moving. --accent-fill is #b2612c, the minimal same-hue, same-saturation darkening that clears 4.5:1 (4.53) -- 3.3 L* below --accent and imperceptible beside it. --accent is untouched and still drives links, borders, washes and focus rings, where it sits on a dark surface and passes comfortably. Worth being precise about what is and isn't the brand color here: Spoolman orange is #dc7734 (static/spoolman.svg, and the v1 client's colorPrimary). It appears nowhere in client_v2/src -- both themes already ship a darkened adaptation of it, 7.5 L* down in dark and 12 L* in light, the latter labelled as such in app.css. White on the brand orange itself would be 3.12:1. So this is a third step along an adaptation already two steps in, not a new brand color, and the logo is untouched. The fill DARKENS on hover (--accent-fill-hover #9b5526, 5.64:1) where --accent lightens; lightening would drop straight back below AA. The light theme already darkened on hover, so the two themes now agree. Light's own fill tokens just mirror its existing accent pair (4.72:1 / 5.88:1) -- they exist so both themes resolve the same variables, not because light needed changing. Applied to every white-on-accent surface, not just the two axe caught: primary buttons, the extra-fields manager's primary button, the mobile add button, the spool inspector's active mode button, the settings segmented tab, and the date picker's selected day and Done button. Toggle's track and the locations loading bar keep plain --accent -- no text sits on them. Audit is now clean on all six surfaces in both themes: zero axe violations.
Mirrors tests_frontend, but drives the Svelte client instead of the legacy React one: same production image, same PostgreSQL-backed compose stack, same "only the first navigation uses a URL" discipline. - smoke.spec.ts clicks through every top-bar tab, asserting each route's title and the app shell, with no browser console errors; a second test proves the library reached the API rather than falling into its offline state. - crud.spec.ts walks the first-use happy path. client_v2 folds the legacy client's three create forms into one "Add spools" modal, so a manufacturer, a filament and a spool are created by a single submit, then verified back in the library list and on the locations page. Two client_v2-specific helpers earn their keep: the top bar renders its nav twice (desktop + mobile, one hidden by CSS) so nav locators need narrowing to the visible one, and NumberInput's accessible name picks up its stepper and help buttons, so numeric fields are found via their form item instead of getByLabel. The suite publishes on host port 8001 so it can run alongside the legacy one. CI gains a test-frontend-v2 job cloned from test-frontend, gating publish-images alongside it. Also fixes tests_frontend/run.py: it only built client/dist, but the Dockerfile copies client_v2/build unconditionally, so the image could not be built from a clean checkout.
* Translated using Weblate (Estonian) Currently translated at 17.3% (50 of 288 strings) Translated using Weblate (Persian) Currently translated at 96.5% (278 of 288 strings) Translated using Weblate (French) Currently translated at 98.9% (285 of 288 strings) Translated using Weblate (Greek) Currently translated at 72.9% (210 of 288 strings) Translated using Weblate (Dutch) Currently translated at 99.3% (286 of 288 strings) Translated using Weblate (Hungarian) Currently translated at 72.2% (208 of 288 strings) Translated using Weblate (Danish) Currently translated at 85.4% (246 of 288 strings) Translated using Weblate (Thai) Currently translated at 98.6% (284 of 288 strings) Translated using Weblate (Japanese) Currently translated at 99.3% (286 of 288 strings) Co-authored-by: Anonymous <noreply@weblate.org> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/el/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/et/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fa/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fr/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ja/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/nl/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/th/ Translation: Spoolman/Web Client * Translated using Weblate (Ukrainian) Currently translated at 93.0% (268 of 288 strings) Translated using Weblate (Greek) Currently translated at 72.9% (210 of 288 strings) Translated using Weblate (Hungarian) Currently translated at 72.2% (208 of 288 strings) Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/el/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/uk/ Translation: Spoolman/Web Client * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 98.6% (284 of 288 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.3% (286 of 288 strings) Translated using Weblate (Hindi (Latin script)) Currently translated at 3.8% (11 of 288 strings) Translated using Weblate (Swedish) Currently translated at 94.7% (273 of 288 strings) Translated using Weblate (Ukrainian) Currently translated at 93.0% (268 of 288 strings) Co-authored-by: Anonymous <noreply@weblate.org> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hi_Latn/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sv/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/uk/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/ Translation: Spoolman/Web Client * Translated using Weblate (Danish) Currently translated at 87.1% (251 of 288 strings) Co-authored-by: srbjessen <srbjessen@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/ Translation: Spoolman/Web Client * Translated using Weblate (Czech) Currently translated at 100.0% (288 of 288 strings) Co-authored-by: Miloslav Kos <kos.m@post.cz> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/cs/ Translation: Spoolman/Web Client * Translated using Weblate (Korean) Currently translated at 100.0% (288 of 288 strings) Translated using Weblate (Korean) Currently translated at 78.1% (225 of 288 strings) Added translation using Weblate (Korean) Co-authored-by: 김태남 <imedia0@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ko/ Translation: Spoolman/Web Client * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (288 of 288 strings) Co-authored-by: Kayz C <kayzed.x@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/ Translation: Spoolman/Web Client * Translated using Weblate (Slovenian) Currently translated at 100.0% (288 of 288 strings) Translated using Weblate (Slovenian) Currently translated at 38.1% (110 of 288 strings) Translated using Weblate (Slovenian) Currently translated at 14.9% (43 of 288 strings) Added translation using Weblate (Slovenian) Co-authored-by: Jernej Pangerc <jernejp21@tuta.com> Co-authored-by: jernejp21 <jernejp21@tuta.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sl/ Translation: Spoolman/Web Client * Translated using Weblate (Slovak) Currently translated at 10.4% (30 of 288 strings) Translated using Weblate (Slovak) Currently translated at 9.7% (28 of 288 strings) Added translation using Weblate (Slovak) Co-authored-by: Pavol Vrba <pvrba2000@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sk/ Translation: Spoolman/Web Client * Translated using Weblate (Hungarian) Currently translated at 94.0% (271 of 288 strings) Co-authored-by: Tamas Veres <tamas.veres@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/ Translation: Spoolman/Web Client * Translated using Weblate (Danish) Currently translated at 100.0% (288 of 288 strings) Translated using Weblate (Danish) Currently translated at 92.0% (265 of 288 strings) Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/ Translation: Spoolman/Web Client * Translated using Weblate (Latvian) Currently translated at 4.5% (13 of 288 strings) Added translation using Weblate (Latvian) Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/lv/ Translation: Spoolman/Web Client * Translated using Weblate (Russian) Currently translated at 100.0% (288 of 288 strings) Translation: Spoolman/Web Client Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/ --------- Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com> Co-authored-by: srbjessen <srbjessen@gmail.com> Co-authored-by: Miloslav Kos <kos.m@post.cz> Co-authored-by: 김태남 <imedia0@gmail.com> Co-authored-by: Kayz C <kayzed.x@gmail.com> Co-authored-by: jernejp21 <jernejp21@tuta.com> Co-authored-by: Pavol Vrba <pvrba2000@gmail.com> Co-authored-by: Tamas Veres <tamas.veres@gmail.com> Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com> Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com> Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com>
Vendors list the same filament in several sizes, so "Polymaker · PLA" rows for the 1 kg and 250 g variants were indistinguishable in both the catalog and SpoolmanDB result lists. Show the net weight on each row, and on the chosen-filament card so the pick can be confirmed on step 2. The row weight sits outside .res-name rather than being appended to the vendor/material line: that element truncates with an ellipsis, which would drop the one field that tells two matching results apart. The chosen card's .cs line wraps instead, so a plain append is safe there. A local filament's weight maps to 0 when unrecorded, so both row sites guard on it rather than rendering "0 g".
Buying the same filament in several colors meant re-entering every spec through the add-spool modal for each one. Add a duplicate flow reachable from three places, all sharing one startDuplicate() path: - a Duplicate button in the filament inspector header - a "duplicate" link on the chosen filament in add-spool step 2, for when you found the right product but the wrong color - "Add & new" after creating a filament, which now hands back the same form pre-copied instead of an empty search The copy carries manufacturer (reusing the existing vendor), material, specs, weights, price, comment and custom fields. Color is cleared and the article number dropped since it is a per-color SKU; external_id never copies. The name is kept as a starting point with the caret at its end, plus a nudge that stays until it is changed. Also fixes two things this surfaced in the add-spool modal: the advanced block had its Extruder Temp and Bed Temp labels swapped (stored data was always correct, only the labels lied), and the name field showed a red "Required" before the user had typed anything, which left no room for the new naming hint. The error now waits for first input or blur; validation and canSubmit are unchanged.
The locations board could only ever answer one question: where is this spool. The same card-and-drag interaction works for any field a spool carries, so make the field a view mode and keep the rest. Location is still the default view and behaves exactly as before. The other views come from the user's own spool fields: any single-value text or choice field now appears in a Group-by menu. A choice field renders a card per choice, including the empty ones, which turns a "Status" field into a kanban board. Which field a card belongs to is decided by one small interface (src/lib/dashboard/fields.ts), so the page never special-cases a field. A field only qualifies if the SPOOL OWNS IT, because a drag moves one spool and must mean exactly one change. That rules out material and vendor, where writing one spool's card would silently rewrite every other spool of the same filament, and lot number, which is stamped on by the manufacturer and would be recording something untrue. Those stay library filters. It also rules out numbers, dates, booleans and multi-choice, whose values don't name a card you can drag into. Backing changes: - /spool/group grows group_by=extra.<key> for a spool's custom fields. The field table is aliased so it stays a distinct FROM element -- the extra-field filters reference the un-aliased table inside correlated subqueries, and without the alias those would correlate to this join instead of standing alone. Grouping is rejected for field types whose value isn't a single repeatable string. group_by is no longer a Literal, so an unsupported value is a 400 rather than a 422. - The saved layout is per field: dashboard_groups and dashboard_spoolorders replace the location-only pair. The old `locations` / `locations_spoolorders` are read once as a seed for the location view and never written back, so an upgrade keeps the shelves it had and the old client keeps working. - The view mode lives in the URL (?by=) so a board can be bookmarked and shared, and is mirrored to localStorage so a bare /dashboard resumes the last one looked at. The URL wins when it says something; a stale ?by= naming a deleted field falls back to location. Verified against SQLite, PostgreSQL, MariaDB and CockroachDB -- Postgres caught a real difference here, it sorts NULLs last where SQLite sorts them first, so the new group assertions are keyed rather than ordered.
Renaming a dashboard card only worked for Location, which has a
server-side rename that moves every spool in one statement. A custom
field had no equivalent, so the card name was read-only unless the group
was empty -- renaming it from the client would have meant patching each
spool one at a time, and only the ones the board had actually paged in.
Generalise the location rename to the spool's other string fields:
PATCH /spool/field/{field} {"value": "Shelf A", "new_value": "Shelf B"}
-> {"spools_updated": 6}
where field is `location` or `extra.<key>`. Archived spools are included
-- the value is the value, and skipping them would leave half the spools
behind. Renaming onto a value already in use merges the two, though the
dashboard still refuses that so it can't happen by accident.
The old value travels in the body rather than the path, unlike
/location/{name}. Field keys are ^[a-z0-9_]+$ and so are path-safe, but
values are not: a location like "Rack A/B" needs an encoded slash, which
proxies routinely mangle. No reason to carry that flaw into the general
form.
Only fields the spool owns can be renamed. material/vendor/filament are
refused because they belong to another entity, and rewriting them "for
these spools" would change filaments that other spools share -- the same
rule that decides what the dashboard can group by, so grouping and
renaming now share one validator.
Like rename_location, no spool event is broadcast per row: the change is
one statement over what may be hundreds of spools, and that fan-out is
what the websocket layer avoids elsewhere.
Verified on all four databases, and in the browser on a 40-spool group
of which the board had loaded 30 -- all 40 moved, and the card renamed in
place without losing its position.
The default design was a QR and one text block holding name, material and
spool id. It printed, but it looked like a debug dump: no manufacturer
anywhere, no color anywhere, and no visual hierarchy, because a text
element carries one size, weight and color for its whole template.
Lay out the right-hand column as four elements instead of one:
Clas Ohlson <- manufacturer, 2.5mm grey
PLA universal <- name, 3.2mm bold, the only wrapping block
filament
PLA . #2 <- material . id, 2.5mm
[============] <- color swatch, bottom-aligned with the QR
Splitting them is what buys the hierarchy; it also lets the manufacturer
sit in a conditional block, so a filament with no vendor prints nothing
there rather than the bare "?" a plain placeholder resolves to. The
material does the same inside the id line, taking its " . " separator
with it.
DEFAULT_SPOOL_TEXT / DEFAULT_FILAMENT_TEXT now hold just that id line,
which is the only part that differs between the two label kinds. That
would have quietly broken the spool/filament toggle for designs already
saved: setDesignKind retargets a text block only when it still matches
the *other* kind's default, and every existing design carries the old
three-line template. So the comparison runs over a list of known default
pairs, newest first, with the pre-redesign pair kept as the second entry.
Also explain "Skip Items", which reads like a mystery until you know
sheets are reusable. The hint goes full-width under the row rather than
in the field's own column, where at three columns it stacked seven lines
deep, and opens with the field name like helpMargin and
helpPrinterMargin already do.
Verified in the browser against a real spool, and the kind-switch
retargeting -- current default, pre-redesign default, and a user-edited
template that must stay untouched -- is covered by unit tests.
Spool Weight asked beginners for a number they have no way of knowing, so its help now names the usual ranges and offers two one-click starting values. Location read as abstract metadata rather than the shelf the spool goes on, so it gets an always-visible hint (not another popup — testers didn't open those) and examples that look like places.
client_v2: edit the manufacturer's extra fields when the add flow creates one
An incomplete add-spool form was answered with a dead Add button, leaving "which field haven't I filled in?" to be found by eye. Worst case the answer was invisible: density is required but only prefilled for known materials, and it lives inside the collapsed Advanced block, so the form could look complete and still refuse. Add stays clickable. Pressing it on an incomplete form reveals every outstanding error at once, lists them above the button as rows that jump to their field, opens the section hiding one, and puts the caret in the first. The list stays as a checklist and shrinks as fields are fixed. Per-field errors now appear once you leave a field rather than immediately, so a freshly opened form is quiet. Make the asterisk mean one thing: every required field carries it and nothing else does. Count was missing one. A "* required" legend says what it means, and the controls carry aria-required/aria-invalid so it is not conveyed by a glyph alone. The extra-fields editor in settings follows the same convention: asterisks on key, name and choices, and a save error now outlines and focuses the field it came from.
The summary panel above the add button was more machinery than the problem needed, and it spent five translation strings restating what the inline field errors already say. Drop it. Pressing Add still reveals every outstanding error and still opens the collapsed Advanced block when the problem is hiding in there; it just takes you to the first bad field and lets the error under it do the talking. The issue count on the Advanced toggle and the "* required" legend go too. The change now adds no new translation strings at all.
Leaving Material blank sent you to Density instead. Density is only prefilled for a material Spoolman recognises, so a blank material surfaced as "Density is required" — an error on the field you didn't fill in because of the field you did miss, and inside the collapsed Advanced block at that. Material is now required on this form, so the error lands on the field that actually needs attention and Advanced stays shut. Typing a material we don't have specs for still asks for a density afterwards, which is the right order to be asked in. Client-side only: the API still accepts a filament with no material, so existing ones are unaffected.
Master reorganized the spool half of the add form into headline / fill / where / money groups and added the roll-size presets. Kept that layout and re-applied the required-field wiring on top of it: the data-field hooks, the per-field touch handlers and the err() calls that decide when an error shows. FIELD_ORDER follows the new reading order too, so Fill level now comes before Spool Weight when the form picks which bad field to jump to.
…elds client_v2: point at missing required fields instead of disabling the add button
The change-filament dialog could only pick a filament that already existed, in the catalog or in SpoolmanDB. But the only way to create one anywhere in client_v2 is the add-spools flow, which always mints at least one spool — so refilling a shelf slot with a roll nobody had catalogued meant adding a throwaway spool and deleting it again, handing out exactly the new spool id the feature exists to avoid (issue Donkie#1010). The dialog now offers the same third path the add-spools flow does. Picking it swaps the search for the new-filament form and applies as one action: the filament (and its manufacturer) are created, then the spool is repointed at it. The spool keeps its number, location, dates and usage history. The form itself moves to NewFilamentCards, shared with AddSpoolModal so both dialogs keep the per-record card layout from Donkie#1038, with its rules and its mapping onto the API in lib/filament/draft.ts. The one difference is the weight/spool weight/price trio: the add-spools flow writes those to the spool it creates as well, and so keeps showing them in its spool block, while here they belong to the filament alone.
…change client_v2: create a filament while changing what a spool holds
Currently translated at 25.4% (137 of 539 strings) Translation: Spoolman/Spoolman Web UI v2 Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/fr/
Translations update from Hosted Weblate
Currently translated at 99.3% (291 of 293 strings) Co-authored-by: Alessandro “Alessandrol” L <alessandrolond@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fr/ Translation: Spoolman/Web Client LEGACY
Translations update
Currently translated at 31.2% (169 of 541 strings) Translation: Spoolman/Spoolman Web UI v2 Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pt_BR/
Translations update from Hosted Weblate
Currently translated at 100.0% (293 of 293 strings) Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/ Translation: Spoolman/Web Client LEGACY
Currently translated at 100.0% (541 of 541 strings) Translation: Spoolman/Spoolman Web UI v2 Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/ru/
Translations update from Hosted Weblate
Currently translated at 100.0% (293 of 293 strings) Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/ Translation: Spoolman/Web Client LEGACY
Translations update
Translations update
Currently translated at 99.3% (291 of 293 strings) Co-authored-by: Liudas Ališauskas <liu2kas3@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/lt/ Translation: Spoolman/Web Client LEGACY
Currently translated at 96.5% (283 of 293 strings) Co-authored-by: Icezaza Ch <icezaza.jar@gmail.com> Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/th/ Translation: Spoolman/Web Client LEGACY
Translations update
PR #5 was squash-merged, so master got the full content of the upstream sync but none of its ancestry: upstream/master is not an ancestor of master, and git still counts 362 upstream commits as missing. The practical cost is that the next sync re-merges all of them. A trial merge of upstream/master onto master produces 27 conflicts across 26 files -- including every file resolved in PR #5 (.env.example, spoolman/env.py, main.py, spool.py, database/utils.py, settings.py, externaldb.py, README.md, pyproject.toml, the strip_color_hex_hash migration) plus the tests_frontend_v2 and guard-translations files fixed afterwards. All of that work would have to be redone by hand, with a fresh chance of getting it wrong. This records the merge without touching any file: -s ours keeps master's tree exactly as it is and only adds 6fd64f9 as a second parent, which is the truth -- that content is already here, it just was not written down. Deliberately merging 6fd64f9, the upstream commit PR #5 actually merged, rather than today's upstream/master. Upstream has moved 4 commits since, and their content is NOT in master; claiming them here would bury them permanently. Verified: the tree is byte-identical before and after. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot wasn't able to review any files in this pull request.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
jpapiez
added a commit
that referenced
this pull request
Aug 18, 2026
PR #5 was squash-merged, which flattened the upstream sync to a single parent: master holds all of upstream's content but git does not know it, so it still counts hundreds of upstream commits as missing and the next sync would re-merge them all -- 27 conflicts across 26 files in a trial run, covering every file resolved in #5 plus the fixes made after it. PR #9 recorded the missing parent, but was itself squash-merged, which stripped the second parent again and reduced it to an empty commit. Hence this commit, pushed directly to master: a merge is the one thing a squash cannot carry, so routing it through another PR just repeats the failure. -s ours keeps master's tree exactly as it is and only adds 6fd64f9 as a second parent. Zero files change; this records a fact that is already true. Merging 6fd64f9 -- the upstream commit PR #5 actually merged -- and not today's upstream/master. Upstream has moved on since, and that newer work is genuinely not in master; claiming it here would bury those commits permanently. Verified: tree hash identical before and after, git diff against master empty, and a trial merge of upstream/master drops from 27 conflicts to 0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
This PR must be merged with a real merge commit — "Create a merge commit". Do NOT squash or rebase.
Squashing this PR would discard the very thing it adds (the second parent) and leave the problem exactly as it is now. If squash is the repo default, switch it for this one merge.
The problem
PR #5 was squash-merged. Squashing flattens a merge into a single-parent commit, so master received the full content of the upstream sync but none of its ancestry:
As a result git still believes we are missing 362 upstream commits, and
git merge-base --is-ancestor upstream/master origin/masteris false.Nothing is wrong with the code — I verified master's content matches the pre-squash merge branch exactly, differing only by the expected follow-ups (#7, #8 and the version bumps). This is purely about recorded history.
Why it matters
Because git doesn't know those commits are in, the next sync re-merges all of them. I ran a trial merge of
upstream/masteronto master:27 conflicts across 26 files — including every file resolved in #5:
...plus the files fixed after #5 (
tests_frontend_v2/*,guard-translations.yml). That includes the migration whosedown_revisionhad to be repointed to avoid a split alembic history that would have failed startup on every server — a subtle fix that would be silently up for re-litigation.Every one of those resolutions would have to be redone by hand, with a fresh chance of getting one wrong.
The fix
git merge -s ours 6fd64f9— records the merge without touching a single file.-s ourskeeps master's tree exactly as it is and only adds the second parent, which is simply the truth: that content is already here, it just was never written down.Deliberately merging
6fd64f9— the upstream commit PR #5 actually merged — rather than today'supstream/master. Upstream has moved 4 commits since, and their content is not in master. Claiming them with-s ourswould bury those 4 commits permanently, so this records only what is genuinely contained.Verification
6a95b88…/6a95b88…— identicalgit diff origin/master HEAD6fd64f9is ancestor of HEADupstream/masterFor next time
Sync PRs must be merged with a merge commit, never squashed. Worth considering disabling squash-merge on this repo, or at least remembering it for sync PRs specifically — this failure is silent, and its cost is only discovered at the following sync.