Compare commits

...
108 Commits
Author SHA1 Message Date
Donkie 362d80e55d Merge pull request #1284 from Donkie/fix/1208-remember-page-size
Remember the Library page size
2026-10-18 09:11:31 +02:00
Donkie 5fc8e6cf42 Remember the Library page size 2026-10-18 09:05:23 +02:00
Donkie b7a282d2eb Merge pull request #1231 from dajiaohuang/fix-reject-odd-length-tag-uids
Reject odd-length tag UIDs
2026-10-17 09:44:13 +02:00
Donkie c979e63513 Merge pull request #1214 from dajiaohuang/fix-extra-field-integer-reject-bool
Reject booleans for integer extra fields
2026-10-17 09:40:49 +02:00
Donkie 0e88109cc6 Merge pull request #1216 from dajiaohuang/fix-websocket-idempotent-disconnect
Make websocket subscriber cleanup idempotent
2026-10-17 09:39:41 +02:00
Donkie ed9124cb06 Merge pull request #1279 from Donkie/fix/1278-register-sl-ko
Offer Slovenian and Korean in the language picker
2026-10-14 19:54:30 +02:00
Donkie f399855d3b Offer Slovenian and Korean in the language picker 2026-10-14 19:27:48 +02:00
github-actions[bot] d102f9475b Merge pull request #1273 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-10-12 15:09:37 +00:00
Julia 61e85c29bf Translated using Weblate (Polish)
Currently translated at 100.0% (629 of 629 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-10-11 15:10:02 +02:00
Julia ef3dee4728 Translated using Weblate (Polish)
Currently translated at 100.0% (628 of 628 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-10-11 15:08:09 +02:00
Donkie 8656e04055 fix(deps): patch sponsors image decoder vulnerabilities
Update sharp from 0.35.3 to 0.35.5 and its matching prebuilt binaries, addressing GHSA-wq5f-xc86-pv6w and GHSA-rgj7-g3m4-5g8c (Dependabot alerts #273 and #256).

Verified npm ci reports zero vulnerabilities and sharp renders the existing sponsors SVG to an 800x570 PNG. No application dependencies or workflow configuration changed.
2026-10-11 15:08:01 +02:00
Donkie 744284391d Merge pull request #1252 from Donkie/dependabot/npm_and_yarn/tests_frontend_v2/sharp-0.35.5
Bump sharp from 0.35.4 to 0.35.5 in /tests_frontend_v2
2026-10-11 14:54:49 +02:00
Donkie f8642bedd6 Merge pull request #1270 from Donkie/dependabot/npm_and_yarn/client/i18next-http-backend-4.0.2
build(deps): bump i18next-http-backend from 3.0.6 to 4.0.2 in /client
2026-10-11 14:54:39 +02:00
Donkie 8285a4da45 Merge pull request #1272 from Donkie/deps/drop-influxdb-client
Drop the unused influxdb-client dependency
2026-10-11 14:54:22 +02:00
Donkie f6f28a8df8 Merge pull request #1274 from Donkie/feat/1173-lot-in-grouped-rows
Add a Show lot numbers toggle to the library
2026-10-11 14:52:15 +02:00
Donkie abdfc73b8d Make lot numbers on spool rows a Filter menu toggle
Always showing the lot was too bulky. It now sits behind "Show lot
numbers" in the Filter menu, next to the archived and no-spools
toggles, off by default. When on, every spool row in any grouping ends
its details line with the lot. Like showEmpty it lives in the URL (lots=1)
and is remembered per browser, since it only adds to what's shown.
2026-10-11 14:40:51 +02:00
Donkie 90713bf5e2 Merge pull request #1275 from Donkie/fix/print-filament-picker-pages
Page the filament picker when printing labels
2026-10-11 14:19:29 +02:00
Donkie 636e12e737 Merge pull request #1266 from Donkie/fix/1181-import-vendor-by-name
Reuse a same-name manufacturer when importing from SpoolmanDB
2026-10-11 14:17:02 +02:00
dependabot[bot] 896e473807 Bump sharp from 0.35.4 to 0.35.5 in /tests_frontend_v2
Bumps [sharp](https://github.com/lovell/sharp) from 0.35.4 to 0.35.5.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.4...v0.35.5)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 10:04:47 +00:00
Donkie b01e4e5bbe Give each platform its own uv cache in the image build
The parallel per-platform builds shared one uv cache mount, so they
queued on uv's cache lock. When uv.lock changed, the emulated armv7
sync held it long enough for the others to hit uv's 300 s lock timeout
and fail publish-images.
2026-10-11 12:02:59 +02:00
Donkie c881740e69 Page the filament picker when printing labels
The filament picker loaded up to 1000 filaments once and filtered them
in the browser, so anything past the first 1000 could not be printed.
It now works like the spool picker: 100 per page with the same pager
and sort, search through the backend, and All adding the current page.
2026-10-11 11:45:00 +02:00
Donkie dc18a3017f Show the lot number on spool rows grouped by filament
Spools of one filament looked alike in the grouped list, with only the
usage status to tell them apart. Add the lot number after it when the
spool has one. A long lot is cut by the existing ellipsis, with the full
text on hover. Flat rows and other groupings are unchanged.
2026-10-11 11:43:21 +02:00
Donkie f7c3988fed Merge remote-tracking branch 'origin/master' into fix/1181-import-vendor-by-name
# Conflicts:
#	client_v2/src/lib/api/spoolSource.test.ts
2026-10-11 11:39:55 +02:00
github-actions[bot] 8d363bbec4 Merge pull request #1199 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-10-11 09:39:21 +00:00
github-actions[bot] 57ef303c93 Merge pull request #1200 from Donkie/weblate
Translations update
2026-10-11 09:39:14 +00:00
Donkie bcf340bdc7 Drop the unused influxdb-client dependency
It came in by accident with the sponsors workflow change and nothing
imports it. Removing it also drops urllib3, reactivex, six and
python-dateutil from the image.
2026-10-11 11:38:01 +02:00
dependabot[bot] 69e541ce67 build(deps): bump i18next-http-backend from 3.0.6 to 4.0.2 in /client
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 3.0.6 to 4.0.2.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v3.0.6...v4.0.2)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-version: 4.0.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-11 09:36:38 +00:00
Micha 6746bc4fce Translated using Weblate (German)
Currently translated at 100.0% (628 of 628 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-10-11 11:33:51 +02:00
baked7015 c492cc05fe Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (613 of 613 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/zh_Hant/
2026-10-11 11:33:50 +02:00
EtienneB 1b039bca19 Translated using Weblate (Dutch)
Currently translated at 56.1% (344 of 613 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/nl/
2026-10-11 11:33:50 +02:00
EtienneB bd9553342f Translated using Weblate (Dutch)
Currently translated at 46.9% (288 of 613 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/nl/
2026-10-11 11:33:49 +02:00
Julia 6a9904adbb Translated using Weblate (Polish)
Currently translated at 100.0% (613 of 613 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-10-11 11:33:49 +02:00
EtienneB 74f93b4afb Translated using Weblate (Dutch)
Currently translated at 37.0% (227 of 613 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/nl/
2026-10-11 11:33:48 +02:00
baked7015 073bf15108 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 97.9% (287 of 293 strings)

Co-authored-by: baked7015 <bingxyz1024@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/
Translation: Spoolman/Web Client LEGACY
2026-10-11 09:33:44 +00:00
EtienneB 15f0124a4c Translated using Weblate (Dutch)
Currently translated at 100.0% (293 of 293 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: EtienneB <eboeziek@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/nl/
Translation: Spoolman/Web Client LEGACY
2026-10-11 09:33:43 +00:00
Donkie 1c87bb9c0e Merge pull request #1269 from Donkie/deps/bump-all
Bump dependencies within their declared ranges
2026-10-11 11:33:25 +02:00
Donkie 715cd47b9f Merge pull request #1268 from Donkie/fix/tag-scan-unknown-wording
Mention filaments in the unknown-tag and any-reader texts
2026-10-11 11:32:37 +02:00
Donkie fa92ab465d Merge pull request #1267 from Donkie/fix/location-rename-slash
Allow renaming a location whose name contains a slash
2026-10-11 11:31:52 +02:00
Donkie a9e1802c2a Merge pull request #1265 from Donkie/fix/add-spool-zero-values
Keep a typed 0 for tare, price and temperatures
2026-10-11 11:30:55 +02:00
Donkie 72031f0ea5 Merge pull request #1264 from Donkie/ci/bot-pr-checks
Run CI on bot PRs and merge Weblate PRs weekly
2026-10-11 11:30:16 +02:00
Donkie a3977509eb Bump dependencies within their declared ranges
Backend via uv lock --upgrade, client and client_v2 via npm update.
SQLAlchemy stays on 2.0.x: 2.1 is in range, but sqlalchemy-cockroachdb
2.0.4 requires <2.1 and uv would otherwise downgrade the dialect to
2.0.3, which lacks that cap. Paraglide stays on 2.23.0: 2.27.0 (with
@inlang/sdk 4) hangs compiling the inlang project.
2026-10-11 10:35:46 +02:00
Donkie 3a8ac87b13 Mention filaments in the unknown-tag and any-reader texts
Tags can be linked to filaments since 0.27, but the unknown-tag toast
only said to open the spool, and the any-reader setting said scans open
spools.
2026-10-11 10:33:44 +02:00
Donkie 3e9c3b68bd Allow renaming a location whose name contains a slash
PATCH /location/{location} returned 404 for names like "Shelf 1/2",
even URL-encoded, because the path is decoded before routing and the
default converter stops at a slash. Use the path converter.
2026-10-11 10:33:19 +02:00
Donkie 9708d02498 Reuse a same-name manufacturer when importing from SpoolmanDB
The import only looked for a vendor by external_id, so a manufacturer
the user had created by hand was missed and a duplicate was made. Fall
back to the existing case-insensitive name match before creating one.
Both external_id lookups now send a quoted term, since the filter is a
substring match otherwise and "Prusa" would match "Prusament".
2026-10-11 10:32:30 +02:00
Donkie 58a01e3389 Keep a typed 0 for tare, price and temperatures
The add-spool form and the new-filament flow turned a typed 0 into
"not set", so a spool or filament could not store a tare or price of 0
and fell back to the filament or vendor value instead. Blank still means
not set; 0 is now sent as 0. Comma decimals go through parseDecimal.
2026-10-11 10:31:49 +02:00
Donkie bb0bcc1b5f Run CI on bot PRs and merge Weblate PRs weekly
Dependabot and Weblate branches are excluded from push runs, and the
root jobs skipped same-repo pull_request runs, so their PRs got no
checks and stayed blocked. Let those PRs run the pull_request suite.

Weblate PRs are now merged by a Monday schedule instead of as soon as
each sync goes green, to cut down the merge commits on master.
2026-10-11 10:24:34 +02:00
Donkie 6b6c13e4f1 Merge pull request #1259 from Donkie/fix/silent-list-caps
Page through filament spools and dashboard groups
2026-10-11 10:11:51 +02:00
Donkie b0faab5cfd Merge pull request #1195 from Donkie/worktree-fix-1165-inverted-qr
Decode inverted QR codes in client_v2 scanner on Firefox
2026-10-11 10:11:45 +02:00
Donkie 62963935eb Merge branch 'master' into fix/silent-list-caps 2026-10-11 09:19:00 +02:00
Donkie 3f8cb8f1d1 Merge pull request #1263 from Donkie/fix/screenshots-wait-for-data
Wait for page data before taking Argos screenshots
2026-10-11 09:11:10 +02:00
Donkie 89fba3f4a8 Wait for page data before taking Argos screenshots
The screenshot spec shot each page as soon as the logo was visible, which
paints before any API data arrives. Argos only waits for aria-busy, which
client_v2 never sets, so the first shot of a page often caught it mid-load
(an empty Library list, half-loaded dashboard cards) and the Registered
blanking ran before the inspector had rendered. Every PR got the same
handful of noise diffs, master's baseline included.

Wait until no API request has been in flight for 300 ms before shooting.
2026-10-11 09:05:23 +02:00
Donkie e72b2d1821 Merge pull request #1262 from Donkie/fix/empty-locations-in-picker
Offer empty dashboard locations in the location pickers
2026-10-10 18:00:25 +02:00
Donkie 4658f42798 Merge pull request #1260 from Donkie/fix/1242-dashboard-card-height
Let dashboard cards use the screen height
2026-10-10 17:59:17 +02:00
Donkie abe5bd909f Merge pull request #1258 from Donkie/fix/low-stock-zero
Keep a stored threshold of 0 across reloads
2026-10-10 17:54:05 +02:00
Donkie 645ef72937 Merge pull request #1257 from Donkie/fix/1243-deep-link-unloaded
Open deep-linked items the list hasn't loaded
2026-10-10 17:53:01 +02:00
Donkie 12199575de Merge pull request #1256 from Donkie/fix/1246-backup-lock
Fix nightly backups failing on network shares, and the backup hang
2026-10-10 17:51:04 +02:00
Donkie 975e0cc702 Merge pull request #1255 from Donkie/fix/write-check-tempfile
Probe data dir writability with a uniquely named temp file
2026-10-10 17:49:37 +02:00
Donkie 15381fcf01 Merge pull request #1254 from Donkie/fix/metrics-stale-spools
Stop exporting archived and deleted spools in /metrics
2026-10-10 17:48:54 +02:00
Donkie ca007aa561 Merge pull request #1253 from Donkie/fix/measure-zero-tare
Keep an explicit 0 tare when measuring a spool
2026-10-10 17:47:03 +02:00
Donkie 43101b2a2d Offer empty dashboard locations in the location pickers
The spool inspector and the add spools dialog listed only what GET
/location returns, which is the locations that hold a spool right now.
An empty location kept as a dashboard card, like an idle toolhead,
could not be picked without typing it or parking another spool there
first. The old client merged its saved `locations` setting into these
dropdowns; this does the same with the dashboard's saved location
cards (which fall back to that old setting). The library's location
filter still lists only locations in use.

Fixes #1261
2026-10-10 15:03:43 +02:00
Donkie a31beee46b Let dashboard cards use the screen height
Card bodies were capped at min(52vh, 420px), so a group of more than
about eight spools scrolled inside its card while most of a desktop
screen stayed empty below it.

The cap now reaches down to the footer for a single row of cards, but
stops at 1000px. A card only fetches its next page when its list is
scrolled, so a body taller than one page of chips (about 1500px) shows
no scrollbar and never loads the rest; the cap proposed in the issue,
calc(100dvh - 220px), did exactly that on a 2160px tall screen. Narrow
touch layouts keep the old cap so a card can't swallow the page's
scroll. A Playwright spec pins the paging on a tall screen.
2026-10-10 14:59:07 +02:00
Donkie 6447198fe3 Page through filament spools and dashboard groups
The filament inspector asked for its first 100 spools and the dashboard
for its first 1000 groups, and both showed the result as if it were
everything. A filament with more spools got a wrong count and a wrong
total remaining, and groups past the first 1000 never got a card.

Both now page through the whole list with a small allPages helper. The
inspector's sort gets an id tie-break so offset paging is stable, and
duplicates from a spool moving up mid-load are dropped.
2026-10-10 14:50:42 +02:00
Donkie c35983ee52 Keep a stored threshold of 0 across reloads
The low-stock threshold was read back with `Number(stored) || default`,
so a threshold of 0 g came back as 150 on every reload. The search
box's colour-similarity slider had the same bug the other way round
(`n > 0`), turning a stored 0 (exact colour only) back into 20.

Read both with parseDecimal, which tells "nothing stored" apart from a
real 0, and still rejects NaN, negatives and other garbage.
2026-10-10 14:44:10 +02:00
Donkie 1e800ec5f8 Open deep-linked items the list hasn't loaded
A QR code or bookmark pointing at a spool that isn't on the first page of
the list left the inspector on "Loading..." for good. Arriving on the
Library rewrites the URL to restore the remembered view, which re-parses
the same selection into a new object. The inspector's fetch effect
followed that object, so it re-ran, its cleanup aborted the request, and
the re-run skipped the id it had already "attempted". Same for filament
and manufacturer links, and for ids that 404.

Follow the selection by value instead, and forget an attempt whose
request was cancelled before it answered.
2026-10-10 14:40:50 +02:00
Donkie 016ca6791e Back up to the target without taking file locks on it
Backup folders on network shares often refuse SQLite's file locks (CIFS
without nobrl, NFS without lockd). SQLite reports a refused lock as
SQLITE_BUSY, so every backup to such a folder retried until the 60 second
give-up and blamed a locked database, while the live database was not
locked at all (#1246).

The target is a scratch .pending file that nothing else opens, so open it
with nolock=1. The give-up message now names the live database, since that
is the only file a backup can still wait on.
2026-10-10 14:36:01 +02:00
Donkie e5944c94e0 Probe data dir writability with a uniquely named temp file
can_write_to_data_dir() touched and then deleted data/test.txt, which
silently wiped any file of the user's that happened to have that name.
It now uses tempfile.NamedTemporaryFile in the data dir, which picks an
unused name and removes only the file it created.
2026-10-10 14:33:28 +02:00
Donkie a35331e50a Stop exporting archived and deleted spools in /metrics
The metrics refresh only ever set label sets, never removed them, so a
spool that was archived or deleted (or a deleted filament, or a renamed
filament's old info labels) kept being exported with its last values
until the server restarted.

Each refresh now drops the label sets the database no longer has. The
current values are set before the stale ones are removed rather than
clearing the gauges first, because /metrics is served from a worker
thread and a scrape landing mid-refresh would otherwise see live series
briefly disappear.
2026-10-10 14:32:47 +02:00
Donkie bcc1ba1ace Stop a locked database from hanging the server during backup
sqlite3's Connection.backup() retries forever while the source is busy,
calling progress with 0 of 0 pages each time. The backup ran synchronously
on the event loop, so a lock that did not clear froze every request and
spammed "Copied 0 of 0 pages." into the log (#1191).

Run backups in a worker thread, give up after the source has been locked
for 60 seconds, and serialize backups now that they can overlap. Per-page
progress drops to debug.
2026-10-10 14:32:21 +02:00
Donkie 629b8228d8 chore: update sponsors image 2026-10-10 09:15:57 +00:00
Donkie 7fbe604cc2 Keep an explicit 0 tare when measuring a spool
measure() treated a spool_weight of 0 the same as a missing one and fell
back to the filament's tare, and since #1164 the vendor's. A refill wound
on no spool has a real tare of 0, so its scale reading was short by the
filament's spool weight. Only a missing tare now falls back.
2026-10-10 11:07:37 +02:00
Donkie bff2a485e6 Merge pull request #1248 from Donkie/feat/filament-manufacturer-list
Filament and manufacturer lists in the Library
2026-10-10 10:17:09 +02:00
Donkie 7e83451226 Catalog lists: Escape closes toolbar menus, tidier footer, list by id
Escape now closes the Filter, Group and Sort menus when they were opened
with a click, which left focus on their button where the menu never saw
the key.

The pager's page numbers and page-size picker wrap as one, so the
catalog's longer summary line no longer strands the picker on a line of
its own on phones.

"Show as a list" in the manufacturer inspector filters by the
manufacturer's id rather than its name, so a namesake stays out.
2026-10-10 10:02:26 +02:00
Donkie 30928407a7 Catalog lists: every sort and filter, plain #ids
The filament and manufacturer lists now sort by every field of the
filament or manufacturer, their custom fields included, plus spool and
filament counts, and offer the same filters as the spool view. A filter
on the spools themselves leaves out the filaments with none, as it does
there.

/spool/group takes the matching sort keys: filament.<column>,
filament.vendor.<column>, filament.extra.<key>,
filament.vendor.extra.<key> and group.filament_count. Keys it can't
resolve are still ignored.

Rows show the id as #id on the left like spool rows, and the Library
tab menu loses its descriptive sublines.
2026-10-10 10:02:26 +02:00
Donkie 02f5c07c13 Add filament and manufacturer lists to the Library
The Library gets two power-user sub-views, /?view=filaments and
/?view=manufacturers, reached from a menu on the Library tab, the search
panel and the manufacturer inspector. Rows are lists rather than tables so
translated labels and long names never widen them. The spool view is
unchanged.

The group endpoint gains what the lists need, all additive: a
group.vendor_name sort, include_empty for group_by=vendor, filament_count
on vendor groups, an opt-in preview of a group's first spools or
filaments, and X-Total-Spools / X-Total-Remaining-Weight headers.
2026-10-10 10:02:26 +02:00
Donkie 022285d64d Add Argos visual snapshots of client_v2 pages to CI
A screenshots Playwright project seeds a fixed dataset on the fresh CI
database and captures the main pages in light/dark at desktop and phone
widths. The Argos reporter uploads them from CI and posts the diff against
master on the PR. Fork PRs upload tokenless.

Also scope the transparency spec's swatch locator to the inspector header,
it picked the first list row once other spools sorted ahead of its own.
2026-10-10 09:35:51 +02:00
Donkie 6b1e01ecd8 chore: update sponsors image 2026-10-04 09:11:19 +00:00
dajiaohuang 594e14b465 Check UID length before byte parity 2026-10-02 23:12:06 +08:00
dajiaohuang bce0d8069a Reject odd-length tag UIDs 2026-10-02 23:06:38 +08:00
dajiaohuang cc34b8ea24 Make websocket subscriber cleanup idempotent 2026-10-02 16:21:16 +08:00
dajiaohuang 82137bf35b Reject booleans for integer extra fields 2026-10-02 16:00:57 +08:00
Donkie 7c4dcc7da7 chore: update sponsors image 2026-10-01 09:38:56 +00:00
Donkie dcb4618399 chore: update sponsors image 2026-09-30 09:11:16 +00:00
Donkie 7a1114f8fc chore: update sponsors image 2026-09-29 09:20:58 +00:00
Donkie 423cf234be Decode inverted QR codes in client_v2 scanner on Firefox
Fixes #1165
2026-09-27 08:58:56 +02:00
Donkie 2c15af00b8 Merge pull request #1187 from Donkie/worktree-phone-nfc-auto-navigate
Auto-navigate on tags read by the phone's own NFC
2026-09-27 08:52:59 +02:00
Donkie ac9a32fa23 Merge pull request #1184 from IdefixRC/feat/scanner-camera-switch
Let the QR scanner pick between cameras
2026-09-27 08:48:16 +02:00
Donkie 787d912dec Merge pull request #1194 from Donkie/worktree-qr-custom-template-fields
Resolve label fields in custom QR URL templates
2026-09-27 08:46:52 +02:00
Donkie 8badd51707 Merge remote-tracking branch 'origin/master' into worktree-phone-nfc-auto-navigate 2026-09-27 08:44:40 +02:00
Donkie 53f09eb2bc Use node:crypto for test tag UIDs 2026-09-27 08:44:40 +02:00
Donkie b3d35d751d Merge pull request #1193 from Donkie/worktree-update-indicator-1192
Add update available indicator
2026-09-27 08:43:27 +02:00
Donkie 9f29826588 Resolve label fields in custom QR URL templates
A spool label's custom QR target can now use fields like {filament.id},
so it can open the filament page while still printing spool fields such
as the lot number. Closes #1152.
2026-09-27 08:41:26 +02:00
Donkie f7c53187c1 Test phone NFC reads with a stand-in NDEFReader
Headless Chromium has no Web NFC, so install a small fake of the surface
client_v2 uses (NDEFReader, scan, reading event, nfc permission) and cover
auto-navigate from a phone read, the unknown-tag toast, starting once
permission is granted, and the link dialog's own read.
2026-09-27 08:38:53 +02:00
Donkie 1e9d74cd6e Merge branch 'master' into worktree-update-indicator-1192 2026-09-27 08:38:16 +02:00
Donkie 1a04139c1d Add update check against GitHub releases (#1192)
The server checks the latest GitHub release in the background at startup
and every 12h, logs a warning when a newer version exists, and exposes
latest_version/update_available on /info. The client_v2 footer shows a
link to the new release. Disable with SPOOLMAN_UPDATE_CHECK=FALSE.
2026-09-27 08:32:57 +02:00
Donkie aae2b4df56 ci: skip the duplicate pull_request run for same-repo branches
Now that every branch builds on push, opening a PR from one ran the whole
workflow twice for the same commit.

Concurrency cannot dedupe these: push and pull_request carry different refs, and
forcing them into one group only races them, killing whichever run started
first. That is the wrong outcome roughly half the time, since publish-images
runs on push and not on pull_request, so the branch image would vanish.

Guard the three jobs that have no `needs` instead. A skipped dependency skips its
dependents, so this cascades to all twelve. Forks are unaffected: a fork's push
fires no event in this repo, so its pull_request run is the only one there is.
2026-09-27 08:32:44 +02:00
Donkie e1ad9feb30 ci: build every branch and publish it to GHCR under the branch name
Branch pushes matched no trigger, so an unmerged fix could not be handed to a
bug reporter as an image. Push now covers all branches except weblate (constant
locale-only commits) and dependabot (arrives as PRs anyway).

publish-images was already gated only on "not a pull request", so it picks
branch pushes up for free; it just had no tag to publish them under. Adding
type=ref,event=branch to the GHCR metadata gives one, skipping master since that
is already :edge. Docker Hub deliberately keeps only edge and tagged releases,
so users browsing it do not see half-finished branches.

Concurrency now cancels superseded runs on any ref except master and tags, which
publish images and must not be interrupted mid-manifest.

Note: once a branch has an open PR, push and pull_request both fire and it runs
twice.
2026-09-27 08:32:44 +02:00
github-actions[bot] 5b93b3b37e Merge pull request #1190 from Donkie/weblate
Translations update
2026-09-26 14:04:04 +00:00
github-actions[bot] bd4b9351aa Merge pull request #1189 from Donkie/weblate
Translations update
2026-09-26 13:57:54 +00:00
jernejp21 958d5bd9ee Translated using Weblate (Slovenian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: jernejp21 <jernejp21@tuta.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sl/
Translation: Spoolman/Web Client LEGACY
2026-09-26 13:57:53 +00:00
github-actions[bot] c9b1215cb6 Merge pull request #1188 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-26 13:57:39 +00:00
jernejp21 586299aaa6 Translated using Weblate (Slovenian)
Currently translated at 22.3% (136 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/sl/
2026-09-26 13:51:41 +00:00
jernejp21 7e98987b87 Translated using Weblate (Slovenian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: jernejp21 <jernejp21@tuta.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sl/
Translation: Spoolman/Web Client LEGACY
2026-09-26 13:51:35 +00:00
RRH74 ba62681856 Translated using Weblate (Spanish)
Currently translated at 22.6% (138 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/es/
2026-09-26 13:51:32 +00:00
IdefixRCandClaude Opus 5.5 05e4da7a10 Pick the scanner camera from a list instead of cycling
Review feedback on #1184. A native select replaces the cycle button, still
shown only when more than one camera is listed. Picking directly suits
desktops, where webcams have real names, and on Android the select opens the
system's full-screen picker. It also removes a trap in the cycle: qr-scanner
opens a default camera when the requested one fails, and cycling on from that
default could never reach the cameras after the failing one. Every camera is
now one choice away, and the select shows the camera actually streaming.

Cameras with no label are listed as "Camera 1", "Camera 2" and so on. The
list now comes from enumerateDevices() directly, because qr-scanner's
listCameras() fills gaps with an untranslated "Default Camera" / "Camera N"
that can't be told apart from a real device name.

A failed switch now shows the same classifyStartFailure() message as a failed
start, instead of leaving the stage black with only a console error. The
picker stays up so another camera can be tried. After a failed open the
scanner is stopped, and setCamera() only restarts a running scanner (or does
nothing for the same id), so a retry starts it explicitly.

The caption and its n/N position are gone, since the select names the
current camera itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:48:46 +08:00
Donkie 0543b9145b Auto-navigate on tags read by the phone's own NFC
Auto-navigate only listened to the scan relay, so a tag tapped against an
Android phone running the client did nothing outside the link-tag dialog.
While auto-navigate is on, also run a background Web NFC reader and feed
its reads through the same handler, looking the holder up by UID.
2026-09-25 20:29:26 +02:00
Donkie 25d25e3701 Merge pull request #1186 from Donkie/worktree-armv7-build-speed
Speed up armv7 image builds when the layer cache misses
2026-09-25 16:11:38 +02:00
Donkie a353983f85 Speed up armv7 image builds on layer-cache misses
Persist uv's cache of source-built wheels (asyncpg, greenlet, psycopg2,
cffi, ...) across CI runs via actions/cache + buildkit-cache-dance, so a
lockfile or base-image change no longer recompiles everything under QEMU
(~11 min -> ~3 min). Move the greenlet patchelf step into the cached
dependency layer so source-only builds skip an apt-get under QEMU, and
drop python3-dev, which only pulled in Debian's unrelated python3.11.
2026-09-25 16:04:21 +02:00
IdefixRCandClaude Opus 5.5 6c5d03d4ec Let the QR scanner switch between cameras
The scanner asks for the 'environment' camera and takes whichever rear camera
the browser hands it. On a phone with several lenses that can be the
telephoto: an Oppo X9 Ultra lists its rear cameras as camera 5, 4, 3, 2, 0 and
Chrome opens the zoom lens, which only fits a label in frame from across the
room. On a desktop the same thing happens with a virtual webcam, and once the
permission is saved the browser never offers the choice again (#693).

When more than one camera is listed, a switch button over the video steps to
the next one and wraps around. Each switch briefly shows the camera's name and
position, since phone lenses are only told apart by numbers. The camera the
user settles on is remembered per browser and opened directly next time.

A remembered camera that is no longer listed falls back to 'environment'.
That is checked before starting, because qr-scanner retries an unavailable
deviceId with no constraint at all, which can land on the front camera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:46:02 +08:00
110 changed files with 10837 additions and 3726 deletions

No files matched your search

+4
View File
@@ -53,6 +53,10 @@ SPOOLMAN_PORT=7912
# Default: FALSE
#SPOOLMAN_METRICS_ENABLED=TRUE
# Periodically check GitHub for a newer Spoolman release, shown in the UI footer and server log
# Default: TRUE
#SPOOLMAN_UPDATE_CHECK=FALSE
# Collect items (filaments, materials, etc.) from an external database
# Set this to a URL of an external database. Set to an empty string to disable
# Default: https://donkie.github.io/SpoolmanDB/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+39 -13
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 800 355" width="800" height="355">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 800 570" width="800" height="570">
<!-- Generated by https://github.com/antfu-collective/sponsorkit -->
<style>
text {
@@ -15,27 +15,53 @@ text {
font-size: 20px;
}
</style>
<text x="400" y="40" text-anchor="middle" class="sponsorkit-tier-title">Sponsors</text><a href="https://github.com/mrgirosky" class="sponsorkit-link" target="_blank" id="mrgirosky">
<text x="400" y="153" text-anchor="middle" class="sponsorkit-name" fill="currentColor">mrgirosky</text>
<text x="400" y="40" text-anchor="middle" class="sponsorkit-tier-title">Gold Sponsors</text><a href="https://joshix.com" class="sponsorkit-link" target="_blank" id="joshix">
<text x="400" y="203" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Josh Wood</text>
<clipPath id="c0">
<rect x="365" y="65" width="70" height="70" rx="35" ry="35" />
<rect x="340" y="65" width="120" height="120" rx="60" ry="60" />
</clipPath>
<image x="365" y="65" width="70" height="70" href="data:image/webp;base64,UklGRvwBAABXRUJQVlA4IPABAAAwDwCdASpQAFAAPmUqkUWkIqGbix0kQAZEsgBnGtdsjzP/Ecq4L39vxb4m/Uz5YXQU8wH7D+sh/nPUr/svT/6h3+YeoB0ygT24/QMuF+zXFvXSjFOioIMm6v60zSJQaxKhX6whSf6VtmBC5SeSGdBguhbrunJoifis9a3V2ak/cYcAAP78bzZ55Ogwoa8uUDxZ449/WJWP06V1jI5rA6VJhBRcgNklWlgccRt8ueZCH6aXt37H7vzCiGyIVoexPxYMaRppan8kXgzEhe2nZubuS6UBzsTwBM6CyU4UM6Q5J/eKXkmvWeltJs5q9OF5sdD/Huwnz+urGJEC3Toq43krvLRw5nxtkTFKonew1+PanXbcDsYJHsDxnkhRa9GgoSvMMVMoVs52Dy8gm8nii7e3ACNXQFqMQSw5B6z857D8OvYeIjh5mIAMvagLZ0+9xKBIyACPxamiN1vVKH3DNLDivXpQIfqpTvSsieLku9Jm2crqNH3H56ZdQRnR369GzSuC/ZHiaTXZ/Bmbdb5Vrxmn/HtBdZGx+LgiNfWNf3167VyoS32aZgJWPfSWFR+FZDwo/oTLFzq0uxND24bfbb+cWlx6Pu854mZCjdOZ27HHVwg8py/8WqJjTnqg6uQN7w4w/h2/kA5vozXEYoAAAAAA" clip-path="url(#c0)"/>
</a><text x="400" y="210" text-anchor="middle" class="sponsorkit-tier-title">Supporters</text><a href="https://github.com/marko-p" class="sponsorkit-link" target="_blank" id="marko-p">
<text x="360" y="303" text-anchor="middle" class="sponsorkit-name" fill="currentColor">marko-p</text>
<image x="340" y="65" width="120" height="120" href="data:image/webp;base64,UklGRsgGAABXRUJQVlA4ILwGAACwJACdASp4AHgAPm0skkYkIqGhL5ENkIANiUfPo+yShwQpAbER6gG8GTCe9OgMo/Ux8Mb6gcyqPI8xGY/5PrE+/PpB5ypAjRkDFzrivwF62s/DK34rzvVhAPgCBtQjb6uCuPF1IciZ/eOsAm2YcRY0JTDqwAeM+a8G/bPnitwjyrcNv4JeKAdm+2kIGNTUplFYHTh5H/6cvX/J2boEqumFpsZ4H+BTtbcCJ1umGOvIdIZ7mJJ4wKAIqBezjekx2kJcEuJv1TQjTzvFltO1PQyGeQS85A0kZIQo/eL9EMzL7yHbRNB95Za7xMB23tgYLYRGioR+cObcTdJh1itU3qSDjOx5GX2qxaVkPqMDgaM/dJltCbRr48Uj9txgtIhRKvgNxLd0fZ2iU1+9n5MIMAD+/PQBSjyc0vbGv1rJpHHondsozJ5is5nynZ+bxtrKmbuULzqkfkHTd2EZMZR4pKqYe4vgUljlur1epNBEkArOAPbtmGjmkwqXRkZtDettTONSDKtn7bi5F52h+cnz5FvLoE7c+pK+ZkBlpZy9Y7Wzx9bvKE/Org0ItinAq7f9TmihQ1eizXWPtgBKFd+Qu7BF+wqFv/68qGwtXD/9iMyD9G0NFBxF/YHjCzsUt0rvHlkPkG3u0s43UFSi+tFMWXfJVDMX9bqV1Y7dvCwhQ7NY+SZCNaoq487tNhvEhIlUb1mqdXv3rxhJ8QLM0iZ5muNzgAZH8rnxR/JlNXsfZhe4ZvsIgsa8/f3eUhXSZ/acnJ1dh8SayPmOJysvTMy4SmXCvIWvFZrAdbXjfUKSV2SDj8CFjFa3S/U+VcTXqTm5RTpQy2R6citHAtZ/FAo0R2l+ubQvHAZbG1naXrdCmvx9IQxQE9DrQrFccqLpAroykRLrIPEyTcvzXPoy6DcrqpzWpVsskaKDksL8HIeM6LvYhAMaz7kmdYfHfQhxnTzQEV3bii5mXAh7RLbsvuml5HPVj3hHYPeWcyqXuwdbKs1ei9Is2LWps/OYoY1/S8HkS7yWsH3lJGIp3YRtJrl0CBj80B5Kc7FCt1kLT7l02XMn2sp4G/Tmc2PTOSToCkOEzWm60rUaX7gU/NBUAHcFJzcveQBzWbtm/QvAzmyYRv27bSozA8jcjTB11XwBz7aRW6bOdhZevq1oNodI75fAQ9SjxsqijU6icL/C17hatpRfNDRK5339AsbtAKnhUpcmbBrYXmYrjfmcXLsTjT9m4yYPLzmAr38FhZbZYTXEwcQ611OLliezqUGH63rwrrD/Y1D7DP5TeGiQ6fSqUs5MbV+bW1zQlCU4ZZ6GFhrOnXhlH6oN2N9aO/Eu35SHfnCxZk2cch4YgaPmOGn8R7/6u2il6/EgXYMDeK7HcSEmss7n1CREawmfINYUCUxLB4s3z4pQQkVOTAy0PM3uylOfQt6LDpQ7ZjnhwGGNbVqPRwsClzPpxRnIAPXaDHJW0tFtNHZSNOmo3m9VzNkVN0wUOHb+/i1tW3RUHhuE+6p8D8C4px/D5MTU4l9INiryqSPO8KR9Pauk1PT7p4Iuf1GbZ9pYXVqIQcpIkYNKRyyJObLYCEVJYyUQ9s1sHx3kWGY8BVNVsZ45IfByrst+0iJywhlRMZLvY719IU2ssAyx9bQyJ+FOZOH11nib/g5i86gKhw1inytSYr7KSrOZ3vEZK0d131xF8D4Tqq5fmEuoaR7R9e/DvkPRxiSPwp6caZYHNjW0vw28jfBIeuc2Hu86STZP0dSWeaIOiTXK74vJEmLdMAUbYCX3jw2eTZJxYSuEI9YivnsnsJSPF9gk8RFk5dx+BVE3a+ypXgbjVO8Bg1tuNmJDEAH22PMgOxKSCbOhQ/ox19PW73FJ4ybJojgF3dPjPKzue/4Hf429x0Jsn2/tlNCrJf0T7XyE1cIgKvt5DbNFfh5Ss0cZe+I/9HUA7JwZCQ5LF2GqEB21XI1qJZor9y46SIjj2HxWJnJPdqHCEe6Tb7D+NLFsjfl+ZJGZu/HX6vjuORb0RIkmbq0aEVzHGjIawLzSZRKs+q7uRCXS2erdaj3ZPTqTV4YfqwFYAW3uYbwD+3Ktu9mIXLpFMsw8IiQNZopAHBbVQo+/Qk+1LBLeHztLhFDya0zWaexlFWshmq4c3J6RepzwNd/yweyeESecwL9eXf8fanxvJ2hOMKKU7Rh1g1ZBdGFJYXrZzN9PgAoBk/w77D0swqmrjebS9hmnON+C9owqx15EgS8f/BGWnupWZs+frjmVzRfc881ei87vK+ccntgAq2JzNWMLH1DB2R+Y8pkBzwAAAA==" clip-path="url(#c0)"/>
</a><text x="400" y="255" text-anchor="middle" class="sponsorkit-tier-title">Supporters</text><a href="https://github.com/swtaylor" class="sponsorkit-link" target="_blank" id="swtaylor">
<text x="360" y="348" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Scott</text>
<clipPath id="c1">
<rect x="335" y="235" width="50" height="50" rx="25" ry="25" />
<rect x="335" y="280" width="50" height="50" rx="25" ry="25" />
</clipPath>
<image x="335" y="235" width="50" height="50" href="data:image/webp;base64,UklGRtIBAABXRUJQVlA4IMYBAABwDQCdASpQAFAAPl0ojkWjoqEdbDQAOAXEsgBkDELGpKsFXfX1S/UBtm/Eg9ZPo//SA9Un0AKtQA42uXnr7NGq14fnKqAKWifgtAJKaGODm1DKqW1JC7w48iNqbR3EsZATHUiQiiAqaWRyc8N5cjpRFSLMAAD++LxD4g2y/4OWQs5nyxE4mqVuPu7/I+5SujmdcC45tAmqYHZ2gdczvCgVNQpllSNIw2yeTkDYN32j0a6AQ9X1emvkndKJ6EYr11vguW/ETlM8zLOlnjGXYE0SWqgylFWx0LOB8AmIYPCcaMPhEiSuv3vwJH/MGwNCm8ozigt1S4p6QFOml1r/Du6VbH/lNxoQp+iitZsJ0PuEO5C8oYLEZzrr2+4aAOdeIg9VBqV5JhRgBrlJLwAoL/3UmeHU/r7RxRYDwA65FtvkN9Qsv4TyDbvHhz59AHOFEVJvOzcxx+v6v6nzqZWJOmEOG3mkGSTfrNY3xRKqJvPvj7Wo7jCOEcqccpNX5TJ/yzeF+4h48fYT2mRgM+w+bkqM6Rb/mwnZannvnJqc7WPUNP2kvHJn/TqFl7UAOrJbPlznsMtCORpct0p/JeCcHjK+sCmNGAAA" clip-path="url(#c1)"/>
<image x="335" y="280" width="50" height="50" href="data:image/webp;base64,UklGRpoEAABXRUJQVlA4II4EAABQFgCdASpQAFAAPmkqkUWkIqGXrAdgQAaEsYBhWgnHTNnEshj28rNZ+GmOXmp93KFPcld/L/07TSyJCfZvbGHb5moRzw0e45eO2MkuhDHxKdpqpve1uYdG0BsLtaR1qQkiaMCv1f1r0sWQvsyKv+yTj4NBtLITGsATKV0IENN3Bl8f6aAHqFPbX/IIwHqJ5dhsp8hpZiO3hdsJnuJjXBSNoo/ohDiJMtm1nZDTgJRCWMgANxShzJ+lEFPAAP79QB9OMqjqK/Wb/XKJUhuVyjCLtjHwBkqY5p46HUIIHw/VhGYrttpwJLsPtJ3UJc+EdV/S8DwYN/yByU6U0rMLIGZSGqa2ErEr7V4GGC9IxFbTvXtQDpVVaddvvt0O1HhsxCFwCfsVJwpY1+j6FX0BkGY97/sXD6e4FiqF4u1+mgpFo5AvXaoOAnyAXGYvs9mrbX7kY2vf2rzqVsTsXUDRBIdM9zf8QYGvyqij+qJIWtiJDJtziH6+PIJPNyYaF3dIun0bWAOTdHDJDIxzmdua4QIZ3+SGIdZ6zj9igFRf/D/OkJzQCGsrZMNHiAul/RsCjH5bv2NQCrlcm8Tek/Bol07kSj6qBCsK/tnGmvsT5mrYoWbQsc4tflV2jYurFzbXEjfmMJTron7S5k+b88/hOxLGsnpY7GN+cajmuqSNIAdZ5Es22QFpRJOcqh4DbtElMbp4JmiIP45o+v5aoyEyIsS6tJ/LWKnofIE+2BdiITSXHMzWWzLZ4yBVsG5jcvHbzsqFp9QFsEMe6GYubnNwvK+3uPptk8jnZN/0geWJiLFMxprrPEhv3N3VEKZ1n4yf3EoOYfywMYJ8syuqMKLLH7PB7tDql0Qxsl77fR4fURVBPGgoHbDUjrhRmEeoMSuuWD6FfZ2sx4D8aLlWKdTcRjMO7i2yNb+apfklcWh9CkNjigw1K6OlcaslrUAguez3uDHP3/e0RlxlD9oXf2i7bPFmeW32p6/krBex+5LE4YroYFb6azBBWEixY90qpd5zwSzarPhNoxV4gRRgKTbMmmIQBTtNa7l3sPIIf0znFnxX+RXwIzLmfRPY66VGkoxV5zEcJwTYngDDe8zfqp4uBhCEIq76aOUn/Kf6ydnnBwkv9EvRuTQ5wEh7jIfn74wudig9529et3sKwVIC/3DyAKgOvwkmC2wiuE/MdSRyVEgJfYBkOWSn8IJEl+ZBUelXSxrKGBeAn3CJUN7vn0hBYCKhrVCuYqCQxB4RtM9ewors7V6olzJUbu6E8WazDVYTU69/pqNi1eOfeYqbv3/il9BdKqx1InGxSUo06kUmrmTyn6cmrDkyIUYrAOttFM6Sy9SKCxPlXgDG3YV1NWmkW8vZQli8jp71xHO/80aXNIOPq4kWo3h4dKuH3JF6yDQVpAN3PJb6rD5ZE8m6JN4hbYWXPjH5YnM0xk8X7xJCgZyaSgFgvc5CeJSohiXsOdwanYvLqgpgjznJYJw+YLcVlbPsLGeg0I1HVpaxWt8Sds1iBtQ+I5ZF2VWTAbdhn9vK+oC8c498LHPQiREbCHMgAA==" clip-path="url(#c1)"/>
</a>
<a href="https://github.com/swtaylor" class="sponsorkit-link" target="_blank" id="swtaylor">
<text x="440" y="303" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Scott</text>
<a href="https://github.com/tlangkabel" class="sponsorkit-link" target="_blank" id="tlangkabel">
<text x="440" y="348" text-anchor="middle" class="sponsorkit-name" fill="currentColor">tlangkabel</text>
<clipPath id="c2">
<rect x="415" y="235" width="50" height="50" rx="25" ry="25" />
<rect x="415" y="280" width="50" height="50" rx="25" ry="25" />
</clipPath>
<image x="415" y="235" width="50" height="50" href="data:image/webp;base64,UklGRpoEAABXRUJQVlA4II4EAABQFgCdASpQAFAAPmkqkUWkIqGXrAdgQAaEsYBhWgnHTNnEshj28rNZ+GmOXmp93KFPcld/L/07TSyJCfZvbGHb5moRzw0e45eO2MkuhDHxKdpqpve1uYdG0BsLtaR1qQkiaMCv1f1r0sWQvsyKv+yTj4NBtLITGsATKV0IENN3Bl8f6aAHqFPbX/IIwHqJ5dhsp8hpZiO3hdsJnuJjXBSNoo/ohDiJMtm1nZDTgJRCWMgANxShzJ+lEFPAAP79QB9OMqjqK/Wb/XKJUhuVyjCLtjHwBkqY5p46HUIIHw/VhGYrttpwJLsPtJ3UJc+EdV/S8DwYN/yByU6U0rMLIGZSGqa2ErEr7V4GGC9IxFbTvXtQDpVVaddvvt0O1HhsxCFwCfsVJwpY1+j6FX0BkGY97/sXD6e4FiqF4u1+mgpFo5AvXaoOAnyAXGYvs9mrbX7kY2vf2rzqVsTsXUDRBIdM9zf8QYGvyqij+qJIWtiJDJtziH6+PIJPNyYaF3dIun0bWAOTdHDJDIxzmdua4QIZ3+SGIdZ6zj9igFRf/D/OkJzQCGsrZMNHiAul/RsCjH5bv2NQCrlcm8Tek/Bol07kSj6qBCsK/tnGmvsT5mrYoWbQsc4tflV2jYurFzbXEjfmMJTron7S5k+b88/hOxLGsnpY7GN+cajmuqSNIAdZ5Es22QFpRJOcqh4DbtElMbp4JmiIP45o+v5aoyEyIsS6tJ/LWKnofIE+2BdiITSXHMzWWzLZ4yBVsG5jcvHbzsqFp9QFsEMe6GYubnNwvK+3uPptk8jnZN/0geWJiLFMxprrPEhv3N3VEKZ1n4yf3EoOYfywMYJ8syuqMKLLH7PB7tDql0Qxsl77fR4fURVBPGgoHbDUjrhRmEeoMSuuWD6FfZ2sx4D8aLlWKdTcRjMO7i2yNb+apfklcWh9CkNjigw1K6OlcaslrUAguez3uDHP3/e0RlxlD9oXf2i7bPFmeW32p6/krBex+5LE4YroYFb6azBBWEixY90qpd5zwSzarPhNoxV4gRRgKTbMmmIQBTtNa7l3sPIIf0znFnxX+RXwIzLmfRPY66VGkoxV5zEcJwTYngDDe8zfqp4uBhCEIq76aOUn/Kf6ydnnBwkv9EvRuTQ5wEh7jIfn74wudig9529et3sKwVIC/3DyAKgOvwkmC2wiuE/MdSRyVEgJfYBkOWSn8IJEl+ZBUelXSxrKGBeAn3CJUN7vn0hBYCKhrVCuYqCQxB4RtM9ewors7V6olzJUbu6E8WazDVYTU69/pqNi1eOfeYqbv3/il9BdKqx1InGxSUo06kUmrmTyn6cmrDkyIUYrAOttFM6Sy9SKCxPlXgDG3YV1NWmkW8vZQli8jp71xHO/80aXNIOPq4kWo3h4dKuH3JF6yDQVpAN3PJb6rD5ZE8m6JN4hbYWXPjH5YnM0xk8X7xJCgZyaSgFgvc5CeJSohiXsOdwanYvLqgpgjznJYJw+YLcVlbPsLGeg0I1HVpaxWt8Sds1iBtQ+I5ZF2VWTAbdhn9vK+oC8c498LHPQiREbCHMgAA==" clip-path="url(#c2)"/>
<image x="415" y="280" width="50" height="50" href="data:image/webp;base64,UklGRlADAABXRUJQVlA4IEQDAACQEQCdASpQAFAAPm0wlEikIqGhJnM6aIANiWVfalewGPbCULeZf9lVKyeAFRcq/sb9iNrYjtrl20Tl9Brpxl6pzt/hrSsHFFGzOJtddJg/shyIiZ1LzVI7175shK6DAWErqJbPidMLUWg3ubKTpxfjBC+/K7Eh9dbQf4YG/eS9ejJPeMpsfu0N2Ta9ObSG201Sy7FkAAD+/lcRS9KSg1a5BZKoJe3h3qj9X3MvB+vnUNlwpci/lOuX9adPeFnDrws/r9sfemPaCgBt/l/Us8nItNrXxn5gmJbR+d1kXCM15yJi87WEtT4HCgIDAlIcW7CNJpCpFzXlmUiZuTpXY9h0L3P1/9DlXWJ6UxR6Eka9lGj1h3gbBdw5XJNayBK6l5knJ75c3VxeMfA41IqIUfRBLqAJxTUAFirrc8GjSidiV2PrpxHD/vIDyqxi0CwSZHZneIXXKdQgElMsiWY7lv2ZYJRbz852fWqChfw8V7O8CmRHJeflh1n6bsJZJW15B0GdmNkd9rEcEEN9c02fOga7TRSa5J9qqm5mQK6ttlG3o20FKndVI0RGhhZ0QoJZiWB2E6EVN8GReTD++Q4tmgx+anuuTQYLlqIbPj0fiNMWruZYNaWBR7lQfGtCltKKoXpGrdsEsb78iI+dXE+PByx8SYCDE7YMosWquKmh38fWmtCWRExHFt6J+fOK7878sckHHUxk7dOZiMG6VJPl1M6s7RVupY4pG4KbEsXrIVmKJ2PMgZqR81XYynE68U6nfMgqjmpQKS+5rFiSdo9GtJr0g9EWmfCGX21fjpknlYAQ4LQMZxVpxCGUkeedmYL/GqnCnxtjZJ4m7M8/y8rkFZZ1HEy8m7h9/qg9sTYZEd+SuGny1fVkAXSsJO6xrQFyB4W5X+QY+dKANG78hoe1ZJqb7LqT4nlWM/49WvYLYLjxLvUEcDmySZFru6bT8XZangg+KyZQR73z0+f7IiRj/PuASJHhqcqirbnHPTDWfKrQu6dWrKHR5kcRlz5Dpnb0gBEPKIQrv5DXBVJRILmF23xJK0rUzUkwQ/L6pkq0Ty8GGXnfzba9DbTT4HHDUwuMOzyNOyuL3/r/ySX1m8qwdpWmnnoAAA==" clip-path="url(#c2)"/>
</a><text x="400" y="400" text-anchor="middle" class="sponsorkit-tier-title">Backers</text><a href="https://polymorphic.dk" class="sponsorkit-link" target="_blank" id="polymorphicdk">
<clipPath id="c3">
<rect x="372.5" y="425" width="25" height="25" rx="12.5" ry="12.5" />
</clipPath>
<image x="372.5" y="425" width="25" height="25" href="data:image/webp;base64,UklGRjYCAABXRUJQVlA4ICoCAADwCwCdASoyADIAPmUqkEWkIquZ/Z4AuAZEsYBfefuIopNZ6c4YXlAnC2/DzVHzOazQULy+IzbYtxgfWvdvZMf3fvpN64RGUtlBqaalSwhyxpsxwa2VBwP9czxhfRUkgHDC3n7wM35VAAD++cjP0J212mopM4/Rh0hOscBf9Z3H09zvnh+baFS5+i/jyZ8i0WlzZRzLr9W56mlBTdHVe7poj6YN27piHQHv4HFq8LNPFIaEo2UCdds7haNciUEC46JgnRrnv7x+SK2UmmeRCGWLE0tMvr9Lj7Msgwnee0YpKdP2gtm7UAQ6hAMR+/hIP28TYk/3Jc3oOFFIcOOAwbvtVofjEaORNOQj5vpSLYgA1YmU3MvsWKJiHK3wAxZSAEODjaEXSgWuFQDo/u4k0dv0NcRqX5vjk4COQg+LoZEOWWOnfY3J0rqmkONX0GtvUXQ1vhgSv0fst67tyvesub67hOcUmsPZCS2W/NVUshMelGBQ6+shQOlHXxNysIkuO9ciE/I5vxamqTWlG92zlO5w2IZIbq2x10b5KfSCjkFr/Lspe+4EXhtwhfaZOKBx8eESeBLmBmMMDoK+v1nSYACGfyvQ7ii2bxvOkchviukeoj4B7Q3i8cHNE2d+he9657d5nwMMJ1cubwm7umN5MGKk5B731r3aX3uCbazwf6lY5MQLnw7xU4XxGtQNkQEHQqsG5ajWgOeLm5upXvEKFTLc8trHi1L8A4pAAA==" clip-path="url(#c3)"/>
</a>
<a href="https://github.com/Kreism" class="sponsorkit-link" target="_blank" id="Kreism">
<clipPath id="c4">
<rect x="402.5" y="425" width="25" height="25" rx="12.5" ry="12.5" />
</clipPath>
<image x="402.5" y="425" width="25" height="25" href="data:image/webp;base64,UklGRlAEAABXRUJQVlA4IEQEAAAQEwCdASoyADIAPm0wkkakIyGhKhzK6IANiUATpygqc9AuwDC76jadzy/+jFdrvoARipqP9l4STP6Jkfek45SHQI9T+wT+s2+cmrUBCJ9Mic9iBdMAlBmtbr3k3fO+bnxwdUPe9YN0drh6w7q4kO+jqNk0uEzUpkgTMYiFhrnVc9qKcB0G35IUcNmQFww80vxGnlkoIEV/s7YNLN7cVA0xQAD++ScfQpSJONSUqCMK+Ol5ZCh0b26aEtYQ7wo7d/iK4GFJ/Oln8/RBtU+uqnwhlmNv/XvVpC/PS5bd8Mbw8mdK7EK/bTtH2GXbGoqm+fSThwv9egzABKWJmjoyNIybs62ApVKMR1syEfaHs9rrVJa4Ku+3KznIaFb3nP3rKOJTl0JxjplCTw0qEoyER2/l8s9Fx7Wj27E7je+BerqTiLU2WKIiK6oWo6qxLj7DM4pmLTmy2HRcdZW4HbJKEvw2qtft+aVqnIpmkpHQ7gIUZB8YqXVLOuf479mRmJSRWUM8XDM+/ZHsiRjQLB8/63zHaV36axymJybL7DPubsyezxXHl3gW1oAHTPcn4dG6tghnVGt61bBjLL4xKpu+jhr9KGulx5hg9zz03dwj6wQGMzLoAS0KS1ootiTv7tSxN8h8BQKdViSfbkmzM/WQZgxko9b7T0mJ3RPfTsolRpzwAOjDjSkxnIA6VC2hoKAHSGYvW/ug0PznrxyLQlqs0eNgZM46ou98sNg2+VDv3Lc+ke9E7g3i9yt7ws/XeGzfyvfyZZlBCDKPStuncmbbhTf808x5SIglbQFAZGxHtRnUaqkoa8E4PlA6mlfzqRey1HGPP/oBPQe25l86Y71vr3eeZn2GcLFzKwjIaDNA9OMteOhej30/TXc+yBzpdTsWtQPyLo3IqXfdT5c8eyWgB4lmNJaOIgcTBCsZW/E/js1Iww91n/KKNlN9puBaomIKqdBj9FbL1nVpR97y+x9x7Vuw2LRI8xu8jNj73/Guv+NyuTRu6t7+QlUXob0HmqrdNuLf8HmZoRE6bv4f6MTQWWfGBSeT6vkpw9I2Qpz3NyC7sC4HMdPjh5JvW4J93BcKqA1N6Nw+bVRvl2ew47w+Kc2vN3IAj5IngbnUvDKEqQZgN+zQ/7BqM5d4YIk6vkyUxeXcAZ0pwBuwstcAJh0sx2PbQrT71ohYigAPz7xq1HAZ/mYsBogBIa5gO2HGgEFavUP9k+jD/GJZvLziNZzsbcX7YdjCdKTH0xwuEqiuufKzOaECClx4DsZuvipljXph50HXFc5XAfZgS+sQOZbpmb6Kf3mMjtfCCDW7efAAxeoPhCCHRcVOV3vJgGILkvW4p2k4VUrVyfDLJ3k9+AriK2tAZroSkIBkalglP1yJ4tYR22YS5slOQ3/CLnU0an8lFGOhWHQVw1CN0JCygQkClgJR4q5+ghh23OXWA/3HEAKNDvo3oAA=" clip-path="url(#c4)"/>
</a><text x="400" y="485" text-anchor="middle" class="sponsorkit-tier-title">Past Sponsors</text><a href="https://github.com/marko-p" class="sponsorkit-link" target="_blank" id="marko-p">
<clipPath id="c5">
<rect x="372.5" y="510" width="25" height="25" rx="12.5" ry="12.5" />
</clipPath>
<image x="372.5" y="510" width="25" height="25" href="data:image/webp;base64,UklGRl4BAABXRUJQVlA4IFIBAAAwCQCdASoyADIAPm00lEakIyIhLhkoAIANiUAYoZG41zSo4e3a0MDqVd5hvyUF1IEn9JgXObpn2Mt9prYfftYf4GRNRZ7hZ/fWD/6kU0akoeFYAP7mh/5A2Gfo2763tH9O1yC1D3TebcFfF/bO2z76334lot7jYxzsHcClMluiJtad5Z2A9ZtsHn0CgO1qVt/DcFFWwoCom7DWqiX/JeWTPmV7+u5ecLmedHJQ6jheSaRadpREwA9UiSGzpAggcCbnuZdKF5py6aWBIPoYdwJPwQTuDBoyC3glR90OjgWIADT7RZMYVBOeJZ0jA7g8++YxIx+GNqekY+gLy77edX+cDhTWBlwoSyzAKTVFGooGOFJwHGufQopr/cr1nMvbodaPTkzq9FZtdY+eVmTADFYRaCF0QDCX+qURMnt+yeGaWDzHEhEsTMeIJbGyRhUdMgAAAA==" clip-path="url(#c5)"/>
</a>
<a href="https://github.com/mrgirosky" class="sponsorkit-link" target="_blank" id="mrgirosky">
<clipPath id="c6">
<rect x="402.5" y="510" width="25" height="25" rx="12.5" ry="12.5" />
</clipPath>
<image x="402.5" y="510" width="25" height="25" href="data:image/webp;base64,UklGRqoBAABXRUJQVlA4IJ4BAADwCwCdASoyADIAPm0wlUYkIyIhMBYJAIANiWYAy5x8BkkfdwjET0C9gJwHmAfqB0gPMB0EPOg6lXn2PaFvyUJKOZTATBzOiskTHqIVkrjKyrKQ96FyqvM1Gv1jQBXFMKi+U5hNm1EI8AD+8QHHdvGR1JUV2sahGaCbtFiJaByUNdMrLaM575u+kR44ggAKv3SXhizdVAvYYtYu0xpoP1QuZzHV05ERx1UDN3JJOZQA2eCKoCPnSiD3aW7Cp+XxYc07Q+Xfur3IkZX89Dy0NN7ogFFINEj83SaUWhjo7BEkmq5OUe8vjhUSout2T6n2+fwYwcD6/3bekvfYqCCWM1HLHyjGroEjvI40NzvNVty9uCAfSiHRL48OpEWqqoi85QWoAQLMK4WoSLcWsXpVCQCI88AiLHFkRtMAV4H+PUfk2ZsEzGSka1EFOtnn3npbU1/aitYgLuS4rcly4D0v9dMawDumQfY5uMacYPo6dkjOTJAdPQDXloB9LbiSBWrbcdnYyZZ/WOaAySoDhCCl3eQFOdBCSqnKG0gHZXAAAAA=" clip-path="url(#c6)"/>
</a>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+119 -119
View File
@@ -31,9 +31,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@@ -50,13 +50,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@@ -73,13 +73,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
@@ -87,7 +87,7 @@
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -97,9 +97,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@@ -114,9 +114,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@@ -131,9 +131,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
@@ -148,9 +148,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
@@ -165,9 +165,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
@@ -182,9 +182,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
@@ -199,9 +199,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
@@ -216,9 +216,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
@@ -233,9 +233,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
@@ -250,9 +250,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
@@ -267,9 +267,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
@@ -286,13 +286,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
@@ -309,13 +309,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
@@ -332,13 +332,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
@@ -355,13 +355,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
@@ -378,13 +378,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
@@ -401,13 +401,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
@@ -424,13 +424,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
@@ -447,18 +447,18 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
@@ -468,9 +468,9 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
@@ -478,7 +478,7 @@
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -488,9 +488,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@@ -508,9 +508,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@@ -528,9 +528,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@@ -713,9 +713,9 @@
}
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -730,31 +730,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
+66 -6
View File
@@ -3,7 +3,13 @@ name: CI
on:
push:
branches:
- master
# Every branch builds, so a fix can be handed to a bug reporter as a Docker
# image before it merges (see publish-images). Weblate pushes locale-only
# commits constantly and dependabot's branches arrive as PRs anyway, so
# bot branches skip push runs and are built by the pull_request run instead.
- "**"
- "!weblate*"
- "!dependabot/**"
tags:
- "v*"
pull_request:
@@ -14,12 +20,12 @@ permissions:
contents: read
packages: read
# A new push to a PR makes the previous run's result irrelevant, so cancel it and
# let the new one have the runners. Gated on the event: the push triggers are
# master and v* tags, and a cancelled tag run is a half-published release.
# A new push makes the previous run's result irrelevant, so cancel it and let the
# new one have the runners. Master and tags are exempt: both publish images, and a
# cancelled run there is a half-published release.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/') }}
env:
GHCR_IMAGE: donkie/spoolman
@@ -29,6 +35,11 @@ jobs:
# Linting
#
style:
# Same-repo branches already got a full run from the push trigger, so skip the
# duplicate pull_request run. Bot branches excluded above still need it, as do
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
# cascades to every other job, since a skipped dependency skips its dependents.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -71,6 +82,11 @@ jobs:
# This is done on native hardware to speed things up, vite is very slow on emulated hardware
#
build-client:
# Same-repo branches already got a full run from the push trigger, so skip the
# duplicate pull_request run. Bot branches excluded above still need it, as do
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
# cascades to every other job, since a skipped dependency skips its dependents.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -218,6 +234,11 @@ jobs:
# Build tester image for integration tests
#
build-tester:
# Same-repo branches already got a full run from the push trigger, so skip the
# duplicate pull_request run. Bot branches excluded above still need it, as do
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
# cascades to every other job, since a skipped dependency skips its dependents.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
runs-on: ubuntu-latest
permissions:
packages: write
@@ -349,6 +370,10 @@ jobs:
test-frontend-v2:
needs: [build-amd64]
runs-on: ubuntu-latest
permissions:
contents: read
# Argos authenticates via OIDC; fork PRs get no id-token and fall back to tokenless upload.
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -376,10 +401,20 @@ jobs:
- name: Start Spoolman
run: docker compose -f tests_frontend_v2/docker-compose.yml up -d --wait
# Screenshots first: they seed a fixed dataset and need the database empty.
# The Argos reporter uploads them and posts the visual diff on the PR.
- name: Take client_v2 screenshots
env:
ARGOS_UPLOAD: "1"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd tests_frontend_v2
npx playwright test --project screenshots
- name: Run client_v2 frontend integration tests
run: |
cd tests_frontend_v2
npx playwright test
npx playwright test --project chromium
- name: Dump Spoolman logs on failure
if: failure()
@@ -494,8 +529,28 @@ jobs:
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.6.1
# Keep uv's cache of wheels built from source (asyncpg, greenlet, ...) across
# runs. They have no armv7 wheels, and compiling them under QEMU is ~9 of the
# ~11 minutes of a build that misses the layer cache (lockfile or base image
# changed). The Dockerfile prunes the cache down to just those wheels, so it's
# small enough to save on every run and always restore the newest one.
- name: Cache uv wheels
uses: actions/cache@v4
with:
path: uv-cache
key: uv-cache-armv7-${{ github.run_id }}
restore-keys: uv-cache-armv7-
- name: Inject uv cache into BuildKit
uses: reproducible-containers/buildkit-cache-dance@v3.4.0
with:
builder: ${{ steps.buildx.outputs.name }}
cache-map: |
{ "uv-cache": "/root/.cache/uv" }
- name: Log in to the Github Container registry
uses: docker/login-action@v3.3.0
with:
@@ -644,6 +699,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# The trailing type=ref,event=branch is what gives an unmerged branch a
# pullable image (ghcr.io/donkie/spoolman:my-branch) for a bug reporter to
# test. It is GHCR-only and skips master (already published as :edge), so
# Docker Hub keeps carrying nothing but :edge and tagged releases.
- name: Extract metadata (tags, labels) for GHCR
id: meta-ghcr
uses: docker/metadata-action@v5.5.1
@@ -655,6 +714,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch,enable=${{ github.ref_name != 'master' }}
- name: Extract metadata (tags, labels) for Docker Hub
id: meta-dh
+78 -55
View File
@@ -4,20 +4,18 @@ name: Auto-merge Weblate PRs
# which opens a pull request for every sync back to this repo (see guard-translations.yml). Those PRs
# are machine-generated, always scoped to a locale tree (client_v2/locales/ today), and gated by the
# full CI suite just like any other PR. Rather than hand-merging each one, this workflow turns on GitHub's native
# auto-merge for Weblate-authored PRs so they merge themselves once all required status checks pass.
# auto-merge once a week for Weblate-authored PRs so they merge themselves once all required status checks pass.
#
# Why auto-merge instead of giving Weblate direct push access to master:
# - Every sync still runs through style/tests/build before it can land — nothing merges unvalidated.
# - Every sync still runs through style/tests/build before it can land; nothing merges unvalidated.
# - No actor gains unrestricted, un-CI'd direct write to master. A compromised Weblate token can only
# open a PR; auto-merge fires solely on green CI for Weblate-authored PRs.
# - GitHub write/bypass can't be scoped to locale files, so direct write would grant all-file access.
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
# contents+pull-requests write let the built-in GITHUB_TOKEN enable auto-merge and perform the merge.
# The token is not a ruleset bypass actor and doesn't need to be: with 0 required approvals it
@@ -30,65 +28,23 @@ jobs:
auto-merge:
runs-on: ubuntu-latest
# Only ever act on Weblate's own translation-sync PRs. Human PRs are never touched.
# pull_request_target runs in the base-repo context (so GITHUB_TOKEN has write access even for
# branch PRs), and this job is safe under it because it never checks out or executes PR code —
# it only calls the GitHub API to enable auto-merge.
if: github.event.pull_request.user.login == 'weblate'
# Scheduled runs use the default-branch workflow. This job never checks out or executes
# PR code; it only calls the GitHub API to inspect PRs and enable auto-merge or merge them.
steps:
# Defence in depth: don't trust the author alone, trust the diff. Even a compromised Weblate
# token can only ever get auto-merged if the PR is confined to locale JSON files. Anything
# else (a code file, a workflow, a non-.json file, a file outside the two locale trees)
# fails this step, auto-merge is never enabled, and the PR waits for a human. The file list is
# read from the API — this step never checks out or runs PR code, so pull_request_target is safe.
# fails the check, auto-merge is never enabled, and the PR waits for a human. The file list is
# read from the API; this step never checks out or runs PR code.
#
# Both locale trees are allowed. client_v2/locales/ is where translations go now; the legacy
# client/public/locales/ tree stays on the list only so that a sync opened before its Weblate
# component was locked isn't stranded half-merged. Locking the component, not this allowlist,
# is what stops new legacy translations (see guard-translations.yml).
- name: Verify PR only changes locale JSON files
- name: Verify and merge open Weblate PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
files="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename')"
if [ -z "$files" ]; then
echo "::error title=Empty diff::Weblate PR reports no changed files; refusing to auto-merge."
exit 1
fi
# Every changed path must be a .json file under client_v2/locales/<lang>/ (active) or
# client/public/locales/<lang>/ (legacy, being wound down).
offending="$(printf '%s\n' "$files" \
| grep -vE '^(client_v2/locales|client/public/locales)/[^/]+/.+\.json$' || true)"
if [ -n "$offending" ]; then
echo "::error title=Out-of-scope Weblate PR::PR touches files outside the locale trees; not auto-merging."
{
echo "## ❌ Auto-merge blocked: out-of-scope changes"
echo ""
echo "This Weblate PR changes files that are **not** locale JSON under"
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
echo ""
echo '```'
echo "$offending"
echo '```'
echo ""
echo "Weblate sync PRs are only auto-merged when every changed file is a translation JSON"
echo "file. This PR isn't, so it has been left for a maintainer to review and merge by hand."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "All $(printf '%s\n' "$files" | wc -l) changed file(s) are locale JSON. Safe to auto-merge."
- name: Enable auto-merge
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
# Merge commit, NOT squash. This is load-bearing for Weblate, not a style preference.
#
# Weblate keeps its own clone of this repo and rebases it onto master to pick up our changes.
@@ -110,4 +66,71 @@ jobs:
# sees them as already upstream and drops them silently. Nothing else forces squash here:
# the repo has allow_merge_commit enabled and the master ruleset carries no linear-history
# rule, and human PRs are merge-committed too.
run: gh pr merge --auto --merge "$PR_URL"
run: |
set -euo pipefail
prs="$(gh pr list --repo "$REPO" --author weblate --state open --limit 10000 --json number,url)"
rows="$(printf '%s\n' "$prs" | jq -r '.[] | [.number, .url] | @tsv')"
failed=0
while IFS=$'\t' read -r PR_NUMBER PR_URL; do
[ -n "$PR_NUMBER" ] || continue
if ! files="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename')"; then
echo "::error::Could not read changed files for $PR_URL; refusing to auto-merge."
echo "## ❌ Auto-merge blocked: could not read files for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
failed=1
continue
fi
if [ -z "$files" ]; then
echo "::error title=Empty diff::Weblate PR $PR_URL reports no changed files; refusing to auto-merge."
echo "## ❌ Auto-merge blocked: empty diff for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
failed=1
continue
fi
# Every changed path must be a .json file under client_v2/locales/<lang>/ (active) or
# client/public/locales/<lang>/ (legacy, being wound down).
offending="$(printf '%s\n' "$files" \
| grep -vE '^(client_v2/locales|client/public/locales)/[^/]+/.+\.json$' || true)"
if [ -n "$offending" ]; then
echo "::error title=Out-of-scope Weblate PR::$PR_URL touches files outside the locale trees; not auto-merging."
{
echo "## ❌ Auto-merge blocked: out-of-scope changes"
echo ""
echo "Weblate PR: $PR_URL"
echo ""
echo "This Weblate PR changes files that are **not** locale JSON under"
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
echo ""
echo '```'
echo "$offending"
echo '```'
echo ""
echo "Weblate sync PRs are only auto-merged when every changed file is a translation JSON"
echo "file. This PR isn't, so it has been left for a maintainer to review and merge by hand."
} >> "$GITHUB_STEP_SUMMARY"
failed=1
continue
fi
echo "$PR_URL: all $(printf '%s\n' "$files" | wc -l) changed file(s) are locale JSON. Safe to auto-merge."
if merge_output="$(gh pr merge --repo "$REPO" --auto --merge "$PR_URL" 2>&1)"; then
echo "$merge_output"
continue
fi
echo "$merge_output"
# GitHub can refuse auto-merge when the PR is already ready to merge.
if [[ "$merge_output" == *"clean status"* || "$merge_output" == *"already mergeable"* ]]; then
if gh pr merge --repo "$REPO" --merge "$PR_URL"; then
continue
fi
fi
echo "::error::Could not merge $PR_URL."
echo "## ❌ Auto-merge blocked: merge failed for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
failed=1
done <<< "$rows"
exit "$failed"
+29 -19
View File
@@ -1,16 +1,25 @@
FROM python:3.14-slim-bookworm AS python-builder
# A multi-platform build runs this stage once per platform in parallel. Each gets
# its own uv cache: wheels are per-platform anyway, and one shared cache made the
# platforms queue on uv's cache lock until the slow emulated armv7 sync pushed the
# others past uv's 300 s lock timeout whenever uv.lock changed.
ARG TARGETARCH
ARG TARGETVARIANT
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_NO_DEV=1
ENV UV_PYTHON_DOWNLOADS=0
# Install system dependencies
RUN apt-get update && apt-get install -y \
# No python3-dev: the base image already ships the 3.14 headers, and python3-dev
# would pull in Debian's whole python3.11 stack. patchelf is for armv7, see below.
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
python3-dev \
libpq-dev \
libffi-dev \
patchelf \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -19,10 +28,23 @@ RUN pip install --no-cache-dir uv
# Install dependencies
WORKDIR /home/app/spoolman
RUN --mount=type=cache,target=/root/.cache/uv \
RUN --mount=type=cache,id=uv-${TARGETARCH}${TARGETVARIANT},target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
uv sync --locked --no-install-project \
&& uv cache prune --ci
# greenlet ships no 32-bit ARM wheel, so on armv7 it is compiled from source.
# setuptools links the C++ extension with gcc, which leaves libstdc++ out of the
# .so's NEEDED list, and it then crashes at import with an undefined libstdc++
# typeinfo symbol, taking startup down during the DB migration. Add libstdc++ to
# the NEEDED list so the loader pulls it in. Only armv7 is affected; amd64 and
# arm64 use a correctly linked prebuilt wheel. Done before the app is copied in so
# the layer stays cached across source-only changes.
RUN if [ "$(uname -m)" = "armv7l" ]; then \
patchelf --add-needed libstdc++.so.6 \
"$(find /home/app/spoolman/.venv -name '_greenlet*.so')"; \
fi
# Copy and install app. No --chown here: the "app" user only exists in the
# runner stage, and Podman (unlike Docker/BuildKit) refuses to resolve it. Final
@@ -30,21 +52,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY migrations /home/app/spoolman/migrations
COPY spoolman /home/app/spoolman/spoolman
COPY alembic.ini README.md uv.lock pyproject.toml /home/app/spoolman/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
# greenlet ships no 32-bit ARM wheel, so on armv7 it is compiled from source.
# setuptools links the C++ extension with gcc, which leaves libstdc++ out of the
# .so's NEEDED list, and it then crashes at import with an undefined libstdc++
# typeinfo symbol, taking startup down during the DB migration. Add libstdc++ to
# the NEEDED list so the loader pulls it in. Only armv7 is affected; amd64 and
# arm64 use a correctly linked prebuilt wheel.
RUN if [ "$(uname -m)" = "armv7l" ]; then \
apt-get update && apt-get install -y --no-install-recommends patchelf \
&& patchelf --add-needed libstdc++.so.6 \
"$(find /home/app/spoolman/.venv -name '_greenlet*.so')" \
&& apt-get clean && rm -rf /var/lib/apt/lists/*; \
fi
RUN --mount=type=cache,id=uv-${TARGETARCH}${TARGETVARIANT},target=/root/.cache/uv \
uv sync --locked \
&& uv cache prune --ci
FROM python:3.14-slim-bookworm AS python-runner
+1112 -759
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -22,7 +22,7 @@
"html-to-image": "^1.11.13",
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"i18next-http-backend": "^4.0.2",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
+20 -12
View File
@@ -277,7 +277,7 @@
"external_id": "Extern ID",
"single_color": "Enkel",
"multi_color": "Meervoudig",
"coaxial": "Gecoëxtrudeerde",
"coaxial": "Gecoëxtrudeerd",
"longitudinal": "Longitudinaal"
},
"titles": {
@@ -291,11 +291,11 @@
"filament": "Filamenten",
"form": {
"filament_updated": "Dit filament is bijgewerkt door iemand/iets anders sinds je deze pagina geopend hebt. Deze veranderingen worden overschreven als je op slaat!",
"import_external_description": "Selecteer een filament in de lijst om de details ervan automatisch in te vullen in het formulier voor het maken van filamenten. Hierdoor worden alle gegevens die u in het formulier hebt ingevoerd, overschreven.<br><br>Er wordt indien nodig automatisch een Fabrikant-object gemaakt als u op OK klikt.",
"import_external_description": "Selecteer een filament uit de lijst om de details automatisch aan het formulier voor het maken van filamenten toe te voegen. Hierdoor worden alle gegevens die je in het formulier ingevoerd hebt, overschreven.<br><br>Er wordt indien nodig automatisch een fabrikant-object aangemaakt als je op OK klikt.",
"import_external": "Importeren van extern"
},
"buttons": {
"add_spool": "Spoel toevoegen"
"add_spool": "Spoel Toevoegen"
}
},
"scanner": {
@@ -314,8 +314,8 @@
"titles": {
"show": "Fabrikant tonen",
"edit": "Fabrikant bewerken",
"clone": "Fabrikant klonen",
"create": "Fabrikant maken",
"clone": "Fabrikant Klonen",
"create": "Fabrikant Aanmaken",
"list": "Fabrikanten",
"show_title": "[Fabrikant #{{id}}] {{name}}"
},
@@ -324,8 +324,8 @@
"comment": "Opmerking",
"name": "Naam",
"id": "ID",
"empty_spool_weight": "Gewicht lege spoel",
"external_id": "Extern ID"
"empty_spool_weight": "Gewicht Lege Spoel",
"external_id": "Externe ID"
},
"vendor": "Fabrikanten",
"form": {
@@ -350,8 +350,8 @@
"kofi": "Tip mij op Ko-fi",
"home": {
"home": "Thuis",
"welcome": "",
"description": ""
"welcome": "Welkom bij je eigen Spoolman-account!",
"description": "Het lijkt erop dat je nog geen spoelen hebt toegevoegd. Zie de <helpPageLink>Help-pagina</helpPageLink> voor hulp bij het opstarten."
},
"settings": {
"header": "Instellingen",
@@ -373,7 +373,7 @@
"tab": "Extra velden",
"params": {
"name": "Naam",
"key": "Key",
"key": "Sleutel",
"field_type": "Type",
"unit": "Eenheid",
"order": "Volgorde",
@@ -398,7 +398,8 @@
"key_not_changed": "Key niet gewijzigd.",
"delete_confirm": "Veld {{name}} verwijderen?",
"delete_confirm_description": "Hierdoor worden het veld en alle bijbehorende gegevens voor alle entiteiten verwijderd.",
"description": "<p>Hier kunt u extra aangepaste velden toevoegen aan uw entiteiten.</p><p>Zodra een veld is toegevoegd, kunt u de key of het type ervan niet wijzigen, en voor velden met keuzetypes kunt u geen keuzes verwijderen of de meervoudige velden wijzigen. Als u een veld verwijdert, worden de bijbehorende gegevens voor alle entiteiten verwijderd.</p><p>De key is hoe andere programma's de gegevens lezen/schrijven, dus of uw aangepaste veld moet worden geïntegreerd met een derde partij programma, zorg ervoor dat u het correct instelt. De standaardwaarde wordt alleen toegepast op nieuwe items.</p><p>Extra velden kunnen niet worden gesorteerd of gefilterd in de tabelweergaven.</p>"
"description": "<p>Hier kunt u extra aangepaste velden toevoegen aan uw entiteiten.</p><p>Zodra een veld is toegevoegd, kunt u de key of het type ervan niet wijzigen, en voor velden met keuzetypes kunt u geen keuzes verwijderen of de meervoudige velden wijzigen. Als u een veld verwijdert, worden de bijbehorende gegevens voor alle entiteiten verwijderd.</p><p>De key is hoe andere programma's de gegevens lezen/schrijven, dus of uw aangepaste veld moet worden geïntegreerd met een derde partij programma, zorg ervoor dat u het correct instelt. De standaardwaarde wordt alleen toegepast op nieuwe items.</p><p>Extra velden kunnen worden weergegeven en gesorteerd in de tabelweergaven. Keuze- en booleaanse-velden bieden daar ook filteropties, en alle typen extra velden ondersteunen het filteren op lege waarden.</p>",
"choices_reorder_hint": "Sleep of gebruik de pijltjestoetsen om deze keuze opnieuw te ordenen."
},
"settings": "Instellingen"
},
@@ -407,5 +408,12 @@
"no_locations_help": "Op deze pagina kunt u uw spoelen op verschillende locaties ordenen. Voeg spoelen toe om aan de slag te gaan!",
"locations": "Locaties",
"new_location": "Nieuwe locatie"
}
},
"theme": {
"light": "Licht",
"dark": "Donker",
"system": "Systeem",
"label": "Thema"
},
"sponsor": "Steun me op GitHub"
}
+10 -2
View File
@@ -248,8 +248,9 @@
"key_not_changed": "Spremenite geslo v nekaj drugega.",
"delete_confirm": "Zbrišem polje {{name}}?",
"delete_confirm_description": "S tem boste izbrisali polje in vse povezane podatke za vse vnose.",
"description": "<p>Tukaj lahko svojim entitetam dodate dodatna polja po meri.</p><p>Ko je polje dodano, njegovega ključa ali tipa ni mogoče spremeniti, pri poljih tipa »izbira« pa ni mogoče odstraniti možnosti ali spremeniti nastavitve za večkratno izbiro. Če polje odstranite, bodo povezani podatki za vse entitete izbrisani.</p><p>Ključ je tisto, kar drugi programi uporabljajo za branje/pisanje podatkov, zato če naj bi se vaše polje po meri integriralo s programom tretje osebe, poskrbite, da ga nastavite pravilno. Privzeta vrednost se uporablja le za nove elemente.</p><p>Dodatna polja ni mogoče razvrstiti ali filtrirati v pogledih tabel.</p>",
"choices_missing_error": "Možnosti ni mogoče odstraniti. Manjkajo naslednje možnosti: {{choices}}"
"description": "<p>Tukaj lahko svojim entitetam dodate dodatna polja po meri.</p><p>Ko je polje dodano, njegovega ključa ali tipa ni mogoče spremeniti, pri poljih tipa »izbira« pa ni mogoče odstraniti možnosti ali spremeniti nastavitve za večkratno izbiro. Če polje odstranite, bodo povezani podatki za vse entitete izbrisani.</p><p>Ključ je tisto, kar drugi programi uporabljajo za branje/pisanje podatkov, zato če naj bi se vaše polje po meri integriralo s programom tretje osebe, poskrbite, da ga nastavite pravilno. Privzeta vrednost se uporablja le za nove elemente.</p><p>V preglednicah je mogoče prikazati in razvrstiti dodatna polja. Polja tipa »izbira« in »booleanska« tam omogočajo tudi možnosti filtriranja, vsi tipi dodatnih polj pa podpirajo filtriranje po praznih vrednostih.</p>",
"choices_missing_error": "Možnosti ni mogoče odstraniti. Manjkajo naslednje možnosti: {{choices}}",
"choices_reorder_hint": "Za spremembo vrstnega reda izbire, povlecite ali uporabite smerne tipke."
}
},
"scanner": {
@@ -397,5 +398,12 @@
"new_location": "Nova lokacija",
"no_location": "Brez lokacije",
"no_locations_help": "Na tej strani lahko kolute razvrstite po lokacijah. Za začetek dodajte nekaj kolutov."
},
"sponsor": "Sponzorirajte me na GitHubu",
"theme": {
"label": "Tema",
"system": "Sistem",
"light": "Svetla",
"dark": "Temna"
}
}
+13 -12
View File
@@ -16,7 +16,7 @@
"cancel": "取消",
"confirm": "您確定嗎?",
"continue": "繼續",
"filter": "過濾器",
"filter": "篩選",
"clear": "清除",
"refresh": "重新整理",
"show": "檢視",
@@ -61,8 +61,8 @@
"printing": {
"generic": {
"title": "列印中",
"description": "調整以下設定以獲得所需的列印佈局。請注意,列表機和您的操作系統可能會套用自己的邊距和縮放比例,因此可能需要進行一些反覆測試才能達到理想效果。在實際標籤上列印之前,先在普通紙上測試。",
"helpMargin": "邊距應配置為與您的標籤紙和列表機相符,調整這些將影響整個網格的大小。",
"description": "調整以下設定以取得所需的列印版面。請注意,印表機與作業系統可能會套用自己的邊距和縮放比例,因此可能需要多次測試。列印實際標籤前,請先使用一般紙張測試。",
"helpMargin": "邊距應設定為與您的標籤紙和印表機相符,調整這些將影響整個網格的大小。",
"rows": "列",
"paperSize": "紙張大小",
"customSize": "自訂",
@@ -70,7 +70,7 @@
"showBorder": "顯示外框",
"previewScale": "預覽縮放比例",
"skipItems": "跳過項目",
"itemCopies": "項目複製",
"itemCopies": "每項份數",
"contentSettings": "內容設定",
"layoutSettings": "佈局設定",
"horizontalSpacing": "水平間距",
@@ -100,7 +100,7 @@
"columns": "欄",
"verticalSpacing": "垂直間距",
"marginTop": "上邊界",
"helpPrinterMargin": "安全區域應設定為列表機能列印的距離紙張的邊界,更改此選項不影響整個網格。"
"helpPrinterMargin": "安全區域應設定為印表機能列印的距離紙張的邊界,更改此選項不影響整個網格。"
},
"qrcode": {
"title": "標籤列印中",
@@ -110,7 +110,7 @@
"textSize": "標籤文字大小",
"showContent": "列印標籤",
"useHTTPUrl": {
"label": "QR碼連結",
"label": "QR 碼連結",
"preview": "預覽:",
"options": {
"default": "預設",
@@ -131,11 +131,11 @@
"showArchived": "檢視已封存",
"noSpoolsSelected": "您尚未選擇任何料盤。",
"selectAll": "選擇/取消全選",
"selectedTotal_other": "已選擇 {{count}} 個料盤"
"selectedTotal_other": "已選擇 {{count}} 個線軸"
}
},
"scanner": {
"description": "掃描Spoolman的QR碼以檢視關於料盤的詳細資料。",
"description": "掃描 Spoolman QR 碼以檢視線軸的詳細資料。",
"error": {
"notAllowed": "您未被允許存取攝影機。",
"streamApiNotSupported": "瀏覽器不支援MediaStream API。",
@@ -144,10 +144,10 @@
"insecureContext": "此頁面未通過 HTTPS 提供服務。",
"unknown": "發生未知的錯誤。({{error}})"
},
"title": "掃描QR碼"
"title": "QR 碼掃描器"
},
"spool": {
"spool": "料盤",
"spool": "線軸",
"fields": {
"id": "ID",
"filament": "線材",
@@ -346,7 +346,7 @@
"general": {
"tab": "一般",
"currency": {
"label": "貨幣設定標籤"
"label": "貨幣"
},
"base_url": {
"tooltip": "生成 QR 碼等功能時使用的基本 URL。",
@@ -394,5 +394,6 @@
"no_location": "未設定位置",
"no_locations_help": "這個頁面讓您將料盤按位置進行整理,請從增加一些線盤來開始!",
"new_location": "新增位置"
}
},
"sponsor": "在 GitHub 上贊助專案"
}
+34 -4
View File
@@ -163,11 +163,12 @@
"filament.fields.weight": "Gewicht",
"filament.fieldsHelp.articleNumber": "z.B. EAN, UPC o.Ä.",
"filament.fieldsHelp.multiColorDirection": "Filamente können auf zwei Arten mehrere Farben haben: entweder durch Koextrusion, wie bei zweifarbigen Filamenten mit konsistenten Mehrfarben, oder durch Farbveränderungen in Längsrichtung, wie bei Gradient-Filamenten, welche die Farben entlang der Spule verschieben.",
"filament.fieldsHelp.spoolWeight": "Das Gewicht der leeren Spule. Wird verwendet, um das gemessene Gewicht der Spule zu berechnen.",
"filament.fieldsHelp.spoolWeight": "Das Gewicht einer leeren Spule. Wird verwendet, um das gemessene Gewicht einer Spule zu bestimmen. Nicht sicher? Pappspulen wiegen normalerweise 130–150 g und Kunststoffspulen 180–250 g – für eine genaue Zahl einfach die Spule auf die Waage legen, sobald sie leer ist.",
"filament.fieldsHelp.weight": "Das Gewicht einer vollen Spule (Nettogewicht). Sollte nicht das Gewicht der Spule, sondern nur dass des Filaments auf der Spule enthalten. Entspricht normalerweise dem Gewicht, das auf der Spule aufgedruckt ist.",
"filament.filament": "Filamente",
"footer.documentation": "Dokumentation",
"footer.reportIssue": "Ein Problem melden",
"footer.updateAvailable": "v{{version}} verfügbar",
"help.help": "Hilfe",
"inspector.addSpoolsOfThis": "Diese Spulen hinzufügen",
"inspector.adjustFailed": "Anpassung des Gewichts fehlgeschlagen.",
@@ -341,6 +342,18 @@
"labels.xMm": "X",
"labels.yMm": "Y",
"library.apiError": "Konnte die Spoolman API nicht erreichen. Läuft das Backend?",
"library.catalog.allFilaments": "Alle Filamente",
"library.catalog.allManufacturers": "Alle Hersteller",
"library.catalog.backToSpools": "Zurück zu Spulen",
"library.catalog.filamentCount_one": "{{count}} Filament",
"library.catalog.filamentCount_other": "{{count}} Filamente",
"library.catalog.manufacturerCount_one": "{{count}} Hersteller",
"library.catalog.manufacturerCount_other": "{{count}} Hersteller",
"library.catalog.noManufacturer": "kein Hersteller",
"library.catalog.notSet": "Nicht gesetzt",
"library.catalog.switchList": "Liste wechseln",
"library.catalog.unitFilaments": "Filamente",
"library.catalog.unitManufacturers": "Hersteller",
"library.collapseGroup": "Verstecke diese Spulen",
"library.dateFilter.ago": "vor {{span}}",
"library.dateFilter.between": "{{from}} – {{to}}",
@@ -377,7 +390,7 @@
"library.sort.nozzle": "Düsen Temperatur",
"library.sortBy": "Sortieren",
"library.unassigned": "nicht zugewiesen",
"library.unitGroups": "Guppen",
"library.unitGroups": "Gruppen",
"library.unitSpools": "Spulen",
"library.unused": "ungenutzt",
"library.unusedGroup": "{{count}} unbenutzt · {{weight}} g jede",
@@ -386,6 +399,8 @@
"library.unusedSubMixed": "{{location}}",
"library.used": "verbraucht",
"library.usedAgo": "vor {{time}} verwendet",
"library.viewMenu.browse": "Durchsuchen",
"library.views": "Bibliotheksansichten",
"loading": "Läd",
"locations.locations": "Orte",
"locations.newLocation": "Neuer Ort",
@@ -425,6 +440,7 @@
"printing.generic.verticalSpacing": "Vertikaler Abstand",
"printing.qrcode.button": "Beschriftung drucken",
"printing.qrcode.customUrl.caveat": "Eine benutzerdefinierte URL wird nicht zurück in Spoolman gescannt – benutze sie, um einen gedruckten QR-Code auf eine externe App oder einen anderen Host zu verweisen.",
"printing.qrcode.customUrl.fieldsHint": "Beschriftungsfelder funktionieren zum Beispiel auch",
"printing.qrcode.customUrl.hint": "Setze die Spulen Id irgendwo mit dem Platzhalter ein",
"printing.qrcode.customUrl.label": "Benutzerdefinierte URL-Vorlage",
"printing.qrcode.useHTTPUrl.label": "QR Code Link",
@@ -435,6 +451,8 @@
"printing.qrcode.useHTTPUrl.tooltip": "Die Standardeinstellung verwendet einen proprietären Link, der nur funktioniert, wenn er mit der Scanfunktion von Spoolman gescannt wird. URL verwendet entweder die in den Einstellungen angegebene Basis-URL oder wenn diese nicht festgelegt ist die aktuelle Seiten-URL.",
"printing.spoolSelect.selectedTotal_one": "{{count}} Spule ausgewählt",
"printing.spoolSelect.selectedTotal_other": "{{count}} Spulen ausgewählt",
"scanner.camera": "Kamera",
"scanner.cameraNumbered": "Kamera {{number}}",
"scanner.description": "Spoolman QR-Code scannen, um Details der Spule anzuzeigen.",
"scanner.error.inUse": "Die Kamera wird bereits von einer anderen Anwendung genutzt.",
"scanner.error.insecureContext": "Die Seite wird nicht über HTTPS bereitgestellt.",
@@ -444,7 +462,9 @@
"scanner.error.unknown": "Ein unbekannter Fehler ist aufgetreten. ({{error}})",
"scanner.starting": "Starte Kamera…",
"scanner.title": "QR-Code Scanner",
"search.browseAll": "Durchsuche alle",
"search.colorHint": "Farbnamen müssen in englisch eingegeben werden.",
"search.hint": "Suche Spulen, Filamente und Hersteller nach Name, Farbe, Material oder nach jedem anderen Feld.",
"search.moreSpools": "+{{count}} mehr",
"search.noResults": "Keine Treffer gefunden",
"search.searching": "Suchen…",
@@ -515,12 +535,12 @@
"settings.library.lowThreshold.desc": "Spulen mit diesem Gewicht oder weniger werden rot markiert (nur in diesem Browser)",
"settings.library.lowThreshold.label": "Niedriger Lagerbestand Schwelle",
"settings.library.tab": "Bibliotheksverhalten",
"settings.scanner.autoNavigate.desc": "Navigiere automatisch in diesem Webbrowser zu einer Spule, die von einem Tag Lesegerät gelesen wird.",
"settings.scanner.autoNavigate.desc": "Navigiere automatisch in diesem Webbrowser zu einer Spule oder Filament, die von einem Tag Lesegerät gelesen wird.",
"settings.scanner.autoNavigate.label": "Auto-Navigation",
"settings.scanner.docs": "Wie man einen Tag Leser einrichtet",
"settings.scanner.lastSeen": "vor {{when}}",
"settings.scanner.pair": "Zum Koppeln tippen",
"settings.scanner.reader.anyDesc": "Scans von jedem Leser, öffnet Spulen in diesem Webbrowser. Wenn du mehr Kontrolle willst, kannst du dich mit einem bestimmten koppeln. Das ist auch der Leser, der verwendet wird, um neue Tags zu Spulen hinzuzufügen.",
"settings.scanner.reader.anyDesc": "Scans von jedem Leser, öffnet Spulen in diesem Webbrowser. Wenn du mehr Kontrolle willst, kannst du dich mit einem bestimmten koppeln. Das ist auch der Leser, der verwendet wird, um neue Tags zu Spulen oder Filamenten hinzuzufügen.",
"settings.scanner.reader.label": "Tag Leser",
"settings.scanner.reader.pairedDesc": "Nur Scans von diesem Leser öffnen Spulen in diesem Webbrowser.",
"settings.scanner.recent": "Kürzliche Leser",
@@ -561,16 +581,21 @@
"tags.added": "Tag verlinkt.",
"tags.addedOn": "Hinzugefügt {{date}}",
"tags.modal.alreadyHere": "Dieser Tag kennzeichnet diese Spule bereits.",
"tags.modal.alreadyHereFilament": "Dieses Tag kennzeichnet dieses Filament bereits.",
"tags.modal.badUid": "Eine Tag UID ist hexadezimal, wie 04A2B3C4.",
"tags.modal.checking": "Prüfen…",
"tags.modal.failed": "Konnte den Tag nicht verknüpfen.",
"tags.modal.filamentNamed": "Filament {{name}}",
"tags.modal.free": "Dieser Tag ist noch mit nichts verbunden.",
"tags.modal.intro": "Wenn du diesen Tag auf einem Reader antippst, findest du dann Spule #{{id}}.",
"tags.modal.introFilament": "Wenn man diesen Tag auf einem Leser antippt, wird dieses Filament dann gefunden.",
"tags.modal.lastSeenOn": "{{reader}} zuletzt gelesen {{uid}}.",
"tags.modal.link": "Verbinde Tag",
"tags.modal.linking": "Verbinden…",
"tags.modal.move": "Verschiebe es hierher von #{{id}}",
"tags.modal.moveFailed": "Konnte den Tag nicht verschieben. Er ist nun möglicherweise mit keiner Spule verbunden – versuche ihn erneut zu verlinken.",
"tags.modal.moveFailedFilament": "Der Tag konnte nicht verschoben werden. Er ist jetzt möglicherweise mit nichts verknüpft – versuche, ihn erneut zu verknüpfen.",
"tags.modal.moveHere": "Bewege es hierhin",
"tags.modal.moving": "Verschieben…",
"tags.modal.phoneReading": "Halte den Tag an dein Handy...",
"tags.modal.phoneScan": "Lese den Tag mit deinem Handy",
@@ -585,16 +610,20 @@
"tags.modal.waitingAny": "Warte auf ein Tippen auf einem beliebigen Leser.",
"tags.modal.waitingOn": "Warte auf ein Tippen auf {{reader}}.",
"tags.moved": "Tag wurde von Spule #{{id}} hierher verschoben.",
"tags.movedFromFilament": "Tag wurde von Filament {{name}} hierher verschoben.",
"tags.nfc.insecureContext": "Das Lesen von Tags erfordert eine sichere Verbindung. Öffne Spoolman über HTTPS, um das NFC deines Handy zu nutzen.",
"tags.nfc.notAllowed": "Diese Seite darf NFC nicht verwenden. Gewähre ihr die Erlaubnis in den Seiten Einstellungen deines Browsers und versuche es erneut.",
"tags.nfc.notReadable": "Der NFC-Leser ist nicht verfügbar — eine andere App könnte ihn gerade benutzen.",
"tags.nfc.notSupported": "Dieses Handy hat kein NFC oder NFC ist in den Systemeinstellungen ausgeschaltet.",
"tags.nfc.unknown": "Konnte keinen Tag lesen.",
"tags.none": "Keine Tag's verbunden.",
"tags.scan.openedFilament": "Tag gescannt: Filament {{name}} geöffnet.",
"tags.scan.openedSpool": "Tag gescannt: Spule #{{id}}, {{name}} geöffnet.",
"tags.scan.unknown": "Unbekannter Tag {{uid}}. Öffne die zugehörige Spule und benutze „Tag hinzufügen“, um ihn zu verknüpfen.",
"tags.section": "Tag's",
"tags.unlink": "Verknüpfung aufheben",
"tags.unlinkBody": "Tag {{uid}} wird stoppen, diese Spule zu identifizieren. Nichts wird direkt auf dem Tag selbst geschrieben, daher kannst du ihn jederzeit wieder — mit dieser Spule oder einer Anderen — verknüpfen.",
"tags.unlinkBodyFilament": "Tag {{uid}} wird aufhören, dieses Filament zu identifizieren. Es wird nichts auf den Tag selbst geschrieben, sodass du ihn jederzeit wieder verknüpfen kannst — hier oder irgendwo anders.",
"tags.unlinkFailed": "Konnte die Verbindung des Tag's nicht entfernen.",
"tags.unlinkTitle": "Verbindung für diesen Tag aufheben?",
"tags.unlinked": "Verbindung des Tag's aufgehoben.",
@@ -616,5 +645,6 @@
"vendor.fields.name": "Name",
"vendor.fields.registered": "Registriert",
"vendor.fieldsHelp.emptySpoolWeight": "Das Gewicht einer leeren Spule von diesem Hersteller.",
"vendor.showAsList": "Zeige als Liste",
"yes": "Ja"
}
+24 -2
View File
@@ -62,6 +62,7 @@
"buttons.filter": "Filter",
"buttons.save": "Save",
"buttons.showArchived": "Show Archived",
"buttons.showLotNumbers": "Show lot numbers",
"buttons.showNoSpools": "Show filaments with no spools",
"buttons.unArchive": "Unarchive",
"changeFilament.adoptWeight": "Set this spool’s full weight to {{full}}",
@@ -167,6 +168,7 @@
"filament.filament": "Filaments",
"footer.documentation": "Documentation",
"footer.reportIssue": "Report an issue",
"footer.updateAvailable": "v{{version}} available",
"help.help": "Help",
"inspector.addSpoolsOfThis": "Add spools of this",
"inspector.adjustFailed": "Failed to adjust weight.",
@@ -349,6 +351,18 @@
"library.dateFilter.since": "Since {{date}}",
"library.dateFilter.to": "To (optional)",
"library.dateFilter.until": "Until {{date}}",
"library.catalog.allFilaments": "All filaments",
"library.catalog.allManufacturers": "All manufacturers",
"library.catalog.backToSpools": "Back to spools",
"library.catalog.filamentCount_one": "{{count}} filament",
"library.catalog.filamentCount_other": "{{count}} filaments",
"library.catalog.manufacturerCount_one": "{{count}} manufacturer",
"library.catalog.manufacturerCount_other": "{{count}} manufacturers",
"library.catalog.noManufacturer": "No manufacturer",
"library.catalog.notSet": "Not set",
"library.catalog.switchList": "Switch list",
"library.catalog.unitFilaments": "filaments",
"library.catalog.unitManufacturers": "manufacturers",
"library.emptyFiltered": "No {{unit}} match the current filters.",
"library.emptyTitle": "No spools yet",
"library.emptyBody": "Add your first spool to start tracking your filament.",
@@ -383,6 +397,8 @@
"library.unusedSubMixed": "{{location}}",
"library.used": "used",
"library.usedAgo": "used {{time}} ago",
"library.viewMenu.browse": "Browse",
"library.views": "Library views",
"loading": "Loading",
"nav.labels": "Labels",
"nav.library": "Library",
@@ -417,6 +433,7 @@
"printing.generic.verticalSpacing": "Vertical Spacing",
"printing.qrcode.button": "Print Labels",
"printing.qrcode.customUrl.caveat": "A custom URL won't scan back into Spoolman — use it to point a printed QR at an external app or a different host.",
"printing.qrcode.customUrl.fieldsHint": "Label fields work too, for example",
"printing.qrcode.customUrl.hint": "Insert the spool id anywhere with the placeholder",
"printing.qrcode.customUrl.label": "Custom URL template",
"printing.qrcode.useHTTPUrl.label": "QR code link",
@@ -427,6 +444,8 @@
"printing.qrcode.useHTTPUrl.tooltip": "Default encodes a Spoolman-only code that only Spoolman's own scanner can read. URL encodes a normal web link that any scanner can open, built from the base URL in settings — or this site's address if that's unset.",
"printing.spoolSelect.selectedTotal_one": "{{count}} spool selected",
"printing.spoolSelect.selectedTotal_other": "{{count}} spools selected",
"scanner.camera": "Camera",
"scanner.cameraNumbered": "Camera {{number}}",
"scanner.description": "Scan a Spoolman QR code to view details about the spool.",
"scanner.error.insecureContext": "The page is not served over HTTPS.",
"scanner.error.notAllowed": "You have not allowed access to the camera.",
@@ -437,6 +456,8 @@
"scanner.starting": "Starting camera...",
"scanner.title": "QR Code Scanner",
"search.colorHint": "Color names must be entered in English.",
"search.browseAll": "Browse all",
"search.hint": "Search spools, filaments and manufacturers by name, color, material or any field.",
"search.moreSpools": "+{{count}} more",
"search.noResults": "No matches found",
"search.searching": "Searching...",
@@ -512,7 +533,7 @@
"settings.scanner.docs": "How to set up a tag reader",
"settings.scanner.lastSeen": "{{when}} ago",
"settings.scanner.pair": "Pair by tapping",
"settings.scanner.reader.anyDesc": "Scans from any reader open spools in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
"settings.scanner.reader.anyDesc": "Scans from any reader open spools or filaments in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
"settings.scanner.reader.label": "Tag Reader",
"settings.scanner.reader.pairedDesc": "Only scans from this reader open spools in this web browser.",
"settings.scanner.recent": "Recent readers",
@@ -591,7 +612,7 @@
"tags.none": "No tags linked.",
"tags.scan.openedFilament": "Tag scanned: opened filament {{name}}.",
"tags.scan.openedSpool": "Tag scanned: opened spool #{{id}}, {{name}}.",
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool it belongs to and use Add tag to link it.",
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool or filament it belongs to and use Add tag to link it.",
"tags.section": "Tags",
"tags.unlink": "Unlink",
"tags.unlinkBody": "Tag {{uid}} will stop identifying this spool. Nothing is written to the tag itself, so you can link it again — to this spool or another one — whenever you like.",
@@ -616,6 +637,7 @@
"vendor.fields.id": "ID",
"vendor.fields.name": "Name",
"vendor.fields.registered": "Registered",
"vendor.showAsList": "Show as a list",
"vendor.fieldsHelp.emptySpoolWeight": "The weight of an empty spool from this manufacturer.",
"yes": "Yes"
}
+5 -2
View File
@@ -1,8 +1,11 @@
{
"add.addAndNew": "",
"add.addN_many": "Añadir {{count}} bobinas",
"add.addN_one": "Añadir {{count}} bobina",
"add.addN_other": "Añadir {{count}} bobinas",
"add.adding": "",
"add.advanced": "",
"add.advancedNote": "",
"add.advanced": "Especificaciones Avanzadas",
"add.advancedNote": "densidad, diámetro, temperaturas (autorrellenado desde material)",
"add.change": "",
"add.count": "",
"add.createNew": "",
+227 -155
View File
@@ -1,186 +1,257 @@
{
"add.addAndNew": "",
"add.adding": "",
"add.advanced": "",
"add.advancedNote": "",
"add.change": "",
"add.count": "",
"add.createNew": "",
"add.createNewSub": "",
"add.dbUnavailable": "",
"add.duplicate": "",
"add.duplicateNote": "",
"add.duplicateRename": "",
"add.duplicateTitle": "",
"add.filamentNamePlaceholder": "",
"add.fill.full": "",
"add.fillLevel": "",
"add.importNote": "",
"add.inCatalog": "",
"add.locationHint": "",
"add.locationPlaceholder": "",
"add.manufacturerPlaceholder": "",
"add.nameHint": "",
"add.newFilament": "",
"add.newFilamentTitle": "",
"add.noCatalog": "",
"add.noManufacturer": "",
"add.pickColor": "",
"add.searchPlaceholder": "",
"add.searching": "",
"add.spoolWeightPreset.cardboard": "",
"add.spoolWeightPreset.plastic": "",
"add.spoolWeightPresetsLead": "",
"add.step2": "",
"add.typeToSearchAll": "",
"add.typeToSearchMatches": "",
"add.useExisting": "",
"add.vendorHint.existing": "",
"add.vendorHint.new": "",
"add.vendorHint.optional": "",
"add.yourCatalog": "",
"add.addAndNew": "Toevoegen & nieuw",
"add.addN_one": "Voeg {{count}} spoel toe",
"add.addN_other": "Voeg {{count}} spoelen toe",
"add.adding": "Toevoegen...",
"add.advanced": "Geavanceerde specificaties",
"add.advancedNote": "dichtheid, diameter, temperaturen (automatisch ingevuld via het material)",
"add.change": "aanpassen",
"add.count": "Aantal",
"add.createNew": "Maak nieuw filament aan",
"add.createNewSub": "niet in je catalogus of {{name}}",
"add.dbUnavailable": "{{name}} niet beschikbaar",
"add.duplicate": "duplicaat",
"add.duplicateNote": "Specificaties, fabrikant en prijs zijn gekopieerd. Geef het een nieuwe naam en kleur.",
"add.duplicateRename": "Nog steeds dezelfde naam als het origineel — geef het een andere naam om ze van elkaar te kunnen onderscheiden.",
"add.duplicateTitle": "Nieuw filament · kopie van “{{name}}”",
"add.filamentNamePlaceholder": "bijv. PolyTerra Matte Sage",
"add.fill.full": "Vol",
"add.fillLevel": "Vul niveau",
"add.importNote": "Dit filament wordt aan je catalogus toegevoegd wanneer je de spoelen toevoegt.",
"add.inCatalog": "in catalogus",
"add.locationHint": "Waar je de spoel fysiek bewaart — op een plank, in een doos of in een printer. Laat dit veld leeg als je ze allemaal op één plek bewaart.",
"add.locationPlaceholder": "bijv. plank A, container 2, printer 1",
"add.manufacturerPlaceholder": "bijv. Polymaker",
"add.nameHint": "Vermeld de kleur — daarmee kun je de filamenten van elkaar onderscheiden.",
"add.newFilament": "nieuw filament",
"add.newFilamentTitle": "Nieuw filament",
"add.noCatalog": "Geen filamenten in je catalogus",
"add.noManufacturer": "Geen fabrikant",
"add.pickColor": "Kies kleur",
"add.searchPlaceholder": "Zoek je catalogus en {{name}}...",
"add.searching": "Zoeken...",
"add.section.filament": "Filament",
"add.section.filamentNote": "Het product op de spoel — een vermelding per kleur.",
"add.section.manufacturer": "Fabrikant",
"add.section.manufacturerNote": "Wie maakt het filament. Eenmalig toegevoegd en vervolgens voor al hun filamenten hergebruikt.",
"add.section.spool": "Spoel",
"add.section.spoolNote": "De fysieke spoelen die in je voorraad terechtkomen.",
"add.section.spoolSharedNote": "Gewicht, spoelgewicht en prijs worden ook bewaard als standaardinstelling voor het nieuwe filament.",
"add.spoolWeightPreset.cardboard": "Karton {{weight}} g",
"add.spoolWeightPreset.plastic": "Plastic {{weight}} g",
"add.spoolWeightPresetsLead": "Begin vanaf een typische spoel:",
"add.step2": "Stap 2 · spoel details",
"add.step2New": "Stap 2 · filament & spoel details",
"add.summary_one": "{{count}} spoel",
"add.summary_other": "{{count}} spoelen",
"add.typeToSearchAll": "Typ om duizenden filamenten te doorzoeken",
"add.typeToSearchMatches": "Typ om te zoeken — geen overeenkomsten",
"add.useExisting": "gebruik een bestaande",
"add.vendorHint.existing": "Gebruik een bestaande fabrikant \"{{name}}\"",
"add.vendorHint.new": "Nieuwe fabrikant \"{{name}}\" wordt aangemaakt",
"add.vendorHint.optional": "Optioneel — laat leeg voor geen fabrikant",
"add.weightPresets": "Algemene spoel gewichten",
"add.yourCatalog": "Je catalogus",
"buttons.apply": "Toepassen",
"buttons.archive": "Archiveren",
"buttons.cancel": "Annuleren",
"buttons.clear": "Wissen",
"buttons.close": "",
"buttons.close": "Sluiten",
"buttons.delete": "Verwijderen",
"buttons.edit": "Bewerken",
"buttons.filter": "Filter",
"buttons.save": "Opslaan",
"buttons.showArchived": "Gearchiveerd tonen",
"buttons.showNoSpools": "Laat filamenten zonder spoel zien",
"buttons.unArchive": "Terugzetten",
"common.decrement": "",
"common.increment": "",
"common.remove": "",
"common.search": "",
"dashboard.addFieldHint": "",
"dashboard.dashboard": "",
"dashboard.deleteGroup": "",
"dashboard.dragHint": "",
"dashboard.dropHere": "",
"dashboard.emptyHelp": "",
"dashboard.errorEmptyName": "",
"dashboard.errorExists": "",
"dashboard.errorRename": "",
"dashboard.lastUsed": "",
"dashboard.newGroup": "",
"dashboard.newGroupName": "",
"dashboard.reorderGroup": "",
"dashboard.unassigned": "",
"datetime.done": "",
"datetime.hour": "",
"datetime.minute": "",
"datetime.nextMonth": "",
"datetime.now": "",
"datetime.pick": "",
"datetime.prevMonth": "",
"datetime.time": "",
"documentTitle.dashboard.list": "",
"documentTitle.labels.list": "",
"documentTitle.library.list": "",
"changeFilament.action": "Aanpassen",
"changeFilament.adoptWeight": "Stel het volledige gewicht van deze spoel in op {{full}}",
"changeFilament.adoptWeightHelp": "Het wordt geregistreerd als {{current}}, op basis van het filament dat het voorheen bevatte. Gebruikt gewicht ({{used}}) blijft hoe dan ook behouden, waardoor {{remaining}} overblijft.",
"changeFilament.apply": "Pas filament aan",
"changeFilament.applying": "Aanpassend...",
"changeFilament.current": "huidig",
"changeFilament.done": "Filament aangepast.",
"changeFilament.failed": "Kon het filament niet veranderen.",
"changeFilament.importNote": "Dit filament wordt aan je catalogus toegevoegd wanneer je de wijziging toepast.",
"changeFilament.intro": "Spoel #{{id}} behoudt zijn nummer, locatie, data en gebruiksgeschiedenis — alleen het filament dat erin zit, verandert.",
"changeFilament.newTag": "nieuw",
"changeFilament.next": "Verander naar",
"changeFilament.now": "Huidig",
"changeFilament.pickBelow": "Kies een filament hieronder",
"changeFilament.title": "Verander filament",
"changeFilament.useExisting": "Kies een bestaand filament",
"changeFilament.weightFollows": "Deze spoel heeft zelf geen volledig gewicht, dus er is nieuw filament nodig: {{full}}, waardoor {{remaining}} overblijft na de {{used}} die al is gebruikt.",
"changeVendor.action": "Aanpassen",
"changeVendor.apply": "Pas fabrikant aan",
"changeVendor.applying": "Aanpassend...",
"changeVendor.clearHint": "Verlaat dit filament zonder fabrikant",
"changeVendor.create": "Maak \"{{name}}\" aan",
"changeVendor.createHint": "Deze fabrikant bestaat nog niet in je lijst — het zal worden toegevoegd",
"changeVendor.current": "huidig",
"changeVendor.done": "Fabrikant gewijzigd.",
"changeVendor.emptySpool": "Lege spoel {{value}}",
"changeVendor.failed": "Kon de fabrikant niet aanpassen.",
"changeVendor.intro": "“{{name}}” behoudt zijn spoelen, specificaties en geschiedenis — alleen de fabrikant waaronder het is bewaard wijzigt.",
"changeVendor.next": "Verander naar",
"changeVendor.noEmptySpool": "Geen leeg spoel gewicht",
"changeVendor.noMatches": "Kan fabrikant niet vinden",
"changeVendor.none": "Nog geen fabrikant",
"changeVendor.noneNote": "Opgeslagen zonder merk",
"changeVendor.now": "Huidig",
"changeVendor.pickBelow": "Kies een fabrikant hieronder",
"changeVendor.searchPlaceholder": "Zoek of noem een fabrikant",
"changeVendor.tareFollows": "Dit filament heeft geen eigen leeg spoelgewicht, dus het is van de fabrikant. Na de verandering is dat {{value}}, wat een lege spoel weegt als je het meet.",
"changeVendor.tareGone": "Dit filament heeft zelf geen leeg spoelgewicht en neemt de {{current}} van de fabrikant over. Na de aanpassing is er niks om in te vullen, totdat je het filament toevoegt.",
"changeVendor.title": "Pas fabrikant aan",
"changeVendor.willBeCreated": "Wordt aangemaakt",
"common.decrement": "Verlagen",
"common.increment": "Verhogen",
"common.remove": "Verwijder",
"common.search": "Zoek",
"dashboard.addFieldHint": "Voeg een tekst- of keuzeveld toe aan de spoelen in Instellingen om er ook op te groeperen",
"dashboard.dashboard": "Dashboard",
"dashboard.deleteGroup": "Verwijder groep",
"dashboard.dragHint": "Sleep een spoel naar een andere kaart om het {{field}} te wijzigen, of binnen een kaart om opnieuw te bestellen",
"dashboard.dropHere": "laat spoelen hier los",
"dashboard.emptyHelp": "Deze pagina groepeert je spoelen op een veld naar keuze, voeg wat spoelen toe om aan de slag te gaan!",
"dashboard.errorEmptyName": "Naam mag niet leeg zijn",
"dashboard.errorExists": "Een groep met die naam bestaat al",
"dashboard.errorRename": "Hernoemen van groep is mislukt",
"dashboard.lastUsed": "Laatst gebruikt {{time}} geleden",
"dashboard.newGroup": "Nieuwe groep",
"dashboard.newGroupName": "Nieuwe groep {{n}}",
"dashboard.reorderGroup": "Sleep om de groep te herordenen",
"dashboard.spoolCount_one": "{{count}} spoel",
"dashboard.spoolCount_other": "{{count}} spoelen",
"dashboard.unassigned": "Niet toegewezen",
"datetime.done": "Klaar",
"datetime.hour": "uur",
"datetime.minute": "minuut",
"datetime.nextMonth": "Volgende maand",
"datetime.now": "Nu",
"datetime.pick": "Kies datum en tijd",
"datetime.prevMonth": "Vorige maand",
"datetime.time": "Tijd",
"documentTitle.dashboard.list": "Dashboard | Spoolman",
"documentTitle.labels.list": "Labels | Spoolman",
"documentTitle.library.list": "Bibliotheek | Spoolman",
"documentTitle.locations.list": "Locaties | Spoolman",
"documentTitle.settings.list": "",
"filament.fields.addColor": "",
"documentTitle.settings.list": "Instellingen | Spoolman",
"filament.fields.addColor": "Voeg kleur toe",
"filament.fields.articleNumber": "Artikel nummer",
"filament.fields.coaxial": "Gecoëxtrudeerde",
"filament.fields.coaxial": "Gecoëxtrudeerd",
"filament.fields.colorHex": "Kleur",
"filament.fields.colorType": "",
"filament.fields.colorType": "Kleurtype",
"filament.fields.comment": "Opmerking",
"filament.fields.density": "Dichtheid",
"filament.fields.diameter": "Diameter",
"filament.fields.externalId": "Extern ID",
"filament.fields.externalId": "Externe ID",
"filament.fields.id": "ID",
"filament.fields.longitudinal": "Longitudinaal",
"filament.fields.material": "Materiaal",
"filament.fields.multiColor": "Meervoudig",
"filament.fields.name": "Naam",
"filament.fields.opacity": "Ondoorzichtigheid",
"filament.fields.price": "Prijs",
"filament.fields.registered": "Geregistreerd",
"filament.fields.removeColor": "",
"filament.fields.settingsBedTemp": "Bed temperatuur",
"filament.fields.removeColor": "Verwijder kleur",
"filament.fields.settingsBedTemp": "Bed Temperatuur",
"filament.fields.settingsExtruderTemp": "Extruder Temperatuur",
"filament.fields.singleColor": "Enkel",
"filament.fields.spoolWeight": "Gewicht spoel",
"filament.fields.spoolWeight": "Gewicht Spoel",
"filament.fields.vendor": "Fabrikant",
"filament.fields.weight": "Gewicht",
"filament.fieldsHelp.articleNumber": "Bv. EAN. UPC, enz.",
"filament.fieldsHelp.multiColorDirection": "Filamenten kunnen op twee manieren meerdere kleuren hebben: door coëxtrusie, zoals tweekleurige filamenten met consistente meerkleuren, of door kleurveranderingen in de lengterichting, zoals verloopfilamenten die van kleur veranderen langs de spoel.",
"filament.fieldsHelp.spoolWeight": "Het gewicht van een lege spoel. Wordt gebruikt om het gemeten gewicht van een spoel te bepalen.",
"filament.fieldsHelp.articleNumber": "Bijv. EAN, UPC, enz.",
"filament.fieldsHelp.multiColorDirection": "Filamenten kunnen op twee manieren meerdere kleuren hebben: door coëxtrusie, zoals tweekleurige filamenten met consistente meerkleurigheid, of door kleurveranderingen in de lengterichting, zoals verloopfilamenten die van kleur veranderen langs de spoel.",
"filament.fieldsHelp.spoolWeight": "Het gewicht van een lege spoel. Wordt gebruikt om het gemeten gewicht van een spoel te bepalen. Onzeker? Kartonnen spoelen wegen meestal 130–150 g en plastic spoelen 180–250 g — voor een exact aantal, zet de spoel op een weegschaal zodra deze leeg is.",
"filament.fieldsHelp.weight": "Het filamentgewicht van een volle spoel (nettogewicht). Dit mag niet het gewicht van de spoel zelf omvatten, alleen het filament. Dit staat meestal op de verpakking.",
"filament.filament": "Filamenten",
"footer.documentation": "",
"footer.reportIssue": "",
"footer.documentation": "Documentatie",
"footer.reportIssue": "Meld een probleem",
"footer.updateAvailable": "v{{version}} beschikbaar",
"help.help": "Help",
"inspector.addSpoolsOfThis": "",
"inspector.adjustFailed": "",
"inspector.adjustHelp": "",
"inspector.adjustWeight": "",
"inspector.apply": "",
"inspector.applying": "",
"inspector.colorHex": "",
"inspector.consumeAmount": "",
"inspector.delete.deleting": "",
"inspector.delete.errorGone": "",
"inspector.delete.errorSpoolsExist": "",
"inspector.delete.errorUnknown": "",
"inspector.delete.filament": "",
"inspector.delete.filamentBlockedHint": "",
"inspector.delete.filamentBlockedTitle": "",
"inspector.delete.filamentBody": "",
"inspector.delete.filamentDone": "",
"inspector.delete.filamentTitle": "",
"inspector.delete.spool": "",
"inspector.delete.spoolBody": "",
"inspector.delete.spoolDone": "",
"inspector.delete.spoolRemaining": "",
"inspector.delete.spoolTitle": "",
"inspector.delete.vendor": "",
"inspector.delete.vendorBody": "",
"inspector.delete.vendorDone": "",
"inspector.delete.vendorTitle": "",
"inspector.duplicateFilament": "",
"inspector.empty": "",
"inspector.enterValidLength": "",
"inspector.enterValidWeight": "",
"inspector.loading": "",
"inspector.manageExtraFields": "",
"inspector.measuredHelp": "",
"inspector.multi": "",
"inspector.newGross": "",
"inspector.noExtraFields": "",
"inspector.notFound": "",
"inspector.ofRemaining": "",
"inspector.openFilament": "",
"inspector.openLink": "",
"inspector.openManufacturer": "",
"inspector.specs": "",
"inspector.spoolsCount": "",
"inspector.spoolsTotalRemaining": "",
"inspector.used": "",
"inspector.addSpoolsOfThis": "Voeg spoelen hiervan toe",
"inspector.adjustFailed": "Aanpassen van gewicht is mislukt.",
"inspector.adjustHelp": "Positief verbruikt filament, negatief voegt het terug.",
"inspector.adjustWeight": "Pas gewicht aan",
"inspector.apply": "Toepassen",
"inspector.applying": "Toepassen...",
"inspector.colorHex": "Hex kleur",
"inspector.consumeAmount": "Verbruikte hoeveelheid:",
"inspector.defaultFrom.filament": "{{value}} · standaard filament",
"inspector.defaultFrom.manufacturer": "{{value}} · standaard fabrikant",
"inspector.delete.deleting": "Verwijderen...",
"inspector.delete.errorGone": "Het was al verwijderd.",
"inspector.delete.errorSpoolsExist": "Kan dit filament niet verwijderen — spoelen gebruiken het nog steeds.",
"inspector.delete.errorUnknown": "Verwijderen mislukt. Zie de server logs voor details.",
"inspector.delete.filament": "Verwijder filament",
"inspector.delete.filamentBlockedHint": "Verwijder die spoelen eerst. Het archiveren ervan is niet voldoende.",
"inspector.delete.filamentBlockedTitle": "Dit filament kan niet verwijderd woden",
"inspector.delete.filamentBlocked_one": "{{count}} spoel gebruikt nog steeds “{{name}}”, inclusief de gearchiveerde.",
"inspector.delete.filamentBlocked_other": "{{count}} spoelen gebruiken nog steeds “{{name}}”, inclusief de gearchiveerde.",
"inspector.delete.filamentBody": "“{{name}}” wordt uit je catalogus verwijderd. Dit kan niet ongedaan worden gemaakt.",
"inspector.delete.filamentDone": "Filament verwijderd.",
"inspector.delete.filamentTitle": "Verwijder dit filament?",
"inspector.delete.spool": "Verwijder spoel",
"inspector.delete.spoolBody": "Spoel #{{id}} van “{{name}}” wordt verwijderd, samen met de gebruiksgeschiedenis. Dit kan niet ongedaan worden gemaakt.",
"inspector.delete.spoolDone": "Spoel verwijderd.",
"inspector.delete.spoolRemaining": "Er is nog {{weight}} filament over. Archiveer het in plaats daarvan als je het alleen maar wilt opruimen.",
"inspector.delete.spoolTitle": "Verwijder deze spoel?",
"inspector.delete.vendor": "Verwijder fabrikant",
"inspector.delete.vendorBody": "“{{name}}” wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
"inspector.delete.vendorDone": "Fabrikant verwijderd.",
"inspector.delete.vendorOrphans_one": "Het filament {{count}} wordt bewaard, maar blijft zonder fabrikant achter.",
"inspector.delete.vendorOrphans_other": "De {{count}} filamenten worden bewaard, maar blijven zonder fabrikant achter.",
"inspector.delete.vendorTitle": "Verwijder deze fabrikant?",
"inspector.duplicateFilament": "Dupliceer",
"inspector.empty": "Selecteer een spoel, filament of fabrikant uit de lijst om de details hier te zien.",
"inspector.enterValidLength": "Voer een geldige lengte in.",
"inspector.enterValidWeight": "Voer een geldig gewicht in.",
"inspector.loading": "Laden...",
"inspector.manageExtraFields": "Velden beheren",
"inspector.measuredHelp": "Totaal gewicht van de spoel + overblijvend filament, afgelezen van een weegschaal.",
"inspector.multi": "multi ×{{count}}",
"inspector.newGross": "Nieuw brutogewicht:",
"inspector.noExtraFields": "Nog geen — voeg je eigen velden toe om iets bij te houden wat Spoolman niet zelf doet.",
"inspector.notFound": "Dit item kon niet worden gevonden. Mogelijk is het verwijderd.",
"inspector.ofRemaining": "van {{weight}} resterende · {{length}} m",
"inspector.openFilament": "Open filament",
"inspector.openLink": "Open {{url}} in een nieuw tabblad",
"inspector.openManufacturer": "Open fabrikant",
"inspector.override.byFilament": "Overschreven door dit filament · {{value}}",
"inspector.override.bySpool": "Overschreven door deze spoel · {{value}}",
"inspector.override.overridesFilament": "Overschrijft filament · {{value}}",
"inspector.override.overridesManufacturer": "Overschrijft fabrikant · {{value}}",
"inspector.specs": "Specs — bewerken op zijn plaats",
"inspector.spoolsCount": "Spoelen · {{count}}",
"inspector.spoolsTotalRemaining": "{{weight}} totaal",
"inspector.used": "gebruikt",
"inspector.vendorSub_one": "{{count}} filament",
"inspector.vendorSub_other": "{{count}} filamenten",
"kofi": "Tip mij op Ko-fi",
"labels.addHead": "",
"labels.addQr": "",
"labels.addRect": "",
"labels.addSwatch": "",
"labels.addText": "",
"labels.align": "",
"labels.alignCenter": "",
"labels.alignLeft": "",
"labels.alignRight": "",
"labels.blank": "",
"labels.bold": "",
"labels.canvasHint": "",
"labels.centerLogo": "",
"labels.chooseField": "",
"labels.clearFill": "",
"labels.copies": "",
"labels.cornerRadiusMm": "",
"labels.createFirst": "",
"labels.cutGuides": "",
"labels.deleteConfirm": "",
"labels.deleteElement": "",
"labels.designName": "",
"labels.designerDesc": "",
"labels.addHead": "Voeg toe",
"labels.addQr": "QR code",
"labels.addRect": "Rechthoek",
"labels.addSwatch": "Kleurstaal",
"labels.addText": "Tekst / veld",
"labels.align": "Uitlijnen",
"labels.alignCenter": "Centreren",
"labels.alignLeft": "Links",
"labels.alignRight": "Rechts",
"labels.blank": "Je hebt nog geen labelontwerpen.",
"labels.bold": "Vet",
"labels.canvasHint": "Sleep om te verplaatsen · klik om te selecteren · handvatten om de grootte te wijzigen.",
"labels.centerLogo": "Centreer Spoolman logo",
"labels.chooseField": "Kies een veld...",
"labels.clearFill": "Leeg vulling (alleen outline)",
"labels.copies": "Kopieën",
"labels.cornerRadiusMm": "Hoekradius",
"labels.createFirst": "Maak je eerste label",
"labels.cutGuides": "Snij gids",
"labels.deleteConfirm": "Verwijder dit label ontwerp?",
"labels.deleteElement": "Verwijder element",
"labels.designName": "Ontwerp naam",
"labels.designerDesc": "Ontwerp QR-codelabels met elk spoel-, filament- of leveranciersveld en print ze vervolgens op vellen of een labelprinter. Ontwerpen worden opgeslagen op de server.",
"labels.designerTitle": "",
"labels.dpi": "",
"labels.dpiHint": "",
@@ -232,7 +303,7 @@
"labels.selectAllShort": "",
"labels.selectAtLeastOne": "",
"labels.selectAtLeastOneFilament": "",
"labels.selectDesign": "",
"labels.selectDesign": "Selecteer labelontwerp",
"labels.selectElement": "",
"labels.selectNoneShort": "",
"labels.selectRecentHint": "",
@@ -368,7 +439,7 @@
"settings.extraFields.addEntityField": "",
"settings.extraFields.booleanFalse": "Nee",
"settings.extraFields.booleanTrue": "Ja",
"settings.extraFields.deleteConfirm": "Veld {{name}} verwijderen?",
"settings.extraFields.deleteConfirm": "Veld {{name}} van {{entity}} verwijderen?",
"settings.extraFields.description.constraints": "Zodra een veld is toegevoegd, kunt u de key of het type ervan niet wijzigen, en voor velden met keuzetypes kunt u geen keuzes verwijderen of de meervoudige velden wijzigen. Als u een veld verwijdert, worden de bijbehorende gegevens voor alle entiteiten verwijderd.",
"settings.extraFields.description.intro": "Hier kunt u extra aangepaste velden toevoegen aan uw entiteiten.",
"settings.extraFields.description.keyUsage": "De key is hoe andere programma's de gegevens lezen/schrijven, dus of uw aangepaste veld moet worden geïntegreerd met een derde partij programma, zorg ervoor dat u het correct instelt. De standaardwaarde wordt alleen toegepast op nieuwe items.",
@@ -399,7 +470,7 @@
"settings.extraFields.params.choices": "Keuzes",
"settings.extraFields.params.defaultValue": "Standaardwaarde",
"settings.extraFields.params.fieldType": "Type",
"settings.extraFields.params.key": "Key",
"settings.extraFields.params.key": "Sleutel",
"settings.extraFields.params.name": "Naam",
"settings.extraFields.params.order": "Volgorde",
"settings.extraFields.params.unit": "Eenheid",
@@ -418,6 +489,7 @@
"settings.library.lowThreshold.desc": "",
"settings.library.lowThreshold.label": "",
"settings.library.tab": "",
"sponsor": "Steun me op GitHub",
"spool.fields.archived": "Gearchiveerd",
"spool.fields.comment": "Opmerking",
"spool.fields.filament": "Filament",
+31 -7
View File
@@ -64,6 +64,7 @@
"buttons.filter": "Filtruj",
"buttons.save": "Zapisz",
"buttons.showArchived": "Pokaż zarchiwizowane",
"buttons.showLotNumbers": "Pokaż numery partii",
"buttons.showNoSpools": "Pokaż filamenty bez szpul",
"buttons.unArchive": "Przywróć",
"changeFilament.action": "Zmień",
@@ -119,7 +120,7 @@
"dashboard.errorEmptyName": "Nazwa nie może być pusta",
"dashboard.errorExists": "Grupa o tej nazwie już istnieje",
"dashboard.errorRename": "Nie udało się zmienić nazwy grupy",
"dashboard.lastUsed": "Ostatnio użyta {{time}} temu",
"dashboard.lastUsed": "Ostatnio użyto {{time}} temu",
"dashboard.newGroup": "Nowa grupa",
"dashboard.newGroupName": "Nowa grupa {{n}}",
"dashboard.reorderGroup": "Przeciągnij, aby zmienić kolejność grupy",
@@ -171,6 +172,7 @@
"filament.filament": "Filamenty",
"footer.documentation": "Dokumentacja",
"footer.reportIssue": "Zgłoś problem",
"footer.updateAvailable": "Nowa wersja: v{{version}}",
"help.help": "Pomoc",
"inspector.addSpoolsOfThis": "Dodaj szpule tego filamentu",
"inspector.adjustFailed": "Nie udało się skorygować wagi.",
@@ -349,6 +351,20 @@
"labels.xMm": "X",
"labels.yMm": "Y",
"library.apiError": "Nie udało się połączyć z API Spoolmana. Czy backend działa?",
"library.catalog.allFilaments": "Wszystkie filamenty",
"library.catalog.allManufacturers": "Wszyscy producenci",
"library.catalog.backToSpools": "Cofnij do szpul",
"library.catalog.filamentCount_few": "{{count}} filamenty",
"library.catalog.filamentCount_many": "{{count}} filamentów",
"library.catalog.filamentCount_one": "{{count}} filament",
"library.catalog.manufacturerCount_few": "{{count}} producenci",
"library.catalog.manufacturerCount_many": "{{count}} producentów",
"library.catalog.manufacturerCount_one": "{{count}} producent",
"library.catalog.noManufacturer": "Brak producenta",
"library.catalog.notSet": "Nie ustawiono",
"library.catalog.switchList": "Przełącz listę",
"library.catalog.unitFilaments": "filamenty",
"library.catalog.unitManufacturers": "producenci",
"library.collapseGroup": "Ukryj te szpule",
"library.dateFilter.ago": "{{span}} temu",
"library.dateFilter.between": "{{from}} – {{to}}",
@@ -370,7 +386,7 @@
"library.groupSpools_few": "{{count}} szpule",
"library.groupSpools_many": "{{count}} szpul",
"library.groupSpools_one": "{{count}} szpula",
"library.nUnused": "×{{count}} nieużywane",
"library.nUnused": "×{{count}} nierozpoczętych",
"library.noLocation": "brak lokalizacji",
"library.noSpools": "Brak szpul",
"library.noValues": "Brak wartości",
@@ -388,13 +404,15 @@
"library.unassigned": "nieprzypisane",
"library.unitGroups": "grupy",
"library.unitSpools": "szpule",
"library.unused": "nieużywane",
"library.unused": "nieużyta",
"library.unusedGroup": "{{count}} nieużywanych · po {{weight}} g",
"library.unusedGroupMixed": "{{count}} nieużywanych",
"library.unusedSub": "po {{weight}} g · {{location}}",
"library.unusedSubMixed": "{{location}}",
"library.used": "używane",
"library.usedAgo": "użyta {{time}} temu",
"library.used": "użyta",
"library.usedAgo": "użyto {{time}} temu",
"library.viewMenu.browse": "Przeglądaj",
"library.views": "Widoki biblioteki",
"loading": "Wczytywanie",
"locations.locations": "Lokalizacje",
"locations.newLocation": "Nowa lokalizacja",
@@ -434,6 +452,7 @@
"printing.generic.verticalSpacing": "Odstęp pionowy",
"printing.qrcode.button": "Drukuj etykiety",
"printing.qrcode.customUrl.caveat": "Własny URL nie wróci skanem do Spoolmana — użyj go, żeby kod QR otwierał zewnętrzną aplikację albo inny host.",
"printing.qrcode.customUrl.fieldsHint": "Pola etykiet również działają, na przykład",
"printing.qrcode.customUrl.hint": "Wstaw identyfikator szpuli w dowolnym miejscu za pomocą placeholdera",
"printing.qrcode.customUrl.label": "Własny szablon URL",
"printing.qrcode.useHTTPUrl.label": "Link w kodzie QR",
@@ -445,6 +464,8 @@
"printing.spoolSelect.selectedTotal_few": "Wybrano {{count}} szpule",
"printing.spoolSelect.selectedTotal_many": "Wybrano {{count}} szpul",
"printing.spoolSelect.selectedTotal_one": "Wybrano {{count}} szpulę",
"scanner.camera": "Kamera",
"scanner.cameraNumbered": "Kamera {{number}}",
"scanner.description": "Zeskanuj kod QR Spoolmana, aby zobaczyć szczegóły szpuli.",
"scanner.error.inUse": "Kamera jest już używana przez inną aplikację.",
"scanner.error.insecureContext": "Strona nie jest serwowana przez HTTPS.",
@@ -454,7 +475,9 @@
"scanner.error.unknown": "Wystąpił nieznany błąd. ({{error}})",
"scanner.starting": "Uruchamianie kamery...",
"scanner.title": "Skaner kodów QR",
"search.browseAll": "Przeglądaj wszystko",
"search.colorHint": "Nazwy kolorów trzeba wpisywać po angielsku.",
"search.hint": "Wyszukuj szpule, filamenty i producentów według nazwy, koloru, materiału lub dowolnego pola.",
"search.moreSpools": "+{{count}} więcej",
"search.noResults": "Brak wyników",
"search.searching": "Szukanie...",
@@ -530,7 +553,7 @@
"settings.scanner.docs": "Jak skonfigurować czytnik tagów",
"settings.scanner.lastSeen": "{{when}} temu",
"settings.scanner.pair": "Sparuj przez przyłożenie tagu",
"settings.scanner.reader.anyDesc": "Skanowanie z dowolnego czytnika otwiera szpule w tej przeglądarce. Możesz sparować konkretny czytnik, jeśli chcesz mieć większą kontrolę. Jest to również czytnik używany do dodawania nowych tagów do szpul i filamentów.",
"settings.scanner.reader.anyDesc": "Skanowanie z dowolnego czytnika otwiera szpule lub filamenty w tej przeglądarce. Możesz sparować konkretny czytnik, aby uzyskać większą kontrolę. Ten czytnik będzie również używany do dodawania nowych tagów do szpul i filamentów.",
"settings.scanner.reader.label": "Czytnik tagów",
"settings.scanner.reader.pairedDesc": "Tylko odczyty z tego czytnika otwierają szpule w tej przeglądarce.",
"settings.scanner.recent": "Ostatnio używane czytniki",
@@ -609,7 +632,7 @@
"tags.none": "Brak połączonych tagów.",
"tags.scan.openedFilament": "Zeskanowano tag: otwarto filament {{name}}.",
"tags.scan.openedSpool": "Zeskanowano tag: otwarto szpulę nr {{id}}, {{name}}.",
"tags.scan.unknown": "Nieznany tag {{uid}}. Otwórz szpulę, do której należy, i użyj opcji „Dodaj tag”, aby go połączyć.",
"tags.scan.unknown": "Nieznany tag {{uid}}. Otwórz szpulę lub filament, do którego należy, i użyj opcji „Dodaj tag”, aby go przypisać.",
"tags.section": "Tagi",
"tags.unlink": "Odłącz",
"tags.unlinkBody": "Tag {{uid}} przestanie identyfikować tę szpulę. Na samym tagu nie są zapisywane żadne dane, więc możesz go ponownie połączyć z tą lub inną szpulą, kiedy tylko zechcesz.",
@@ -635,5 +658,6 @@
"vendor.fields.name": "Nazwa",
"vendor.fields.registered": "Zarejestrowano",
"vendor.fieldsHelp.emptySpoolWeight": "Waga pustej szpuli tego producenta.",
"vendor.showAsList": "Pokaż jako listę",
"yes": "Tak"
}
+465 -316
View File
@@ -1,90 +1,141 @@
{
"add.addAndNew": "",
"add.adding": "",
"add.advanced": "",
"add.advancedNote": "",
"add.change": "",
"add.count": "",
"add.createNew": "",
"add.createNewSub": "",
"add.dbUnavailable": "",
"add.duplicate": "",
"add.duplicateNote": "",
"add.duplicateRename": "",
"add.duplicateTitle": "",
"add.filamentNamePlaceholder": "",
"add.fill.full": "",
"add.fillLevel": "",
"add.importNote": "",
"add.inCatalog": "",
"add.locationHint": "",
"add.locationPlaceholder": "",
"add.manufacturerPlaceholder": "",
"add.nameHint": "",
"add.newFilament": "",
"add.newFilamentTitle": "",
"add.noCatalog": "",
"add.noManufacturer": "",
"add.pickColor": "",
"add.searchPlaceholder": "",
"add.searching": "",
"add.spoolWeightPreset.cardboard": "",
"add.spoolWeightPreset.plastic": "",
"add.spoolWeightPresetsLead": "",
"add.step2": "",
"add.typeToSearchAll": "",
"add.typeToSearchMatches": "",
"add.useExisting": "",
"add.vendorHint.existing": "",
"add.vendorHint.new": "",
"add.vendorHint.optional": "",
"add.yourCatalog": "",
"add.addAndNew": "Dodaj in novo",
"add.adding": "Dodajanje ...",
"add.advanced": "Napredne specifikacije",
"add.advancedNote": "gostota, premer, temperature (samodejno izpolnjeno iz materiala)",
"add.change": "spremeni",
"add.count": "Število",
"add.createNew": "Ustvari nov filament",
"add.createNewSub": "ni v vašem katalogu ali {{name}}",
"add.dbUnavailable": "{{name}} ni na voljo",
"add.duplicate": "podvoji",
"add.duplicateNote": "Specifikacije, proizvajalec in cena so bili prekopirani. Določite novo ime in barvo.",
"add.duplicateRename": "Še vedno ima isto ime kot izvirnik — preimenujte ga, da ju boste razlikovali.",
"add.duplicateTitle": "Nov filament · kopija „{{name}}“",
"add.filamentNamePlaceholder": "npr. PolyTerra Matte Sage",
"add.fill.full": "Polno",
"add.fillLevel": "Raven napolnjenosti",
"add.importNote": "Ta filament bo dodan v vaš katalog, ko dodate kolute.",
"add.inCatalog": "v katalogu",
"add.locationHint": "Kjer fizično hranite kolut — polica, škatla ali tiskalnik. Pustite prazno, če jih vse hranite na istem mestu.",
"add.locationPlaceholder": "npr. Polica A, suha škatla 2, tiskalnik 1",
"add.manufacturerPlaceholder": "npr. Polymaker",
"add.nameHint": "Vključite barvo — po njej boste ločili svoje filamente.",
"add.newFilament": "nov filament",
"add.newFilamentTitle": "Nov filament",
"add.noCatalog": "V vašem katalogu ni filamentov",
"add.noManufacturer": "Ni proizvajalca",
"add.pickColor": "Izberi barvo",
"add.searchPlaceholder": "Iskanje po vašem katalogu in {{name}} ...",
"add.searching": "Iskanje ...",
"add.section.filament": "Filament",
"add.section.filamentNote": "Izdelek na kolutu – en vnos na barvo.",
"add.section.manufacturer": "Proizvajalec",
"add.section.manufacturerNote": "Kdo izdeluje filament. Doda se enkrat, nato pa uporabi za vse njihove filamente.",
"add.section.spool": "Kolut",
"add.section.spoolNote": "Fizični koluti, ki gredo v vaš inventar.",
"add.section.spoolSharedNote": "Teža, teža koluta in cena se shranijo tudi kot privzete vrednosti novega filamenta.",
"add.spoolWeightPreset.cardboard": "Karton {{weight}} g",
"add.spoolWeightPreset.plastic": "Plastika {{weight}} g",
"add.spoolWeightPresetsLead": "Začnite s tipičnim kolutom:",
"add.step2": "2. korak · podrobnosti o kolutu",
"add.step2New": "2. korak · podrobnosti o filamentu in kolutu",
"add.typeToSearchAll": "Tipkajte za iskanje med tisoči filamenti",
"add.typeToSearchMatches": "Tipkajte za iskanje — ni zadetkov",
"add.useExisting": "uporabi obstoječega",
"add.vendorHint.existing": "Uporaba obstoječega proizvajalca „{{name}}“",
"add.vendorHint.new": "Ustvarjen bo nov proizvajalec „{{name}}“",
"add.vendorHint.optional": "Izbirno — pustite prazno, če ni proizvajalca",
"add.weightPresets": "Običajne velikosti zvitkov",
"add.yourCatalog": "Vaš katalog",
"buttons.apply": "Uporabi",
"buttons.archive": "Arhiviraj",
"buttons.cancel": "Prekliči",
"buttons.clear": "Počisti",
"buttons.close": "",
"buttons.close": "Zapri",
"buttons.delete": "Izbriši",
"buttons.edit": "Uredi",
"buttons.filter": "Filter",
"buttons.save": "Shrani",
"buttons.showArchived": "Prikaži arhiv",
"buttons.showNoSpools": "Prikaži filamente brez kolutov",
"buttons.unArchive": "Odstrani iz arhiva",
"common.decrement": "",
"common.increment": "",
"common.remove": "",
"common.search": "",
"dashboard.addFieldHint": "",
"dashboard.dashboard": "",
"dashboard.deleteGroup": "",
"dashboard.dragHint": "",
"dashboard.dropHere": "",
"dashboard.emptyHelp": "",
"dashboard.errorEmptyName": "",
"dashboard.errorExists": "",
"dashboard.errorRename": "",
"dashboard.lastUsed": "",
"dashboard.newGroup": "",
"dashboard.newGroupName": "",
"dashboard.reorderGroup": "",
"dashboard.unassigned": "",
"datetime.done": "",
"datetime.hour": "",
"datetime.minute": "",
"datetime.nextMonth": "",
"datetime.now": "",
"datetime.pick": "",
"datetime.prevMonth": "",
"datetime.time": "",
"documentTitle.dashboard.list": "",
"documentTitle.labels.list": "",
"documentTitle.library.list": "",
"changeFilament.action": "Spremeni",
"changeFilament.adoptWeight": "Nastavi polno težo tega koluta na {{full}}",
"changeFilament.adoptWeightHelp": "Zabeležen je kot {{current}} na podlagi filamenta, ki je bil na njem prej. Porabljena teža ({{used}}) se ohrani v obeh primerih, kar pomeni {{remaining}} preostanka.",
"changeFilament.apply": "Spremeni filament",
"changeFilament.applying": "Spreminjanje ...",
"changeFilament.current": "trenutno",
"changeFilament.done": "Filament je spremenjen.",
"changeFilament.failed": "Filamenta ni bilo mogoče spremeniti.",
"changeFilament.importNote": "Ta filament bo dodan v vaš katalog, ko uveljavite spremembo.",
"changeFilament.intro": "Kolut št. {{id}} ohrani svojo številko, lokacijo, datume in zgodovino uporabe – spremeni se le filament na njem.",
"changeFilament.newTag": "novo",
"changeFilament.next": "Spremeni v",
"changeFilament.now": "Trenutno",
"changeFilament.pickBelow": "Spodaj izberite filament",
"changeFilament.title": "Spremeni filament",
"changeFilament.useExisting": "Izberite obstoječ filament",
"changeFilament.weightFollows": "Ta kolut nima lastne polne teže, zato prevzame težo novega filamenta: {{full}}, kar po že porabljenih {{used}} pusti {{remaining}}.",
"changeVendor.action": "Spremeni",
"changeVendor.apply": "Spremeni proizvajalca",
"changeVendor.applying": "Spreminjanje ...",
"changeVendor.clearHint": "Pusti ta filament brez proizvajalca",
"changeVendor.create": "Ustvari „{{name}}“",
"changeVendor.createHint": "Še ni med vašimi proizvajalci – dodan bo",
"changeVendor.current": "trenutno",
"changeVendor.done": "Proizvajalec je spremenjen.",
"changeVendor.emptySpool": "Prazen kolut {{value}}",
"changeVendor.failed": "Proizvajalca ni bilo mogoče spremeniti.",
"changeVendor.intro": "„{{name}}“ ohrani svoje kolute, specifikacije in zgodovino – spremeni se le proizvajalec, pod katerega je uvrščen.",
"changeVendor.next": "Spremeni v",
"changeVendor.noEmptySpool": "Ni teže praznega koluta",
"changeVendor.noMatches": "Noben proizvajalec se ne ujema",
"changeVendor.none": "Ni še proizvajalcev",
"changeVendor.noneNote": "Brez blagovne znamke",
"changeVendor.now": "Trenutno",
"changeVendor.pickBelow": "Spodaj izberite proizvajalca",
"changeVendor.searchPlaceholder": "Iščite ali poimenujte proizvajalca",
"changeVendor.tareFollows": "Ta filament nima lastne teže praznega koluta, zato prevzame težo proizvajalca. Po spremembi je to {{value}}, kar predstavlja težo praznega koluta, ko ga izmerite.",
"changeVendor.tareGone": "Ta filament nima lastne teže praznega koluta in prevzame proizvajalčevo vrednost {{current}}. Po spremembi ni nobene vrednosti, ki bi jo lahko prevzel, dokler je ne določite filamentu.",
"changeVendor.title": "Spremeni proizvajalca",
"changeVendor.willBeCreated": "Bo ustvarjeno",
"common.decrement": "Zmanjšaj",
"common.increment": "Povečaj",
"common.remove": "Odstrani",
"common.search": "Išči",
"dashboard.addFieldHint": "V nastavitvah dodajte besedilno polje ali polje z izbiro h kolutom, da boste lahko združevali tudi po tem",
"dashboard.dashboard": "Nadzorna plošča",
"dashboard.deleteGroup": "Izbriši skupino",
"dashboard.dragHint": "Povlecite kolut na drugo kartico, da spremenite njegovo polje {{field}}, ali znotraj kartice, da spremenite vrstni red",
"dashboard.dropHere": "spustite kolute sem",
"dashboard.emptyHelp": "Ta stran združuje vaše kolute po polju po vaši izbiri; za začetek dodajte nekaj kolutov!",
"dashboard.errorEmptyName": "Ime ne more biti prazno",
"dashboard.errorExists": "Skupina s tem imenom že obstaja",
"dashboard.errorRename": "Preimenovanje skupine ni uspelo",
"dashboard.lastUsed": "Zadnjič uporabljeno pred {{time}}",
"dashboard.newGroup": "Nova skupina",
"dashboard.newGroupName": "Nova skupina {{n}}",
"dashboard.reorderGroup": "Povlecite za spremembo vrstnega reda skupine",
"dashboard.unassigned": "Nedodeljeno",
"datetime.done": "Končano",
"datetime.hour": "Ura",
"datetime.minute": "Minuta",
"datetime.nextMonth": "Naslednji mesec",
"datetime.now": "Zdaj",
"datetime.pick": "Izberite datum in čas",
"datetime.prevMonth": "Prejšnji mesec",
"datetime.time": "Čas",
"documentTitle.dashboard.list": "Nadzorna plošča | Spoolman",
"documentTitle.labels.list": "Nalepke | Spoolman",
"documentTitle.library.list": "Knjižnica | Spoolman",
"documentTitle.locations.list": "Lokacije | Kolutar",
"documentTitle.settings.list": "",
"filament.fields.addColor": "",
"documentTitle.settings.list": "Nastavitve | Spoolman",
"filament.fields.addColor": "Dodaj barvo",
"filament.fields.articleNumber": "Številka izdelka",
"filament.fields.coaxial": "Soosno",
"filament.fields.colorHex": "Barva",
"filament.fields.colorType": "",
"filament.fields.colorType": "Vrsta barve",
"filament.fields.comment": "Komentar",
"filament.fields.density": "Gostota",
"filament.fields.diameter": "Premer",
@@ -94,9 +145,10 @@
"filament.fields.material": "Material",
"filament.fields.multiColor": "Večbarvno",
"filament.fields.name": "Ime",
"filament.fields.opacity": "Prosojnost",
"filament.fields.price": "Cena",
"filament.fields.registered": "Registrirano",
"filament.fields.removeColor": "",
"filament.fields.removeColor": "Odstrani barvo",
"filament.fields.settingsBedTemp": "Temperatura mize",
"filament.fields.settingsExtruderTemp": "Temperatura ekstruderja",
"filament.fields.singleColor": "Enobarvno",
@@ -108,210 +160,235 @@
"filament.fieldsHelp.spoolWeight": "Masa praznega koluta. Uporablja se za določitev izmerjene mase koluta.",
"filament.fieldsHelp.weight": "Masa filamenta na polnem kolutu (neto masa). V to se ne sme vključiti mase samega koluta, temveč le masa filamenta. To je običajno navedeno na embalaži.",
"filament.filament": "Filamenti",
"footer.documentation": "",
"footer.reportIssue": "",
"footer.documentation": "Dokumentacija",
"footer.reportIssue": "Prijavi težavo",
"help.help": "Pomoč",
"inspector.addSpoolsOfThis": "",
"inspector.adjustFailed": "",
"inspector.adjustHelp": "",
"inspector.adjustWeight": "",
"inspector.apply": "",
"inspector.applying": "",
"inspector.colorHex": "",
"inspector.consumeAmount": "",
"inspector.delete.deleting": "",
"inspector.delete.errorGone": "",
"inspector.delete.errorSpoolsExist": "",
"inspector.delete.errorUnknown": "",
"inspector.delete.filament": "",
"inspector.delete.filamentBlockedHint": "",
"inspector.delete.filamentBlockedTitle": "",
"inspector.delete.filamentBody": "",
"inspector.delete.filamentDone": "",
"inspector.delete.filamentTitle": "",
"inspector.delete.spool": "",
"inspector.delete.spoolBody": "",
"inspector.delete.spoolDone": "",
"inspector.delete.spoolRemaining": "",
"inspector.delete.spoolTitle": "",
"inspector.delete.vendor": "",
"inspector.delete.vendorBody": "",
"inspector.delete.vendorDone": "",
"inspector.delete.vendorTitle": "",
"inspector.duplicateFilament": "",
"inspector.empty": "",
"inspector.enterValidLength": "",
"inspector.enterValidWeight": "",
"inspector.loading": "",
"inspector.manageExtraFields": "",
"inspector.measuredHelp": "",
"inspector.multi": "",
"inspector.newGross": "",
"inspector.noExtraFields": "",
"inspector.notFound": "",
"inspector.ofRemaining": "",
"inspector.openFilament": "",
"inspector.openLink": "",
"inspector.openManufacturer": "",
"inspector.specs": "",
"inspector.spoolsCount": "",
"inspector.spoolsTotalRemaining": "",
"inspector.used": "",
"inspector.addSpoolsOfThis": "Dodaj kolute tega filamenta",
"inspector.adjustFailed": "Prilagoditev teže ni uspela.",
"inspector.adjustHelp": "Pozitivna vrednost porablja filament, negativna ga doda nazaj.",
"inspector.adjustWeight": "Prilagodi težo",
"inspector.apply": "Uporabi",
"inspector.applying": "Uporabljanje ...",
"inspector.colorHex": "Heksadecimalna koda barve",
"inspector.consumeAmount": "Količina porabe:",
"inspector.defaultFrom.filament": "{{value}} · privzeto za filament",
"inspector.defaultFrom.manufacturer": "{{value}} · privzeto za proizvajalca",
"inspector.delete.deleting": "Brisanje ...",
"inspector.delete.errorGone": "Že prej je bil izbrisan.",
"inspector.delete.errorSpoolsExist": "Tega filamenta ni mogoče izbrisati – koluti ga še vedno uporabljajo.",
"inspector.delete.errorUnknown": "Brisanje ni uspelo. Za podrobnosti si oglejte strežniške dnevnike.",
"inspector.delete.filament": "Izbriši filament",
"inspector.delete.filamentBlockedHint": "Najprej izbrišite te kolute. Arhiviranje ne zadostuje.",
"inspector.delete.filamentBlockedTitle": "Tega filamenta ni mogoče izbrisati",
"inspector.delete.filamentBody": "„{{name}}“ bo odstranjen iz vašega kataloga. Tega dejanja ni mogoče razveljaviti.",
"inspector.delete.filamentDone": "Filament je izbrisan.",
"inspector.delete.filamentTitle": "Želite izbrisati ta filament?",
"inspector.delete.spool": "Izbriši kolut",
"inspector.delete.spoolBody": "Kolut št. {{id}} izdelka „{{name}}“ bo odstranjen skupaj z zgodovino uporabe. Tega dejanja ni mogoče razveljaviti.",
"inspector.delete.spoolDone": "Kolut je izbrisan.",
"inspector.delete.spoolRemaining": "Preostalo je še {{weight}} filamenta. Če ga želite le umakniti, ga raje arhivirajte.",
"inspector.delete.spoolTitle": "Želite izbrisati ta kolut?",
"inspector.delete.vendor": "Izbriši proizvajalca",
"inspector.delete.vendorBody": "„{{name}}“ bo odstranjen. Tega dejanja ni mogoče razveljaviti.",
"inspector.delete.vendorDone": "Proizvajalec je izbrisan.",
"inspector.delete.vendorTitle": "Želite izbrisati tega proizvajalca?",
"inspector.duplicateFilament": "Podvoji",
"inspector.empty": "Na seznamu izberite kolut, filament ali proizvajalca, da si tukaj ogledate podrobnosti.",
"inspector.enterValidLength": "Vnesite veljavno dolžino.",
"inspector.enterValidWeight": "Vnesite veljavno težo.",
"inspector.loading": "Nalaganje ...",
"inspector.manageExtraFields": "Upravljanje polj",
"inspector.measuredHelp": "Skupna teža koluta + preostalega filamenta, kot je razvidno s tehtnice.",
"inspector.multi": "več ×{{count}}",
"inspector.newGross": "Nova bruto teža:",
"inspector.noExtraFields": "Še nič — dodajte svoja polja za sledenje vsemu, čemur Spoolman ne sledi.",
"inspector.notFound": "Tega predmeta ni bilo mogoče najti. Morda je bil izbrisan.",
"inspector.ofRemaining": "od {{weight}} preostalo · {{length}} m",
"inspector.openFilament": "Odpri filament",
"inspector.openLink": "Odpri {{url}} v novem zavihku",
"inspector.openManufacturer": "Odpri proizvajalca",
"inspector.override.byFilament": "Povoženo s tem filamentom · {{value}}",
"inspector.override.bySpool": "Povoženo s tem kolutom · {{value}}",
"inspector.override.overridesFilament": "Povozi filament · {{value}}",
"inspector.override.overridesManufacturer": "Povozi proizvajalca · {{value}}",
"inspector.specs": "Specifikacije — uredi na mestu",
"inspector.spoolsCount": "Koluti · {{count}}",
"inspector.spoolsTotalRemaining": "{{weight}} skupaj",
"inspector.used": "porabljeno",
"kofi": "Prispevajte na Ko-fi",
"labels.addHead": "",
"labels.addQr": "",
"labels.addRect": "",
"labels.addSwatch": "",
"labels.addText": "",
"labels.align": "",
"labels.alignCenter": "",
"labels.alignLeft": "",
"labels.alignRight": "",
"labels.blank": "",
"labels.bold": "",
"labels.canvasHint": "",
"labels.centerLogo": "",
"labels.chooseField": "",
"labels.clearFill": "",
"labels.copies": "",
"labels.cornerRadiusMm": "",
"labels.createFirst": "",
"labels.cutGuides": "",
"labels.deleteConfirm": "",
"labels.deleteElement": "",
"labels.designName": "",
"labels.designerDesc": "",
"labels.designerTitle": "",
"labels.dpi": "",
"labels.dpiHint": "",
"labels.duplicate": "",
"labels.fields.bedTemp": "",
"labels.fields.diameterMm": "",
"labels.fields.groupVendor": "",
"labels.fields.initialG": "",
"labels.fields.netWeightG": "",
"labels.fields.remainingG": "",
"labels.fill": "",
"labels.fitWidth": "",
"labels.fitWidthHint": "",
"labels.fontMm": "",
"labels.gridInfo": "",
"labels.hMm": "",
"labels.heightMm": "",
"labels.hintNowrap": "",
"labels.hintSwatch": "",
"labels.hintWrap": "",
"labels.addHead": "Dodaj",
"labels.addQr": "Koda QR",
"labels.addRect": "Pravokotnik",
"labels.addSwatch": "Barvni vzorec",
"labels.addText": "Besedilo / polje",
"labels.align": "Poravnaj",
"labels.alignCenter": "Sredina",
"labels.alignLeft": "Levo",
"labels.alignRight": "Desno",
"labels.blank": "Nimate še nobenih načrtov nalepk.",
"labels.bold": "Krepko",
"labels.canvasHint": "Povlecite za premikanje · kliknite za izbiro · ročaji za spreminjanje velikosti.",
"labels.centerLogo": "Sredinski logotip Spoolman",
"labels.chooseField": "Izberite polje ...",
"labels.clearFill": "Brez polnila (samo obroba)",
"labels.copies": "Kopije",
"labels.cornerRadiusMm": "Polmer kota",
"labels.createFirst": "Ustvarite svojo prvo nalepko",
"labels.cutGuides": "Vodilne črte za rezanje",
"labels.deleteConfirm": "Želite izbrisati ta načrt nalepke?",
"labels.deleteElement": "Izbriši element",
"labels.designName": "Ime načrta",
"labels.designerDesc": "Oblikujte nalepke s kodami QR s poljubnim poljem za kolut, filament ali dobavitelja, nato pa jih natisnite na pole ali s tiskalnikom nalepk. Načrti so shranjeni na strežniku.",
"labels.designerTitle": "Oblikovalnik nalepk",
"labels.dpi": "Ločljivost",
"labels.dpiHint": "Uskladite s tiskalnikom, da bodo majhne nalepke ostre: 203 dpi za večino termičnih tiskalnikov nalepk, 300 ali več za laserske in brizgalne tiskalnike.",
"labels.duplicate": "Podvoji",
"labels.exportDescAml": "Shrani eno datoteko nalepke .aml na izbrano nalepko, vsaka velikosti {{w}}×{{h}} mm. Odpre se v aplikacijah, temelječih na LPAPI, ki so priložene večini termičnih tiskalnikov nalepk in preberejo fizično velikost iz datoteke.",
"labels.exportDescPng": "Shrani eno sliko PNG na izbrano nalepko, vsaka velikosti {{w}}×{{h}} mm.",
"labels.exportFormat": "Format datoteke",
"labels.exportFormatOptions.aml": "Datoteka nalepke AML",
"labels.exportFormatOptions.png": "Slika PNG",
"labels.fields.bedTemp": "Temp. delovne plošče",
"labels.fields.diameterMm": "Premer (mm)",
"labels.fields.groupVendor": "Dobavitelj",
"labels.fields.initialG": "Začetna (g)",
"labels.fields.netWeightG": "Neto teža (g)",
"labels.fields.remainingG": "Preostalo (g)",
"labels.fill": "Polnilo",
"labels.fitWidth": "Prilagodi širino stolpcem",
"labels.fitWidthHint": "Nastavite širino nalepke tako, da izbrano število stolpcev zapolni stran.",
"labels.fontMm": "Pisava",
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} nalepk / stran",
"labels.hMm": "V",
"labels.heightMm": "Višina",
"labels.hintNowrap": "Besedilo ostane v eni vrstici; vse, kar presega širino, je odrezano.",
"labels.hintSwatch": "Ob tiskanju se zapolni z barvami filamenta na kolutu.",
"labels.hintWrap": "Obdajte polje, da ga skrijete, ko je prazno:",
"labels.imageDesc": "",
"labels.imageZipNote": "",
"labels.insertField": "",
"labels.kind.qr": "",
"labels.kind.rect": "",
"labels.kind.swatch": "",
"labels.kind.text": "",
"labels.labelSize": "",
"labels.labelType": "",
"labels.layout": "",
"labels.modeImage": "",
"labels.modeLabel": "",
"labels.modeSheet": "",
"labels.new": "",
"labels.newText": "",
"labels.noDesignsOption": "",
"labels.noFilaments": "",
"labels.noSpools": "",
"labels.onePerLabel": "",
"labels.preparing": "",
"labels.preview": "",
"labels.previewWith": "",
"labels.radiusMm": "",
"labels.sampleData": "",
"labels.saved": "",
"labels.saving": "",
"labels.searchFilaments": "",
"labels.searchSpools": "",
"labels.selectAllShort": "",
"labels.selectAtLeastOne": "",
"labels.selectAtLeastOneFilament": "",
"labels.selectDesign": "",
"labels.selectElement": "",
"labels.selectNoneShort": "",
"labels.selectRecentHint": "",
"labels.selectRecentShort": "",
"labels.showingFilament": "",
"labels.showingReal": "",
"labels.showingSpool": "",
"labels.showingTags": "",
"labels.sizeMm": "",
"labels.skipHint": "",
"labels.sortHint": "",
"labels.sortId": "",
"labels.sortNewest": "",
"labels.stroke": "",
"labels.strokeMm": "",
"labels.tabDesign": "",
"labels.tabPrint": "",
"labels.textTemplate": "",
"labels.typeFilament": "",
"labels.typeHint": "",
"labels.typeSpool": "",
"labels.unknownFilament": "",
"labels.wMm": "",
"labels.widthMm": "",
"labels.wrapText": "",
"labels.xMm": "",
"labels.yMm": "",
"library.apiError": "",
"library.collapseGroup": "",
"library.emptyBody": "",
"library.emptyFiltered": "",
"library.emptyTitle": "",
"library.expandGroup": "",
"library.filterBy": "",
"library.groupBy": "",
"library.groupNone": "",
"library.nUnused": "",
"library.noLocation": "",
"library.noValues": "",
"library.openGroup": "",
"library.openManufacturer": "",
"library.section.extra": "",
"library.section.filament": "",
"library.section.spool": "",
"library.section.vendor": "",
"library.showMore": "",
"library.sort.nozzle": "",
"library.sortBy": "",
"library.unassigned": "",
"library.unitGroups": "",
"library.unitSpools": "",
"library.unused": "",
"library.unusedGroup": "",
"library.unusedGroupMixed": "",
"library.unusedSub": "",
"library.unusedSubMixed": "",
"library.used": "",
"library.usedAgo": "",
"labels.imageZipNote": "Več kot {{threshold}} nalepk se dostavi kot ena datoteka zip.",
"labels.insertField": "Vstavi polje",
"labels.kind.qr": "Koda QR",
"labels.kind.rect": "Pravokotnik",
"labels.kind.swatch": "Vzorec",
"labels.kind.text": "Besedilo",
"labels.labelSize": "Velikost nalepke",
"labels.labelType": "Vrsta nalepke",
"labels.layout": "Postavitev",
"labels.modeImage": "Datoteke",
"labels.modeLabel": "Tiskalnik nalepk",
"labels.modeSheet": "Pola (ploščice)",
"labels.new": "Novo",
"labels.newText": "Novo besedilo",
"labels.noDesignsOption": "Ni še načrtov",
"labels.noFilaments": "Ni filamentov.",
"labels.noSpools": "Ni kolutov.",
"labels.onePerLabel": "Ena stran {{w}}×{{h}} mm na nalepko.",
"labels.preparing": "Priprava ...",
"labels.preview": "Predogled",
"labels.previewWith": "Predogled z",
"labels.radiusMm": "Polmer",
"labels.sampleData": "Vzorčni podatki",
"labels.saveAsFormat": "Shrani kot {{format}}",
"labels.saved": "Shranjeno",
"labels.saving": "Shranjevanje ...",
"labels.searchFilaments": "Išči filamente ...",
"labels.searchSpools": "Išči kolute ...",
"labels.selectAllShort": "Vse",
"labels.selectAtLeastOne": "Izberite vsaj en kolut.",
"labels.selectAtLeastOneFilament": "Izberite vsaj en filament.",
"labels.selectDesign": "Izberi načrt nalepke",
"labels.selectElement": "Izberite element za urejanje ali pa ga dodajte iz palete.",
"labels.selectNoneShort": "Brez",
"labels.selectRecentHint": "Izberite kolute, dodane v zadnjih 24 urah",
"labels.selectRecentShort": "Nedavno ({{count}})",
"labels.showingFilament": "Prikaz filamenta št. {{id}}",
"labels.showingReal": "Prikaz dejanskih podatkov o kolutu.",
"labels.showingSpool": "Prikaz koluta št. {{id}}",
"labels.showingTags": "Polja so prikazana kot oznake, dokler zgoraj ne izberete koluta.",
"labels.sizeMm": "Velikost",
"labels.skipHint": "Možnost Preskoči elemente pusti toliko praznih celic pred prvo nalepko, kar vam omogoča ponovno uporabo delno porabljene pole: preštejte že odlepljene nalepke od leve proti desni z vrha.",
"labels.sortHint": "Razvrsti kolute",
"labels.sortId": "#",
"labels.sortNewest": "Najnovejše",
"labels.stroke": "Črta",
"labels.strokeMm": "Črta",
"labels.tabDesign": "Načrt",
"labels.tabPrint": "Natisni",
"labels.textTemplate": "Besedilo / predloga",
"labels.typeFilament": "Filament",
"labels.typeHint": "Nalepke za filamente izpustijo polja, ki so specifična za kolute, njihova koda QR pa vodi do filamenta namesto do koluta.",
"labels.typeSpool": "Kolut",
"labels.unknownFilament": "Neznan filament",
"labels.wMm": "Š",
"labels.widthMm": "Širina",
"labels.wrapText": "Prelomi besedilo",
"labels.xMm": "X",
"labels.yMm": "Y",
"library.apiError": "Povezava z API-jem Spoolman ni uspela. Ali zaledje deluje?",
"library.collapseGroup": "Skrij te kolute",
"library.dateFilter.ago": "pred {{span}}",
"library.dateFilter.between": "{{from}} – {{to}}",
"library.dateFilter.customRange": "Obseg po meri",
"library.dateFilter.from": "Od",
"library.dateFilter.last": "Zadnjih {{span}}",
"library.dateFilter.never": "Nikoli",
"library.dateFilter.olderThan": "Starejše od {{span}}",
"library.dateFilter.since": "Od {{date}}",
"library.dateFilter.to": "Do (neobvezno)",
"library.dateFilter.until": "Do {{date}}",
"library.emptyBody": "Dodajte svoj prvi kolut, da začnete slediti svojemu filamentu.",
"library.emptyFiltered": "Noben {{unit}} ne ustreza trenutnim filtrom.",
"library.emptyTitle": "Ni še kolutov",
"library.expandGroup": "Prikaži te kolute",
"library.filterBy": "Filtriraj po",
"library.groupBy": "Skupina",
"library.groupNone": "Brez (ravno)",
"library.nUnused": "×{{count}} neporabljeno",
"library.noLocation": "brez lokacije",
"library.noSpools": "Ni kolutov",
"library.noValues": "Ni vrednosti",
"library.openGroup": "Odpri {{name}}",
"library.openManufacturer": "Odpri proizvajalca {{name}}",
"library.resizeList": "Spremeni velikost seznama (dvojni klik za ponastavitev)",
"library.reverseSort": "Obrni vrstni red razvrščanja",
"library.section.extra": "Dodatna polja",
"library.section.filament": "Filament",
"library.section.spool": "Kolut",
"library.section.vendor": "Proizvajalec",
"library.showMore": "prikaži še {{count}}",
"library.sort.nozzle": "Temp. šobe",
"library.sortBy": "Razvrsti",
"library.unassigned": "nedodeljeno",
"library.unitGroups": "skupine",
"library.unitSpools": "koluti",
"library.unused": "neporabljeno",
"library.unusedGroup": "{{count}} neporabljenih · {{weight}} g vsak",
"library.unusedGroupMixed": "{{count}} neporabljenih",
"library.unusedSub": "{{weight}} g vsak · {{location}}",
"library.unusedSubMixed": "{{location}}",
"library.used": "porabljeno",
"library.usedAgo": "uporabljeno pred {{time}}",
"loading": "Nalaganje",
"locations.locations": "Lokacije",
"locations.newLocation": "Nova lokacija",
"locations.noLocation": "Brez lokacije",
"locations.noLocationsHelp": "Na tej strani lahko kolute razvrstite po lokacijah. Za začetek dodajte nekaj kolutov.",
"nav.labels": "",
"nav.library": "",
"nav.labels": "Nalepke",
"nav.library": "Knjižnica",
"no": "Ne",
"notifications.error": "Napaka (status code: {{statusCode}})",
"notifications.saveSuccessful": "Uspešno shranjeno!",
"pagination.empty": "",
"pagination.next": "",
"pagination.pageSize": "",
"pagination.perPage": "",
"pagination.prev": "",
"pagination.range": "",
"pagination.empty": "Ni {{unit}}",
"pagination.next": "Naslednja stran",
"pagination.pageSize": "Velikost strani",
"pagination.perPage": "{{size}} / stran",
"pagination.prev": "Prejšnja stran",
"pagination.range": "{{from}}–{{to}} od {{total}} {{unit}}",
"printing.generic.columns": "Stolpci",
"printing.generic.customSize": "Po meri",
"printing.generic.description": "Prilagodite spodnje nastavitve, da dobite želeno postavitev tiskanja. Upoštevajte, da lahko tiskalniki in vaš operacijski sistem uporabijo svoje robove in merilo, zato boste morda morali nekajkrat poskusiti, preden bo vse pravilno. Preizkusite to na navadnem listu papirja, preden natisnete na dejanske nalepke.",
"printing.generic.dimensions": "Dimenzije",
"printing.generic.gridTooWide": "",
"printing.generic.gridTooWide": "Nalepke so širše od strani. Zmanjšajte število stolpcev, razmik ali robove.",
"printing.generic.helpMargin": "Robove je treba nastaviti tako, da ustrezajo papirju za nalepke in tiskalniku; njihova sprememba bo vplivala na velikost celotne mreže.",
"printing.generic.helpPrinterMargin": "Varnostno območje je treba nastaviti glede na to, kako blizu roba papirja lahko tiskalnik tiska; sprememba teh nastavitev ne bo vplivala na celotno mrežo.",
"printing.generic.horizontalSpacing": "Vodoraven razmik",
@@ -329,11 +406,11 @@
"printing.generic.skipItems": "Preskoči elemente",
"printing.generic.verticalSpacing": "Navpičen razmik",
"printing.qrcode.button": "Natisni nalepke",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
"printing.qrcode.customUrl.caveat": "URL po meri se ne bo skeniral nazaj v Spoolman — uporabite ga za usmerjanje natisnjene kode QR na zunanjo aplikacijo ali drugega gostitelja.",
"printing.qrcode.customUrl.hint": "Z uporabo mesta za vstavljanje vstavite ID koluta kamor koli",
"printing.qrcode.customUrl.label": "Predloga URL-ja po meri",
"printing.qrcode.useHTTPUrl.label": "Povezava QR kode",
"printing.qrcode.useHTTPUrl.options.custom": "",
"printing.qrcode.useHTTPUrl.options.custom": "URL po meri",
"printing.qrcode.useHTTPUrl.options.default": "Privzeto",
"printing.qrcode.useHTTPUrl.options.url": "URL",
"printing.qrcode.useHTTPUrl.preview": "Predogled:",
@@ -343,31 +420,32 @@
"printing.spoolSelect.selectedTotal_other": "Izbranih {{count}} kolutov",
"printing.spoolSelect.selectedTotal_two": "Izbrana {{count}} koluta",
"scanner.description": "Preberite Kolutarjevo QR kodo za ogled podrobnosti o kolutu.",
"scanner.error.inUse": "",
"scanner.error.inUse": "Kamero že uporablja druga aplikacija.",
"scanner.error.insecureContext": "Stran ni dostopna prek protokola HTTPS.",
"scanner.error.notAllowed": "Niste omogočili dostopa do kamere.",
"scanner.error.notFound": "Kamere ni bilo mogoče najti.",
"scanner.error.streamApiNotSupported": "Brskalnik ne podpira MediaStream API.",
"scanner.error.unknown": "Prišlo je do neznane napake. ({{error}})",
"scanner.starting": "",
"scanner.starting": "Zagon kamere ...",
"scanner.title": "Čitalnik QR kode",
"search.colorHint": "",
"search.noResults": "",
"search.searching": "",
"search.section.filaments": "",
"search.section.spools": "",
"search.section.vendors": "",
"search.threshold": "",
"search.unknownFilament": "",
"settings.appearance.tab": "",
"settings.appearance.theme.dark": "",
"settings.appearance.theme.desc": "",
"settings.appearance.theme.label": "",
"settings.appearance.theme.light": "",
"settings.appearance.theme.system": "",
"settings.extraFields.addChoice": "",
"settings.extraFields.addChoicesFirst": "",
"settings.extraFields.addEntityField": "",
"search.colorHint": "Imena barv morajo biti vnesena v angleščini.",
"search.moreSpools": "še +{{count}}",
"search.noResults": "Ni najdenih zadetkov",
"search.searching": "Iskanje ...",
"search.section.filaments": "Filamenti",
"search.section.spools": "Koluti",
"search.section.vendors": "Proizvajalci",
"search.threshold": "Ujemanje barv",
"search.unknownFilament": "Neznan filament",
"settings.appearance.tab": "Videz",
"settings.appearance.theme.dark": "Temno",
"settings.appearance.theme.desc": "Barvna tema (samo za ta brskalnik)",
"settings.appearance.theme.label": "Tema",
"settings.appearance.theme.light": "Svetlo",
"settings.appearance.theme.system": "Sistemsko",
"settings.extraFields.addChoice": "dodaj izbiro ...",
"settings.extraFields.addChoicesFirst": "Najprej dodajte izbire",
"settings.extraFields.addEntityField": "Dodaj polje za {{entity}}",
"settings.extraFields.booleanFalse": "Ne",
"settings.extraFields.booleanTrue": "Da",
"settings.extraFields.deleteConfirm": "Zbrišem polje {{name}}?",
@@ -375,14 +453,14 @@
"settings.extraFields.description.intro": "Tukaj lahko svojim entitetam dodate dodatna polja po meri.",
"settings.extraFields.description.keyUsage": "Ključ je tisto, kar drugi programi uporabljajo za branje/pisanje podatkov, zato če naj bi se vaše polje po meri integriralo s programom tretje osebe, poskrbite, da ga nastavite pravilno. Privzeta vrednost se uporablja le za nove elemente.",
"settings.extraFields.description.tableViews": "Dodatna polja ni mogoče razvrstiti ali filtrirati v pogledih tabel.",
"settings.extraFields.editorEdit": "",
"settings.extraFields.editorNew": "",
"settings.extraFields.errors.choiceNeeded": "",
"settings.extraFields.errors.choicesRemoved": "",
"settings.extraFields.errors.deleteFailed": "",
"settings.extraFields.errors.keyFormat": "",
"settings.extraFields.errors.nameRequired": "",
"settings.extraFields.errors.saveFailed": "",
"settings.extraFields.editorEdit": "Uredi polje za {{entity}}",
"settings.extraFields.editorNew": "Novo polje za {{entity}}",
"settings.extraFields.errors.choiceNeeded": "Polja z izbirami potrebujejo vsaj eno izbiro.",
"settings.extraFields.errors.choicesRemoved": "Obstoječih izbir ni mogoče odstraniti: {{choices}}.",
"settings.extraFields.errors.deleteFailed": "Brisanje polja ni uspelo.",
"settings.extraFields.errors.keyFormat": "Ključ lahko vsebuje samo male črke, številke in podčrtaje.",
"settings.extraFields.errors.nameRequired": "Ime je obvezno.",
"settings.extraFields.errors.saveFailed": "Shranjevanje polja ni uspelo.",
"settings.extraFields.fieldType.boolean": "Logična vrednost",
"settings.extraFields.fieldType.choice": "Izbira",
"settings.extraFields.fieldType.datetime": "Datum in čas",
@@ -391,13 +469,13 @@
"settings.extraFields.fieldType.integer": "Celo število",
"settings.extraFields.fieldType.integerRange": "Območje celih števil",
"settings.extraFields.fieldType.text": "Besedilo",
"settings.extraFields.max": "",
"settings.extraFields.min": "",
"settings.extraFields.multiSuffix": "",
"settings.extraFields.multiple": "",
"settings.extraFields.namePlaceholder": "",
"settings.extraFields.max": "največ",
"settings.extraFields.min": "najmanj",
"settings.extraFields.multiSuffix": " · več",
"settings.extraFields.multiple": "Več",
"settings.extraFields.namePlaceholder": "Prikazno ime",
"settings.extraFields.nonUniqueKeyError": "Ključ mora biti edinstven.",
"settings.extraFields.none": "",
"settings.extraFields.none": "Za {{entity}} še ni definiranih dodatnih polj.",
"settings.extraFields.params.choices": "Izbire",
"settings.extraFields.params.defaultValue": "Privzeta vrednost",
"settings.extraFields.params.fieldType": "Tip",
@@ -405,21 +483,37 @@
"settings.extraFields.params.name": "Ime",
"settings.extraFields.params.order": "Zaporedje",
"settings.extraFields.params.unit": "Enota",
"settings.extraFields.saveField": "",
"settings.extraFields.reorderChoice": "Povlecite ali uporabite puščice za spremembo vrstnega reda te izbire.",
"settings.extraFields.saveField": "Shrani polje",
"settings.extraFields.tab": "Dodatna polja",
"settings.general.baseUrl.label": "Osnovni URL",
"settings.general.baseUrl.tooltip": "Osnoven URL, ki se uporablja pri ustvarjanju elementov, kot npr. QR-kode.",
"settings.general.currency.desc": "",
"settings.general.currency.desc": "Trimestna koda ISO (npr. EUR, USD), ki se uporablja za cene",
"settings.general.currency.label": "Valuta",
"settings.general.roundPrices.label": "Zaokrožene cene",
"settings.general.roundPrices.tooltip": "Zaokroži cene na najbližje celo število.",
"settings.general.tab": "Splošno",
"settings.header": "Nastavitve",
"settings.language.desc": "",
"settings.language.label": "",
"settings.library.lowThreshold.desc": "",
"settings.library.lowThreshold.label": "",
"settings.library.tab": "",
"settings.language.desc": "Jezik vmesnika (samo za ta brskalnik)",
"settings.language.label": "Jezik",
"settings.library.lowThreshold.desc": "Koluti s to težo ali manj so označeni rdeče (samo za ta brskalnik)",
"settings.library.lowThreshold.label": "Prag za nizko zalogo",
"settings.library.tab": "Vedenje knjižnice",
"settings.scanner.autoNavigate.desc": "V tem spletnem brskalniku samodejno navigiraj do koluta ali filamenta, ki ga zazna čitalnik oznak.",
"settings.scanner.autoNavigate.label": "Samodejna navigacija",
"settings.scanner.docs": "Kako nastaviti čitalnik oznak",
"settings.scanner.lastSeen": "pred {{when}}",
"settings.scanner.pair": "Poveži s tapkanjem",
"settings.scanner.reader.anyDesc": "Skeniranja s katerega koli čitalnika odprejo kolute v tem spletnem brskalniku. Za natančnejši nadzor se lahko povežete z določenim čitalnikom. To je tudi čitalnik, ki bo uporabljen za dodajanje novih oznak kolutom in filamentom.",
"settings.scanner.reader.label": "Čitalnik oznak",
"settings.scanner.reader.pairedDesc": "Samo skeniranja s tega čitalnika odprejo kolute v tem spletnem brskalniku.",
"settings.scanner.recent": "Nedavni čitalniki",
"settings.scanner.recentNone": "V pomnilniku ni čitalnika; nastavite čitalnik oznak in nato preberite katero koli oznako, da se pojavi tukaj.",
"settings.scanner.tab": "Skeniranje oznak",
"settings.scanner.unpair": "Uporabi kateri koli čitalnik",
"settings.scanner.use": "Uporabi tega",
"settings.scanner.waiting": "Tapnite katero koli oznako na čitalniku, ki ga želite ...",
"sponsor": "Sponzorirajte me na GitHubu",
"spool.fields.archived": "Arhivirano",
"spool.fields.comment": "Komentar",
"spool.fields.filament": "Filament",
@@ -433,26 +527,81 @@
"spool.fields.price": "Cena",
"spool.fields.registered": "Registrirano",
"spool.fields.remainingWeight": "Preostala masa",
"spool.fields.spoolWeight": "Teža koluta",
"spool.fields.usedWeight": "Porabljena masa",
"spool.fields.weight": "Teža",
"spool.fieldsHelp.location": "Kje se nahaja kolut, če imate več mest za shranjevanje.",
"spool.fieldsHelp.lotNr": "Številka serije proizvajalca. Uporablja se za zagotavljanje barvne doslednosti tiska, če se uporablja več kolutov.",
"spool.fieldsHelp.measuredWeight": "Koliko tehtata filament in kolut.",
"spool.fieldsHelp.price": "Cena polnega koluta. Če ta vrednost ni nastavljena, se namesto nje upošteva cena filamenta.",
"spool.fieldsHelp.remainingWeight": "Koliko filamenta je še na kolutu. Pri novem kolutu se ta količina ujema z maso koluta.",
"spool.fieldsHelp.spoolWeight": "Teža tega koluta, ko je prazen. Pustite prazno, da upoštevate težo koluta za ta filament, in jo nastavite le, ko se ta specifični kolut razlikuje – npr. ponovno navit kolut ali zamenjava kartonskega jedra s plastičnim. To je vrednost, ki se odšteje od meritve pri prilagajanju glede na izmerjeno težo, zato napačna vrednost napačno prikaže preostanek filamenta.",
"spool.fieldsHelp.usedWeight": "Koliko filamenta je bilo porabljenega s koluta. Pri novem kolutu je vrednost 0 g.",
"spool.fieldsHelp.weight": "Koliko filamenta je bilo na tem kolutu, ko je bil poln (neto teža, brez samega koluta). Pustite prazno, da upoštevate težo filamenta, in jo nastavite le, ko se ta specifični kolut razlikuje – npr. delno porabljen kolut ali isti filament na večjem kolutu.",
"spool.form.measurementType.length": "Dolžina",
"spool.form.measurementType.weight": "Masa",
"spool.spool": "Koluti",
"topbar.addSpools": "",
"topbar.searchPlaceholder": "",
"tags.add": "Dodaj oznako",
"tags.added": "Oznaka je povezana.",
"tags.addedOn": "Dodano {{date}}",
"tags.modal.alreadyHere": "Ta oznaka že identificira ta kolut.",
"tags.modal.alreadyHereFilament": "Ta oznaka že identificira ta filament.",
"tags.modal.badUid": "UID oznake je heksadecimalen, npr. 04A2B3C4.",
"tags.modal.checking": "Preverjanje ...",
"tags.modal.failed": "Oznake ni bilo mogoče povezati.",
"tags.modal.filamentNamed": "filament {{name}}",
"tags.modal.free": "Ta oznaka še ni povezana z ničemer.",
"tags.modal.intro": "S tapkanjem te oznake na čitalniku boste nato našli kolut št. {{id}}.",
"tags.modal.introFilament": "S tapkanjem te oznake na čitalniku boste nato našli ta filament.",
"tags.modal.lastSeenOn": "{{reader}} je nazadnje prebral {{uid}}.",
"tags.modal.link": "Poveži oznako",
"tags.modal.linking": "Povezovanje ...",
"tags.modal.move": "Premakni sem s št. {{id}}",
"tags.modal.moveFailed": "Oznake ni bilo mogoče premakniti. Morda zdaj ni povezana z nobenim kolutom – poskusite jo znova povezati.",
"tags.modal.moveFailedFilament": "Oznake ni bilo mogoče premakniti. Morda zdaj ni povezana z ničemer – poskusite jo znova povezati.",
"tags.modal.moveHere": "Premakni sem",
"tags.modal.moving": "Premikanje ...",
"tags.modal.phoneReading": "Pridržite oznako ob telefonu ...",
"tags.modal.phoneScan": "Preberi oznako s tem telefonom",
"tags.modal.spoolNamed": "kolut št. {{id}}, {{name}}",
"tags.modal.spoolShort": "kolut št. {{id}}",
"tags.modal.takenBy": "Trenutno povezano s kolutom {{spool}}.",
"tags.modal.title": "Poveži oznako",
"tags.modal.uidHelp": "Ločila in velike začetnice ne igrajo vloge: 04:a2:b3:c4, 04-A2-B3-C4 in 04a2b3c4 so vse ista oznaka.",
"tags.modal.uidLabel": "UID oznake",
"tags.modal.uidPlaceholder": "04A2B3C4",
"tags.modal.useLastSeen": "Uporabi jo",
"tags.modal.waitingAny": "Čakanje na tapkanje na katerem koli čitalniku.",
"tags.modal.waitingOn": "Čakanje na tapkanje na {{reader}}.",
"tags.moved": "Oznaka je bila premaknjena sem s koluta št. {{id}}.",
"tags.movedFromFilament": "Oznaka je bila premaknjena sem s filamenta {{name}}.",
"tags.nfc.insecureContext": "Branje oznak zahteva varno povezavo. Odprite Spoolman prek HTTPS, da uporabite NFC svojega telefona.",
"tags.nfc.notAllowed": "To spletno mesto nima dovoljenja za uporabo NFC. Podelite dovoljenje v nastavitvah spletnega mesta v brskalniku in poskusite znova.",
"tags.nfc.notReadable": "Čitalnik NFC ni na voljo – morda ga uporablja druga aplikacija.",
"tags.nfc.notSupported": "Ta telefon nima NFC ali pa je NFC izklopljen v sistemskih nastavitvah.",
"tags.nfc.unknown": "Oznake ni bilo mogoče prebrati.",
"tags.none": "Ni povezanih oznak.",
"tags.scan.openedFilament": "Oznaka skenirana: odprt filament {{name}}.",
"tags.scan.openedSpool": "Oznaka skenirana: odprt kolut št. {{id}}, {{name}}.",
"tags.scan.unknown": "Neznana oznaka {{uid}}. Odprite kolut, ki mu pripada, in uporabite možnost Dodaj oznako, da jo povežete.",
"tags.section": "Oznake",
"tags.unlink": "Prekini povezavo",
"tags.unlinkBody": "Oznaka {{uid}} ne bo več identificirala tega koluta. Na samo oznako se nič ne zapiše, zato jo lahko kadar koli znova povežete – s tem kolutom ali drugim.",
"tags.unlinkBodyFilament": "Oznaka {{uid}} ne bo več identificirala tega filamenta. Na samo oznako se nič ne zapiše, zato jo lahko kadar koli znova povežete – tukaj ali drugje.",
"tags.unlinkFailed": "Povezave z oznako ni bilo mogoče prekiniti.",
"tags.unlinkTitle": "Želite prekiniti povezavo s to oznako?",
"tags.unlinked": "Povezava z oznako je prekinjena.",
"tags.unlinking": "Prekinjanje povezave ...",
"topbar.addSpools": "Dodaj kolute",
"topbar.searchPlaceholder": "Iskanje — poskusite »rdeča« ali »pla«",
"unknown": "Neznano",
"validation.hexDigits": "",
"validation.maxChars": "",
"validation.mustBeGt": "",
"validation.mustBeMax": "",
"validation.mustBeMin": "",
"validation.mustBeNumber": "",
"validation.required": "",
"validation.hexDigits": "Uporabite 6 ali 8 heksadecimalnih številk",
"validation.maxChars": "Največ {{max}} znakov",
"validation.mustBeGt": "Mora biti večje od {{value}}",
"validation.mustBeMax": "Mora biti {{value}} ali manj",
"validation.mustBeMin": "Mora biti {{value}} ali več",
"validation.mustBeNumber": "Mora biti številka",
"validation.required": "Obvezno",
"vendor.fields.comment": "Komentar",
"vendor.fields.emptySpoolWeight": "Masa praznega koluta",
"vendor.fields.externalId": "Zunanji ID",
+509 -356
View File
@@ -1,330 +1,408 @@
{
"add.addAndNew": "",
"add.addN_other": "",
"add.adding": "",
"add.advanced": "",
"add.advancedNote": "",
"add.change": "",
"add.count": "",
"add.createNew": "",
"add.createNewSub": "",
"add.dbUnavailable": "",
"add.duplicate": "",
"add.duplicateNote": "",
"add.duplicateRename": "",
"add.duplicateTitle": "",
"add.filamentNamePlaceholder": "",
"add.fill.full": "",
"add.fillLevel": "",
"add.importNote": "",
"add.inCatalog": "",
"add.locationHint": "",
"add.locationPlaceholder": "",
"add.manufacturerPlaceholder": "",
"add.nameHint": "",
"add.newFilament": "",
"add.newFilamentTitle": "",
"add.noCatalog": "",
"add.noManufacturer": "",
"add.pickColor": "",
"add.searchPlaceholder": "",
"add.searching": "",
"add.spoolWeightPreset.cardboard": "",
"add.spoolWeightPreset.plastic": "",
"add.spoolWeightPresetsLead": "",
"add.step2": "",
"add.summary_other": "",
"add.typeToSearchAll": "",
"add.typeToSearchMatches": "",
"add.useExisting": "",
"add.vendorHint.existing": "",
"add.vendorHint.new": "",
"add.vendorHint.optional": "",
"add.yourCatalog": "",
"add.addAndNew": "新增後再新增",
"add.addN_other": "新增 {{count}} 個線軸",
"add.adding": "正在新增...",
"add.advanced": "高階參數",
"add.advancedNote": "密度、直徑、溫度(依材料自動帶入)",
"add.change": "更改",
"add.count": "數量",
"add.createNew": "建立新線材",
"add.createNewSub": "不在您的目錄或 {{name}} 中",
"add.dbUnavailable": "{{name}} 不可用",
"add.duplicate": "建立副本",
"add.duplicateNote": "規格、製造商和價格已複製。請為其指定新的名稱和顏色。",
"add.duplicateRename": "名稱仍與原線材相同 — 請重新命名以便區分。",
"add.duplicateTitle": "新線材 · {{name}} 的副本",
"add.filamentNamePlaceholder": "例如:PolyTerra Matte Sage",
"add.fill.full": "全滿",
"add.fillLevel": "裝填程度",
"add.importNote": "當您新增線軸時,此線材將被自動新增到您的目錄中。",
"add.inCatalog": "已在目錄中",
"add.locationHint": "線軸的實際存放位置 — 例如架子、乾燥箱或印表機。如果您將所有線材放在同一位置,可留空。",
"add.locationPlaceholder": "例如:架子 A、乾燥箱 2、印表機 1",
"add.manufacturerPlaceholder": "例如:Polymaker",
"add.nameHint": "建議包含顏色資訊 — 這是區分不同線材的關鍵。",
"add.newFilament": "新線材",
"add.newFilamentTitle": "新增線材",
"add.noCatalog": "目錄中沒有線材",
"add.noManufacturer": "無製造商",
"add.pickColor": "選擇顏色",
"add.searchPlaceholder": "搜尋您的目錄和 {{name}}...",
"add.searching": "正在搜尋...",
"add.section.filament": "線材",
"add.section.filamentNote": "線軸上的產品,每種顏色各建立一筆。",
"add.section.manufacturer": "製造商",
"add.section.manufacturerNote": "線材生產商。只需新增一次,即可重複用於其旗下的所有線材。",
"add.section.spool": "線軸",
"add.section.spoolNote": "要加入庫存的實體線軸。",
"add.section.spoolSharedNote": "線材重量、線軸重量和價格也會儲存為新線材的預設值。",
"add.spoolWeightPreset.cardboard": "紙軸 {{weight}} 克",
"add.spoolWeightPreset.plastic": "塑膠軸 {{weight}} 克",
"add.spoolWeightPresetsLead": "從常見線軸開始:",
"add.step2": "步驟 2 · 線軸詳細資料",
"add.step2New": "步驟 2 · 線材與線軸詳細資料",
"add.summary_other": "{{count}} 個線軸",
"add.typeToSearchAll": "輸入文字,搜尋數千種線材",
"add.typeToSearchMatches": "輸入文字以搜尋(目前沒有相符結果)",
"add.useExisting": "使用現有線材",
"add.vendorHint.existing": "使用現有製造商「{{name}}」",
"add.vendorHint.new": "將建立新製造商「{{name}}」",
"add.vendorHint.optional": "選填 — 留空表示不指定製造商",
"add.weightPresets": "常見線材重量",
"add.yourCatalog": "您的線材目錄",
"buttons.apply": "套用",
"buttons.archive": "封存",
"buttons.cancel": "取消",
"buttons.clear": "清除",
"buttons.close": "",
"buttons.close": "關閉",
"buttons.delete": "刪除",
"buttons.edit": "編輯",
"buttons.filter": "過濾器",
"buttons.filter": "篩選",
"buttons.save": "儲存",
"buttons.showArchived": "檢視已封存",
"buttons.showNoSpools": "顯示沒有線軸的線材規格",
"buttons.unArchive": "取消封存",
"common.decrement": "",
"common.increment": "",
"common.remove": "",
"common.search": "",
"dashboard.addFieldHint": "",
"dashboard.dashboard": "",
"dashboard.deleteGroup": "",
"dashboard.dragHint": "",
"dashboard.dropHere": "",
"dashboard.emptyHelp": "",
"dashboard.errorEmptyName": "",
"dashboard.errorExists": "",
"dashboard.errorRename": "",
"dashboard.lastUsed": "",
"dashboard.newGroup": "",
"dashboard.newGroupName": "",
"dashboard.reorderGroup": "",
"dashboard.spoolCount_other": "",
"dashboard.unassigned": "",
"datetime.done": "",
"datetime.hour": "",
"datetime.minute": "",
"datetime.nextMonth": "",
"datetime.now": "",
"datetime.pick": "",
"datetime.prevMonth": "",
"datetime.time": "",
"documentTitle.dashboard.list": "",
"documentTitle.labels.list": "",
"documentTitle.library.list": "",
"changeFilament.action": "更改",
"changeFilament.adoptWeight": "將此線軸的滿載重量設為 {{full}}",
"changeFilament.adoptWeightHelp": "先前記錄的滿載重量為 {{current}}(來自更改前的線材)。無論如何都會保留已使用重量({{used}}),剩餘重量為 {{remaining}}。",
"changeFilament.apply": "更改線材",
"changeFilament.applying": "正在更改...",
"changeFilament.current": "目前",
"changeFilament.done": "線材已更改。",
"changeFilament.failed": "無法更改線材。",
"changeFilament.importNote": "當您套用此更改時,此線材將被新增到您的目錄中。",
"changeFilament.intro": "線軸 #{{id}} 會保留編號、位置、日期和使用記錄,只有所裝的線材會變更。",
"changeFilament.newTag": "新",
"changeFilament.next": "更改為",
"changeFilament.now": "目前為",
"changeFilament.pickBelow": "請在下方選擇線材",
"changeFilament.title": "更改線材類型",
"changeFilament.useExisting": "選擇現有線材",
"changeFilament.weightFollows": "此線軸沒有自訂滿載重量,因此將使用新線材的設定:{{full}}。在扣除已使用的 {{used}} 後,剩餘 {{remaining}}。",
"changeVendor.action": "更改",
"changeVendor.apply": "更改製造商",
"changeVendor.applying": "正在更改...",
"changeVendor.clearHint": "保持此線材無製造商狀態",
"changeVendor.create": "建立「{{name}}」",
"changeVendor.createHint": "尚不在您的製造商清單中 — 將自動新增",
"changeVendor.current": "目前",
"changeVendor.done": "製造商已更改。",
"changeVendor.emptySpool": "空線軸重量 {{value}}",
"changeVendor.failed": "無法更改製造商。",
"changeVendor.intro": "「{{name}}」的線軸、規格和歷史記錄都會保留,只有所屬製造商會變更。",
"changeVendor.next": "更改為",
"changeVendor.noEmptySpool": "未設定空線軸重量",
"changeVendor.noMatches": "無相符的製造商",
"changeVendor.none": "尚無製造商",
"changeVendor.noneNote": "未歸類到任何品牌",
"changeVendor.now": "目前為",
"changeVendor.pickBelow": "請在下方選擇製造商",
"changeVendor.searchPlaceholder": "搜尋或輸入製造商名稱",
"changeVendor.tareFollows": "此線材沒有自訂空線軸重量,因此將繼承其製造商的空線軸重量。更改後空線軸重量為 {{value}},即空線軸實測的重量。",
"changeVendor.tareGone": "此線材原本繼承了製造商的空線軸重量({{current}})。更改後將不再有可繼承的空線軸重量,直到您為此線材單獨指定一個。",
"changeVendor.title": "更改製造商",
"changeVendor.willBeCreated": "將被建立",
"common.decrement": "減少",
"common.increment": "增加",
"common.remove": "移除",
"common.search": "搜尋",
"dashboard.addFieldHint": "在「設定」中為線軸新增文字或選項欄位,即可依該欄位分組",
"dashboard.dashboard": "儀表板",
"dashboard.deleteGroup": "刪除群組",
"dashboard.dragHint": "將線軸拖到其他卡片以變更其 {{field}},或在卡片內拖曳以重新排序",
"dashboard.dropHere": "將線軸拖放到此處",
"dashboard.emptyHelp": "本頁會依您選擇的欄位將線軸分組。請先新增一些線軸!",
"dashboard.errorEmptyName": "名稱不能為空",
"dashboard.errorExists": "已存在同名群組",
"dashboard.errorRename": "重新命名群組失敗",
"dashboard.lastUsed": "{{time}} 前使用",
"dashboard.newGroup": "新增群組",
"dashboard.newGroupName": "新增群組 {{n}}",
"dashboard.reorderGroup": "拖曳以調整群組順序",
"dashboard.spoolCount_other": "{{count}} 個線軸",
"dashboard.unassigned": "未分配",
"datetime.done": "完成",
"datetime.hour": "時",
"datetime.minute": "分",
"datetime.nextMonth": "下個月",
"datetime.now": "現在",
"datetime.pick": "選擇日期與時間",
"datetime.prevMonth": "上個月",
"datetime.time": "時間",
"documentTitle.dashboard.list": "儀表板 | Spoolman",
"documentTitle.labels.list": "標籤列印 | Spoolman",
"documentTitle.library.list": "線材庫 | Spoolman",
"documentTitle.locations.list": "位置| Spoolman",
"documentTitle.settings.list": "",
"filament.fields.addColor": "",
"documentTitle.settings.list": "設定 | Spoolman",
"filament.fields.addColor": "新增顏色",
"filament.fields.articleNumber": "產品編號",
"filament.fields.coaxial": "混色",
"filament.fields.colorHex": "顏色",
"filament.fields.colorType": "",
"filament.fields.colorType": "顏色類型",
"filament.fields.comment": "備註",
"filament.fields.density": "密度",
"filament.fields.diameter": "線徑",
"filament.fields.externalId": "內部ID",
"filament.fields.externalId": "外部 ID",
"filament.fields.id": "ID",
"filament.fields.longitudinal": "縱向",
"filament.fields.material": "材料",
"filament.fields.multiColor": "多色",
"filament.fields.name": "名稱",
"filament.fields.opacity": "透明度",
"filament.fields.price": "價格",
"filament.fields.registered": "已登記",
"filament.fields.removeColor": "",
"filament.fields.removeColor": "移除顏色",
"filament.fields.settingsBedTemp": "床台溫度",
"filament.fields.settingsExtruderTemp": "擠出機溫度",
"filament.fields.singleColor": "單色",
"filament.fields.spoolWeight": "料盤重量",
"filament.fields.spoolWeight": "線軸重量",
"filament.fields.vendor": "製造商",
"filament.fields.weight": "重量",
"filament.fieldsHelp.articleNumber": "例如EAN、UPC等。",
"filament.fieldsHelp.multiColorDirection": "線材的顏色可通過兩種方式呈現多樣性:一種是共擠技術,例如雙色線材,具有穩定的多色效果;另一種是沿線材長度的顏色變化,例如漸變線材,顏色會隨著線材捲的長度逐漸轉變。",
"filament.fieldsHelp.spoolWeight": "空線盤的重量,用於計算整捲線的實際重量。",
"filament.fieldsHelp.weight": "整捲線材的重量(淨重)。此重量不應包含線盤本身的重量,僅限於線材的重量,通常會標示在包裝上。",
"filament.fieldsHelp.multiColorDirection": "線材可透過兩種方式呈現多種顏色:共擠(如雙色線材,各色沿整段保持一致)或縱向漸層(顏色沿線材長度變化)。",
"filament.fieldsHelp.spoolWeight": "空線軸的重量,用於計算整捲線的實際重量。",
"filament.fieldsHelp.weight": "整捲線材的重量(淨重)。此重量不應包含線軸本身的重量,僅限於線材的重量,通常會標示在包裝上。",
"filament.filament": "線材",
"footer.documentation": "",
"footer.reportIssue": "",
"footer.documentation": "說明文件",
"footer.reportIssue": "回報問題",
"footer.updateAvailable": "可更新至 v{{version}}",
"help.help": "說明",
"inspector.addSpoolsOfThis": "",
"inspector.adjustFailed": "",
"inspector.adjustHelp": "",
"inspector.adjustWeight": "",
"inspector.apply": "",
"inspector.applying": "",
"inspector.colorHex": "",
"inspector.consumeAmount": "",
"inspector.delete.deleting": "",
"inspector.delete.errorGone": "",
"inspector.delete.errorSpoolsExist": "",
"inspector.delete.errorUnknown": "",
"inspector.delete.filament": "",
"inspector.delete.filamentBlockedHint": "",
"inspector.delete.filamentBlockedTitle": "",
"inspector.delete.filamentBlocked_other": "",
"inspector.delete.filamentBody": "",
"inspector.delete.filamentDone": "",
"inspector.delete.filamentTitle": "",
"inspector.delete.spool": "",
"inspector.delete.spoolBody": "",
"inspector.delete.spoolDone": "",
"inspector.delete.spoolRemaining": "",
"inspector.delete.spoolTitle": "",
"inspector.delete.vendor": "",
"inspector.delete.vendorBody": "",
"inspector.delete.vendorDone": "",
"inspector.delete.vendorOrphans_other": "",
"inspector.delete.vendorTitle": "",
"inspector.duplicateFilament": "",
"inspector.empty": "",
"inspector.enterValidLength": "",
"inspector.enterValidWeight": "",
"inspector.loading": "",
"inspector.manageExtraFields": "",
"inspector.measuredHelp": "",
"inspector.multi": "",
"inspector.newGross": "",
"inspector.noExtraFields": "",
"inspector.notFound": "",
"inspector.ofRemaining": "",
"inspector.openFilament": "",
"inspector.openLink": "",
"inspector.openManufacturer": "",
"inspector.specs": "",
"inspector.spoolsCount": "",
"inspector.spoolsTotalRemaining": "",
"inspector.used": "",
"inspector.vendorSub_other": "",
"inspector.addSpoolsOfThis": "新增此規格的線軸",
"inspector.adjustFailed": "調整重量失敗。",
"inspector.adjustHelp": "正數表示消耗線材,負數表示退回/增加線材。",
"inspector.adjustWeight": "調整用量/重量",
"inspector.apply": "套用",
"inspector.applying": "正在套用...",
"inspector.colorHex": "十六進位色碼",
"inspector.consumeAmount": "消耗數量:",
"inspector.defaultFrom.filament": "{{value}} · 線材預設值",
"inspector.defaultFrom.manufacturer": "{{value}} · 製造商預設值",
"inspector.delete.deleting": "正在刪除...",
"inspector.delete.errorGone": "此項目已刪除。",
"inspector.delete.errorSpoolsExist": "無法刪除此線材 — 仍有線軸在使用它。",
"inspector.delete.errorUnknown": "刪除失敗。詳細資料請檢視伺服器日誌。",
"inspector.delete.filament": "刪除線材規格",
"inspector.delete.filamentBlockedHint": "請先刪除這些線軸;只有封存並不足夠。",
"inspector.delete.filamentBlockedTitle": "無法刪除此線材規格",
"inspector.delete.filamentBlocked_other": "仍有 {{count}} 個線軸正在使用「{{name}}」(包含已封存項)。",
"inspector.delete.filamentBody": "「{{name}}」 將從您的目錄中完全移除。此操作不可撤銷。",
"inspector.delete.filamentDone": "線材規格已刪除。",
"inspector.delete.filamentTitle": "確認刪除此線材規格?",
"inspector.delete.spool": "刪除線軸",
"inspector.delete.spoolBody": "線軸 #{{id}}(「{{name}}」)及其歷史使用記錄將被完全移除。此操作不可撤銷。",
"inspector.delete.spoolDone": "線軸已刪除。",
"inspector.delete.spoolRemaining": "該捲仍剩餘 {{weight}} 線材。如果您只是想清理介面,可以將其封存。",
"inspector.delete.spoolTitle": "確認刪除此線軸?",
"inspector.delete.vendor": "刪除製造商",
"inspector.delete.vendorBody": "製造商「{{name}}」將被移除。此操作不可撤銷。",
"inspector.delete.vendorDone": "製造商已刪除。",
"inspector.delete.vendorOrphans_other": "其下的 {{count}} 種線材將被保留,但會變為無製造商狀態。",
"inspector.delete.vendorTitle": "確認刪除此製造商?",
"inspector.duplicateFilament": "建立副本",
"inspector.empty": "請從清單中選擇線軸、線材規格或製造商以在此檢視詳細資料。",
"inspector.enterValidLength": "請輸入有效的長度。",
"inspector.enterValidWeight": "請輸入有效的重量。",
"inspector.loading": "正在載入...",
"inspector.manageExtraFields": "管理自訂欄位",
"inspector.measuredHelp": "線軸 + 剩餘線材的電子秤實測總毛重。",
"inspector.multi": "多色 ×{{count}}",
"inspector.newGross": "新的實測總毛重:",
"inspector.noExtraFields": "尚無額外欄位。您可以新增自訂欄位,追蹤 Spoolman 尚未涵蓋的屬性。",
"inspector.notFound": "找不到此項目,可能已被刪除。",
"inspector.ofRemaining": "剩餘 {{weight}} · 約 {{length}} 公尺",
"inspector.openFilament": "開啟線材規格",
"inspector.openLink": "在新標籤頁中開啟 {{url}}",
"inspector.openManufacturer": "開啟製造商",
"inspector.override.byFilament": "已被此線材覆蓋 · {{value}}",
"inspector.override.bySpool": "已被此線軸覆蓋 · {{value}}",
"inspector.override.overridesFilament": "覆蓋了線材預設 · {{value}}",
"inspector.override.overridesManufacturer": "覆蓋了製造商預設 · {{value}}",
"inspector.specs": "規格 — 直接編輯",
"inspector.spoolsCount": "線軸數 · {{count}}",
"inspector.spoolsTotalRemaining": "總剩餘 {{weight}}",
"inspector.used": "已使用",
"inspector.vendorSub_other": "{{count}} 種線材",
"kofi": "在Ko-fi上打賞我",
"labels.addHead": "",
"labels.addQr": "",
"labels.addRect": "",
"labels.addSwatch": "",
"labels.addText": "",
"labels.align": "",
"labels.alignCenter": "",
"labels.alignLeft": "",
"labels.alignRight": "",
"labels.blank": "",
"labels.bold": "",
"labels.canvasHint": "",
"labels.centerLogo": "",
"labels.chooseField": "",
"labels.clearFill": "",
"labels.copies": "",
"labels.cornerRadiusMm": "",
"labels.createFirst": "",
"labels.cutGuides": "",
"labels.deleteConfirm": "",
"labels.deleteElement": "",
"labels.designName": "",
"labels.designerDesc": "",
"labels.designerTitle": "",
"labels.dpi": "",
"labels.dpiHint": "",
"labels.duplicate": "",
"labels.fields.bedTemp": "",
"labels.fields.diameterMm": "",
"labels.fields.groupVendor": "",
"labels.fields.initialG": "",
"labels.fields.netWeightG": "",
"labels.fields.remainingG": "",
"labels.filamentsSelected_other": "",
"labels.fill": "",
"labels.fitWidth": "",
"labels.fitWidthHint": "",
"labels.fontMm": "",
"labels.gridInfo": "",
"labels.hMm": "",
"labels.heightMm": "",
"labels.hintNowrap": "",
"labels.hintSwatch": "",
"labels.hintWrap": "",
"labels.addHead": "新增元素",
"labels.addQr": "QR 碼",
"labels.addRect": "矩形框",
"labels.addSwatch": "顏色色塊",
"labels.addText": "文字 / 欄位",
"labels.align": "對齊方式",
"labels.alignCenter": "置中",
"labels.alignLeft": "左對齊",
"labels.alignRight": "右對齊",
"labels.blank": "您還沒有任何標籤設計樣式。",
"labels.bold": "粗體",
"labels.canvasHint": "拖曳以移動 · 點擊以選取 · 拖曳控制點以調整大小。",
"labels.centerLogo": "將 Spoolman 標誌置中",
"labels.chooseField": "選擇屬性欄位...",
"labels.clearFill": "清除填色(僅保留外框)",
"labels.copies": "列印份數",
"labels.cornerRadiusMm": "圓角半徑",
"labels.createFirst": "建立您的第一個標籤樣式",
"labels.cutGuides": "剪裁參考線",
"labels.deleteConfirm": "確認刪除此標籤設計?",
"labels.deleteElement": "刪除元素",
"labels.designName": "設計樣式名稱",
"labels.designerDesc": "使用任意線軸、線材或製造商欄位設計 QR 碼標籤,再列印到標籤紙或標籤印表機。設計會儲存在伺服器上。",
"labels.designerTitle": "標籤設計器",
"labels.dpi": "列印解析度",
"labels.dpiHint": "請配合印表機解析度,讓小型標籤保持清晰:多數熱感式標籤印表機為 203 dpi,雷射與噴墨印表機則為 300 dpi 以上。",
"labels.duplicate": "建立副本",
"labels.exportDescAml": "為選中的每個標籤產生一個 .aml 檔案(尺寸 {{w}}×{{h}} mm)。大多數熱敏印表機附帶的 LPAPI 軟體支援讀取此類檔案中的物理尺寸。",
"labels.exportDescPng": "為選中的每個標籤產生一張 PNG 圖片(尺寸 {{w}}×{{h}} mm)。",
"labels.exportFormat": "匯出檔案格式",
"labels.exportFormatOptions.aml": "AML 標籤檔案",
"labels.exportFormatOptions.png": "PNG 圖片",
"labels.fields.bedTemp": "熱床溫度",
"labels.fields.diameterMm": "線徑 (mm)",
"labels.fields.groupVendor": "製造商",
"labels.fields.initialG": "初始總重 (g)",
"labels.fields.netWeightG": "線材淨重 (g)",
"labels.fields.remainingG": "剩餘重量 (g)",
"labels.filamentsSelected_other": "已選擇 {{count}} 種線材",
"labels.fill": "填色",
"labels.fitWidth": "依欄數調整寬度",
"labels.fitWidthHint": "自動設定標籤寬度,使所選的列數正好填滿整張頁面。",
"labels.fontMm": "字號",
"labels.gridInfo": "{{cols}} 列 × {{rows}} 行 = 每頁 {{perPage}} 個標籤",
"labels.hMm": "高",
"labels.heightMm": "高度",
"labels.hintNowrap": "文字保持單行顯示,超出寬度的部分將被截斷。",
"labels.hintSwatch": "列印時會使用線軸所裝線材的顏色。",
"labels.hintWrap": "將欄位包在條件式中,使其在空白時隱藏:",
"labels.imageDesc": "",
"labels.imageZipNote": "",
"labels.insertField": "",
"labels.kind.qr": "",
"labels.kind.rect": "",
"labels.kind.swatch": "",
"labels.kind.text": "",
"labels.labelSize": "",
"labels.labelType": "",
"labels.layout": "",
"labels.modeImage": "",
"labels.modeLabel": "",
"labels.modeSheet": "",
"labels.new": "",
"labels.newText": "",
"labels.noDesignsOption": "",
"labels.noFilaments": "",
"labels.noSpools": "",
"labels.onePerLabel": "",
"labels.preparing": "",
"labels.preview": "",
"labels.previewWith": "",
"labels.printN_other": "",
"labels.radiusMm": "",
"labels.sampleData": "",
"labels.saved": "",
"labels.saving": "",
"labels.searchFilaments": "",
"labels.searchSpools": "",
"labels.selectAllShort": "",
"labels.selectAtLeastOne": "",
"labels.selectAtLeastOneFilament": "",
"labels.selectDesign": "",
"labels.selectElement": "",
"labels.selectNoneShort": "",
"labels.selectRecentHint": "",
"labels.selectRecentShort": "",
"labels.showingFilament": "",
"labels.showingReal": "",
"labels.showingSpool": "",
"labels.showingTags": "",
"labels.sizeMm": "",
"labels.skipHint": "",
"labels.sortHint": "",
"labels.sortId": "",
"labels.sortNewest": "",
"labels.stroke": "",
"labels.strokeMm": "",
"labels.tabDesign": "",
"labels.tabPrint": "",
"labels.textTemplate": "",
"labels.typeFilament": "",
"labels.typeHint": "",
"labels.typeSpool": "",
"labels.unknownFilament": "",
"labels.wMm": "",
"labels.widthMm": "",
"labels.wrapText": "",
"labels.xMm": "",
"labels.yMm": "",
"library.apiError": "",
"library.collapseGroup": "",
"library.emptyBody": "",
"library.emptyFiltered": "",
"library.emptyTitle": "",
"library.expandGroup": "",
"library.filterBy": "",
"library.groupBy": "",
"library.groupNone": "",
"library.groupSpools_other": "",
"library.nUnused": "",
"library.noLocation": "",
"library.noValues": "",
"library.openGroup": "",
"library.openManufacturer": "",
"library.section.extra": "",
"library.section.filament": "",
"library.section.spool": "",
"library.section.vendor": "",
"library.showMore": "",
"library.sort.nozzle": "",
"library.sortBy": "",
"library.unassigned": "",
"library.unitGroups": "",
"library.unitSpools": "",
"library.unused": "",
"library.unusedGroup": "",
"library.unusedGroupMixed": "",
"library.unusedSub": "",
"library.unusedSubMixed": "",
"library.used": "",
"library.usedAgo": "",
"labels.imageZipNote": "超過 {{threshold}} 個標籤時,所有標籤會合併成單一壓縮檔供下載。",
"labels.insertField": "插入變數欄位",
"labels.kind.qr": "QR 碼",
"labels.kind.rect": "矩形框",
"labels.kind.swatch": "色塊",
"labels.kind.text": "文字",
"labels.labelSize": "標籤尺寸",
"labels.labelType": "標籤類型",
"labels.layout": "版面配置",
"labels.modeImage": "檔案",
"labels.modeLabel": "標籤印表機",
"labels.modeSheet": "標籤紙(平鋪)",
"labels.new": "新增",
"labels.newText": "新文字框",
"labels.noDesignsOption": "尚無設計",
"labels.noFilaments": "無線材規格。",
"labels.noSpools": "無線軸。",
"labels.onePerLabel": "每個標籤使用一張 {{w}}×{{h}} mm 的頁面。",
"labels.preparing": "正在準備...",
"labels.preview": "預覽",
"labels.previewWith": "預覽資料來源",
"labels.printN_other": "列印 {{count}} 張標籤",
"labels.radiusMm": "圓角半徑",
"labels.sampleData": "範例資料",
"labels.saveAsFormat": "另存為 {{format}}",
"labels.saved": "已儲存",
"labels.saving": "正在儲存...",
"labels.searchFilaments": "搜尋線材規格...",
"labels.searchSpools": "搜尋線軸...",
"labels.selectAllShort": "全選",
"labels.selectAtLeastOne": "請至少選擇一個線軸。",
"labels.selectAtLeastOneFilament": "請至少選擇一種線材。",
"labels.selectDesign": "選擇標籤樣式",
"labels.selectElement": "選擇要編輯的元素,或從工具欄新增新元素。",
"labels.selectNoneShort": "取消全選",
"labels.selectRecentHint": "選擇過去 24 小時內新增的線軸",
"labels.selectRecentShort": "近期新增 ({{count}})",
"labels.showingFilament": "正在顯示線材規格 #{{id}}",
"labels.showingReal": "正在顯示真實線軸資料。",
"labels.showingSpool": "正在顯示線軸 #{{id}}",
"labels.showingTags": "選擇上方的線軸前,欄位會以佔位符顯示。",
"labels.sizeMm": "尺寸",
"labels.skipHint": "跳過格子可以在第一張標籤前保留空位,方便您繼續使用已打過一部分的標籤紙:從左到右、從上到下數出已被撕掉的標籤數量即可。",
"labels.sortHint": "排序方式",
"labels.sortId": "編號",
"labels.sortNewest": "最新加入",
"labels.stroke": "描邊顏色",
"labels.strokeMm": "描邊粗細",
"labels.tabDesign": "畫布設計",
"labels.tabPrint": "列印設定",
"labels.textTemplate": "文字 / 模板",
"labels.typeFilament": "線材規格標籤",
"labels.typeHint": "線材標籤不包含線軸專用欄位,其 QR 碼會連結到線材而非特定線軸。",
"labels.typeSpool": "實體線軸標籤",
"labels.unknownFilament": "未知線材",
"labels.wMm": "寬",
"labels.widthMm": "寬度",
"labels.wrapText": "自動換行",
"labels.xMm": "X 座標",
"labels.yMm": "Y 座標",
"library.apiError": "無法連線至 Spoolman API。後端服務是否正在執行?",
"library.collapseGroup": "摺疊這些線軸",
"library.dateFilter.ago": "{{span}} 前",
"library.dateFilter.between": "{{from}} – {{to}}",
"library.dateFilter.customRange": "自訂時間段",
"library.dateFilter.from": "起始時間",
"library.dateFilter.last": "最近 {{span}}",
"library.dateFilter.never": "從未",
"library.dateFilter.olderThan": "早於 {{span}}",
"library.dateFilter.since": "自 {{date}} 起",
"library.dateFilter.to": "截止時間(選填)",
"library.dateFilter.until": "截至 {{date}}",
"library.emptyBody": "新增第一個線軸,開始追蹤線材。",
"library.emptyFiltered": "沒有符合目前篩選條件的 {{unit}}。",
"library.emptyTitle": "暫無線軸",
"library.expandGroup": "展開這些線軸",
"library.filterBy": "篩選依據",
"library.groupBy": "群組依據",
"library.groupNone": "無(平面清單)",
"library.groupSpools_other": "{{count}} 個線軸",
"library.nUnused": "×{{count}} 捲未拆封",
"library.noLocation": "未指定存放位置",
"library.noSpools": "無線軸",
"library.noValues": "無資料",
"library.openGroup": "開啟 {{name}}",
"library.openManufacturer": "開啟製造商 {{name}}",
"library.resizeList": "調整清單寬度(雙擊以重設)",
"library.reverseSort": "切換升序/降序排序",
"library.section.extra": "額外欄位",
"library.section.filament": "線材規格",
"library.section.spool": "線軸",
"library.section.vendor": "製造商",
"library.showMore": "展開剩餘 {{count}} 項",
"library.sort.nozzle": "噴頭溫度",
"library.sortBy": "排序依據",
"library.unassigned": "未指派",
"library.unitGroups": "組",
"library.unitSpools": "捲",
"library.unused": "未拆封/未使用",
"library.unusedGroup": "{{count}} 捲未拆封 · 每捲 {{weight}} 克",
"library.unusedGroupMixed": "{{count}} 捲未拆封",
"library.unusedSub": "每捲 {{weight}} 克 · 位於 {{location}}",
"library.unusedSubMixed": "位於 {{location}}",
"library.used": "已拆封/使用中",
"library.usedAgo": "上次使用於 {{time}} 前",
"loading": "讀取中",
"locations.locations": "位置",
"locations.newLocation": "新增位置",
"locations.noLocation": "未設定位置",
"locations.noLocationsHelp": "這個頁面讓您將料盤按位置進行整理,請從增加一些線盤來開始!",
"nav.labels": "",
"nav.library": "",
"nav.labels": "標籤列印",
"nav.library": "線材庫存",
"no": "否",
"notifications.error": "錯誤 ( 狀態碼:{{statusCode}} )",
"notifications.saveSuccessful": "儲存成功!",
"pagination.empty": "",
"pagination.next": "",
"pagination.pageSize": "",
"pagination.perPage": "",
"pagination.prev": "",
"pagination.range": "",
"pagination.empty": "沒有 {{unit}}",
"pagination.next": "下一頁",
"pagination.pageSize": "每頁顯示",
"pagination.perPage": "每頁 {{size}} 筆",
"pagination.prev": "上一頁",
"pagination.range": "第 {{from}}–{{to}} 筆,共 {{total}} 筆 {{unit}}",
"printing.generic.columns": "欄",
"printing.generic.customSize": "自訂",
"printing.generic.description": "調整以下設定以獲得所需的列印佈局。請注意,列表機和您的操作系統可能會套用自己的邊距和縮放比例,因此可能需要進行一些反覆測試才能達到理想效果。在實際標籤上列印之前,先在普通紙上測試。",
"printing.generic.description": "調整以下設定以取得所需的列印版面。請注意,印表機與作業系統可能會套用自己的邊距和縮放比例,因此可能需要多次測試。列印實際標籤前,請先使用一般紙張測試。",
"printing.generic.dimensions": "尺寸",
"printing.generic.gridTooWide": "",
"printing.generic.helpMargin": "邊距應配置為與您的標籤紙和列表機相符,調整這些將影響整個網格的大小。",
"printing.generic.helpPrinterMargin": "安全區域應設定為列表機能列印的距離紙張的邊界,更改此選項不影響整個網格。",
"printing.generic.gridTooWide": "標籤排版寬度已超出頁面總寬。請減少列數、縮小間距或調整頁邊距。",
"printing.generic.helpMargin": "邊距應設定為與您的標籤紙和印表機相符,調整這些將影響整個網格的大小。",
"printing.generic.helpPrinterMargin": "安全區域應設定為印表機能列印的距離紙張的邊界,更改此選項不影響整個網格。",
"printing.generic.horizontalSpacing": "水平間距",
"printing.generic.itemCopies": "項目複製",
"printing.generic.itemCopies": "每項份數",
"printing.generic.marginBottom": "下邊界",
"printing.generic.marginLeft": "左邊界",
"printing.generic.marginRight": "右邊界",
@@ -338,57 +416,61 @@
"printing.generic.skipItems": "跳過項目",
"printing.generic.verticalSpacing": "垂直間距",
"printing.qrcode.button": "列印標籤",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
"printing.qrcode.useHTTPUrl.label": "QR碼連結",
"printing.qrcode.useHTTPUrl.options.custom": "",
"printing.qrcode.customUrl.caveat": "Spoolman 內建掃描器無法直接讀取使用自訂 URL 的 QR 碼;這通常用於連結外部軟體或第三方網頁。",
"printing.qrcode.customUrl.fieldsHint": "也可使用標籤欄位,例如",
"printing.qrcode.customUrl.hint": "可使用佔位符,將線軸識別碼插入連結中的任意位置",
"printing.qrcode.customUrl.label": "自訂 URL 模板",
"printing.qrcode.useHTTPUrl.label": "QR 碼連結",
"printing.qrcode.useHTTPUrl.options.custom": "自訂網頁 URL",
"printing.qrcode.useHTTPUrl.options.default": "預設",
"printing.qrcode.useHTTPUrl.options.url": "URL",
"printing.qrcode.useHTTPUrl.preview": "預覽:",
"printing.qrcode.useHTTPUrl.tooltip": "將使用專屬連結,僅在使用 Spoolman 的掃描功能掃描時有效(預設)。URL 使用設定中指定的基礎 URL,若未設置則使用目前頁面的 URL。",
"printing.spoolSelect.selectedTotal_other": "已選擇 {{count}} 個料盤",
"scanner.description": "掃描Spoolman的QR碼以檢視關於料盤的詳細資料。",
"scanner.error.inUse": "",
"printing.qrcode.useHTTPUrl.tooltip": "「預設」會產生只有 Spoolman 內建掃描器可讀取的專用代碼;「網址」則會產生任何掃描器都能開啟的一般網頁連結。連結會使用設定中的基礎網址;若未設定,則使用目前網站的網址。",
"printing.spoolSelect.selectedTotal_other": "已選擇 {{count}} 個線軸",
"scanner.camera": "相機",
"scanner.cameraNumbered": "相機 {{number}}",
"scanner.description": "掃描 Spoolman QR 碼以檢視線軸的詳細資料。",
"scanner.error.inUse": "攝影機正由其他應用程式使用。",
"scanner.error.insecureContext": "此頁面未通過 HTTPS 提供服務。",
"scanner.error.notAllowed": "您未被允許存取攝影機。",
"scanner.error.notFound": "未找到攝影機。",
"scanner.error.streamApiNotSupported": "瀏覽器不支援MediaStream API。",
"scanner.error.unknown": "發生未知的錯誤。({{error}})",
"scanner.starting": "",
"scanner.title": "掃描QR碼",
"search.colorHint": "",
"search.noResults": "",
"search.searching": "",
"search.section.filaments": "",
"search.section.spools": "",
"search.section.vendors": "",
"search.threshold": "",
"search.unknownFilament": "",
"settings.appearance.tab": "",
"settings.appearance.theme.dark": "",
"settings.appearance.theme.desc": "",
"settings.appearance.theme.label": "",
"settings.appearance.theme.light": "",
"settings.appearance.theme.system": "",
"settings.extraFields.addChoice": "",
"settings.extraFields.addChoicesFirst": "",
"settings.extraFields.addEntityField": "",
"scanner.starting": "正在啟動攝影機...",
"scanner.title": "QR 碼掃描器",
"search.colorHint": "顏色名稱必須輸入英文(如 red、blue)。",
"search.moreSpools": "+ 其餘 {{count}} 個線軸",
"search.noResults": "找不到相符結果",
"search.searching": "正在搜尋...",
"search.section.filaments": "線材規格",
"search.section.spools": "線軸",
"search.section.vendors": "製造商",
"search.threshold": "顏色相符容差",
"search.unknownFilament": "未知線材",
"settings.appearance.tab": "外觀",
"settings.appearance.theme.dark": "深色模式",
"settings.appearance.theme.desc": "介面配色主題(僅對目前瀏覽器生效)",
"settings.appearance.theme.label": "主題設定",
"settings.appearance.theme.light": "淺色模式",
"settings.appearance.theme.system": "跟隨系統",
"settings.extraFields.addChoice": "新增選項...",
"settings.extraFields.addChoicesFirst": "請先新增預設選項",
"settings.extraFields.addEntityField": "新增{{entity}}欄位",
"settings.extraFields.booleanFalse": "否",
"settings.extraFields.booleanTrue": "是",
"settings.extraFields.deleteConfirm": "刪除欄位 {{name}}?",
"settings.extraFields.deleteConfirm": "要刪除 {{entity}} 的欄位「{{name}}」嗎?",
"settings.extraFields.description.constraints": "一旦加入欄位,您將無法更改其鍵值或類型,對於選擇類型欄位,您也無法刪除選項或更改多選狀態。如果刪除欄位,所有實體的相關資料將被刪除。",
"settings.extraFields.description.intro": "在此,您可以為您的實體加入額外的自訂欄位。",
"settings.extraFields.description.keyUsage": "鍵值是其他程式讀取/寫入資料的方式,因此如果您的自訂欄位需要與第三方程式整合,請確保正確設置。預設值僅適用於新品項。",
"settings.extraFields.description.tableViews": "額外的欄位無法在表格視圖中排序或過濾。",
"settings.extraFields.editorEdit": "",
"settings.extraFields.editorNew": "",
"settings.extraFields.errors.choiceNeeded": "",
"settings.extraFields.errors.choicesRemoved": "",
"settings.extraFields.errors.deleteFailed": "",
"settings.extraFields.errors.keyFormat": "",
"settings.extraFields.errors.nameRequired": "",
"settings.extraFields.errors.saveFailed": "",
"settings.extraFields.description.keyUsage": "鍵值是其他程式讀取/寫入資料的方式,因此如果您的自訂欄位需要與第三方程式整合,請確保正確設定。預設值僅適用於新品項。",
"settings.extraFields.description.tableViews": "額外欄位可顯示於表格中並用於排序。選項與布林欄位也會提供篩選選項,所有額外欄位類型都可篩選空值。",
"settings.extraFields.editorEdit": "編輯{{entity}}欄位",
"settings.extraFields.editorNew": "新增{{entity}}欄位",
"settings.extraFields.errors.choiceNeeded": "選項類欄位必須至少包含一個預設選項。",
"settings.extraFields.errors.choicesRemoved": "不能刪除已存在的選項:{{choices}}。",
"settings.extraFields.errors.deleteFailed": "刪除欄位失敗。",
"settings.extraFields.errors.keyFormat": "鍵值只能包含小寫字母、數字和底線。",
"settings.extraFields.errors.nameRequired": "名稱為必填項。",
"settings.extraFields.errors.saveFailed": "儲存欄位失敗。",
"settings.extraFields.fieldType.boolean": "布林",
"settings.extraFields.fieldType.choice": "選項",
"settings.extraFields.fieldType.datetime": "日期時間",
@@ -397,13 +479,13 @@
"settings.extraFields.fieldType.integer": "整數",
"settings.extraFields.fieldType.integerRange": "整數範圍",
"settings.extraFields.fieldType.text": "文字",
"settings.extraFields.max": "",
"settings.extraFields.min": "",
"settings.extraFields.multiSuffix": "",
"settings.extraFields.multiple": "",
"settings.extraFields.namePlaceholder": "",
"settings.extraFields.max": "最大值",
"settings.extraFields.min": "最小值",
"settings.extraFields.multiSuffix": " · 多選",
"settings.extraFields.multiple": "允許多選",
"settings.extraFields.namePlaceholder": "介面顯示名稱",
"settings.extraFields.nonUniqueKeyError": "鍵值必須為唯一。",
"settings.extraFields.none": "",
"settings.extraFields.none": "尚未定義{{entity}}的額外欄位。",
"settings.extraFields.params.choices": "選項",
"settings.extraFields.params.defaultValue": "預設值",
"settings.extraFields.params.fieldType": "類型",
@@ -411,21 +493,37 @@
"settings.extraFields.params.name": "名稱",
"settings.extraFields.params.order": "順序",
"settings.extraFields.params.unit": "單位",
"settings.extraFields.saveField": "",
"settings.extraFields.reorderChoice": "拖曳或使用方向鍵調整此選項的順序。",
"settings.extraFields.saveField": "儲存欄位",
"settings.extraFields.tab": "額外欄位",
"settings.general.baseUrl.label": "基本URL",
"settings.general.baseUrl.tooltip": "生成 QR 碼等功能時使用的基本 URL。",
"settings.general.currency.desc": "",
"settings.general.currency.label": "貨幣設定標籤",
"settings.general.baseUrl.label": "基礎 URL",
"settings.general.baseUrl.tooltip": "產生 QR 碼等功能時使用的基本 URL。",
"settings.general.currency.desc": "顯示價格時使用的三字母 ISO 代碼(例如 EUR、USD)",
"settings.general.currency.label": "貨幣",
"settings.general.roundPrices.label": "價格四捨五入",
"settings.general.roundPrices.tooltip": "將價格四捨五入至最接近的整數。",
"settings.general.tab": "一般",
"settings.header": "設定",
"settings.language.desc": "",
"settings.language.label": "",
"settings.library.lowThreshold.desc": "",
"settings.library.lowThreshold.label": "",
"settings.library.tab": "",
"settings.language.desc": "介面顯示語言(僅對目前瀏覽器生效)",
"settings.language.label": "顯示語言",
"settings.library.lowThreshold.desc": "當線材剩餘重量等於或低於此設定值時,將在介面標紅警示(僅對目前瀏覽器生效)",
"settings.library.lowThreshold.label": "低庫存臨界值",
"settings.library.tab": "線材庫偏好",
"settings.scanner.autoNavigate.desc": "標籤讀取器讀到標籤時,自動在此瀏覽器開啟對應的線軸或線材。",
"settings.scanner.autoNavigate.label": "讀卡自動跳轉",
"settings.scanner.docs": "如何設定標籤讀取器",
"settings.scanner.lastSeen": "{{when}} 前",
"settings.scanner.pair": "感應標籤以配對",
"settings.scanner.reader.anyDesc": "任何讀取器的掃描都可在此瀏覽器開啟線軸。若需要更精確的控制,可配對特定讀取器;新增線軸或線材標籤時也會使用這個讀取器。",
"settings.scanner.reader.label": "標籤讀取器",
"settings.scanner.reader.pairedDesc": "只有此讀取器的掃描會在本瀏覽器開啟線軸。",
"settings.scanner.recent": "最近使用的讀取器",
"settings.scanner.recentNone": "目前沒有已記錄的讀取器。設定標籤讀取器並讀取任意標籤後,它就會顯示在這裡。",
"settings.scanner.tab": "標籤掃描",
"settings.scanner.unpair": "使用任意讀取器",
"settings.scanner.use": "使用這個讀取器",
"settings.scanner.waiting": "請在要使用的讀取器上感應任意標籤...",
"sponsor": "在 GitHub 上贊助專案",
"spool.fields.archived": "封存",
"spool.fields.comment": "備註",
"spool.fields.filament": "線材",
@@ -439,32 +537,87 @@
"spool.fields.price": "價格",
"spool.fields.registered": "登記於",
"spool.fields.remainingWeight": "剩餘重量",
"spool.fields.spoolWeight": "線軸重量",
"spool.fields.usedWeight": "已用重量",
"spool.fieldsHelp.location": "如果您的料盤存於多個位置,請填寫料盤所在位置。",
"spool.fieldsHelp.lotNr": "製造商的批號。如果使用多個線盤,可用於確保列印的顏色一致。",
"spool.fieldsHelp.measuredWeight": "線材和料盤的重量為多少。",
"spool.fieldsHelp.price": "完整線盤的價格。如果未設定,則會假設為線材的價格。",
"spool.fieldsHelp.remainingWeight": "料盤所剩餘的線材。對於新的料盤來說應等於料盤重量。",
"spool.fieldsHelp.usedWeight": "料盤所使用的線材重量。一個新料盤應該為0克。",
"spool.fields.weight": "初始線材淨重",
"spool.fieldsHelp.location": "如果您的線軸存於多個位置,請填寫線軸所在位置。",
"spool.fieldsHelp.lotNr": "製造商的批號。如果使用多個線軸,可用於確保列印的顏色一致。",
"spool.fieldsHelp.measuredWeight": "線材和線軸的重量為多少。",
"spool.fieldsHelp.price": "完整線軸的價格。如果未設定,則會假設為線材的價格。",
"spool.fieldsHelp.remainingWeight": "線軸所剩餘的線材。對於新的線軸來說應等於線軸重量。",
"spool.fieldsHelp.spoolWeight": "此線軸空載時的重量。留空會沿用線材的預設線軸重量;若此線軸不同,例如重新繞線或更換軸心材質,請在此單獨設定。依實測重量調整時會扣除此重量,數值不正確會影響剩餘線材的計算。",
"spool.fieldsHelp.usedWeight": "此線軸已使用的線材重量。新線軸應為 0 g。",
"spool.fieldsHelp.weight": "此線軸裝滿時的線材淨重,不含線軸本身。留空會沿用線材的預設重量;若此線軸不同,例如只有部分線材或容量較大,請在此單獨設定。",
"spool.form.measurementType.length": "長度",
"spool.form.measurementType.weight": "重量",
"spool.spool": "料盤",
"topbar.addSpools": "",
"topbar.searchPlaceholder": "",
"spool.spool": "線軸",
"tags.add": "新增標籤",
"tags.added": "標籤已連結。",
"tags.addedOn": "連結於 {{date}}",
"tags.modal.alreadyHere": "此標籤已識別這個線軸。",
"tags.modal.alreadyHereFilament": "此標籤已識別這個線材。",
"tags.modal.badUid": "標籤 UID 應為十六進位格式,如 04A2B3C4。",
"tags.modal.checking": "正在檢查...",
"tags.modal.failed": "無法連結標籤。",
"tags.modal.filamentNamed": "線材 {{name}}",
"tags.modal.free": "此標籤尚未連結任何項目。",
"tags.modal.intro": "日後在讀取器上感應此標籤,即可找到線軸 #{{id}}。",
"tags.modal.introFilament": "日後在讀取器上感應此標籤,即可找到這個線材。",
"tags.modal.lastSeenOn": "{{reader}} 上次讀取了 {{uid}}。",
"tags.modal.link": "連結標籤",
"tags.modal.linking": "正在連結...",
"tags.modal.move": "從 #{{id}} 移到這裡",
"tags.modal.moveFailed": "無法移動標籤。標籤目前可能未連結任何線軸,請重新連結。",
"tags.modal.moveFailedFilament": "無法移動標籤。標籤目前可能未連結任何項目,請重新連結。",
"tags.modal.moveHere": "移到這裡",
"tags.modal.moving": "正在移動...",
"tags.modal.phoneReading": "請將標籤貼近手機感應區…",
"tags.modal.phoneScan": "使用目前手機讀取標籤",
"tags.modal.spoolNamed": "線軸 #{{id}}({{name}})",
"tags.modal.spoolShort": "線軸 #{{id}}",
"tags.modal.takenBy": "目前連結至 {{spool}}。",
"tags.modal.title": "連結標籤",
"tags.modal.uidHelp": "分隔符和大小寫不影響識別:04:a2:b3:c4、04-A2-B3-C4 與 04a2b3c4 代表同一張標籤。",
"tags.modal.uidLabel": "標籤 UID",
"tags.modal.uidPlaceholder": "例如:04A2B3C4",
"tags.modal.useLastSeen": "使用它",
"tags.modal.waitingAny": "等待任意讀取器感應標籤...",
"tags.modal.waitingOn": "等待讀取器 {{reader}} 感應標籤...",
"tags.moved": "標籤已從線軸 #{{id}} 轉移至此處。",
"tags.movedFromFilament": "已將標籤從線材 {{name}} 移到這裡。",
"tags.nfc.insecureContext": "讀取 NFC 標籤需要安全連線。請在 HTTPS 環境下開啟 Spoolman 以呼叫手機 NFC 功能。",
"tags.nfc.notAllowed": "目前頁面未獲得 NFC 使用權限。請在瀏覽器網站設定中授予權限後重試。",
"tags.nfc.notReadable": "NFC 模組不可用 — 可能有其他套用正在佔用。",
"tags.nfc.notSupported": "此手機不支援 NFC,或系統設定中未開啟 NFC 功能。",
"tags.nfc.unknown": "無法讀取標籤。",
"tags.none": "尚未連結標籤。",
"tags.scan.openedFilament": "已掃描標籤:已開啟線材 {{name}}。",
"tags.scan.openedSpool": "已掃描標籤:已開啟線軸 #{{id}}({{name}})。",
"tags.scan.unknown": "未知標籤 {{uid}}。請開啟其所屬線軸,使用「新增標籤」加以連結。",
"tags.section": "電子標籤",
"tags.unlink": "解除連結",
"tags.unlinkBody": "解除後,標籤 {{uid}} 將不再識別這個線軸。標籤本身不會寫入任何資料,因此之後仍可將它重新連結到此線軸或其他線軸。",
"tags.unlinkBodyFilament": "解除後,標籤 {{uid}} 將不再識別這個線材。標籤本身不會寫入任何資料,因此之後仍可將它重新連結到此線材或其他項目。",
"tags.unlinkFailed": "無法解除標籤連結。",
"tags.unlinkTitle": "要解除此標籤的連結嗎?",
"tags.unlinked": "已解除標籤連結。",
"tags.unlinking": "正在解除連結...",
"topbar.addSpools": "新增線軸",
"topbar.searchPlaceholder": "搜尋,例如「red」或「pla」",
"unknown": "未知",
"validation.hexDigits": "",
"validation.maxChars": "",
"validation.mustBeGt": "",
"validation.mustBeMax": "",
"validation.mustBeMin": "",
"validation.mustBeNumber": "",
"validation.required": "",
"validation.hexDigits": "請使用 6 位或 8 位十六進位字元",
"validation.maxChars": "最多允許 {{max}} 個字元",
"validation.mustBeGt": "必須大於 {{value}}",
"validation.mustBeMax": "必須小於或等於 {{value}}",
"validation.mustBeMin": "必須大於或等於 {{value}}",
"validation.mustBeNumber": "必須為數字",
"validation.required": "此項為必填項",
"vendor.fields.comment": "備註",
"vendor.fields.emptySpoolWeight": "空料盤重量",
"vendor.fields.externalId": "外部ID",
"vendor.fields.emptySpoolWeight": "空線軸重量",
"vendor.fields.externalId": "外部 ID",
"vendor.fields.id": "ID",
"vendor.fields.name": "名稱",
"vendor.fields.registered": "已登記",
"vendor.fieldsHelp.emptySpoolWeight": "此製造商空線盤的重量。",
"vendor.fieldsHelp.emptySpoolWeight": "此製造商空線軸的重量。",
"yes": "是"
}
+442 -414
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -35,6 +35,8 @@
"ta",
"th",
"lt",
"tr"
"tr",
"sl",
"ko"
]
}
+15 -3
View File
@@ -76,6 +76,15 @@ async function ensureOk(res: Response, method: string, path: string): Promise<Re
export interface RawPage {
items: unknown[];
total: number;
/** The group endpoint's totals across every page, when it sends them. */
totalSpools?: number;
totalRemaining?: number;
}
function numberHeader(res: Response, name: string): number | undefined {
const header = res.headers.get(name);
const n = header != null && header !== '' ? Number(header) : NaN;
return Number.isNaN(n) ? undefined : n;
}
// Reads take an optional AbortSignal so a view can cancel what it no longer needs
@@ -91,9 +100,12 @@ export async function getList(
): Promise<RawPage> {
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
const items = (await res.json()) as unknown[];
const header = res.headers.get('x-total-count');
const total = header != null && header !== '' ? Number(header) : items.length;
return { items, total: Number.isNaN(total) ? items.length : total };
return {
items,
total: numberHeader(res, 'x-total-count') ?? items.length,
totalSpools: numberHeader(res, 'x-total-spools'),
totalRemaining: numberHeader(res, 'x-total-remaining-weight')
};
}
export async function getJson<T = unknown>(
+2
View File
@@ -11,6 +11,8 @@ export interface Info {
external_db_name: string;
git_commit?: string;
build_date?: string;
latest_version?: string | null;
update_available?: boolean;
}
export function getInfo(): Promise<Info> {
+12 -1
View File
@@ -172,7 +172,18 @@ export function mapGroup(g: Json): GroupSummary {
totalRemaining: g.total_remaining_weight ?? 0,
hasStock: (g.spool_count ?? 0) > 0,
lastUsedLabel: formatDurationShort(g.last_used),
lastUsedSort: 0
lastUsedSort: 0,
filamentCount: g.filament_count ?? undefined,
previewSpools: (g.spools as Json[] | undefined)?.map((s) => ({
id: String(s.id),
remaining: s.remaining_weight ?? null,
initial: s.initial_weight ?? null
})),
previewFilaments: (g.filaments as Json[] | undefined)?.map((f) => ({
id: String(f.id),
colors: colorsFromApi(f),
direction: f.multi_color_direction ?? undefined
}))
};
}
+118 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildGroupQuery } from './query';
import { allPages, buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query';
import type { LibraryState } from '$lib/library/params';
// A filament with no spools is the one you need to re-order, and grouping by
@@ -14,6 +14,7 @@ import type { LibraryState } from '$lib/library/params';
function state(over: Partial<LibraryState> = {}): LibraryState {
return {
view: 'spools',
selection: null,
group: 'filament',
sortKey: 'last_used',
@@ -21,6 +22,7 @@ function state(over: Partial<LibraryState> = {}): LibraryState {
filters: [],
showArchived: false,
showEmpty: true,
showLotNumbers: false,
page: 1,
pageSize: 20,
...over
@@ -87,3 +89,118 @@ describe('buildGroupQuery — includeEmpty', () => {
expect(buildGroupQuery(state({ showArchived: false })).includeEmpty).toBe(true);
});
});
// The catalog sub-views page the group endpoint too, always including the empty
// groups. The order is what holds the filament list's manufacturer sections
// together across pages, so it is pinned down here.
describe('buildCatalogQuery', () => {
const catalog = (over: Partial<LibraryState> = {}) =>
state({ view: 'filaments', group: 'vendor', sortKey: 'name', sortAsc: true, showEmpty: false, ...over });
it('lists every filament, manufacturer first, then by name', () => {
const q = buildCatalogQuery(catalog({ page: 3, pageSize: 50 }));
expect(q).toMatchObject({ field: 'filament', includeEmpty: true, preview: true, limit: 50, offset: 100 });
expect(q.sort).toEqual([
{ field: 'group.vendor_name', dir: 'asc' },
{ field: 'group.title', dir: 'asc' }
]);
});
it('drops the manufacturer lead when the list is flat', () => {
expect(buildCatalogQuery(catalog({ group: 'none', sortAsc: false })).sort).toEqual([
{ field: 'group.title', dir: 'desc' }
]);
});
it('breaks weight and recency ties by name', () => {
expect(buildCatalogQuery(catalog({ sortKey: 'remaining_weight', sortAsc: false })).sort).toEqual([
{ field: 'group.vendor_name', dir: 'asc' },
{ field: 'group.total_remaining', dir: 'desc' },
{ field: 'group.title', dir: 'asc' }
]);
expect(buildCatalogQuery(catalog({ group: 'none', sortKey: 'last_used' })).sort).toEqual([
{ field: 'group.last_used', dir: 'asc' },
{ field: 'group.title', dir: 'asc' }
]);
});
it('pages manufacturers in the manufacturer view', () => {
const q = buildCatalogQuery(catalog({ view: 'manufacturers', group: 'none' }));
expect(q).toMatchObject({ field: 'vendor', includeEmpty: true, preview: true });
expect(q.sort).toEqual([{ field: 'group.title', dir: 'asc' }]);
});
it('orders by the fields of the filament and manufacturer, custom ones too', () => {
const sortOf = (sortKey: string, view: LibraryState['view'] = 'filaments') =>
buildCatalogQuery(catalog({ view, group: 'none', sortKey, sortAsc: false })).sort[0];
expect(sortOf('filament.settings_extruder_temp')).toEqual({
field: 'filament.settings_extruder_temp',
dir: 'desc'
});
expect(sortOf('filament.extra.shelf')).toEqual({ field: 'filament.extra.shelf', dir: 'desc' });
expect(sortOf('filament.vendor.extra.country', 'manufacturers')).toEqual({
field: 'filament.vendor.extra.country',
dir: 'desc'
});
expect(sortOf('filament_count', 'manufacturers')).toEqual({ field: 'group.filament_count', dir: 'desc' });
// A filament's custom field means nothing to a manufacturer, nor a spool's to either.
expect(sortOf('filament.extra.shelf', 'manufacturers').field).toBe('group.title');
expect(sortOf('location').field).toBe('group.title');
});
it('takes any filter, leaving out the empty ones under a filter about spools', () => {
// "Which filaments do I have on Shelf A" is a fair question of the catalog,
// but a filament with no spools isn't anywhere, and the API refuses to say.
const filters = [{ prop: 'location', value: 'Shelf A' }];
expect(buildCatalogQuery(catalog({ filters }))).toMatchObject({ includeEmpty: false });
expect(buildVendorTotalsQuery(catalog({ filters }), ['1'])).toMatchObject({ includeEmpty: false });
expect(buildCatalogQuery(catalog({ filters: [{ prop: 'material', value: 'PLA' }] }))).toMatchObject({
includeEmpty: true
});
});
it('narrows the section totals to the manufacturers on the page', () => {
const filters = [{ prop: 'material', value: 'PLA' }];
const q = buildVendorTotalsQuery(catalog({ filters, showArchived: true }), ['1', '4']);
expect(q).toMatchObject({
field: 'vendor',
includeEmpty: true,
allowArchived: true,
limit: 2,
offset: 0
});
expect(q.filters).toEqual({ material: ['PLA'], vendorId: ['1', '4'] });
});
});
// The dashboard and the filament inspector used to stop at their first page
// (1000 groups, 100 spools) and show the rest as if it didn't exist.
describe('allPages', () => {
const rows = Array.from({ length: 25 }, (_, i) => i);
const pager = (size: number) => {
const offsets: number[] = [];
const fetchPage = async (offset: number) => {
offsets.push(offset);
return { items: rows.slice(offset, offset + size), total: rows.length };
};
return { offsets, fetchPage };
};
it('keeps fetching until it has every row', async () => {
const { offsets, fetchPage } = pager(10);
expect(await allPages(10, fetchPage)).toEqual(rows);
expect(offsets).toEqual([0, 10, 20]);
});
it('stops after one request when the first page holds everything', async () => {
const { offsets, fetchPage } = pager(100);
expect(await allPages(100, fetchPage)).toEqual(rows);
expect(offsets).toEqual([0]);
});
it('stops on an exact multiple without asking for an empty page', async () => {
const { offsets, fetchPage } = pager(5);
expect(await allPages(5, fetchPage)).toHaveLength(25);
expect(offsets).toEqual([0, 5, 10, 15, 20]);
});
});
+84 -20
View File
@@ -1,7 +1,6 @@
import type { GroupField, GroupQuery, GroupSummary, SortField, SpoolQuery } from './types';
import type { LibraryState } from '$lib/library/params';
import { resolveSortField, resolveGroupSortField } from '$lib/utils/library';
import { isDateFilterProp } from '$lib/library/dateFilter';
import type { GroupField, GroupQuery, GroupSummary, Page, SortField, SpoolQuery } from './types';
import { isSpoolScopedFilter, type CatalogView, type LibraryState } from '$lib/library/params';
import { resolveSortField, resolveGroupSortField, catalogSortField } from '$lib/utils/library';
import { settings } from '$lib/stores/settings.svelte';
// Translates the URL-borne LibraryState into concrete /spool and /spool/group
@@ -20,22 +19,6 @@ function currentFilters(state: LibraryState): Record<string, string[]> {
return filters;
}
/**
* Filter props that describe a SPOOL rather than the filament it is of.
*
* The distinction only matters for empty filament groups (see includeEmpty
* below). Everything not listed here — filament, material, vendor, direction,
* and the `filament.extra.` / `filament.vendor.extra.` fields — is a fact about
* the filament, which a filament with no spools still has.
*/
function isSpoolScopedFilter(prop: string): boolean {
if (prop === 'location' || prop === 'lot') return true;
if (isDateFilterProp(prop)) return true;
// `extra.<key>` is the spool's own custom field; the filament's and the
// vendor's are prefixed, so a bare `extra.` is the spool-scoped one.
return prop.startsWith('extra.');
}
/**
* Whether to ask for filaments that have no spools at all (#1092).
*
@@ -82,6 +65,70 @@ export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): Grou
};
}
/**
* Whether a catalog page lists the filaments (or manufacturers) with no spools
* too. It does unless a filter asks about the spools themselves: a filament with
* none can't be on Shelf A, and the API refuses the pairing (see wantsEmptyGroups).
*/
function catalogIncludesEmpty(state: LibraryState): boolean {
return !state.filters.some((f) => isSpoolScopedFilter(f.prop));
}
/**
* Page of a catalog sub-view: one group per filament (or manufacturer), those
* with no spools included (see catalogIncludesEmpty), with the preview the row
* draws from.
*/
export function buildCatalogQuery(state: LibraryState, signal?: AbortSignal): GroupQuery {
const field = catalogSortField(state.view as CatalogView, state.sortKey) ?? 'group.title';
const sort: SortField[] = [{ field, dir: state.sortAsc ? 'asc' : 'desc' }];
// Manufacturer sections are runs of consecutive rows (see library/catalog), so
// the manufacturer has to lead the order or a section would break apart.
if (state.view === 'filaments' && state.group === 'vendor') {
sort.unshift({ field: 'group.vendor_name', dir: 'asc' });
}
// Most orders tie a lot (every filament with no spools has 0 g and was never
// used; many share a material), so the name settles ties instead of the
// database's whim.
if (field !== 'group.title') sort.push({ field: 'group.title', dir: 'asc' });
return {
field: state.view === 'manufacturers' ? 'vendor' : 'filament',
filters: currentFilters(state),
sort,
allowArchived: state.showArchived,
includeEmpty: catalogIncludesEmpty(state),
preview: true,
limit: state.pageSize,
offset: (state.page - 1) * state.pageSize,
lowThreshold: settings.lowThreshold,
signal
};
}
/**
* The aggregates behind the manufacturer section headers on one page of the
* filament catalog: the same filters, narrowed to the manufacturers on screen.
* Including the empty ones whenever the page does is what makes the filament
* count cover the same filaments as the rows under the header.
*/
export function buildVendorTotalsQuery(
state: LibraryState,
vendorIds: string[],
signal?: AbortSignal
): GroupQuery {
return {
field: 'vendor',
filters: { ...currentFilters(state), vendorId: vendorIds },
sort: [],
allowArchived: state.showArchived,
includeEmpty: catalogIncludesEmpty(state),
limit: vendorIds.length,
offset: 0,
lowThreshold: settings.lowThreshold,
signal
};
}
/** Spools of one group, ordered by the chosen Sort. */
export function buildScopedSpoolQuery(
state: LibraryState,
@@ -123,3 +170,20 @@ export function buildFlatSpoolQuery(state: LibraryState, signal?: AbortSignal):
signal
};
}
/**
* Every row of a paged list, fetched `size` at a time: for views that need the
* whole set (every dashboard card, every spool of one filament) without asking
* the server for all of it in one response.
*/
export async function allPages<T>(
size: number,
fetchPage: (offset: number) => Promise<Page<T>>
): Promise<T[]> {
const all: T[] = [];
for (;;) {
const page = await fetchPage(all.length);
all.push(...page.items);
if (page.items.length < size || all.length >= page.total) return all;
}
}
+190
View File
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config', () => ({ API_BASE: 'http://spoolman.test/api/v1' }));
import { inventory } from '$lib/stores/inventory.svelte';
import { spoolSource } from './spoolSource';
// GET /location only knows locations that hold a spool, so an idle toolhead set up
// as a dashboard card could not be picked for a spool (#1261).
const setting = (value: unknown) => ({ value: JSON.stringify(value), is_set: true, type: 'object' });
function serve(locations: string[], settings: Record<string, unknown> | null) {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.endsWith('/location')) return Response.json(locations);
if (settings === null) return new Response('{}', { status: 500 });
return Response.json(settings);
})
);
}
afterEach(() => vi.unstubAllGlobals());
describe('locationChoices', () => {
it('adds the empty locations kept as dashboard cards', async () => {
serve(['Shelf', 'Printer 1'], {
dashboard_groups: setting({ location: ['Toolhead 1', 'Shelf', ''], material: ['PLA'] })
});
expect(await spoolSource.locationChoices()).toEqual(['Printer 1', 'Shelf', 'Toolhead 1']);
});
it("reads the old client's saved locations when the dashboard has none", async () => {
serve(['Shelf'], { locations: setting(['Toolhead 2']) });
expect(await spoolSource.locationChoices()).toEqual(['Shelf', 'Toolhead 2']);
});
it('still offers the locations in use when settings fail to load', async () => {
serve(['Shelf'], null);
expect(await spoolSource.locationChoices()).toEqual(['Shelf']);
});
});
describe('importExternalFilament', () => {
const ext = {
id: 'Prusa_PLA',
manufacturer: 'Prusa',
name: 'PLA',
material: 'PLA',
density: 1.24,
diameter: 1.75,
weight: 1000
};
type ApiVendor = { id: number; name: string; external_id?: string };
function serveImport(vendors: ApiVendor[], filamentIds: string[] = []) {
const fetch = vi.fn(async (url: string, init?: RequestInit) => {
const parsed = new URL(url);
const resource = parsed.pathname.split('/').pop();
if (init?.method === 'POST') {
const body = JSON.parse(init.body as string);
if (resource === 'vendor') {
const vendor = { id: 100, ...body };
vendors.push(vendor);
return Response.json(vendor);
}
return Response.json({ id: 200, ...body, vendor: vendors.find((v) => v.id === body.vendor_id) });
}
const rows =
resource === 'vendor' ? vendors : filamentIds.map((external_id, i) => ({ id: i + 1, external_id }));
const term = parsed.searchParams.get('external_id');
if (term === null) return Response.json(rows);
return Response.json(
rows.filter((row) =>
term.startsWith('"') && term.endsWith('"')
? row.external_id === term.slice(1, -1)
: row.external_id?.toLowerCase().includes(term.toLowerCase())
)
);
});
vi.stubGlobal('fetch', fetch);
return fetch;
}
it('reuses a hand-made vendor with the same name in different case without editing it', async () => {
const vendor = { id: 10, name: 'pRUSA' };
const fetch = serveImport([vendor]);
expect((await spoolSource.importExternalFilament(ext)).vendorId).toBe('10');
expect(vendor).toEqual({ id: 10, name: 'pRUSA' });
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1);
expect(fetch.mock.calls.every(([, init]) => !['PATCH', 'PUT'].includes(init?.method ?? ''))).toBe(true);
});
it('prefers an exact external_id over a matching name', async () => {
const fetch = serveImport([
{ id: 10, name: 'Prusa' },
{ id: 20, name: 'Renamed manufacturer', external_id: 'Prusa' }
]);
expect((await spoolSource.importExternalFilament(ext)).vendorId).toBe('20');
expect(fetch.mock.calls.map(([url]) => new URL(url).searchParams.get('external_id'))).toEqual([
'"Prusa_PLA"',
'"Prusa"',
null
]);
});
it('does not reuse substring external_ids for vendors or filaments', async () => {
const vendors = [{ id: 10, name: 'Prusament', external_id: 'Prusament' }];
const fetch = serveImport(vendors, ['Prusa_PLA_Plus']);
const imported = await spoolSource.importExternalFilament(ext);
expect(imported.id).toBe('200');
expect(imported.vendorId).toBe('100');
expect(vendors[1]).toEqual({ id: 100, name: 'Prusa', external_id: 'Prusa' });
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(2);
});
it('returns an already imported filament without creating anything', async () => {
const fetch = serveImport([], [ext.id]);
expect((await spoolSource.importExternalFilament(ext)).id).toBe('1');
expect(fetch).toHaveBeenCalledTimes(1);
expect(new URL(fetch.mock.calls[0][0]).searchParams.get('external_id')).toBe('"Prusa_PLA"');
});
});
describe('createFilament', () => {
it.each([undefined, 0, 0.5])('sends optional numeric values unchanged: %s', async (value) => {
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
expect(body.spool_weight).toBe(value);
expect(body.price).toBe(value);
expect(body.settings_extruder_temp).toBe(value);
expect(body.settings_bed_temp).toBe(value);
return Response.json({ id: 1, ...body });
});
vi.stubGlobal('fetch', fetch);
await spoolSource.createFilament({
name: 'Test',
vendorName: '',
material: 'PLA',
density: 1.24,
diameter: 1.75,
colors: [],
spoolWeight: value,
price: value,
nozzleTemp: value,
bedTemp: value
});
expect(fetch).toHaveBeenCalledOnce();
});
});
describe('listFilamentPage', () => {
it.each(['asc', 'desc'] as const)(
'requests a bounded page in %s order and caches its relations',
async (dir) => {
const fetch = vi.fn(async () =>
Response.json([{ id: 1201, name: 'Page filament', vendor: { id: 1201, name: 'Page vendor' } }], {
headers: { 'X-Total-Count': '1500' }
})
);
vi.stubGlobal('fetch', fetch);
const signal = new AbortController().signal;
const page = await spoolSource.listFilamentPage({
sort: [{ field: 'id', dir }],
limit: 100,
offset: 100,
signal
});
expect(fetch).toHaveBeenCalledExactlyOnceWith(
`http://spoolman.test/api/v1/filament?sort=id%3A${dir}&limit=100&offset=100`,
{ signal }
);
expect(page.total).toBe(1500);
expect(page.items.map((f) => f.id)).toEqual(['1201']);
expect(inventory.filamentById('1201')).toEqual(page.items[0]);
expect(inventory.vendorById('1201')?.name).toBe('Page vendor');
}
);
it('keeps the preview batch API returning an array', async () => {
const fetch = vi.fn(async () => Response.json([{ id: 1, name: 'Preview' }]));
vi.stubGlobal('fetch', fetch);
expect((await spoolSource.listFilaments(8)).map((f) => f.id)).toEqual(['1']);
expect(fetch).toHaveBeenCalledExactlyOnceWith(
'http://spoolman.test/api/v1/filament?sort=id%3Adesc&limit=8&offset=0',
{ signal: undefined }
);
});
});
+56 -31
View File
@@ -27,6 +27,7 @@ import { filamentLabel, type FilterOption } from '$lib/utils/library';
import type { EntityType } from './fields';
import { type ExternalFilament } from './external';
import { searchAll } from './search';
import { loadLayout } from '$lib/dashboard/layout';
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
@@ -64,7 +65,11 @@ const FILTER_PARAM: Record<string, string> = {
vendor: 'filament.vendor.name',
direction: 'filament.multi_color_direction',
location: 'location',
lot: 'lot_nr'
lot: 'lot_nr',
// Not in the filter menu, which picks manufacturers by name: the manufacturer
// inspector's "Show as a list" filters by id, so two of the same name stay
// apart, and the filament list narrows its section totals the same way.
vendorId: 'filament.vendor.id'
};
// Filters whose values are passed through verbatim rather than double-quoted for
@@ -72,7 +77,8 @@ const FILTER_PARAM: Record<string, string> = {
// an int and would reject if quoted. `direction` is passed verbatim so its empty
// value (single-color = no direction) hits the backend's NULL-match branch, which
// quoting would turn into an exact match on the literal empty string instead.
const UNQUOTED_FILTERS = new Set(['filament', 'direction']);
// `vendorId` is numeric like `filament`.
const UNQUOTED_FILTERS = new Set(['filament', 'direction', 'vendorId']);
// A filter prop is either one of the fixed categories above or an extra-field
// filter whose prop is already the query param the backend expects — `extra.<key>`
@@ -167,11 +173,12 @@ class HttpSpoolSource {
limit: query.limit,
offset: query.offset,
allow_archived: query.allowArchived ? 'true' : undefined,
include_empty: query.includeEmpty ? 'true' : undefined
include_empty: query.includeEmpty ? 'true' : undefined,
preview: query.preview ? 'true' : undefined
};
applyFilters(params, query.filters);
const { items, total } = await getList('/spool/group', params, query.signal);
const { items, total, totalSpools, totalRemaining } = await getList('/spool/group', params, query.signal);
for (const g of items as Json[]) {
if (g.filament) {
inventory.upsertFilament(mapFilament(g.filament));
@@ -179,7 +186,7 @@ class HttpSpoolSource {
}
if (g.vendor) inventory.upsertVendor(mapVendor(g.vendor));
}
return { items: (items as Json[]).map(mapGroup), total };
return { items: (items as Json[]).map(mapGroup), total, totalSpools, totalRemaining };
}
async listSpools(query: SpoolQuery): Promise<Page<Spool>> {
@@ -196,20 +203,32 @@ class HttpSpoolSource {
return { items: cacheSpools(items), total };
}
/**
* Load a batch of filaments for the label picker/preview, newest first, and
* cache them. Filament catalogs are far smaller than spool inventories, so a
* single generous page covers virtually every library; the label picker also
* has a search box for anything past the cap.
*/
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
const items = await getJson<Json[]>('/filament', { limit, sort: 'id:desc' }, signal);
const filaments = items.map((f) => {
/** Load and cache one page of filaments, including the catalog total. */
async listFilamentPage(
query: Pick<SpoolQuery, 'sort' | 'limit' | 'offset' | 'signal'>
): Promise<Page<Filament>> {
const { items, total } = await getList(
'/filament',
{ sort: sortParam(query.sort), limit: query.limit, offset: query.offset },
query.signal
);
const filaments = (items as Json[]).map((f) => {
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
return mapFilament(f);
});
inventory.upsertFilaments(filaments);
return filaments;
return { items: filaments, total };
}
/** Load a batch for label previews, newest first. */
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
const { items } = await this.listFilamentPage({
sort: [{ field: 'id', dir: 'desc' }],
limit,
offset: 0,
signal
});
return items;
}
async searchFilaments(query: string, limit = 8): Promise<Filament[]> {
@@ -254,7 +273,7 @@ class HttpSpoolSource {
*/
async importExternalFilament(ext: ExternalFilament): Promise<Filament> {
// Already imported?
const existing = await getJson<Json[]>('/filament', { external_id: ext.id });
const existing = await getJson<Json[]>('/filament', { external_id: quote(ext.id) });
if (existing.length) {
if (existing[0].vendor) inventory.upsertVendor(mapVendor(existing[0].vendor));
const f = mapFilament(existing[0]);
@@ -262,19 +281,14 @@ class HttpSpoolSource {
return f;
}
// Get-or-create the vendor (matched by external_id = manufacturer name).
let vendorId: number;
const vendors = await getJson<Json[]>('/vendor', { external_id: ext.manufacturer });
// Match the vendor by external_id first, then by name.
let vendorId: number | undefined;
const vendors = await getJson<Json[]>('/vendor', { external_id: quote(ext.manufacturer) });
if (vendors.length) {
inventory.upsertVendor(mapVendor(vendors[0]));
vendorId = vendors[0].id;
} else {
const created = await postJson<Json>('/vendor', {
name: ext.manufacturer,
external_id: ext.manufacturer
});
inventory.upsertVendor(mapVendor(created));
vendorId = created.id;
vendorId = await this.getOrCreateVendor(ext.manufacturer, undefined, ext.manufacturer);
}
const body: Json = {
@@ -305,10 +319,10 @@ class HttpSpoolSource {
/**
* Find a vendor by (case-insensitive) name or create it; returns its id.
*
* `extra` only applies to a vendor this call creates — matching an existing
* `extra` and `externalId` only apply to a vendor this call creates — matching an existing
* name links that record, it never edits it.
*/
async getOrCreateVendor(name: string, extra?: Extra): Promise<number | undefined> {
async getOrCreateVendor(name: string, extra?: Extra, externalId?: string): Promise<number | undefined> {
const trimmed = name.trim();
if (!trimmed) return undefined;
const all = await getJson<Json[]>('/vendor');
@@ -319,6 +333,7 @@ class HttpSpoolSource {
}
const created = await postJson<Json>('/vendor', {
name: trimmed,
external_id: externalId,
extra: extra && Object.keys(extra).length ? extra : undefined
});
inventory.upsertVendor(mapVendor(created));
@@ -335,10 +350,10 @@ class HttpSpoolSource {
density: draft.density,
diameter: draft.diameter,
weight: draft.weight || undefined,
spool_weight: draft.spoolWeight || undefined,
settings_extruder_temp: draft.nozzleTemp || undefined,
settings_bed_temp: draft.bedTemp || undefined,
price: draft.price || undefined,
spool_weight: draft.spoolWeight,
settings_extruder_temp: draft.nozzleTemp,
settings_bed_temp: draft.bedTemp,
price: draft.price,
article_number: draft.articleNumber || undefined,
comment: draft.comment || undefined,
extra: draft.extra && Object.keys(draft.extra).length ? draft.extra : undefined,
@@ -532,6 +547,16 @@ class HttpSpoolSource {
async locations(): Promise<string[]> {
return getJson<string[]>('/location');
}
/**
* Locations to offer when placing a spool. GET /location only knows the ones that
* hold a spool right now, so the empty ones kept as dashboard cards (an idle
* toolhead, a new shelf) are added from the saved layout, as the old client did
* with its `locations` setting (#1261).
*/
async locationChoices(): Promise<string[]> {
const [inUse, layout] = await Promise.all([this.locations(), loadLayout().catch(() => null)]);
return [...new Set([...inUse, ...(layout?.groups.location ?? [])])].filter(Boolean).sort();
}
/**
* Replace one value of one spool field wherever it occurs, in a single request. Works for
* any field the spool owns, so the dashboard can rename a whole group without paging in its
+29 -2
View File
@@ -45,6 +45,27 @@ export interface GroupSummary {
lastUsedLabel: string;
/** Numeric recency for group ordering (higher = more recent). */
lastUsedSort: number;
/** Vendor groups only: how many filaments the group spans — every matching
* filament of the manufacturer when includeEmpty, else those with spools. */
filamentCount?: number;
/** Filament groups, with GroupQuery.preview: the first few spools (by id). */
previewSpools?: SpoolPreview[];
/** Vendor groups, with GroupQuery.preview: the first few filaments (by name). */
previewFilaments?: FilamentPreview[];
}
/** Just enough of a spool to draw its fill pip. Grams; null when unknown. */
export interface SpoolPreview {
id: string;
remaining: number | null;
initial: number | null;
}
/** Just enough of a filament to draw its swatch. */
export interface FilamentPreview {
id: string;
colors: string[];
direction?: MultiColorDirection;
}
export interface GroupScope {
@@ -74,9 +95,11 @@ export interface GroupQuery {
sort: SortField[];
/** Include archived spools in the group aggregates (default: excluded). */
allowArchived?: boolean;
/** List matching filaments that hold no matching spools as groups of zero
* (filament grouping only; see buildGroupQuery). */
/** List matching filaments (filament grouping) or manufacturers (vendor
* grouping) that hold no matching spools, as groups of zero. */
includeEmpty?: boolean;
/** Attach previewSpools / previewFilaments to filament / vendor groups. */
preview?: boolean;
limit: number;
offset: number;
lowThreshold: number;
@@ -88,6 +111,10 @@ export interface Page<T> {
items: T[];
/** Total matching rows across all pages (from X-Total-Count). */
total: number;
/** Group pages only: spools and remaining grams summed over ALL matching
* groups, not just this page (X-Total-Spools / X-Total-Remaining-Weight). */
totalSpools?: number;
totalRemaining?: number;
}
// --- cross-entity search (GET /search) -----------------------------------
@@ -20,6 +20,7 @@
import { ExternalSearch } from '$lib/api/externalSearch.svelte';
import { roundGrams, weightAuto } from '$lib/utils/format';
import { numErr } from '$lib/utils/validate';
import { parseDecimal } from '$lib/utils/numeric';
import {
FILAMENT_ADVANCED_KEYS,
FILAMENT_FIELD_ORDER,
@@ -140,7 +141,7 @@
fields.ensure('filament');
fields.ensure('vendor');
spoolSource
.locations()
.locationChoices()
.then((l) => (locations = l))
.catch(() => {});
if (presetFilamentId) {
@@ -277,9 +278,9 @@
chosen = c;
netWeight = String(cWeight(c) || 1000);
const sw = cSpoolWeight(c);
spoolWeight = sw ? String(sw) : '';
spoolWeight = sw != null ? String(sw) : '';
const p = cPrice(c);
price = p ? String(p) : '';
price = p != null ? String(p) : '';
resetSpoolForm();
step = 2;
}
@@ -382,24 +383,24 @@
}
const n = countN;
const net = Number(netWeight) || 0;
const spool = Number(spoolWeight) || 0;
const net = parseDecimal(netWeight) ?? 0;
const spool = parseDecimal(spoolWeight) ?? 0;
const body: Record<string, unknown> = {
filament_id: filamentId,
initial_weight: Number(netWeight) || undefined,
spool_weight: Number(spoolWeight) || undefined,
price: parseFloat(price) || undefined,
initial_weight: parseDecimal(netWeight) ?? undefined,
spool_weight: parseDecimal(spoolWeight) ?? undefined,
price: parseDecimal(price) ?? undefined,
location: location.trim() || undefined,
lot_nr: lot.trim() || undefined,
comment: comment.trim() || undefined
};
if (fillMode === 'used') body.used_weight = Number(fillWeight) || 0;
else if (fillMode === 'remaining') body.remaining_weight = Number(fillWeight) || 0;
else if (fillMode === 'measured')
if (fillMode === 'used') body.used_weight = parseDecimal(fillWeight) ?? undefined;
else if (fillMode === 'remaining') body.remaining_weight = parseDecimal(fillWeight) ?? undefined;
else if (fillMode === 'measured' && parseDecimal(fillWeight) !== null)
// Rounded, because this subtraction is where float dust gets born: weighing a full
// 1000 g spool on a 128.11 g core gives 1000 + 128.11 − 1128.11 = 2.3e-13, and a
// spool created with that much used is not an unused spool any more (#986).
body.used_weight = roundGrams(Math.max(0, net + spool - (Number(fillWeight) || 0)));
body.used_weight = roundGrams(Math.max(0, net + spool - (parseDecimal(fillWeight) ?? 0)));
if (firstUsed) body.first_used = firstUsed;
if (lastUsed) body.last_used = lastUsed;
if (Object.keys(extraValues).length) body.extra = extraValues;
@@ -440,9 +441,9 @@
// Cross-check the fill amount against the net/empty-spool weights above.
// Only applies the upper bound when those weights are themselves valid so
// we don't cascade an unrelated error into this field.
const netN = Number(netWeight);
const netValid = netWeight.trim() !== '' && Number.isFinite(netN) && netN > 0;
const spoolN = Number.isFinite(Number(spoolWeight)) ? Number(spoolWeight) : 0;
const netN = parseDecimal(netWeight);
const netValid = netN !== null && netN > 0;
const spoolN = parseDecimal(spoolWeight) ?? 0;
if (fillMode === 'measured') {
// Weight on the scale = filament left + empty spool, so it can be at most
// net+spool (full) and at least the empty-spool weight (nothing left).
@@ -725,8 +726,8 @@
<button
type="button"
class="preset"
class:on={Number(netWeight) === preset}
aria-pressed={Number(netWeight) === preset}
class:on={parseDecimal(netWeight) === preset}
aria-pressed={parseDecimal(netWeight) === preset}
onclick={() => (netWeight = String(preset))}>{weightAuto(preset)}</button
>
{/each}
@@ -16,6 +16,14 @@
Spoolman
{#if info}
v{info.version}{#if info.git_commit}<span class="commit"> ({info.git_commit})</span>{/if}
{#if info.update_available && info.latest_version}
<a
class="update"
href="https://github.com/Donkie/Spoolman/releases/tag/v{info.latest_version}"
target="_blank"
rel="noopener noreferrer">{m['footer.updateAvailable']({ version: info.latest_version })}</a
>
{/if}
{/if}
·
<a href="https://github.com/Donkie/Spoolman">{m['footer.documentation']()}</a>
@@ -56,6 +64,11 @@
.commit {
color: var(--text-faint);
}
.update {
margin-left: 4px;
color: var(--accent);
font-weight: 600;
}
.sponsor {
flex: none;
display: inline-flex;
+273 -1
View File
@@ -1,7 +1,13 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve --
The menu hrefs come from resolve() / catalogHref(), which already apply the deploy base path. */
import { resolve } from '$app/paths';
import type { Pathname } from '$app/types';
import { page } from '$app/stores';
import { tick } from 'svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Check from '@lucide/svelte/icons/check';
import { catalogHref, parseLibraryState } from '$lib/library/params';
import * as m from '$lib/paraglide/messages';
const tabs = [
@@ -19,11 +25,159 @@
const path = $page.url.pathname.slice(basePath.length) || '/';
return href === '/' ? path === '/' : path.startsWith(href);
}
// The Library tab's chevron menu switches between the Library's three views.
// They all live at '/', so the tab itself stays active for each of them.
let libraryView = $derived(parseLibraryState($page.url.searchParams).view);
let onLibrary = $derived(isActive('/'));
const views = [
{
href: resolve('/'),
view: 'spools',
label: m['search.section.spools']
},
{
href: catalogHref('filaments'),
view: 'filaments',
label: m['filament.filament']
},
{
href: catalogHref('manufacturers'),
view: 'manufacturers',
label: m['search.section.vendors']
}
] as const;
let menuOpen = $state(false);
let split = $state<HTMLElement>();
let chevron = $state<HTMLButtonElement>();
let menuEl = $state<HTMLElement>();
function items(): HTMLElement[] {
return menuEl ? [...menuEl.querySelectorAll<HTMLElement>('[role^="menuitem"]')] : [];
}
// Fixed rather than absolute: the phone nav row scrolls horizontally, which
// would clip an absolutely-positioned menu. Placed from the tab when it opens.
let menuPos = $state({ top: 0, left: 0 });
async function openMenu(focusFirst: boolean) {
if (split) {
const r = split.getBoundingClientRect();
const width = Math.min(220, window.innerWidth - 36);
menuPos = { top: r.bottom + 6, left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)) };
}
menuOpen = true;
if (!focusFirst) return;
await tick();
items()[0]?.focus();
}
function closeMenu(refocus = false) {
menuOpen = false;
if (refocus) chevron?.focus();
}
// detail === 0 means the click came from Enter/Space, not a pointer.
function onChevronClick(e: MouseEvent) {
if (menuOpen) closeMenu();
else openMenu(e.detail === 0);
}
function onChevronKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
openMenu(true);
} else if (e.key === 'Escape' && menuOpen) {
// Opened by a click, focus is still here rather than in the menu.
e.preventDefault();
e.stopPropagation();
closeMenu();
}
}
function onMenuKeydown(e: KeyboardEvent) {
const list = items();
const i = list.indexOf(document.activeElement as HTMLElement);
let next: number | undefined;
if (e.key === 'ArrowDown') next = (i + 1) % list.length;
else if (e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = list.length - 1;
if (next !== undefined) {
e.preventDefault();
list[next].focus();
} else if (e.key === 'Escape') {
// Don't let it also close a surrounding dialog or panel.
e.preventDefault();
e.stopPropagation();
closeMenu(true);
} else if (e.key === 'Tab') {
closeMenu();
}
}
// Capture phase, like Combobox, so a click that something else swallows still closes the menu.
$effect(() => {
if (!menuOpen) return;
const onDown = (e: PointerEvent) => {
if (split && !split.contains(e.target as Node)) closeMenu();
};
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
</script>
<nav class="tabs">
{#each tabs as tab (tab.href)}
<a href={resolve(tab.href)} class="tab" class:active={isActive(tab.href)}>{tab.label()}</a>
{#if tab.href === '/'}
<span class="split" class:active={isActive('/')} class:open={menuOpen} bind:this={split}>
<a href={resolve('/')} class="tab" aria-current={isActive('/') ? 'page' : undefined}>{tab.label()}</a>
<button
type="button"
class="chevron"
bind:this={chevron}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={m['library.views']()}
onclick={onChevronClick}
onkeydown={onChevronKeydown}
>
<ChevronDown size={13} />
</button>
{#if menuOpen}
<div
class="menu"
role="menu"
tabindex="-1"
aria-label={m['library.views']()}
bind:this={menuEl}
style:top="{menuPos.top}px"
style:left="{menuPos.left}px"
onkeydown={onMenuKeydown}
>
{#snippet row(v: (typeof views)[number])}
{@const checked = onLibrary && libraryView === v.view}
<a
class="mi"
class:sel={checked}
href={v.href}
role="menuitemradio"
aria-checked={checked}
onclick={() => closeMenu()}
>
<span class="mi-check" aria-hidden="true"
>{#if checked}<Check size={15} />{/if}</span
>
<span class="mi-label">{v.label()}</span>
</a>
{/snippet}
{@render row(views[0])}
<div class="menu-sep" role="separator"></div>
<div class="menu-title" role="presentation">{m['library.viewMenu.browse']()}</div>
{@render row(views[1])}
{@render row(views[2])}
</div>
{/if}
</span>
{:else}
<a href={resolve(tab.href)} class="tab" class:active={isActive(tab.href)}>{tab.label()}</a>
{/if}
{/each}
</nav>
@@ -60,4 +214,122 @@
.tab.active:hover {
background: var(--accent-wash);
}
/* Library tab: label + chevron read as one pill. The wash sits on the wrapper,
so the label drops its own background and the chevron only tints on hover/open. */
.split {
position: relative;
display: inline-flex;
align-items: stretch;
border-radius: var(--radius);
}
.split .tab {
padding-right: 4px;
border-radius: var(--radius) 0 0 var(--radius);
background: none;
}
.split.active {
background: var(--accent-wash);
}
.split.active .tab {
font-weight: 600;
color: var(--accent-soft);
}
.chevron {
width: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 0 var(--radius) var(--radius) 0;
background: none;
color: var(--text-dim);
cursor: pointer;
}
.split.active .chevron {
color: var(--accent-soft);
}
.chevron:hover,
.split.open .chevron {
color: var(--text);
background: var(--accent-wash);
}
.split.active .chevron:hover,
.split.active.open .chevron {
color: var(--accent-soft);
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
.split .tab:focus-visible,
.chevron:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.menu {
position: fixed;
z-index: 50;
width: 220px;
max-width: calc(100vw - 36px);
padding: 4px 0;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.menu-title {
padding: 7px 14px 3px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.menu-sep {
height: 1px;
background: var(--border-soft);
margin: 4px 0;
}
.mi {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 8px 14px;
color: var(--text-2);
text-decoration: none;
}
.mi:hover,
.mi:focus-visible {
background: var(--surface-raised);
color: var(--text);
outline: none;
}
.mi:focus-visible {
box-shadow: inset 2px 0 0 var(--accent);
}
.mi-check {
flex: none;
width: 15px;
height: 18px;
display: flex;
align-items: center;
color: var(--accent-soft);
}
.mi-label {
font-size: 12.5px;
}
.mi.sel .mi-label {
color: var(--accent-soft);
font-weight: 600;
}
/* Phone nav row: 36px+ tap targets, matching the row's own height. */
@media (max-width: 860px) {
.chevron {
width: 36px;
min-height: 38px;
}
.mi {
min-height: 44px;
}
}
</style>
+72 -67
View File
@@ -12,6 +12,9 @@
pageSize: number;
total: number;
unit?: string;
/** Replaces the "1–20 of 50" range with the caller's own line, e.g. the
* catalog's "11 filaments · 23 spools · 15.1 kg". */
summary?: string;
onpage: (page: number) => void;
onpagesize: (size: number) => void;
/** When set, page controls render as real `<a href>` links (open-in-new-tab,
@@ -19,11 +22,15 @@
hrefFor?: (page: number) => string;
}
let { page, pageSize, total, unit = '', onpage, onpagesize, hrefFor }: Props = $props();
let { page, pageSize, total, unit = '', summary, onpage, onpagesize, hrefFor }: Props = $props();
let pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)));
let from = $derived(total === 0 ? 0 : (page - 1) * pageSize + 1);
let to = $derived(Math.min(total, page * pageSize));
let countLabel = $derived(
summary ??
(total === 0 ? m['pagination.empty']({ unit }) : m['pagination.range']({ from, to, total, unit }))
);
// Windowed page numbers with 1 … n … last.
let pages = $derived.by(() => {
@@ -48,73 +55,66 @@
</script>
<div class="pager">
<span class="count">
{#if total === 0}{m['pagination.empty']({ unit })}{:else}{m['pagination.range']({
from,
to,
total,
unit
})}{/if}
</span>
<span class="count">{countLabel}</span>
<div class="spacer"></div>
{#if pageCount > 1}
<div class="nums">
{#if hrefFor && page > 1}
<a
class="pg nav"
href={hrefFor(page - 1)}
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></a
>
{:else}
<button
class="pg nav"
disabled={page <= 1}
onclick={() => go(page - 1)}
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></button
>
{/if}
{#each pages as p, i (i)}
{#if p === '…'}
<span class="ellipsis">…</span>
{:else if hrefFor && p !== page}
<a class="pg" href={hrefFor(p)} data-sveltekit-keepfocus data-sveltekit-noscroll>{p}</a>
<div class="controls">
{#if pageCount > 1}
<div class="nums">
{#if hrefFor && page > 1}
<a
class="pg nav"
href={hrefFor(page - 1)}
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></a
>
{:else}
<button class="pg" class:active={p === page} onclick={() => go(p)}>{p}</button>
<button
class="pg nav"
disabled={page <= 1}
onclick={() => go(page - 1)}
aria-label={m['pagination.prev']()}><ChevronLeft size={16} /></button
>
{/if}
{/each}
{#if hrefFor && page < pageCount}
<a
class="pg nav"
href={hrefFor(page + 1)}
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label={m['pagination.next']()}><ChevronRight size={16} /></a
>
{:else}
<button
class="pg nav"
disabled={page >= pageCount}
onclick={() => go(page + 1)}
aria-label={m['pagination.next']()}><ChevronRight size={16} /></button
>
{/if}
</div>
{/if}
{#each pages as p, i (i)}
{#if p === '…'}
<span class="ellipsis">…</span>
{:else if hrefFor && p !== page}
<a class="pg" href={hrefFor(p)} data-sveltekit-keepfocus data-sveltekit-noscroll>{p}</a>
{:else}
<button class="pg" class:active={p === page} onclick={() => go(p)}>{p}</button>
{/if}
{/each}
{#if hrefFor && page < pageCount}
<a
class="pg nav"
href={hrefFor(page + 1)}
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label={m['pagination.next']()}><ChevronRight size={16} /></a
>
{:else}
<button
class="pg nav"
disabled={page >= pageCount}
onclick={() => go(page + 1)}
aria-label={m['pagination.next']()}><ChevronRight size={16} /></button
>
{/if}
</div>
{/if}
<select
class="size"
value={pageSize}
onchange={(e) => onpagesize(Number(e.currentTarget.value))}
aria-label={m['pagination.pageSize']()}
>
{#each sizes as s (s)}
<option value={s}>{m['pagination.perPage']({ size: s })}</option>
{/each}
</select>
<select
class="size"
value={pageSize}
onchange={(e) => onpagesize(Number(e.currentTarget.value))}
aria-label={m['pagination.pageSize']()}
>
{#each sizes as s (s)}
<option value={s}>{m['pagination.perPage']({ size: s })}</option>
{/each}
</select>
</div>
</div>
<style>
@@ -130,12 +130,17 @@
color: var(--text-dim);
flex-wrap: wrap;
}
.spacer {
flex: 1;
}
.count {
white-space: nowrap;
}
/* Page numbers and page size wrap as one, so a long count line (the catalog's
summary on a phone) never strands the page-size picker on a line alone. */
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.nums {
display: flex;
align-items: center;
@@ -3,6 +3,13 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { parseSpoolCode } from '$lib/utils/spoolCode';
import {
type Camera,
rememberCamera,
rememberedCamera,
startingCamera,
videoCameras
} from '$lib/utils/cameraChoice';
import * as m from '$lib/paraglide/messages';
import X from '@lucide/svelte/icons/x';
@@ -16,10 +23,52 @@
let error = $state<string | null>(null);
let starting = $state(false);
// Every camera the browser lists, read once the scanner is running (labels
// are only readable after camera permission). More than one shows the picker.
let cameras = $state<Camera[]>([]);
// The camera the picker shows as selected: the one streaming, or the one that
// just failed to open.
let currentCamera = $state('');
let switching = $state(false);
// The running scanner, for the picker. Null while starting or closed.
let active: QrScanner | null = null;
function close() {
onclose?.();
}
/** The deviceId of the camera actually streaming, whichever one we asked for. */
function streamingCameraId(): string | undefined {
const stream = video?.srcObject;
return stream instanceof MediaStream ? stream.getVideoTracks()[0]?.getSettings().deviceId : undefined;
}
async function pickCamera(id: string) {
const scanner = active;
if (!scanner || switching) return;
switching = true;
error = null;
try {
await scanner.setCamera(id);
// After a failed open the scanner is stopped, and setCamera only
// restarts a running one (or does nothing for the same id), so a
// retry has to start it again.
if (!streamingCameraId()) await scanner.start();
if (scanner !== active) return; // closed while switching
// qr-scanner opens some other camera if the requested one fails, so
// show and remember what is actually streaming.
currentCamera = streamingCameraId() ?? id;
rememberCamera(currentCamera);
} catch (err) {
if (scanner !== active) return;
console.error('QR scanner failed to switch camera:', err);
currentCamera = id;
error = await classifyStartFailure();
} finally {
switching = false;
}
}
// A scanned code that decodes to a Spoolman spool or filament opens it in the
// Library. Non-Spoolman codes are ignored so the camera keeps scanning.
function onDecode(result: QrScanner.ScanResult) {
@@ -60,6 +109,8 @@
error = null;
starting = false;
cameras = [];
currentCamera = '';
// Insecure (non-HTTPS, non-localhost) origins don't expose the camera API
// at all — qr-scanner would only report a generic "camera not found", so
@@ -79,21 +130,36 @@
// qr-scanner is a browser-only library, so load it lazily inside the effect.
// A static import would drag its default export into the SSR bundle, where
// this client-only usage is stripped and Rollup then warns it is unused.
import('qr-scanner')
.then(({ default: QrScanner }) => {
Promise.all([
import('qr-scanner'),
// Only to check the remembered camera still exists; any failure just
// means we fall back to the rear camera.
navigator.mediaDevices.enumerateDevices().catch(() => [])
])
.then(async ([{ default: QrScanner }, devices]) => {
if (cancelled) return;
const ids = videoCameras(devices).map((c) => c.id);
scanner = new QrScanner(el, onDecode, {
// Prefer the rear camera; ignore frames that don't decode.
preferredCamera: 'environment',
// The camera picked last time, else the rear one; ignore frames that don't decode.
preferredCamera: startingCamera(rememberedCamera(), ids),
highlightScanRegion: true,
highlightCodeOutline: true,
maxScansPerSecond: 5,
returnDetailedScanResult: true
});
return scanner.start();
})
.then(() => {
if (cancelled) scanner?.stop();
// Also decode light-on-dark labels (#1165). The jsQR worker used where
// BarcodeDetector is missing (Firefox) only tries dark-on-light by default.
scanner.setInversionMode('both');
await scanner.start();
if (cancelled) {
scanner?.stop();
return;
}
active = scanner;
currentCamera = streamingCameraId() ?? '';
// The camera is already running, so a failed listing only costs the picker.
const list = await navigator.mediaDevices.enumerateDevices().catch(() => []);
if (!cancelled) cameras = videoCameras(list);
})
.catch(async (err) => {
if (cancelled) return;
@@ -106,6 +172,7 @@
return () => {
cancelled = true;
active = null;
scanner?.destroy();
scanner = null;
};
@@ -144,6 +211,22 @@
<div class="msg">{m['scanner.starting']()}</div>
{/if}
</div>
{#if cameras.length > 1}
<!-- Stays up after a failed switch, so another camera can be tried. -->
<select
class="camera"
value={currentCamera}
disabled={switching}
aria-label={m['scanner.camera']()}
onchange={(e) => pickCamera(e.currentTarget.value)}
>
{#each cameras as camera, i (camera.id)}
<option value={camera.id}>{camera.label || m['scanner.cameraNumbered']({ number: i + 1 })}</option
>
{/each}
</select>
{/if}
</div>
</div>
{/if}
@@ -229,4 +312,16 @@
align-items: center;
justify-content: center;
}
.camera {
margin: -6px 20px 20px;
background: var(--input-bg);
border: 1px solid var(--border-input);
border-radius: var(--radius);
color: var(--text);
padding: 7px 10px;
font-size: 13px;
}
.camera:disabled {
opacity: 0.55;
}
</style>
@@ -108,6 +108,9 @@
<p class="hint">
{m['printing.qrcode.customUrl.hint']()} <code>{'{id}'}</code>
</p>
<p class="hint">
{m['printing.qrcode.customUrl.fieldsHint']()} <code>{'{filament.id}'}</code>
</p>
<p class="hint">{m['printing.qrcode.customUrl.caveat']()}</p>
{:else}
<p class="hint">{m['printing.qrcode.useHTTPUrl.tooltip']()}</p>
@@ -4,7 +4,7 @@
import Pagination from '$components/Pagination.svelte';
import NumberInput from '../NumberInput.svelte';
import LabelCanvas from './LabelCanvas.svelte';
import { labelKind, type LabelDesign } from '$lib/labels/types';
import { labelKind, type LabelKind, type LabelDesign } from '$lib/labels/types';
import type { LabelBinding } from '$lib/labels/template';
import { PAPER_NAMES, layoutMargin, paperSize, sheetGrid } from '$lib/labels/paper';
import { printLabels, exportLabels, ZIP_THRESHOLD } from '$lib/labels/print';
@@ -33,7 +33,7 @@
let selected = $state<Set<number>>(new Set(untrack(() => preselected)));
let search = $state('');
// Default to newest-first so spools you just added sit at the top of the list —
// Default to newest-first so items you just added sit at the top of the list,
// the common case when printing labels for a fresh batch.
let sort = $state<'newest' | 'id'>('newest');
let loading = $state(true);
@@ -41,8 +41,8 @@
let printing = $state(false);
let saving = $state(false);
// The full inventory can be thousands of spools, so loading every one up front
// was an extremely heavy query. Instead the list shows one page at a time and
// The full inventory can be thousands of spools or filaments, so loading it all
// up front was an extremely heavy query. Instead the list shows one page at a time and
// search pulls in anything else on demand. Pages rather than infinite scroll so
// "All" stays honest: it adds the page you are looking at.
const PAGE = 100;
@@ -58,6 +58,8 @@
// search result (null when not searching). All index into the reactive inventory
// cache, so the list stays live as spools are edited or removed.
let pageIds = $state<number[]>([]);
let pageFIds = $state<string[]>([]);
let resultFIds = $state<string[] | null>(null);
let recentIds = $state<number[]>([]);
let resultIds = $state<number[] | null>(null);
let pageNo = $state(1);
@@ -85,29 +87,38 @@
const dir = $derived(sort === 'newest' ? 'desc' : 'asc');
$effect(() => {
if (kind !== 'spool') return;
void kind;
untrack(() => (pageNo = 1));
});
$effect(() => {
const ctrl = new AbortController();
void loadPage(dir, (pageNo - 1) * pageSize, pageSize, ctrl.signal);
void loadPage(kind, dir, (pageNo - 1) * pageSize, pageSize, ctrl.signal);
return () => ctrl.abort();
});
async function loadPage(dir: 'asc' | 'desc', offset: number, limit: number, signal: AbortSignal) {
async function loadPage(
kind: LabelKind,
dir: 'asc' | 'desc',
offset: number,
limit: number,
signal: AbortSignal
) {
loading = true;
try {
const page = await spoolSource.listSpools({
filters: {},
sort: [{ field: 'id', dir }],
limit,
offset,
lowThreshold: settings.lowThreshold,
signal
});
const query = { sort: [{ field: 'id', dir }], limit, offset, signal };
const page =
kind === 'filament'
? await spoolSource.listFilamentPage(query)
: await spoolSource.listSpools({ ...query, filters: {}, lowThreshold: settings.lowThreshold });
if (signal.aborted) return;
pageIds = page.items.map((s) => s.id);
if (kind === 'filament') pageFIds = (page.items as Filament[]).map((f) => f.id);
else pageIds = (page.items as Spool[]).map((s) => s.id);
total = page.total;
listEl?.scrollTo(0, 0);
} catch (e) {
// Abandoning the tab mid-load is exactly when cancelling matters most.
if (isAbortError(e, signal)) return;
console.error('Failed to load spools for printing', e);
console.error(`Failed to load ${kind}s for printing`, e);
} finally {
if (!signal.aborted) loading = false;
}
@@ -148,28 +159,34 @@
}
}
// Search hits the backend rather than filtering the preloaded slice, so spools
// Search hits the backend rather than filtering the preloaded slice, so entities
// outside the latest batch stay findable. Debounced, and superseded queries are
// aborted on cleanup.
$effect(() => {
if (kind !== 'spool') return;
const q = search.trim();
const searchKind = kind;
const q = (searchKind === 'filament' ? fSearch : search).trim();
if (!q) {
resultIds = null;
resultFIds = null;
searching = false;
return;
}
searching = true;
const ctrl = new AbortController();
const timer = setTimeout(() => void runSearch(q, ctrl.signal), 250);
const timer = setTimeout(() => void runSearch(searchKind, q, ctrl.signal), 250);
return () => {
clearTimeout(timer);
ctrl.abort();
};
});
async function runSearch(q: string, signal: AbortSignal) {
async function runSearch(kind: LabelKind, q: string, signal: AbortSignal) {
try {
const { spools, filaments } = await searchAll(q, COLOR_THRESHOLD, signal);
if (signal.aborted) return;
if (kind === 'filament') {
resultFIds = filaments.map((m) => m.entity.id);
return;
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const ids = new Set<number>(spools.map((m) => m.entity.id));
// The /search spool pass only matches spool-native fields, so also pull
@@ -187,10 +204,10 @@
});
for (const s of page.items) ids.add(s.id);
}
resultIds = [...ids];
if (!signal.aborted) resultIds = [...ids];
} catch (e) {
if (isAbortError(e, signal)) return;
console.error('Spool search failed', e);
console.error(`${kind} search failed`, e);
} finally {
if (!signal.aborted) searching = false;
}
@@ -254,27 +271,8 @@
}
// --- Filament selection (filament labels) ------------------------------------
// Filament catalogs are small enough to load in one page and filter client-side,
// so the filament picker is simpler than the spool one: no incremental paging,
// no backend search, no "recent" window.
let selectedF = $state<Set<string>>(new Set());
let fSearch = $state('');
let fLoading = $state(true);
$effect(() => {
if (kind !== 'filament') return;
const ctrl = new AbortController();
fLoading = true;
spoolSource
.listFilaments(1000, ctrl.signal)
.catch((e) => {
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filaments for printing', e);
})
.finally(() => {
if (!ctrl.signal.aborted) fLoading = false;
});
return () => ctrl.abort();
});
function filamentLabel(f: Filament): string {
const v = inventory.vendorById(f.vendorId);
@@ -282,16 +280,12 @@
return `#${f.id} · ${name}${f.material ? ' · ' + f.material : ''}`;
}
const visibleFilaments = $derived.by(() => {
const q = fSearch.trim().toLowerCase();
return inventory.filaments
.filter((f) => {
if (!q) return true;
const v = inventory.vendorById(f.vendorId);
return `${f.name} ${f.material} ${v?.name ?? ''}`.toLowerCase().includes(q);
})
.sort((a, b) => a.name.localeCompare(b.name));
});
const visibleFilaments = $derived(
(resultFIds ?? pageFIds)
.map((id) => inventory.filamentById(id))
.filter((f): f is Filament => !!f)
.sort((a, b) => (sort === 'newest' ? Number(b.id) - Number(a.id) : Number(a.id) - Number(b.id)))
);
function toggleF(id: string) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selectedF` updates via reassignment below
@@ -301,7 +295,7 @@
selectedF = next;
}
function selectAllF() {
selectedF = new Set(visibleFilaments.map((f) => f.id));
selectedF = new Set([...selectedF, ...visibleFilaments.map((f) => f.id)]);
}
function clearAllF() {
selectedF = new Set();
@@ -381,21 +375,31 @@
}
</script>
{#snippet sortControls()}
<div class="seg sort" title={kind === 'spool' ? m['labels.sortHint']() : undefined}>
<button class:active={sort === 'newest'} onclick={() => setSort('newest')}
>{m['labels.sortNewest']()}</button
>
<button class:active={sort === 'id'} onclick={() => setSort('id')}>{m['labels.sortId']()}</button>
</div>
{/snippet}
<div class="print-panel">
<div class="col spools">
{#if kind === 'filament'}
<div class="col-head">
<span>{m['filament.filament']()}</span>
<div class="mini-actions">
<button onclick={selectAllF}>{m['labels.selectAllShort']()}</button>
<button onclick={selectAllF} disabled={loading || searching}>{m['labels.selectAllShort']()}</button>
<button onclick={clearAllF}>{m['labels.selectNoneShort']()}</button>
</div>
</div>
<div class="search-row">
<input class="search" placeholder={m['labels.searchFilaments']()} bind:value={fSearch} />
{@render sortControls()}
</div>
<div class="spool-list">
{#if fLoading && visibleFilaments.length === 0}
<div class="spool-list" bind:this={listEl}>
{#if loading || (searching && visibleFilaments.length === 0)}
<div class="muted">{m.loading()}…</div>
{:else if visibleFilaments.length === 0}
<div class="muted">{m['labels.noFilaments']()}</div>
@@ -414,7 +418,7 @@
<div class="col-head">
<span>{m['spool.spool']()}</span>
<div class="mini-actions">
<button onclick={selectAll}>{m['labels.selectAllShort']()}</button>
<button onclick={selectAll} disabled={loading || searching}>{m['labels.selectAllShort']()}</button>
<button onclick={selectRecent} disabled={recentCount === 0} title={m['labels.selectRecentHint']()}
>{m['labels.selectRecentShort']({ count: recentCount })}</button
>
@@ -423,12 +427,7 @@
</div>
<div class="search-row">
<input class="search" placeholder={m['labels.searchSpools']()} bind:value={search} />
<div class="seg sort" title={m['labels.sortHint']()}>
<button class:active={sort === 'newest'} onclick={() => setSort('newest')}
>{m['labels.sortNewest']()}</button
>
<button class:active={sort === 'id'} onclick={() => setSort('id')}>{m['labels.sortId']()}</button>
</div>
{@render sortControls()}
</div>
<div class="spool-list" bind:this={listEl}>
{#if loading || (searching && visibleSpools.length === 0)}
@@ -445,21 +444,21 @@
{/each}
{/if}
</div>
{#if !resultIds && total > pageSize}
<Pagination
page={pageNo}
{pageSize}
{total}
unit={m['library.unitSpools']()}
onpage={(p) => (pageNo = p)}
onpagesize={(s) => {
pageSize = s;
pageNo = 1;
}}
/>
{/if}
<div class="count">{m['printing.spoolSelect.selectedTotal']({ count: selected.size })}</div>
{/if}
{#if !(kind === 'filament' ? resultFIds : resultIds) && total > pageSize}
<Pagination
page={pageNo}
{pageSize}
{total}
unit={kind === 'filament' ? m['library.catalog.unitFilaments']() : m['library.unitSpools']()}
onpage={(p) => (pageNo = p)}
onpagesize={(s) => {
pageSize = s;
pageNo = 1;
}}
/>
{/if}
</div>
<div class="col layout">
@@ -0,0 +1,294 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve --
Every href below comes from resolve() or params.catalogHref(), which already
resolves against the deploy base path; resolving again would double-apply it. */
import { resolve } from '$app/paths';
import { tick } from 'svelte';
import { catalogHref, type CatalogView } from '$lib/library/params';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Check from '@lucide/svelte/icons/check';
import * as m from '$lib/paraglide/messages';
interface Props {
view: CatalogView;
/** Rows in the whole list (not the page); absent until the first load. */
count?: number;
}
let { view, count }: Props = $props();
const lists = [
{ view: 'filaments', label: m['library.catalog.allFilaments'] },
{ view: 'manufacturers', label: m['library.catalog.allManufacturers'] }
] as const;
let title = $derived(lists.find((l) => l.view === view)!.label());
// The menu follows the Library tab's (NavTabs): arrow keys walk the items,
// Escape hands focus back to the button, a click anywhere else closes it.
let open = $state(false);
let wrap = $state<HTMLElement>();
let button = $state<HTMLButtonElement>();
let menuEl = $state<HTMLElement>();
function items(): HTMLElement[] {
return menuEl ? [...menuEl.querySelectorAll<HTMLElement>('[role^="menuitem"]')] : [];
}
async function openMenu(focusFirst: boolean) {
open = true;
if (!focusFirst) return;
await tick();
items()[0]?.focus();
}
function closeMenu(refocus = false) {
open = false;
if (refocus) button?.focus();
}
// detail === 0 means the click came from Enter/Space, not a pointer.
function onButtonClick(e: MouseEvent) {
if (open) closeMenu();
else openMenu(e.detail === 0);
}
function onButtonKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
openMenu(true);
} else if (e.key === 'Escape' && open) {
// Opened by a click, focus is still here rather than in the menu.
e.preventDefault();
e.stopPropagation();
closeMenu();
}
}
function onMenuKeydown(e: KeyboardEvent) {
const list = items();
const i = list.indexOf(document.activeElement as HTMLElement);
let next: number | undefined;
if (e.key === 'ArrowDown') next = (i + 1) % list.length;
else if (e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = list.length - 1;
if (next !== undefined) {
e.preventDefault();
list[next].focus();
} else if (e.key === 'Escape') {
// Don't let it also close a surrounding dialog or panel.
e.preventDefault();
e.stopPropagation();
closeMenu(true);
} else if (e.key === 'Tab') {
closeMenu();
}
}
// Capture phase, like Combobox, so a click that something else swallows still closes the menu.
$effect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (wrap && !wrap.contains(e.target as Node)) closeMenu();
};
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
</script>
<!-- Where you are and the way back: the catalogs are side trips from the spool
list, so the spools stay one click away and the title says which list this is. -->
<div class="head">
<a class="back" href={resolve('/')} title={m['library.catalog.backToSpools']()}
><ChevronLeft size={16} />{m['search.section.spools']()}</a
>
<span class="slash" aria-hidden="true">/</span>
<div class="title-wrap" bind:this={wrap}>
<button
type="button"
class="title"
class:open
bind:this={button}
aria-haspopup="menu"
aria-expanded={open}
title={m['library.catalog.switchList']()}
onclick={onButtonClick}
onkeydown={onButtonKeydown}
>
<span class="label">{title}</span>
{#if count !== undefined}<span class="count mono">{count}</span>{/if}
<ChevronDown size={14} />
</button>
{#if open}
<!-- eslint-disable-next-line svelte/no-static-element-interactions -- arrow keys bubble up from the focused item -->
<div
class="menu"
role="menu"
tabindex="-1"
aria-label={m['library.catalog.switchList']()}
bind:this={menuEl}
onkeydown={onMenuKeydown}
>
{#each lists as l (l.view)}
{@const checked = l.view === view}
<a
class="mi"
class:sel={checked}
href={catalogHref(l.view)}
role="menuitemradio"
aria-checked={checked}
onclick={() => closeMenu()}
>
<span class="mi-check" aria-hidden="true"
>{#if checked}<Check size={15} />{/if}</span
>
<span class="mi-label">{l.label()}</span>
</a>
{/each}
<div class="menu-sep" role="separator"></div>
<a class="mi" href={resolve('/')} role="menuitem" onclick={() => closeMenu()}>
<span class="mi-check" aria-hidden="true"></span>
<span class="mi-label">{m['library.catalog.backToSpools']()}</span>
</a>
</div>
{/if}
</div>
</div>
<style>
.head {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
min-height: 34px;
padding: 8px 14px 0;
flex: none;
}
.back {
display: inline-flex;
align-items: center;
gap: 3px;
flex: none;
height: 32px;
padding: 0 8px 0 4px;
margin-left: -6px;
border-radius: var(--radius);
color: var(--text-dim);
font-size: 12.5px;
text-decoration: none;
}
.back:hover {
background: var(--surface-2);
color: var(--text);
}
.slash {
flex: none;
padding: 0 2px;
color: var(--text-faint);
font-size: 14px;
}
.title-wrap {
position: relative;
min-width: 0;
}
.title {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
height: 32px;
padding: 0 8px;
border: 0;
border-radius: var(--radius);
background: none;
color: var(--text);
font-family: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.title:hover,
.title.open {
background: var(--surface-2);
}
/* A long translation gives way before the count and the chevron do. */
.label {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
flex: none;
font-size: 11px;
font-weight: 400;
color: var(--text-faint);
}
.back:focus-visible,
.title:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
min-width: 220px;
padding: 4px 0;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.menu-sep {
height: 1px;
background: var(--border-soft);
margin: 4px 0;
}
.mi {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 8px 14px;
color: var(--text-2);
font-size: 12.5px;
text-decoration: none;
white-space: nowrap;
}
.mi:hover,
.mi:focus-visible {
background: var(--surface-raised);
color: var(--text);
outline: none;
}
.mi:focus-visible {
box-shadow: inset 2px 0 0 var(--accent);
}
.mi.sel {
color: var(--accent-soft);
font-weight: 600;
}
.mi-check {
flex: none;
width: 15px;
display: flex;
color: var(--accent-soft);
}
/* Phone: the same 44px tap targets as the rest of the mobile chrome. */
@media (max-width: 860px) {
.head {
padding-top: 6px;
}
.back,
.title {
height: 44px;
}
.title {
font-size: 16px;
}
.mi {
min-height: 44px;
}
}
</style>
@@ -0,0 +1,286 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve --
Section links come from a src/lib/library/params.ts helper, which already
resolves against the deploy base path; resolving again would double-apply it. */
import CatalogHeader from './CatalogHeader.svelte';
import CatalogRow from './CatalogRow.svelte';
import ListToolbar from './ListToolbar.svelte';
import Pagination from '../Pagination.svelte';
import Factory from '@lucide/svelte/icons/factory';
import type { GroupSummary } from '$lib/api/types';
import type { CatalogView, LibraryState } from '$lib/library/params';
import * as params from '$lib/library/params';
import { catalogSections } from '$lib/library/catalog';
import { buildCatalogQuery, buildVendorTotalsQuery } from '$lib/api/query';
import { spoolSource } from '$lib/api/spoolSource';
import { isAbortError } from '$lib/api/http';
import { live } from '$lib/api/live';
import { weightAuto } from '$lib/utils/format';
import * as m from '$lib/paraglide/messages';
// The filament and manufacturer catalogs: sub-views of the Library that page
// the group endpoint the way FilamentList does, one row per filament or
// manufacturer, spools or not.
let { libraryState }: { libraryState: LibraryState } = $props();
let view = $derived(libraryState.view as CatalogView);
// What is on screen, swapped whole when a load lands. `sectioned` belongs to the
// rows it was loaded with, not to the current URL: while a regrouped page is on
// its way, the old rows keep the layout they arrived in.
let groups = $state<GroupSummary[]>([]);
let sectioned = $state(false);
/** Section totals by manufacturer id, for the manufacturers on this page. */
let vendorTotals = $state<Record<string, GroupSummary>>({});
let total = $state<number>();
let totalSpools = $state<number>();
let totalRemaining = $state<number>();
let loading = $state(false);
let errored = $state(false);
/** Bumped by live events; forces a refetch of the page. */
let revision = $state(0);
// One request chain at a time, aborted by the effect's cleanup when superseded,
// exactly like FilamentList. The section totals need the page's manufacturer
// ids, so they follow the page rather than racing it, and both land together.
let reqId = 0;
$effect(() => {
const ctrl = new AbortController();
const s = libraryState;
const q = buildCatalogQuery(s, ctrl.signal);
const withSections = s.view === 'filaments' && s.group === 'vendor';
void revision; // refetch on live events
const mine = ++reqId;
loading = true;
errored = false;
(async () => {
const page = await spoolSource.listGroups(q);
const totals: Record<string, GroupSummary> = {};
// The filaments with no manufacturer have no vendor group to total them.
const ids = withSections
? [...new Set(page.items.map((g) => g.vendorId).filter((id): id is string => !!id))]
: [];
// The totals only dress the section headers, so losing them costs the
// headers their meta line, not the page its rows.
if (ids.length) {
try {
const vendors = await spoolSource.listGroups(buildVendorTotalsQuery(s, ids, ctrl.signal));
for (const v of vendors.items) totals[v.key] = v;
} catch (err) {
if (isAbortError(err, ctrl.signal)) throw err;
console.error('Failed to load the manufacturer totals', err);
}
}
if (mine !== reqId) return;
groups = page.items;
sectioned = withSections;
vendorTotals = totals;
total = page.total;
totalSpools = page.totalSpools;
totalRemaining = page.totalRemaining;
loading = false;
})().catch((err) => {
if (mine !== reqId || isAbortError(err, ctrl.signal)) return;
console.error('Failed to load the catalog', err);
errored = true;
loading = false;
});
return () => ctrl.abort();
});
// Live updates, coalesced with the same leading-edge throttle as FilamentList
// (see the reasoning there): the first event refetches at once, a burst folds
// into one catch-up refetch when the window ends.
const COALESCE_MS = 300;
let cooldown: ReturnType<typeof setTimeout> | undefined;
let coalesced = false;
function bumpRevision() {
if (cooldown) {
coalesced = true;
return;
}
revision++;
cooldown = setTimeout(() => {
cooldown = undefined;
if (coalesced) {
coalesced = false;
bumpRevision();
}
}, COALESCE_MS);
}
$effect(() => {
const offs = (['spool', 'filament', 'vendor'] as const).map((resource) =>
live.subscribe(resource, {}, bumpRevision)
);
return () => {
offs.forEach((off) => off());
if (cooldown) clearTimeout(cooldown);
cooldown = undefined;
coalesced = false;
};
});
let sections = $derived(sectioned ? catalogSections(groups) : []);
let unit = $derived(
view === 'filaments' ? m['library.catalog.unitFilaments']() : m['library.catalog.unitManufacturers']()
);
function countLabel(count: number): string {
return view === 'filaments'
? m['library.catalog.filamentCount']({ count })
: m['library.catalog.manufacturerCount']({ count });
}
// "11 filaments · 23 spools · 15.1 kg": the whole list, not the page. The two
// totals come from response headers an older server doesn't send.
let summary = $derived(
total
? [
countLabel(total),
totalSpools != null ? m['library.groupSpools']({ count: totalSpools }) : '',
totalRemaining != null ? weightAuto(totalRemaining) : ''
]
.filter(Boolean)
.join(' · ')
: undefined
);
</script>
<div class="list">
<CatalogHeader {view} count={total} />
<ListToolbar {libraryState} />
<div class="rows scroll-y" class:loading>
{#if sectioned}
{#each sections as sec (sec.vendorId + ':' + sec.rows[0].key)}
{@const totals = vendorTotals[sec.vendorId]}
<!-- One box per section, so its sticky header lets go where the section ends. -->
<div>
<div class="section">
{#if sec.vendorId}
<a
class="vendor"
href={params.selectHrefFromState(libraryState, 'vendor', sec.vendorId)}
title={m['library.openManufacturer']({ name: sec.vendorName ?? '' })}
data-sveltekit-keepfocus
data-sveltekit-noscroll
>
<Factory size={12} />
<span class="vname">{sec.vendorName}</span>
</a>
{:else}
<span class="vendor-none">{m['library.catalog.noManufacturer']()}</span>
{/if}
{#if totals}
<span class="meta"
>{countLabel(totals.filamentCount ?? 0)} · {weightAuto(totals.totalRemaining)}</span
>
{/if}
</div>
{#each sec.rows as group (group.key)}
<CatalogRow {group} {libraryState} />
{/each}
</div>
{/each}
{:else}
{#each groups as group (group.field + ':' + group.key)}
<CatalogRow {group} {libraryState} showVendor={group.field === 'filament'} />
{/each}
{/if}
{#if errored}
<div class="empty">{m['library.apiError']()}</div>
{:else if total === 0 && !loading}
<div class="empty">
{libraryState.filters.length ? m['library.emptyFiltered']({ unit }) : m['pagination.empty']({ unit })}
</div>
{/if}
</div>
<Pagination
page={libraryState.page}
pageSize={libraryState.pageSize}
total={total ?? 0}
{unit}
{summary}
onpage={(p) => params.setPage(p)}
onpagesize={(s) => params.setPageSize(s)}
hrefFor={(p) => params.pageHrefFromState(libraryState, p)}
/>
</div>
<style>
.list {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.rows {
flex: 1;
transition: opacity 0.1s;
}
.rows.loading {
opacity: 0.6;
}
/* The manufacturer section bar: sunken like a group header, and sticky so a
long run of filaments still says whose they are. */
.section {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 7px 14px;
background: var(--surface-sunken);
border-top: 1px solid var(--border-soft);
font-size: 11px;
color: var(--text-faint);
}
/* The padding makes a ~28px tap target (over the 24px WCAG 2.2 floor) while the
matching negative margin keeps the bar as tight as it looks. */
.vendor {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
padding: 7px;
margin: -7px;
border-radius: var(--radius-sm);
/* --accent-soft, not --accent-link: it holds AA on the sunken surface in both
themes (see GroupHeader). */
color: var(--accent-soft);
font-size: 12px;
font-weight: 600;
text-decoration: none;
}
.vendor:hover {
background: var(--surface-raised);
color: var(--accent-link-hover);
}
.vendor:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.vname,
.meta {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vendor-none {
font-size: 12px;
font-style: italic;
}
.meta {
margin-left: auto;
flex: none;
max-width: 60%;
}
.empty {
padding: 40px 14px;
text-align: center;
font-size: 12.5px;
color: var(--text-dim);
}
</style>
@@ -0,0 +1,290 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve --
The href comes from a src/lib/library/params.ts helper, which already resolves
against the deploy base path; resolving again would double-apply it. */
import Swatch from '../Swatch.svelte';
import MaterialBadge from '../MaterialBadge.svelte';
import type { GroupSummary } from '$lib/api/types';
import type { LibraryState } from '$lib/library/params';
import * as params from '$lib/library/params';
import { spoolPips } from '$lib/library/catalog';
import { truncTitle } from '$lib/actions/truncated';
import { inventory } from '$lib/stores/inventory.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { weightAuto } from '$lib/utils/format';
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages';
// One row of a catalog sub-view: a filament or a manufacturer. Both share the
// anatomy — picture, a name line, one details line, a fixed stock column — so
// nothing a user types or a translation spells out can widen the list: every
// text truncates in place.
interface Props {
/** A filament or vendor group from the catalog query. */
group: GroupSummary;
libraryState: LibraryState;
/** Flat filament list: no section header names the manufacturer, so the row does. */
showVendor?: boolean;
}
let { group, libraryState, showVendor = false }: Props = $props();
let kind = $derived<'filament' | 'vendor'>(group.field === 'vendor' ? 'vendor' : 'filament');
let href = $derived(params.selectHrefFromState(libraryState, kind, group.key));
let selected = $derived(params.isSelected(page.url.searchParams, kind, group.key));
// The group carries the aggregates; the entity itself (upserted into the cache
// by the same request) carries the fields the details line shows.
let filament = $derived(kind === 'filament' ? inventory.filamentById(group.key) : undefined);
let vendor = $derived(kind === 'vendor' ? inventory.vendorById(group.key) : undefined);
let pips = $derived(spoolPips(group.previewSpools ?? [], settings.lowThreshold));
// Up to four of a manufacturer's filaments, overlapping in a 24px square.
const STACK_OFFSETS = [
[0, 0],
[10, 0],
[0, 10],
[10, 10]
];
</script>
<a class="row" class:selected {href} data-sveltekit-keepfocus data-sveltekit-noscroll>
<span class="id mono">#{group.key}</span>
{#if kind === 'filament'}
<Swatch colors={group.colors} direction={group.direction} size={24} radius={6} />
{:else}
<span class="stack" aria-hidden="true">
{#each (group.previewFilaments ?? []).slice(0, 4) as f, i (f.id)}
<span class="dot" style:left="{STACK_OFFSETS[i][0]}px" style:top="{STACK_OFFSETS[i][1]}px"
><Swatch colors={f.colors} direction={f.direction} size={14} radius={4} /></span
>
{/each}
</span>
{/if}
<span class="body">
<span class="line">
<!-- The name gives way first; the badge and the id stay whole. -->
<span class="name" use:truncTitle>{group.title}</span>
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
</span>
<!-- Labels dim, values bright, one line ending in an ellipsis. -->
<span class="details" use:truncTitle>
{#if kind === 'filament'}
{#if showVendor}
<span class="it"
><span class="v" class:unset={!group.vendorName}
>{group.vendorName ?? m['library.catalog.noManufacturer']()}</span
></span
>
{/if}
{#if filament}
<span class="it"><span class="v">{filament.diameter} mm</span></span>
<span class="it"
><span class="k">{m['library.sort.nozzle']()}</span>
<span class="v" class:unset={!filament.nozzleTemp}
>{filament.nozzleTemp ? `${filament.nozzleTemp} °C` : m['library.catalog.notSet']()}</span
></span
>
{/if}
{:else}
<span class="it"
><span class="v">{m['library.catalog.filamentCount']({ count: group.filamentCount ?? 0 })}</span
></span
>
<!-- An empty spool weight of 0 g is no weight at all: the API's null maps to 0. -->
<span class="it"
><span class="k">{m['vendor.fields.emptySpoolWeight']()}</span>
<span class="v" class:unset={!vendor?.emptyWeight}
>{vendor?.emptyWeight ? weightAuto(vendor.emptyWeight) : m['library.catalog.notSet']()}</span
></span
>
{/if}
</span>
</span>
<span class="stock">
{#if kind === 'filament' && group.spoolCount === 0}
<span class="no-spools">{m['library.noSpools']()}</span>
{:else}
<span class="kg">{weightAuto(group.totalRemaining)}</span>
{#if kind === 'filament'}
<!-- One pip per spool, filled by what's left; past five the count carries
the rest. A spool of unknown size gets a plain grey pip. -->
<span class="pips" title={m['library.groupSpools']({ count: group.spoolCount })}>
{#each pips as p, i (i)}
<span class="pip" class:low={p.low} class:unknown={p.fill === null} aria-hidden="true"
><span class="fill" style:width="{(p.fill ?? 1) * 100}%"></span></span
>
{/each}
<span class="count">{group.spoolCount}</span>
</span>
{:else}
<span class="count">{m['library.groupSpools']({ count: group.spoolCount })}</span>
{/if}
{/if}
</span>
</a>
<style>
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px 9px 12px;
border-top: 1px solid var(--hairline);
border-left: 2px solid transparent;
color: inherit;
text-decoration: none;
}
.row:hover {
background: var(--surface-2);
}
.row.selected {
background: var(--accent-wash);
border-left-color: var(--accent);
}
.row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.stack {
position: relative;
flex: none;
width: 24px;
height: 24px;
}
/* A ring in the page colour cuts each swatch out of the one beneath it. */
.dot {
position: absolute;
display: flex;
border-radius: 4px;
box-shadow: 0 0 0 1.5px var(--bg);
}
.body {
flex: 1;
min-width: 0;
}
.line {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.name {
min-width: 0;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Same id column as the spool rows. */
.id {
flex: none;
width: 36px;
font-size: 11px;
color: var(--text-muted);
}
.details {
display: block;
margin-top: 3px;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.it + .it::before {
content: '·';
margin: 0 6px;
color: var(--text-faint);
}
.k {
color: var(--text-faint);
}
.v {
color: var(--text-2);
}
.v.unset {
color: var(--text-faint);
font-style: italic;
}
/* Fixed, so the column of weights lines up down the list whatever the rows say. */
.stock {
flex: none;
width: 92px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.kg {
font-size: 12px;
color: var(--text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.pips {
display: flex;
align-items: center;
gap: 3px;
}
.pip {
width: 11px;
height: 4px;
border-radius: 2px;
background: var(--track);
overflow: hidden;
}
.fill {
display: block;
height: 100%;
background: var(--accent);
}
.pip.low .fill {
background: var(--danger-soft);
}
.pip.unknown .fill {
background: var(--text-faint);
}
.count {
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.pips .count {
margin-left: 3px;
font-size: 10px;
}
/* The unused-spool colours: a filament with nothing on the shelf is a
shopping-list entry, and a catalog holds many of them, so it stays calm. */
.no-spools {
font-size: 11px;
padding: 2px 7px;
border-radius: var(--radius-sm);
background: var(--unused-bg);
border: 1px solid var(--unused-border);
color: var(--unused-text);
white-space: nowrap;
}
@media (max-width: 860px) {
.row {
min-height: 56px;
}
.name {
font-size: 14px;
}
.details {
font-size: 11.5px;
}
.stock {
width: 78px;
}
.kg {
font-size: 12.5px;
}
}
</style>
@@ -34,6 +34,7 @@
import { pct, weightAuto } from '$lib/utils/format';
import { usageLabel } from '$lib/utils/library';
import { spoolSource } from '$lib/api/spoolSource';
import { allPages } from '$lib/api/query';
import { isAbortError } from '$lib/api/http';
import { classifyDeleteFailure, planFilamentDelete } from '$lib/library/deletion';
import { toasts } from '$lib/stores/toasts.svelte';
@@ -80,6 +81,7 @@
// fetch's own cache upsert (which replaces the `filament` prop object) doesn't
// re-trigger this effect in an infinite loop.
let filamentId = $derived(filament.id);
const SPOOL_PAGE = 100;
let spools = $state<Spool[]>([]);
// Bumped by live spool events so adding/removing/editing a spool refetches
// this filament's list (the fetch below is server-ordered, not read from the
@@ -96,24 +98,33 @@
const id = filamentId;
const archived = showArchived;
void revision; // refetch on live spool events
let cancelled = false;
spoolSource
.listSpools({
const ctrl = new AbortController();
// Every spool, not the first page: the count and total above are read off
// this list. That renders all of them; a filament with thousands of spools
// would want a "show more" here and the group endpoint's totals for the header.
allPages(SPOOL_PAGE, (offset) =>
spoolSource.listSpools({
filters: {},
sort: [{ field: 'last_used', dir: 'desc' }],
sort: [
{ field: 'last_used', dir: 'desc' },
{ field: 'id', dir: 'asc' }
],
groupScope: { field: 'filament', key: id },
allowArchived: archived,
limit: 100,
offset: 0,
lowThreshold: settings.lowThreshold
limit: SPOOL_PAGE,
offset,
lowThreshold: settings.lowThreshold,
signal: ctrl.signal
})
.then((page) => {
if (!cancelled) spools = page.items;
)
.then((items) => {
// A spool used mid-load moves up the sort and can turn up on two pages.
if (!ctrl.signal.aborted) spools = [...new Map(items.map((s) => [s.id, s])).values()];
})
.catch((e) => console.error('Failed to load filament spools', e));
return () => {
cancelled = true;
};
.catch((e) => {
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filament spools', e);
});
return () => ctrl.abort();
});
const saver = makeSaver<string, Partial<Filament>>((id, patch) =>
@@ -138,7 +138,7 @@
{/each}
{:else}
{#each flatVMs as vm (vm.spool.id)}
<SpoolRow {vm} showSwatch indent={14} />
<SpoolRow showLotNumbers={libraryState.showLotNumbers} {vm} showSwatch indent={14} />
{/each}
{/if}
@@ -182,15 +182,33 @@
/>
{#if !collapsed && !noSpools}
{#each inUse as vm (vm.spool.id)}
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
<SpoolRow
showLotNumbers={libraryState.showLotNumbers}
{vm}
{showSwatch}
indent={26}
context={group.field}
/>
{/each}
{#each unusedByFilament as unused (unused[0].spool.id)}
{#if unused.length === 1}
<!-- A lone unused spool gains nothing from a collapsing header — it just
adds a click and a row — so render it inline like the used spools. -->
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
<SpoolRow
showLotNumbers={libraryState.showLotNumbers}
vm={unused[0]}
{showSwatch}
indent={26}
context={group.field}
/>
{:else}
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
<UnusedRow
showLotNumbers={libraryState.showLotNumbers}
{unused}
{showSwatch}
indent={26}
context={group.field}
/>
{/if}
{/each}
{#if moreCount > 0}
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { asset } from '$app/paths';
import SpoolInspector from './SpoolInspector.svelte';
import FilamentInspector from './FilamentInspector.svelte';
@@ -6,7 +7,6 @@
import type { Selection } from '$lib/types';
import { inventory } from '$lib/stores/inventory.svelte';
import { spoolSource } from '$lib/api/spoolSource';
import { isAbortError } from '$lib/api/http';
import * as m from '$lib/paraglide/messages';
let { selection }: { selection: Selection | null } = $props();
@@ -30,17 +30,24 @@
// across runs to stop a genuine miss / 404 from re-fetching every tick.
let attempted = '';
let loading = $state(false);
// The effect follows the selection by value, not by object. Arriving on the
// Library rewrites the URL to restore the remembered view (see +page.svelte),
// which parses the same selection into a new object; following the object
// re-ran the effect mid-fetch and its cleanup aborted the request (#1243).
let key = $derived(sel ? `${sel.kind}:${sel.id}` : '');
$effect(() => {
const s = sel;
if (!s || found) return;
const key = `${s.kind}:${s.id}`;
if (attempted === key) return;
attempted = key;
const k = key;
if (!k || found) return;
if (attempted === k) return;
attempted = k;
loading = true;
const s = untrack(() => sel)!;
const ctrl = new AbortController();
let pending = true;
const done = (err?: unknown) => {
if (isAbortError(err, ctrl.signal)) return; // superseded selection; leave loading for the next run
if (ctrl.signal.aborted) return; // superseded; the run that aborted it owns `loading`
pending = false;
if (err) console.warn('deep-link fetch failed', err);
loading = false;
};
@@ -52,7 +59,12 @@
: spoolSource.fetchVendor(s.id, ctrl.signal);
p.then(() => done()).catch(done);
return () => ctrl.abort();
return () => {
// Cancelled before it answered: forget the attempt, so whichever run
// comes next asks again instead of waiting on a request that is gone.
if (pending) attempted = '';
ctrl.abort();
};
});
</script>
@@ -11,7 +11,13 @@
withinLast,
type DateFilterProp
} from '$lib/library/dateFilter';
import { sortDefs, filamentLabel, type FilterOption, type SortDef } from '$lib/utils/library';
import {
sortDefs,
catalogSortDefs,
filamentLabel,
type FilterOption,
type SortDef
} from '$lib/utils/library';
import { filterByQuery, matchesTerms, searchTerms } from '$lib/utils/match';
import MenuSearch from '../MenuSearch.svelte';
import Swatch from '../Swatch.svelte';
@@ -33,6 +39,10 @@
// Named libraryState, not `state`, to avoid shadowing the $state rune.
let { libraryState }: { libraryState: LibraryState } = $props();
// The catalog sub-views (filaments, manufacturers) reuse this toolbar with the
// few groupings, orders and filters a list of filaments can answer.
let catalog = $derived(params.isCatalogView(libraryState));
type Menu = 'filter' | 'group' | 'sort' | null;
let open = $state<Menu>(null);
@@ -260,7 +270,11 @@
close();
}
let sorts = $derived(sortDefs(fields.get('spool')));
let sorts = $derived<SortDef[]>(
libraryState.view === 'spools'
? sortDefs(fields.get('spool'))
: catalogSortDefs(libraryState.view, fields.get('filament'), fields.get('vendor'))
);
let activeSort = $derived(sorts.find((s) => s.key === libraryState.sortKey) ?? sorts[0]);
const groupOptions: { key: GroupMode; labelKey: () => string }[] = [
@@ -270,8 +284,15 @@
{ key: 'location', labelKey: m['spool.fields.location'] },
{ key: 'none', labelKey: m['library.groupNone'] }
];
// The filament catalog sections by manufacturer or not at all; the manufacturer
// catalog has nothing to group by and shows no Group control.
const catalogGroupOptions: typeof groupOptions = [
{ key: 'vendor', labelKey: m['filament.fields.vendor'] },
{ key: 'none', labelKey: m['library.groupNone'] }
];
let groupChoices = $derived(catalog ? catalogGroupOptions : groupOptions);
let groupLabel = $derived(
groupOptions.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
groupChoices.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
);
function chipLabel(prop: string, value: string): string {
@@ -280,6 +301,11 @@
// Date filters hold a range, whose value is grammar rather than something to
// show ("-24h.." reads as "Last 24 hours").
if (isDateFilterProp(prop)) return `${label}: ${rangeLabel(value)}`;
// A manufacturer picked by id (the inspector's "Show as a list"): its name,
// so the chip reads like the by-name Manufacturer chip the menu makes.
if (prop === 'vendorId') {
return `${m['filament.fields.vendor']()}: ${inventory.vendorById(value)?.name ?? '#' + value}`;
}
// Filament filters store the numeric id; show the filament's name instead.
if (prop === 'filament') {
const fil = inventory.filamentById(value);
@@ -340,6 +366,9 @@
let noSpoolsMatches = $derived(
libraryState.group === 'filament' && matchesTerms(m['buttons.showNoSpools'](), searchTerms(menuQuery))
);
let lotNumbersMatches = $derived(
libraryState.view === 'spools' && matchesTerms(m['buttons.showLotNumbers'](), searchTerms(menuQuery))
);
// An option's secondary text is on screen, so it has to be searchable: typing
// "petg" against a list that visibly says PETG must not come back empty.
let visibleOptions = $derived(
@@ -375,7 +404,12 @@
<div
class="toolbar"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onkeydown={(e) => {
e.stopPropagation();
// A menu opened by a click leaves focus on its button, out here rather than
// in the menu, so this is where Escape has to close it.
if (e.key === 'Escape' && open) close();
}}
role="toolbar"
tabindex="-1"
>
@@ -412,10 +446,12 @@
</div>
<div class="controls">
<button class="link-btn" onclick={() => toggle('group')}
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
<ChevronDown size={13} /></button
>
{#if libraryState.view !== 'manufacturers'}
<button class="link-btn" onclick={() => toggle('group')}
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
<ChevronDown size={13} /></button
>
{/if}
<!-- One chip, two controls (#1091): the field opens the menu, the arrow only
flips the direction. Reversing an order used to mean reopening the menu
and hunting down the field that was already selected. A divider and a
@@ -491,7 +527,28 @@
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
</button>
{/if}
{#if !archivedMatches && !noSpoolsMatches && !visibleCategories.length}
{#if lotNumbersMatches}
{#if visibleCategories.length && !archivedMatches && !noSpoolsMatches}<div
class="menu-sep"
></div>{/if}
<button
class="menu-item"
role="menuitemcheckbox"
aria-checked={libraryState.showLotNumbers}
onclick={() => {
params.setShowLotNumbers(!libraryState.showLotNumbers);
close();
}}
>
<span class="mi-check"
>{#if libraryState.showLotNumbers}<SquareCheck size={15} />{:else}<Square
size={15}
/>{/if}</span
>
<span class="mi-label">{m['buttons.showLotNumbers']()}</span>
</button>
{/if}
{#if !archivedMatches && !noSpoolsMatches && !lotNumbersMatches && !visibleCategories.length}
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
{/if}
{:else}
@@ -590,7 +647,7 @@
{#if open === 'group'}
<div class="menu group-menu">
{#each groupOptions as g (g.key)}
{#each groupChoices as g (g.key)}
<button
class="menu-item"
class:sel={libraryState.group === g.key}
@@ -6,14 +6,17 @@
import Swatch from '../Swatch.svelte';
import MaterialBadge from '../MaterialBadge.svelte';
import Factory from '@lucide/svelte/icons/factory';
import List from '@lucide/svelte/icons/list';
import { goto } from '$app/navigation';
import { searchAll } from '$lib/api/search';
import type { SearchResults } from '$lib/api/types';
import { openSearchResult, searchResultHref } from '$lib/library/params';
import { catalogHref, openSearchResult, searchResultHref } from '$lib/library/params';
import { truncTitle } from '$lib/actions/truncated';
import { page } from '$app/state';
import { inventory } from '$lib/stores/inventory.svelte';
import { fields } from '$lib/stores/fields.svelte';
import { weightAuto } from '$lib/utils/format';
import { parseDecimal } from '$lib/utils/numeric';
import type { EntityKind } from '$lib/types';
import * as m from '$lib/paraglide/messages';
@@ -31,8 +34,9 @@
function loadThreshold(): number {
if (typeof localStorage === 'undefined') return DEFAULT_THRESHOLD;
const n = Number(localStorage.getItem(THRESHOLD_KEY));
return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRESHOLD;
// 0 is a real choice on the slider (exact colour only), so keep it.
const n = parseDecimal(localStorage.getItem(THRESHOLD_KEY));
return n !== null && n >= 0 ? n : DEFAULT_THRESHOLD;
}
let query = $state('');
@@ -100,14 +104,30 @@
});
interface FlatItem {
kind: EntityKind;
/** 'browse' rows are plain links to a catalog list, not entities. */
kind: EntityKind | 'browse';
id: string;
href?: string;
/** Unique per row. A spool can be both its own result and a pill under a
* filament, so kind+id alone would highlight two rows at once. */
key: string;
}
let flat = $derived<FlatItem[]>(
const browseFilaments = {
kind: 'browse',
id: 'filaments',
href: catalogHref('filaments'),
key: 'browse:filaments'
} as const;
const browseManufacturers = {
kind: 'browse',
id: 'manufacturers',
href: catalogHref('manufacturers'),
key: 'browse:manufacturers'
} as const;
let emptyQuery = $derived(query.trim().length === 0);
let resultItems = $derived<FlatItem[]>(
results
? [
...results.spools.map((m) => ({
@@ -134,8 +154,18 @@
: []
);
let hasResults = $derived(flat.length > 0);
let showPanel = $derived(open && query.trim().length > 0);
let hasResults = $derived(resultItems.length > 0);
// Browse links sit last so they never displace the first result under Enter,
// and are reachable by the same arrow keys.
let flat = $derived<FlatItem[]>(
emptyQuery
? [browseFilaments, browseManufacturers]
: hasResults
? [...resultItems, browseFilaments, browseManufacturers]
: []
);
// Focused-but-empty shows the hint and the browse links instead of nothing.
let showPanel = $derived(open);
// Each result is a real `<a href>` (see searchResultHref) so it opens in a new
// tab / copies its address like any link. Close and clear the panel afterwards.
@@ -158,6 +188,13 @@
dismiss();
}
// Arrowing past the panel's fold must bring the row along; the browse links
// sit last, so without this they'd be picked while out of sight.
$effect(() => {
if (activeIndex < 0) return;
wrapper?.querySelector('.panel .active')?.scrollIntoView({ block: 'nearest' });
});
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
@@ -174,7 +211,10 @@
if (activeIndex >= 0 && activeIndex < flat.length) {
e.preventDefault();
const item = flat[activeIndex];
chooseByKey(item.kind, item.id);
if (item.kind === 'browse') {
goto(item.href!);
dismiss();
} else chooseByKey(item.kind, item.id);
}
}
}
@@ -248,7 +288,30 @@
</div>
{/if}
{#if loading && !results}
{#if emptyQuery}
<p class="empty-hint">{m['search.hint']()}</p>
<div class="section-label">{m['library.viewMenu.browse']()}</div>
<a
class="result"
class:active={activeIndex === indexOf(browseFilaments.key)}
href={browseFilaments.href}
data-sveltekit-noscroll
onclick={onResultClick}
>
<span class="vendor-icon" aria-hidden="true"><List size={14} /></span>
<span class="text"><span class="title">{m['library.catalog.allFilaments']()}</span></span>
</a>
<a
class="result"
class:active={activeIndex === indexOf(browseManufacturers.key)}
href={browseManufacturers.href}
data-sveltekit-noscroll
onclick={onResultClick}
>
<span class="vendor-icon" aria-hidden="true"><Factory size={14} /></span>
<span class="text"><span class="title">{m['library.catalog.allManufacturers']()}</span></span>
</a>
{:else if loading && !results}
<div class="msg">{m['search.searching']()}</div>
{:else if errored}
<div class="msg">{m['library.apiError']()}</div>
@@ -377,6 +440,21 @@
</a>
{/each}
{/if}
<div class="foot">
{m['search.browseAll']()}
<a
href={browseFilaments.href}
class:active={activeIndex === indexOf(browseFilaments.key)}
onclick={onResultClick}>{m['library.catalog.unitFilaments']()}</a
>
·
<a
href={browseManufacturers.href}
class:active={activeIndex === indexOf(browseManufacturers.key)}
onclick={onResultClick}>{m['library.catalog.unitManufacturers']()}</a
>
</div>
{/if}
</div>
{/if}
@@ -442,6 +520,31 @@
color: var(--text-muted);
padding: 8px 12px 4px;
}
.empty-hint {
margin: 0;
padding: 8px 12px 6px;
font-size: 12px;
line-height: 1.45;
color: var(--text-dim);
}
.foot {
margin-top: 4px;
padding: 8px 12px 4px;
border-top: 1px solid var(--border-soft);
font-size: 11.5px;
color: var(--text-dim);
}
.foot a {
color: var(--accent-link);
text-decoration: none;
padding: 2px 3px;
border-radius: 3px;
}
.foot a:hover,
.foot a.active {
text-decoration: underline;
background: var(--surface-2);
}
.msg {
padding: 18px 12px;
text-align: center;
@@ -124,7 +124,7 @@
let serverLocations = $state<string[]>([]);
$effect(() => {
spoolSource
.locations()
.locationChoices()
.then((l) => (serverLocations = l))
.catch(() => {});
});
@@ -16,11 +16,12 @@
indent?: number;
/** Listing context — drops whatever the enclosing group already implies. */
context?: RowContext;
showLotNumbers?: boolean;
}
let { vm, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
let { vm, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
let identity = $derived(rowIdentity(vm, context));
let identity = $derived(rowIdentity(vm, context, showLotNumbers));
let selected = $derived(params.isSelected(page.url.searchParams, 'spool', String(vm.spool.id)));
</script>
@@ -14,9 +14,10 @@
indent?: number;
/** Listing context, forwarded to the expanded per-instance rows. */
context?: RowContext;
showLotNumbers?: boolean;
}
let { unused, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
let { unused, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
// Unused spools (never drawn from — used_weight 0) of one filament are
// collapsed into a single summary row. Clicking can't know *which* instance
@@ -109,7 +110,7 @@
{#each unused as vm (vm.spool.id)}
<!-- Divider-mode instances align with the sibling used rows (same indent)
so the divider reads as the header for the section beneath it. -->
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} />
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} {showLotNumbers} />
{/each}
</div>
{/if}
@@ -5,6 +5,7 @@
import Swatch from '../Swatch.svelte';
import Button from '../Button.svelte';
import ConfirmDialog from '../ConfirmDialog.svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Trash2 from '@lucide/svelte/icons/trash-2';
import EditableField from '../EditableField.svelte';
import NumberInput from '../NumberInput.svelte';
@@ -156,6 +157,13 @@
onclose={() => (confirmOpen = false)}
/>
{#snippet showAsList()}
<!-- By id, not name, so a namesake manufacturer stays out; it shows as a normal dismissable chip. -->
<a class="golist" href={params.catalogHref('filaments', [{ prop: 'vendorId', value: vendor.id }])}
>{m['vendor.showAsList']()} <ChevronRight size={13} /></a
>
{/snippet}
<div class="grid">
<div class="col">
<SectionLabel>{m['filament.fields.vendor']()}</SectionLabel>
@@ -186,7 +194,8 @@
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} manage />
</div>
<div class="col">
<SectionLabel>{m['filament.filament']()}</SectionLabel>
<SectionLabel right={filaments.length ? showAsList : undefined}>{m['filament.filament']()}</SectionLabel
>
<div class="fils">
{#each filaments as f (f.id)}
<a
@@ -249,6 +258,17 @@
gap: 0 32px;
padding: 4px 20px 24px;
}
.golist {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
color: var(--accent-link);
text-decoration: none;
}
.golist:hover {
text-decoration: underline;
}
.fils {
display: flex;
flex-direction: column;
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { emptyFilamentDraft, filamentDraftFrom, filamentWeightsFrom, toNewFilamentDraft } from './draft';
import { mapFilament } from '$lib/api/map';
describe('filament numeric drafts', () => {
it.each([
['', undefined],
['0', 0],
['0,5', 0.5],
['garbage', undefined]
])('preserves optional numeric input %j as %s', (raw, expected) => {
const draft = { ...emptyFilamentDraft(), density: '1,24', nozzleTemp: raw, bedTemp: raw };
const result = toNewFilamentDraft(draft, { weight: '1000,5', spoolWeight: raw, price: raw }, {});
expect(result).toMatchObject({
density: 1.24,
diameter: 1.75,
weight: 1000.5,
spoolWeight: expected,
price: expected,
nozzleTemp: expected,
bedTemp: expected
});
});
it('keeps zero values when duplicating a filament', () => {
const filament = mapFilament({
id: 1,
weight: 1000,
spool_weight: 0,
price: 0,
settings_extruder_temp: 0,
settings_bed_temp: 0
});
expect(filamentWeightsFrom(filament)).toEqual({ weight: '1000', spoolWeight: '0', price: '0' });
expect(filamentDraftFrom(filament, '')).toMatchObject({ nozzleTemp: '0', bedTemp: '0' });
});
});
+10 -19
View File
@@ -80,8 +80,8 @@ export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraf
multiColorDirection: undefined,
density: String(f.density),
diameter: String(f.diameter),
nozzleTemp: f.nozzleTemp ? String(f.nozzleTemp) : '',
bedTemp: f.bedTemp ? String(f.bedTemp) : '',
nozzleTemp: f.nozzleTemp != null ? String(f.nozzleTemp) : '',
bedTemp: f.bedTemp != null ? String(f.bedTemp) : '',
articleNumber: '',
comment: f.comment
};
@@ -91,8 +91,8 @@ export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraf
export function filamentWeightsFrom(f: Filament): FilamentWeights {
return {
weight: String(f.weight || 1000),
spoolWeight: f.spoolWeight ? String(f.spoolWeight) : '',
price: f.price ? String(f.price) : ''
spoolWeight: f.spoolWeight != null ? String(f.spoolWeight) : '',
price: f.price != null ? String(f.price) : ''
};
}
@@ -178,15 +178,6 @@ function num(v: string): number | undefined {
return parseDecimal(v) ?? undefined;
}
/**
* Same, but treating zero as "not given". The weight/price trio is optional on a
* filament and a recorded zero says nothing a missing value doesn't, so a blank
* and a typed 0 are sent identically rather than pinning the record to 0.
*/
function numPos(v: string): number | undefined {
return num(v) || undefined;
}
/** Turn a validated draft into the body {@link spoolSource.createFilament} expects. */
export function toNewFilamentDraft(
draft: FilamentDraft,
@@ -200,14 +191,14 @@ export function toNewFilamentDraft(
// Both are required and > 0 by the time this runs, so the fallbacks below are
// only there to keep the types honest.
density: num(draft.density) ?? 0,
diameter: numPos(draft.diameter) ?? Number(DEFAULT_DIAMETER),
weight: numPos(weights.weight),
spoolWeight: numPos(weights.spoolWeight),
diameter: num(draft.diameter) ?? Number(DEFAULT_DIAMETER),
weight: num(weights.weight) || undefined,
spoolWeight: num(weights.spoolWeight),
colors: draft.colors,
multiColorDirection: draft.multiColorDirection,
nozzleTemp: numPos(draft.nozzleTemp),
bedTemp: numPos(draft.bedTemp),
price: numPos(weights.price),
nozzleTemp: num(draft.nozzleTemp),
bedTemp: num(draft.bedTemp),
price: num(weights.price),
articleNumber: draft.articleNumber.trim() || undefined,
comment: draft.comment.trim() || undefined,
extra
+3 -1
View File
@@ -34,7 +34,9 @@ export const languages: Record<string, LanguageMeta> = {
ta: { name: 'தமிழ்', code: 'ta-IN' },
th: { name: 'ไทย', code: 'th-TH' },
lt: { name: 'Lietuvių', code: 'lt-LT' },
tr: { name: 'Türkçe', code: 'tr-TR' }
tr: { name: 'Türkçe', code: 'tr-TR' },
sl: { name: 'Slovenščina', code: 'sl-SI' },
ko: { name: '한국어', code: 'ko-KR' }
};
export const languageCodes = Object.keys(languages);
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { qrContent } from './qr';
import type { QrElement } from './types';
import type { LabelBinding } from './template';
const qr = (urlTemplate: string): QrElement => ({
id: 'q',
type: 'qr',
x: 0,
y: 0,
size: 10,
ec: 'H',
encoding: 'custom',
urlTemplate,
logo: false
});
describe('qrContent custom template', () => {
const binding = { spool: { id: 7, lot: 'L1' }, filament: { id: 42 } } as unknown as LabelBinding;
it('resolves label fields so a spool label can link to its filament (#1152)', () => {
const el = qr('http://host/filament/show/{filament.id}?spool={id}&lot={spool.lot}');
expect(qrContent(el, 7, { baseUrl: '' }, binding)).toBe('http://host/filament/show/42?spool=7&lot=L1');
});
it('still substitutes {id} without a binding', () => {
expect(qrContent(qr('x/{id}'), 7, { baseUrl: '' })).toBe('x/7');
});
});
+13 -3
View File
@@ -1,12 +1,14 @@
import QRCode from 'qrcode';
import type { QrElement, LabelKind } from './types';
import { resolveTemplate, type LabelBinding } from './template';
// QR content + geometry. The QR encodes a link back to the label's subject — a
// spool for spool labels, a filament for filament labels. The first two forms are
// understood by Spoolman's scanner ($lib/utils/spoolCode.ts):
// scheme → WEB+SPOOLMAN:S-<id> / F-<id> (compact custom URI)
// url → <base_url>/spool/show/<id> or /filament/show/<id> (opens in a browser)
// custom → the element's urlTemplate (user-supplied, {id} substituted)
// custom → the element's urlTemplate (user-supplied, {id} and
// label fields like {filament.id} substituted)
// A custom target is meant for a third-party app or host and won't scan back
// into Spoolman.
@@ -17,8 +19,16 @@ export interface QrContext {
}
/** Build the string encoded in a QR element for a given subject id. */
export function qrContent(el: QrElement, id: number | string, ctx: QrContext): string {
return qrTemplate(el, ctx).replace('{id}', String(id));
export function qrContent(
el: QrElement,
id: number | string,
ctx: QrContext,
binding?: LabelBinding
): string {
const content = qrTemplate(el, ctx).replace('{id}', String(id));
// A custom target can also reference label fields, e.g. a spool label whose QR
// opens the filament page via {filament.id} (#1152).
return el.encoding === 'custom' && binding ? resolveTemplate(content, binding) : content;
}
/**
+1 -1
View File
@@ -91,7 +91,7 @@ export function elementToShape(el: LabelElement, ctx: RenderContext): ShapeSpec
// encoding — a long URL/custom target is denser than the compact scheme.
const content =
subjectId !== undefined
? qrContent(el, subjectId, qrCtx)
? qrContent(el, subjectId, qrCtx, ctx.binding)
: qrTemplate(el, qrCtx).replace('{id}', '0');
return {
kind: 'qr',
+3 -2
View File
@@ -43,8 +43,9 @@ export interface QrElement extends BaseElement {
*/
encoding: 'scheme' | 'url' | 'custom';
/**
* Template used when `encoding === 'custom'`. Must contain an `{id}`
* placeholder, which is replaced with the spool id. Ignored otherwise.
* Template used when `encoding === 'custom'`. `{id}` is replaced with the
* label's subject id, and label fields like `{filament.id}` are resolved too.
* Ignored otherwise.
*/
urlTemplate?: string;
/** Draw the Spoolman logo in the centre of the code. */
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { catalogSections, MIN_PIP_FILL, spoolPips } from './catalog';
import type { GroupSummary } from '$lib/api/types';
function filament(key: string, vendorId?: string): GroupSummary {
return {
field: 'filament',
key,
title: `F${key}`,
subtitle: '',
badge: '',
vendorId,
vendorName: vendorId ? `V${vendorId}` : undefined,
colors: [],
spoolCount: 0,
inUseCount: 0,
unusedCount: 0,
totalRemaining: 0,
hasStock: false,
lastUsedLabel: '',
lastUsedSort: 0
};
}
describe('catalogSections', () => {
it('groups consecutive rows of one manufacturer, keeping the server order', () => {
const page = [filament('1', '7'), filament('2', '7'), filament('3', '9'), filament('4')];
const sections = catalogSections(page);
expect(sections.map((s) => [s.vendorId, s.vendorName, s.rows.map((r) => r.key)])).toEqual([
['7', 'V7', ['1', '2']],
['9', 'V9', ['3']],
['', undefined, ['4']]
]);
});
it('returns no sections for an empty page', () => {
expect(catalogSections([])).toEqual([]);
});
});
describe('spoolPips', () => {
it('fills by remaining over initial and flags low stock at the threshold', () => {
const pips = spoolPips(
[
{ id: '1', remaining: 500, initial: 1000 },
{ id: '2', remaining: 150, initial: 1000 },
{ id: '3', remaining: 1000, initial: 1000 }
],
150
);
expect(pips).toEqual([
{ fill: 0.5, low: false },
{ fill: 0.15, low: true },
{ fill: 1, low: false }
]);
});
it('keeps a sliver visible on an empty spool and caps an overfull one', () => {
const [empty, over] = spoolPips(
[
{ id: '1', remaining: 0, initial: 1000 },
{ id: '2', remaining: 1200, initial: 1000 }
],
150
);
expect(empty.fill).toBe(MIN_PIP_FILL);
expect(over.fill).toBe(1);
});
it('marks the fill unknown when the spool size is', () => {
const pips = spoolPips(
[
{ id: '1', remaining: 80, initial: null },
{ id: '2', remaining: null, initial: null },
{ id: '3', remaining: 400, initial: 0 }
],
150
);
expect(pips).toEqual([
{ fill: null, low: true },
{ fill: null, low: false },
{ fill: null, low: false }
]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { GroupSummary, SpoolPreview } from '$lib/api/types';
// Pure helpers behind the catalog sub-views' rows: how a page of filament groups
// splits into manufacturer sections, and how a filament's preview spools draw as
// fill pips.
/** A run of filaments sharing one manufacturer, under one section header. */
export interface CatalogSection {
/** Empty for the filaments with no manufacturer set. */
vendorId: string;
vendorName?: string;
rows: GroupSummary[];
}
/**
* Split a page of filament groups into manufacturer sections.
*
* The page arrives sorted by manufacturer first (group.vendor_name), so a
* section is simply a run of consecutive rows: no regrouping, and the server's
* order within a section is kept. A section cut by a page boundary starts the
* next page under a repeat of its header, which is what tells you whose
* filaments you are still looking at.
*/
export function catalogSections(groups: GroupSummary[]): CatalogSection[] {
const sections: CatalogSection[] = [];
for (const g of groups) {
const vendorId = g.vendorId ?? '';
const last = sections.at(-1);
if (last && last.vendorId === vendorId) last.rows.push(g);
else sections.push({ vendorId, vendorName: g.vendorName, rows: [g] });
}
return sections;
}
export interface Pip {
/** Share of the spool left, 0–1; null when the spool's size isn't known. */
fill: number | null;
low: boolean;
}
/**
* A pip this nearly empty still shows a sliver, so a spool that is all but
* gone reads as "almost out" rather than as a blank slot.
*/
export const MIN_PIP_FILL = 0.06;
/** One pip per preview spool, filled by remaining ÷ initial. */
export function spoolPips(spools: SpoolPreview[], lowThreshold: number): Pip[] {
return spools.map(({ remaining, initial }) => ({
fill:
remaining == null || !initial || initial <= 0
? null
: Math.max(MIN_PIP_FILL, Math.min(1, remaining / initial)),
// At or under the threshold, the same line settings.isLow draws everywhere else.
low: remaining != null && remaining <= lowThreshold
}));
}
+123
View File
@@ -131,3 +131,126 @@ describe('parseLibraryState', () => {
});
});
});
// The filament and manufacturer lists are sub-views of the Library, picked by
// `view`. They are entered fresh every time (the Library tab always lands on
// spools), so the spool view's remembered layout must never leak into them, and
// each has its own defaults that the URL omits.
describe('catalog views', () => {
async function load() {
vi.resetModules();
stub(GROUPED_BY_LOCATION);
return import('./params');
}
it('defaults to manufacturer sections, alphabetical', async () => {
const { parseLibraryState } = await load();
expect(parseLibraryState(new URLSearchParams('view=filaments'))).toMatchObject({
view: 'filaments',
group: 'vendor',
sortKey: 'name',
sortAsc: true
});
expect(parseLibraryState(new URLSearchParams('view=manufacturers'))).toMatchObject({
view: 'manufacturers',
group: 'none',
sortKey: 'name',
sortAsc: true
});
});
it('falls back to spools for an unknown view', async () => {
const { parseLibraryState } = await load();
expect(parseLibraryState(new URLSearchParams('view=colours')).view).toBe('spools');
});
it('only takes the groupings and sorts the view offers', async () => {
const { parseLibraryState } = await load();
expect(
parseLibraryState(new URLSearchParams('view=filaments&group=none&sort=last_used&dir=desc'))
).toMatchObject({ group: 'none', sortKey: 'last_used', sortAsc: false });
expect(parseLibraryState(new URLSearchParams('view=filaments&sort=filament.extra.shelf'))).toMatchObject({
sortKey: 'filament.extra.shelf'
});
// A spool-only grouping or sort is not an option here, a filament's custom
// field is not one in the manufacturer list, and that list has nothing to
// group by at all.
expect(parseLibraryState(new URLSearchParams('view=filaments&group=location&sort=price'))).toMatchObject({
group: 'vendor',
sortKey: 'name'
});
expect(
parseLibraryState(new URLSearchParams('view=manufacturers&group=vendor&sort=filament.extra.shelf'))
).toMatchObject({ group: 'none', sortKey: 'name' });
});
it('keeps filters on the spools themselves', async () => {
const { parseLibraryState } = await load();
const state = parseLibraryState(
new URLSearchParams('view=filaments&f=material%3APLA&f=location%3AShelf&empty=1')
);
expect(state.filters).toEqual([
{ prop: 'material', value: 'PLA' },
{ prop: 'location', value: 'Shelf' }
]);
// Filaments with no spools are always listed here, so there's no toggle to carry.
expect(state.showEmpty).toBe(false);
});
it('is left alone by the remembered spool view', async () => {
expect(await hrefFor(GROUPED_BY_LOCATION, '?view=filaments')).toBeNull();
expect(await hrefFor(GROUPED_BY_LOCATION, '?view=manufacturers&sel=vendor:3')).toBeNull();
});
it('links to a fresh catalog, omitting its own defaults', async () => {
const { catalogHref } = await load();
expect(catalogHref('filaments')).toBe('/?view=filaments');
expect(catalogHref('filaments', [{ prop: 'vendor', value: 'Prusa:ment' }])).toBe(
'/?view=filaments&f=vendor%3APrusa%253Ament'
);
expect(catalogHref('manufacturers')).toBe('/?view=manufacturers');
});
it('keeps the view when a row is selected', async () => {
const { parseLibraryState, selectHrefFromState } = await load();
const state = parseLibraryState(new URLSearchParams('view=filaments&group=none'));
expect(selectHrefFromState(state, 'filament', '7')).toBe('?view=filaments&group=none&sel=filament%3A7');
});
});
describe('lot number visibility', () => {
it('parses off by default, opt-in on spools, and off in catalogs', async () => {
const { parseLibraryState, selectHrefFromState } = await import('./params');
expect(parseLibraryState(new URLSearchParams()).showLotNumbers).toBe(false);
const state = parseLibraryState(new URLSearchParams('lots=1'));
expect(state.showLotNumbers).toBe(true);
expect(selectHrefFromState(state, 'spool', '7')).toBe('?lots=1&sel=spool%3A7');
for (const view of ['filaments', 'manufacturers']) {
expect(parseLibraryState(new URLSearchParams(`view=${view}&lots=1`)).showLotNumbers).toBe(false);
}
});
it('restores the preference and lets an explicit URL win', async () => {
const stored = '{"group":"filament","sort":"last_used","asc":false,"lots":true}';
expect(await hrefFor(stored, '')).toBe('/?lots=1');
expect(await hrefFor(stored, '?lots=0')).toBeNull();
});
});
describe('page size', () => {
const DEFAULT_VIEW = '{"group":"filament","sort":"last_used","asc":false';
it('restores a stored page size on a bare URL', async () => {
expect(await hrefFor(`${DEFAULT_VIEW},"size":100}`, '')).toBe('/?size=100');
});
it('lets an explicit URL page size win', async () => {
expect(await hrefFor(`${DEFAULT_VIEW},"size":100}`, '?size=50')).toBeNull();
});
it('falls back to the default for an old stored view or an invalid size', async () => {
expect(await hrefFor(`${DEFAULT_VIEW}}`, '')).toBeNull();
expect(await hrefFor(`${DEFAULT_VIEW},"size":"100"}`, '')).toBeNull();
expect(await hrefFor(`${DEFAULT_VIEW},"size":-5}`, '')).toBeNull();
});
});
+133 -24
View File
@@ -1,7 +1,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { EntityKind, Selection } from '$lib/types';
import { isGroupOrderable, defaultSortAsc } from '$lib/utils/library';
import { isGroupOrderable, defaultSortAsc, catalogSortAsc, catalogSortField } from '$lib/utils/library';
import { isDateFilterProp, parseDateFilter } from './dateFilter';
import { rememberedView, rememberView } from './viewPrefs';
@@ -22,14 +22,27 @@ import { rememberedView, rememberView } from './viewPrefs';
export type GroupMode = 'filament' | 'vendor' | 'material' | 'location' | 'none';
/**
* Which list the Library shows. Spools is the Library proper; the other two are
* the power-user catalog sub-views, reached from the Library tab's menu, the
* search panel and the manufacturer inspector. The view is never remembered:
* opening the Library always lands on spools.
*/
export type LibraryView = 'spools' | 'filaments' | 'manufacturers';
export type CatalogView = Exclude<LibraryView, 'spools'>;
export interface FilterChip {
prop: string;
value: string;
}
export interface LibraryState {
view: LibraryView;
selection: Selection | null;
/** In the filament view only `vendor` (manufacturer sections) or `none`; the
* manufacturer view is always `none`. */
group: GroupMode;
/** In a catalog view, a key of utils/library's catalogSortDefs. */
sortKey: string;
sortAsc: boolean;
filters: FilterChip[];
@@ -37,18 +50,21 @@ export interface LibraryState {
showArchived: boolean;
/** Also list filaments you hold no spools of, when grouping by filament. */
showEmpty: boolean;
/** Append lot numbers to spool row secondary text. */
showLotNumbers: boolean;
page: number;
pageSize: number;
}
const GROUP_MODES: GroupMode[] = ['filament', 'vendor', 'material', 'location', 'none'];
const ENTITY_KINDS: EntityKind[] = ['spool', 'filament', 'vendor'];
const VIEWS: LibraryView[] = ['spools', 'filaments', 'manufacturers'];
/** The params that spell out how the list is laid out, as opposed to what it
* holds. A URL naming none of them is the one that defers to the remembered
* view; naming any of them describes a view of its own, which is taken whole so
* a stored grouping never gets spliced onto a link's sort. */
const VIEW_PARAMS = ['group', 'sort', 'dir', 'empty'];
const VIEW_PARAMS = ['view', 'group', 'sort', 'dir', 'empty', 'lots', 'size'];
const DEFAULTS = {
group: 'filament' as GroupMode,
@@ -56,10 +72,38 @@ const DEFAULTS = {
sortAsc: false,
showArchived: false,
showEmpty: false,
showLotNumbers: false,
page: 1,
pageSize: 20
};
type Layout = Pick<LibraryState, 'group' | 'sortKey' | 'sortAsc'>;
/** Each view's default layout: what an untouched URL for it means, and so what
* serialisation omits. The catalogs read alphabetically, with sections. */
const LAYOUT_DEFAULTS: Record<LibraryView, Layout> = {
spools: { group: DEFAULTS.group, sortKey: DEFAULTS.sortKey, sortAsc: DEFAULTS.sortAsc },
filaments: { group: 'vendor', sortKey: 'name', sortAsc: true },
manufacturers: { group: 'none', sortKey: 'name', sortAsc: true }
};
/**
* Filter props that describe a SPOOL rather than the filament it is of.
*
* Everything not listed here — filament, material, vendor, direction, and the
* `filament.extra.` / `filament.vendor.extra.` fields — is a fact about the
* filament, which a filament with no spools still has. The API refuses the
* spool-scoped ones together with include_empty, so the lists that ask for empty
* filaments stop asking while one is active (see query.ts).
*/
export function isSpoolScopedFilter(prop: string): boolean {
if (prop === 'location' || prop === 'lot') return true;
if (isDateFilterProp(prop)) return true;
// `extra.<key>` is the spool's own custom field; the filament's and the
// vendor's are prefixed, so a bare `extra.` is the spool-scoped one.
return prop.startsWith('extra.');
}
function parseFilters(raw: string[]): FilterChip[] {
return (
raw
@@ -92,6 +136,9 @@ function parsePositiveInt(value: string | null, fallback: number): number {
* would be served stale after that preference changed (see rememberedViewHref).
*/
export function parseLibraryState(params: URLSearchParams): LibraryState {
const rawView = params.get('view') as LibraryView | null;
const view = rawView && VIEWS.includes(rawView) ? rawView : 'spools';
const layout = LAYOUT_DEFAULTS[view];
const group = params.get('group') as GroupMode | null;
const sel = params.get('sel');
@@ -99,26 +146,51 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
const kind = si > 0 ? (sel!.slice(0, si) as EntityKind) : null;
const selection = kind && ENTITY_KINDS.includes(kind) ? { kind, id: sel!.slice(si + 1) } : null;
const dir = params.get('dir');
const sortAsc = dir === null ? layout.sortAsc : dir === 'asc';
const common = {
view,
selection,
sortAsc,
filters: parseFilters(params.getAll('f')),
showArchived: params.get('arch') === '1',
showEmpty: params.get('empty') === '1',
showLotNumbers: params.get('lots') === '1',
page: parsePositiveInt(params.get('page'), DEFAULTS.page),
pageSize: parsePositiveInt(params.get('size'), DEFAULTS.pageSize)
};
if (view !== 'spools') {
// A catalog lists the filaments (or manufacturers) with no spools as well,
// so there is no "No spools" toggle to carry.
const sort = params.get('sort');
return {
...common,
group: view === 'filaments' && group === 'none' ? 'none' : layout.group,
sortKey: sort && catalogSortField(view, sort) ? sort : layout.sortKey,
showEmpty: false,
showLotNumbers: false
};
}
const rawGroup = group && GROUP_MODES.includes(group) ? group : DEFAULTS.group;
const sortKey = params.get('sort') ?? DEFAULTS.sortKey;
const sortAsc = params.get('dir') === 'asc';
// Enforce the grouped-view invariant: a group can only be ordered by a
// group-orderable sort. A hand-crafted or stale URL pairing a grouping with a
// per-spool sort renders flat, honouring the more specific sort intent. (Our
// own mutators never emit such a pairing — see setSortKey/setGroup.)
return {
selection,
...common,
group: isGroupOrderable(sortKey, rawGroup) ? rawGroup : 'none',
sortKey,
sortAsc,
filters: parseFilters(params.getAll('f')),
showArchived: params.get('arch') === '1',
showEmpty: params.get('empty') === '1',
page: parsePositiveInt(params.get('page'), DEFAULTS.page),
pageSize: parsePositiveInt(params.get('size'), DEFAULTS.pageSize)
sortKey
};
}
/** Whether `state` is one of the catalog sub-views rather than the spool list. */
export function isCatalogView(state: LibraryState): boolean {
return state.view !== 'spools';
}
/**
* Whether `kind`/`id` is the current selection, tested against a URLSearchParams
* (pass a reactive `page.url.searchParams` so list rows re-highlight on
@@ -131,9 +203,11 @@ export function isSelected(params: URLSearchParams, kind: EntityKind, id: string
/** Encode Library state back to a canonical query string (no leading `?`). */
function serializeState(s: LibraryState): string {
const p = new URLSearchParams();
if (s.group !== DEFAULTS.group) p.set('group', s.group);
if (s.sortKey !== DEFAULTS.sortKey) p.set('sort', s.sortKey);
if (s.sortAsc !== DEFAULTS.sortAsc) p.set('dir', s.sortAsc ? 'asc' : 'desc');
const layout = LAYOUT_DEFAULTS[s.view];
if (s.view !== 'spools') p.set('view', s.view);
if (s.group !== layout.group) p.set('group', s.group);
if (s.sortKey !== layout.sortKey) p.set('sort', s.sortKey);
if (s.sortAsc !== layout.sortAsc) p.set('dir', s.sortAsc ? 'asc' : 'desc');
// Encode prop and value separately so a `:` inside either (locations, lot
// numbers) survives the round-trip.
for (const f of s.filters) {
@@ -141,6 +215,7 @@ function serializeState(s: LibraryState): string {
}
if (s.showArchived !== DEFAULTS.showArchived) p.set('arch', s.showArchived ? '1' : '0');
if (s.showEmpty !== DEFAULTS.showEmpty) p.set('empty', s.showEmpty ? '1' : '0');
if (s.showLotNumbers !== DEFAULTS.showLotNumbers) p.set('lots', s.showLotNumbers ? '1' : '0');
if (s.page !== DEFAULTS.page) p.set('page', String(s.page));
if (s.pageSize !== DEFAULTS.pageSize) p.set('size', String(s.pageSize));
if (s.selection) p.set('sel', `${s.selection.kind}:${s.selection.id}`);
@@ -154,7 +229,7 @@ function currentState(): LibraryState {
/**
* Where entering the Library on `url` should actually land: the same view with
* the remembered grouping and sort spelled out in the query string, or null when
* the remembered grouping, sort and page size spelled out in the query string, or null when
* the URL already describes a view (or there's nothing worth restoring). The
* Library page navigates there on entry, replacing the history entry — see
* routes/+page.svelte.
@@ -182,7 +257,9 @@ export function rememberedViewHref(url: URL): string | null {
group === DEFAULTS.group &&
stored.sortKey === DEFAULTS.sortKey &&
stored.sortAsc === DEFAULTS.sortAsc &&
stored.showEmpty === DEFAULTS.showEmpty
stored.showEmpty === DEFAULTS.showEmpty &&
stored.showLotNumbers === DEFAULTS.showLotNumbers &&
stored.pageSize === DEFAULTS.pageSize
) {
return null;
}
@@ -195,7 +272,9 @@ export function rememberedViewHref(url: URL): string | null {
group,
sortKey: stored.sortKey,
sortAsc: stored.sortAsc,
showEmpty: stored.showEmpty
showEmpty: stored.showEmpty,
showLotNumbers: stored.showLotNumbers,
pageSize: stored.pageSize
});
return `${url.pathname}?${qs}`;
}
@@ -212,13 +291,18 @@ function navigate(next: LibraryState, replace = false): void {
// setSortKey so it always matches the view actually shown, including
// setGroup's fallback to the default sort. Back/forward doesn't come through
// here: stepping through history replays old views without redefining what
// "the Library" means next time it's opened fresh.
rememberView({
group: next.group,
sortKey: next.sortKey,
sortAsc: next.sortAsc,
showEmpty: next.showEmpty
});
// "the Library" means next time it's opened fresh. Only the spool view is
// remembered; a catalog's layout means nothing to the spool list.
if (next.view === 'spools') {
rememberView({
group: next.group,
sortKey: next.sortKey,
sortAsc: next.sortAsc,
showEmpty: next.showEmpty,
showLotNumbers: next.showLotNumbers,
pageSize: next.pageSize
});
}
const qs = serializeState(next);
// Both targets are base-path-independent: a bare `?query` resolves against the
@@ -235,6 +319,11 @@ function navigate(next: LibraryState, replace = false): void {
export function setGroup(group: GroupMode): void {
const s = currentState();
// Every catalog sort orders sections and rows alike, so nothing to reconcile.
if (s.view !== 'spools') {
navigate({ ...s, group, page: DEFAULTS.page });
return;
}
// A group can only be ordered three ways; if the active sort isn't one of
// them, fall back to the default group ordering so the new view is coherent
// rather than silently sorting only within groups.
@@ -247,6 +336,11 @@ export function setGroup(group: GroupMode): void {
/** Pick a sort key; re-selecting the active key flips its direction. */
export function setSortKey(key: string): void {
const s = currentState();
if (s.view !== 'spools') {
const sortAsc = s.sortKey === key ? !s.sortAsc : catalogSortAsc(s.view, key);
navigate({ ...s, sortKey: key, sortAsc, page: DEFAULTS.page });
return;
}
const sortAsc = s.sortKey === key ? !s.sortAsc : defaultSortAsc(key);
// A per-spool ranking can't order groups, so switch to the flat list where
// the ranking is actually visible instead of reordering only within groups.
@@ -290,6 +384,10 @@ export function setShowEmpty(showEmpty: boolean): void {
navigate({ ...currentState(), showEmpty, page: DEFAULTS.page });
}
export function setShowLotNumbers(showLotNumbers: boolean): void {
navigate({ ...currentState(), showLotNumbers, page: DEFAULTS.page });
}
export function setShowArchived(showArchived: boolean): void {
navigate({ ...currentState(), showArchived, page: DEFAULTS.page });
}
@@ -351,6 +449,17 @@ export function libraryHref(kind: EntityKind, id: string): string {
return `${resolve('/')}?sel=${kind}:${id}`;
}
/**
* Absolute (base-aware) href to a catalog view in its default layout, optionally
* pre-filtered (e.g. one manufacturer's filaments, from its inspector). Like
* {@link libraryHref} it carries nothing over from the current view: a catalog
* is entered fresh.
*/
export function catalogHref(view: CatalogView, filters: FilterChip[] = []): string {
const fresh = parseLibraryState(new URLSearchParams());
return `${resolve('/')}?${serializeState({ ...fresh, ...LAYOUT_DEFAULTS[view], view, filters })}`;
}
/**
* Href for a search result. The search box lives in the layout, so a result can
* be picked from any route: on the Library page we merge into the current view
+50 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseStoredView } from './viewPrefs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseStoredView, rememberView } from './viewPrefs';
// The stored view is the only piece of Library state that doesn't come from the
// URL (#1036), so it's also the only piece that can arrive malformed — from an
@@ -13,7 +13,9 @@ describe('parseStoredView', () => {
group: 'location',
sortKey: 'name',
sortAsc: true,
showEmpty: false
showEmpty: false,
showLotNumbers: false,
pageSize: 20
});
});
@@ -22,7 +24,9 @@ describe('parseStoredView', () => {
group: 'filament',
sortKey: 'name',
sortAsc: true,
showEmpty: true
showEmpty: true,
showLotNumbers: false,
pageSize: 20
});
});
@@ -54,7 +58,48 @@ describe('parseStoredView', () => {
group: 'colour',
sortKey: 'name',
sortAsc: false,
showEmpty: false
showEmpty: false,
showLotNumbers: false,
pageSize: 20
});
});
});
afterEach(() => vi.unstubAllGlobals());
it('round-trips the lot number preference through storage', () => {
let stored: string | null = null;
vi.stubGlobal('localStorage', {
getItem: () => stored,
setItem: (_key: string, value: string) => {
stored = value;
}
});
const view = {
group: 'filament',
sortKey: 'name',
sortAsc: true,
showEmpty: false,
showLotNumbers: true,
pageSize: 20
};
rememberView(view);
expect(parseStoredView(stored)).toEqual(view);
rememberView({ ...view, showLotNumbers: false });
expect(parseStoredView(stored)?.showLotNumbers).toBe(false);
});
it('defaults lot numbers off for an old stored view with empty enabled', () => {
expect(parseStoredView('{"group":"filament","sort":"name","asc":true,"empty":true}')?.showLotNumbers).toBe(
false
);
});
it('round-trips the page size and defaults it for old or invalid entries', () => {
const old = '{"group":"filament","sort":"name","asc":true';
expect(parseStoredView(`${old},"size":100}`)?.pageSize).toBe(100);
expect(parseStoredView(`${old}}`)?.pageSize).toBe(20);
expect(parseStoredView(`${old},"size":"100"}`)?.pageSize).toBe(20);
expect(parseStoredView(`${old},"size":0}`)?.pageSize).toBe(20);
expect(parseStoredView(`${old},"size":2.5}`)?.pageSize).toBe(20);
});
+29 -12
View File
@@ -16,12 +16,15 @@
// cache, one URL per distinct view — depends on the address bar being the whole
// truth about what's on screen.
//
// Only grouping and sort are remembered, plus the one toggle that ADDS rows
// (showEmpty). Filters, search and archived-visibility hide spools, and a hidden
// filter silently restored days later reads as missing data rather than as a
// preference. Showing filaments you own no spools of cannot produce that
// confusion -- restoring it can only put more on screen, never less -- so it is
// remembered while the hiding toggles still are not.
// Only grouping and sort are remembered, the page size (a layout choice that
// hides nothing, it just sets how many rows load at once), plus toggles that ADD
// information (showEmpty adds rows, showLotNumbers adds text). Filters, search and archived-
// visibility hide spools, and a hidden filter silently restored days later reads
// as missing data rather than as a preference. Showing empty filaments or lot
// numbers cannot produce that confusion -- restoring them can only put more on
// screen, never less -- so they are remembered while the hiding toggles are not.
const DEFAULT_PAGE_SIZE = 20;
const KEY = 'spoolman-v2-library-view';
@@ -35,6 +38,8 @@ export interface StoredView {
sortKey: string;
sortAsc: boolean;
showEmpty: boolean;
showLotNumbers: boolean;
pageSize: number;
}
/** Rebuild a stored view from its JSON, or null if there isn't a usable one. */
@@ -43,15 +48,23 @@ export function parseStoredView(raw: string | null): StoredView | null {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;
const { group, sort, asc, empty } = parsed as Record<string, unknown>;
const { group, sort, asc, empty, lots, size } = parsed as Record<string, unknown>;
if (typeof group !== 'string' || typeof sort !== 'string' || typeof asc !== 'boolean') {
return null;
}
// `empty` arrived after this key shipped, so an entry written by an older
// build simply has no such field. Treating that as "off" keeps those
// `empty`, `lots` and `size` arrived after this key shipped, so an entry written
// by an older build simply has no such fields. Treating that as "off" keeps those
// entries valid; rejecting them would throw away a grouping the user
// picked long ago the first time they loaded a new version.
return { group, sortKey: sort, sortAsc: asc, showEmpty: empty === true };
return {
group,
sortKey: sort,
sortAsc: asc,
showEmpty: empty === true,
showLotNumbers: lots === true,
// An absent or unusable size is the default, not a reason to drop the entry.
pageSize: typeof size === 'number' && Number.isInteger(size) && size > 0 ? size : DEFAULT_PAGE_SIZE
};
} catch {
// Corrupt entry: the shipped view is no worse than what a first-time
// visitor gets.
@@ -80,7 +93,9 @@ export function rememberView(view: StoredView): void {
prev.group === view.group &&
prev.sortKey === view.sortKey &&
prev.sortAsc === view.sortAsc &&
prev.showEmpty === view.showEmpty
prev.showEmpty === view.showEmpty &&
prev.showLotNumbers === view.showLotNumbers &&
prev.pageSize === view.pageSize
) {
// Selecting a spool or turning a page navigates too; only the changes that
// actually move the view are worth a synchronous localStorage write.
@@ -109,7 +124,9 @@ function write(view: StoredView): void {
group: view.group,
sort: view.sortKey,
asc: view.sortAsc,
empty: view.showEmpty
empty: view.showEmpty,
lots: view.showLotNumbers,
size: view.pageSize
})
);
} catch {
+4 -2
View File
@@ -1,4 +1,5 @@
import { getSettings, setSetting, parseSetting } from '$lib/api/settings';
import { parseDecimal } from '$lib/utils/numeric';
// Server-backed user settings (currency, round_prices, base_url) plus one
// client-only preference (low-stock threshold, persisted to localStorage — the
@@ -15,8 +16,9 @@ class Settings {
async load() {
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem(LOW_THRESHOLD_KEY);
if (stored) this.lowThreshold = Number(stored) || this.lowThreshold;
// 0 g is a real threshold, so `||` can't stand in for "nothing stored".
const stored = parseDecimal(localStorage.getItem(LOW_THRESHOLD_KEY));
if (stored !== null && stored >= 0) this.lowThreshold = stored;
}
try {
const s = await getSettings();
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/api/settings', () => ({
getSettings: async () => ({}),
setSetting: async () => {},
parseSetting: <T>(_: unknown, fallback: T) => fallback
}));
const { settings } = await import('./settings.svelte');
function storeThreshold(value: string | null) {
vi.stubGlobal('localStorage', {
getItem: () => value,
setItem: () => {}
});
}
describe('low-stock threshold', () => {
afterEach(() => {
vi.unstubAllGlobals();
settings.lowThreshold = 150;
});
it('keeps a stored 0 across a reload', async () => {
storeThreshold('0');
await settings.load();
expect(settings.lowThreshold).toBe(0);
});
it('reads a stored threshold', async () => {
storeThreshold('42.5');
await settings.load();
expect(settings.lowThreshold).toBe(42.5);
});
it('falls back to the default for nothing stored or garbage', async () => {
for (const bad of [null, '', 'NaN', 'abc', '-5', 'Infinity']) {
storeThreshold(bad);
await settings.load();
expect(settings.lowThreshold, String(bad)).toBe(150);
}
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { startingCamera, videoCameras } from './cameraChoice';
// A phone can expose half a dozen cameras, and which one the browser picks for
// 'environment' is up to the browser: on some it's the telephoto, which only
// reads a label from across the room (#693). These decide which camera the
// scanner opens with and what the camera picker offers.
// The order Chrome enumerates them on an Oppo X9 Ultra.
const cameras = [
{ id: 'c1', label: 'camera 1, facing front' },
{ id: 'c5', label: 'camera 5, facing back' },
{ id: 'c4', label: 'camera 4, facing back' },
{ id: 'c3', label: 'camera 3, facing back' },
{ id: 'c2', label: 'camera 2, facing back' },
{ id: 'c0', label: 'camera 0, facing back' }
];
const ids = cameras.map((c) => c.id);
describe('startingCamera', () => {
it('opens the camera the user picked last time', () => {
expect(startingCamera('c0', ids)).toBe('c0');
});
it('asks for the rear camera when nothing was picked yet', () => {
expect(startingCamera(null, ids)).toBe('environment');
});
it('asks for the rear camera when the picked one is gone', () => {
// qr-scanner retries a missing deviceId with no constraint at all, which
// can land on the front camera. The rear one is the better fallback.
expect(startingCamera('unplugged-webcam', ids)).toBe('environment');
});
it('asks for the rear camera before the browser reveals device ids', () => {
// Without camera permission enumerateDevices lists every camera with an
// empty id, and an empty saved id must not match one of those.
expect(startingCamera('', ['', ''])).toBe('environment');
});
});
describe('videoCameras', () => {
const device = (kind: MediaDeviceKind, deviceId: string, label = '') => ({ kind, deviceId, label });
it('lists the video inputs in the order the browser gives them', () => {
expect(
videoCameras([
device('audioinput', 'mic', 'Microphone'),
device('videoinput', 'c1', 'camera 1, facing front'),
device('videoinput', 'c5', 'camera 5, facing back'),
device('audiooutput', 'spk', 'Speaker')
])
).toEqual([
{ id: 'c1', label: 'camera 1, facing front' },
{ id: 'c5', label: 'camera 5, facing back' }
]);
});
it('keeps an empty label empty so the picker can number it', () => {
// qr-scanner's listCameras() fills these in with an untranslated
// "Default Camera" / "Camera N", which the picker couldn't tell apart
// from a real device name.
expect(videoCameras([device('videoinput', 'c1')])).toEqual([{ id: 'c1', label: '' }]);
});
});
+57
View File
@@ -0,0 +1,57 @@
// Which camera the QR scanner opens with, remembered per browser.
//
// Asking for the 'environment' camera lets the browser choose among every rear
// camera, and on a phone with several lenses that can be the telephoto: the
// scanner then only reads a label from across the room (#693). A desktop has
// the same problem with a virtual webcam listed first. The scanner offers a
// picker of every camera, and the one the user chooses is what it opens with
// next time.
const KEY = 'spoolman-v2-scanner-camera';
export interface Camera {
id: string;
label: string;
}
/**
* What to hand qr-scanner as `preferredCamera`: the remembered camera if the
* browser still lists it, otherwise the rear camera. Checked up front because a
* deviceId qr-scanner can't open makes it fall back to any camera at all, which
* can be the front one.
*/
export function startingCamera(saved: string | null, deviceIds: readonly string[]): string {
return saved && deviceIds.includes(saved) ? saved : 'environment';
}
/**
* The cameras among the browser's media devices, in its order. An empty label
* stays empty for the caller to number: qr-scanner's listCameras() would fill in
* an untranslated "Default Camera" / "Camera N" that can't be told apart from a
* real device name.
*/
export function videoCameras(
devices: readonly Pick<MediaDeviceInfo, 'kind' | 'deviceId' | 'label'>[]
): Camera[] {
return devices.filter((d) => d.kind === 'videoinput').map((d) => ({ id: d.deviceId, label: d.label }));
}
/** The camera the user last switched to in this browser, if any. */
export function rememberedCamera(): string | null {
if (typeof localStorage === 'undefined') return null;
try {
return localStorage.getItem(KEY);
} catch {
// Storage disabled (private mode, blocked third-party context).
return null;
}
}
export function rememberCamera(id: string): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(KEY, id);
} catch {
/* nothing to do — remembering the camera is a convenience, not a requirement */
}
}
+43 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { groupUnusedByFilament, usageLabel } from './library';
import { groupUnusedByFilament, rowIdentity, usageLabel } from './library';
import type { SpoolVM } from './library';
import { mapSpool } from '$lib/api/map';
@@ -89,3 +89,45 @@ describe('groupUnusedByFilament', () => {
expect(groupUnusedByFilament([])).toEqual([]);
});
});
describe('rowIdentity', () => {
const vm = (lot: string, unused = false): SpoolVM =>
({
spool: { lot, unused },
filament: { name: 'Galaxy Black', material: 'PLA', diameter: 1.75 },
vendor: { name: 'Prusament' },
rightLabel: unused ? 'unused' : 'used 3 days ago'
}) as SpoolVM;
const contexts = ['flat', 'filament', 'vendor', 'material', 'location'] as const;
const subs = ['PLA · 1.75 mm', 'used 3 days ago', 'PLA · 1.75 mm', '1.75 mm', 'PLA · 1.75 mm'];
it.each(contexts)('defaults to master text in %s rows', (context) => {
expect(rowIdentity(vm('LOT'), context)).toEqual({
title: context === 'filament' ? '' : context === 'vendor' ? 'Galaxy Black' : 'Prusament Galaxy Black',
sub: subs[contexts.indexOf(context)]
});
});
it.each(contexts)('appends the trimmed lot when enabled in %s rows', (context) => {
const identity = rowIdentity(vm(''), context);
expect(rowIdentity(vm(' LOT-2026-0915-A7 '), context, true)).toEqual({
...identity,
sub: `${identity.sub} · Lot Nr LOT-2026-0915-A7`
});
});
it.each(contexts)('adds nothing for blank lots in %s rows', (context) => {
for (const lot of ['', ' ']) {
expect(rowIdentity(vm(lot), context, true)).toEqual(rowIdentity(vm(lot), context));
}
});
it('shows the lot for individual unused spools too', () => {
expect(rowIdentity(vm('24A-1187', true), 'filament', true).sub).toBe('unused · Lot Nr 24A-1187');
});
it('omits the separator when the existing secondary text is empty', () => {
expect(rowIdentity({ ...vm('LOT'), rightLabel: '' }, 'filament', true).sub).toBe('Lot Nr LOT');
});
});
+132 -4
View File
@@ -115,7 +115,16 @@ export interface RowIdentity {
* only causes truncation — so we drop it and surface the part that actually
* distinguishes this spool instead.
*/
export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
export function rowIdentity(vm: SpoolVM, ctx: RowContext, showLotNumbers = false): RowIdentity {
const identity = contextualIdentity(vm, ctx);
const lot = showLotNumbers ? vm.spool.lot.trim() : '';
if (lot) {
identity.sub = [identity.sub, `${m['spool.fields.lotNr']()} ${lot}`].filter(Boolean).join(' · ');
}
return identity;
}
function contextualIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
const { vendor, filament } = vm;
const dims = `${filament.material} · ${filament.diameter} mm`;
switch (ctx) {
@@ -139,9 +148,12 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
/**
* Distinguish spools of one and the same filament. They're identical except for
* how they've been used — and #id, fill, weight and location already sit in their
* own columns — so there's nothing to make a bold title out of. The usage status
* is all the row claims; the inspector has the rest.
* how they've been used and which lot they came from — #id, fill, weight and
* location already sit in their own columns — so there's nothing to make a bold
* title out of. The usage status leads, since every row can say it; the lot
* number joins only when the user turns it on and there is one (#1173). A long
* lot is what the ellipsis cuts, with the full text on hover. The inspector has
* the rest.
*
* The comment deliberately stays out of it. It reads like a label on the handful
* of spools whose owner treats it as one, but it's a free-form textarea: people
@@ -246,6 +258,122 @@ export const FIXED_SORTS: SortDef[] = [
const FIXED_SORT_KEYS = new Set(FIXED_SORTS.map((s) => s.key));
/**
* A sort of the filament or manufacturer list. Those page the group endpoint, so
* each orders whole groups: by an aggregate over their spools (`group.*`), or by
* a field of the filament or manufacturer, which the endpoint takes in the spool
* endpoint's dotted form. `key` is what the URL carries.
*/
export interface CatalogSortDef extends SortDef {
/** The group endpoint's sort field. */
field: string;
/** Reads A→Z when first picked, rather than biggest/newest first. */
asc?: boolean;
}
function fieldSort(
key: string,
labelKey: () => string,
section: SortDef['section'],
extra: Partial<CatalogSortDef> = {}
): CatalogSortDef {
return { key, field: key, labelKey, section, ...extra };
}
const STOCK_SORTS: CatalogSortDef[] = [
{
key: 'remaining_weight',
field: 'group.total_remaining',
labelKey: m['spool.fields.remainingWeight'],
section: 'spool',
unit: 'g'
},
{ key: 'last_used', field: 'group.last_used', labelKey: m['spool.fields.lastUsed'], section: 'spool' },
{ key: 'spool_count', field: 'group.spool_count', labelKey: m['search.section.spools'], section: 'spool' }
];
const FILAMENT_CATALOG_SORTS: CatalogSortDef[] = [
...STOCK_SORTS,
{ key: 'name', field: 'group.title', labelKey: m['filament.fields.name'], section: 'filament', asc: true },
fieldSort('filament.id', m['filament.fields.id'], 'filament'),
fieldSort('filament.material', m['filament.fields.material'], 'filament', { asc: true }),
fieldSort('filament.color_hex', m['filament.fields.colorHex'], 'filament', { asc: true }),
fieldSort('filament.diameter', m['filament.fields.diameter'], 'filament', { unit: 'mm' }),
fieldSort('filament.density', m['filament.fields.density'], 'filament'),
fieldSort('filament.settings_extruder_temp', m['filament.fields.settingsExtruderTemp'], 'filament', {
unit: '°C'
}),
fieldSort('filament.settings_bed_temp', m['filament.fields.settingsBedTemp'], 'filament', { unit: '°C' }),
fieldSort('filament.weight', m['filament.fields.weight'], 'filament', { unit: 'g' }),
fieldSort('filament.spool_weight', m['filament.fields.spoolWeight'], 'filament', { unit: 'g' }),
fieldSort('filament.price', m['filament.fields.price'], 'filament'),
fieldSort('filament.article_number', m['filament.fields.articleNumber'], 'filament', { asc: true }),
fieldSort('filament.external_id', m['filament.fields.externalId'], 'filament', { asc: true }),
fieldSort('filament.registered', m['filament.fields.registered'], 'filament'),
fieldSort('filament.vendor.name', m['filament.fields.vendor'], 'vendor', { asc: true })
];
const VENDOR_CATALOG_SORTS: CatalogSortDef[] = [
...STOCK_SORTS,
{
key: 'filament_count',
field: 'group.filament_count',
labelKey: m['filament.filament'],
section: 'filament'
},
{ key: 'name', field: 'group.title', labelKey: m['vendor.fields.name'], section: 'vendor', asc: true },
fieldSort('filament.vendor.id', m['vendor.fields.id'], 'vendor'),
fieldSort('filament.vendor.empty_spool_weight', m['vendor.fields.emptySpoolWeight'], 'vendor', {
unit: 'g'
}),
fieldSort('filament.vendor.external_id', m['vendor.fields.externalId'], 'vendor', { asc: true }),
fieldSort('filament.vendor.registered', m['vendor.fields.registered'], 'vendor')
];
const FILAMENT_EXTRA = 'filament.extra.';
const VENDOR_EXTRA = 'filament.vendor.extra.';
/**
* Every sort a catalog view offers: the stock aggregates, the entity's own
* fields and its custom fields. The filament list can also order by the
* manufacturer's fields, since every filament has (at most) one.
*/
export function catalogSortDefs(
view: 'filaments' | 'manufacturers',
filamentFields: FieldDef[] = [],
vendorFields: FieldDef[] = []
): CatalogSortDef[] {
const extra = (prefix: string, defs: FieldDef[], section: SortDef['section']) =>
defs.map((f) => fieldSort(prefix + f.key, () => f.name, section, { unit: f.unit ?? undefined }));
return view === 'filaments'
? [
...FILAMENT_CATALOG_SORTS,
...extra(VENDOR_EXTRA, vendorFields, 'vendor'),
...extra(FILAMENT_EXTRA, filamentFields, 'extra')
]
: [...VENDOR_CATALOG_SORTS, ...extra(VENDOR_EXTRA, vendorFields, 'extra')];
}
/**
* The group endpoint's sort field for a catalog sort key, or undefined when the
* key isn't one the view offers (a stale or hand-edited URL). Custom-field keys
* are taken on their prefix: which fields exist is only known once the field
* registry has loaded, and the endpoint skips a key it doesn't know.
*/
export function catalogSortField(view: 'filaments' | 'manufacturers', key: string): string | undefined {
const fixed = view === 'filaments' ? FILAMENT_CATALOG_SORTS : VENDOR_CATALOG_SORTS;
const def = fixed.find((s) => s.key === key);
if (def) return def.field;
if (key.startsWith(VENDOR_EXTRA) || (view === 'filaments' && key.startsWith(FILAMENT_EXTRA))) return key;
return undefined;
}
/** Default direction for a freshly picked catalog sort. */
export function catalogSortAsc(view: 'filaments' | 'manufacturers', key: string): boolean {
const fixed = view === 'filaments' ? FILAMENT_CATALOG_SORTS : VENDOR_CATALOG_SORTS;
return fixed.find((s) => s.key === key)?.asc ?? false;
}
// The sort key that reduces to each grouping axis's own title. Grouping by X and
// sorting by X's name are the same operation on the group list, so this key maps
// to the backend's `group.title` (alphabetical) ordering for that axis.
+49 -31
View File
@@ -75,48 +75,66 @@ function reasonFor(err: unknown): NfcErrorReason {
/**
* Wait for one tag to be tapped and resolve with its UID.
*
* The UID arrives in whatever spelling the platform uses (Chrome's is
* colon-separated lowercase) and is passed on untouched — normalization belongs
* to the server, which does it for every reader rather than for this one.
*
* Scanning continues until a tag is read or `signal` aborts, so callers must
* always pass one and abort it when their dialog closes; otherwise the radio
* keeps running behind a screen nobody is looking at.
*
* Rejects with an `NfcError` carrying a reason, or with the abort's own reason
* when cancelled — check `signal.aborted` before reporting anything.
* Rejects like `watchTagUids`.
*/
export function readTagUid(signal: AbortSignal): Promise<string> {
return new Promise<string>((resolve, reject) => watchTagUids(signal, resolve, reject));
}
/**
* Call `onUid` with the UID of every tag tapped until `signal` aborts.
*
* The UID arrives in whatever spelling the platform uses (Chrome's is
* colon-separated lowercase) and is passed on untouched — normalization belongs
* to the server, which does it for every reader rather than for this one.
*
* `onError` gets an `NfcError` carrying a reason, or the abort's own reason when
* cancelled — check `signal.aborted` before reporting anything. Without a user
* gesture, starting only succeeds if NFC permission was already granted; it
* fails with `notAllowed` instead of prompting.
*/
export function watchTagUids(
signal: AbortSignal,
onUid: (uid: string) => void,
onError: (err: unknown) => void
): void {
if (typeof window === 'undefined' || !('NDEFReader' in window)) {
return Promise.reject(new NfcError('unsupported'));
onError(new NfcError('unsupported'));
return;
}
if (!window.isSecureContext) {
onError(new NfcError('insecureContext'));
return;
}
if (signal.aborted) {
onError(signal.reason);
return;
}
if (!window.isSecureContext) return Promise.reject(new NfcError('insecureContext'));
const Ctor = (window as unknown as { NDEFReader: NDEFReaderCtor }).NDEFReader;
return new Promise<string>((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
let reader: NDEFReaderLike;
try {
reader = new Ctor();
} catch (err) {
reject(new NfcError(reasonFor(err)));
return;
}
let reader: NDEFReaderLike;
try {
reader = new Ctor();
} catch (err) {
onError(new NfcError(reasonFor(err)));
return;
}
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
reader.addEventListener('reading', (ev) => resolve(ev.serialNumber));
// A tag that came and went before it could be read. Not fatal and not worth
// reporting: the user's next tap is the retry, and the scan is still live.
reader.addEventListener('readingerror', () => {});
signal.addEventListener('abort', () => onError(signal.reason), { once: true });
reader.addEventListener('reading', (ev) => onUid(ev.serialNumber));
// A tag that came and went before it could be read. Not fatal and not worth
// reporting: the user's next tap is the retry, and the scan is still live.
reader.addEventListener('readingerror', () => {});
// scan() is what raises the permission prompt, so its rejection is the one
// that carries a reason worth showing.
reader.scan({ signal }).catch((err) => {
if (signal.aborted) return; // our own cancellation, already rejected above
reject(new NfcError(reasonFor(err)));
});
// scan() is what raises the permission prompt, so its rejection is the one
// that carries a reason worth showing.
reader.scan({ signal }).catch((err) => {
if (signal.aborted) return; // our own cancellation, already reported above
onError(new NfcError(reasonFor(err)));
});
}
+10
View File
@@ -6,6 +6,16 @@ import { filterDecimalInsertion, isPartialDecimal, normalizeDecimal, parseDecima
// number may reach the API.
describe('parseDecimal', () => {
it.each([
['', undefined],
[' ', undefined],
['0', 0],
['0,5', 0.5],
['garbage', undefined]
])('converts optional API input %j to %s', (raw, expected) => {
expect(parseDecimal(raw) ?? undefined).toBe(expected);
});
it('reads a decimal point', () => {
expect(parseDecimal('1.75')).toBe(1.75);
});
+89 -40
View File
@@ -10,7 +10,9 @@
import { serverInfo } from '$lib/stores/serverInfo.svelte';
import { theme } from '$lib/stores/theme.svelte';
import { startLiveSync } from '$lib/api/liveSync';
import { scanRelay } from '$lib/api/scanRelay';
import { scanRelay, type TagScan } from '$lib/api/scanRelay';
import { findTagHolder } from '$lib/api/tags';
import { nfcSupported, watchTagUids } from '$lib/utils/nfc';
import { scanner, isBrowsableRoute } from '$lib/stores/scanner.svelte';
import { toasts } from '$lib/stores/toasts.svelte';
import { inventory } from '$lib/stores/inventory.svelte';
@@ -62,47 +64,94 @@
if (!scanner.autoNavigate) return;
return scanRelay.subscribe(scanner.pool, (scan) => {
scanner.receive(scan);
// Read inside the handler, never in the effect body: depending on the route
// here would tear the socket down and rebuild it on every navigation.
// A page you are configuring reacts to nothing — not even the toast, which
// during pairing would explain how to link the tag you just tapped to pair.
if (!isBrowsableRoute(page.route.id)) return;
// A tag identifies a spool or a filament, and either opens in the inspector.
const hit = scan.spool
? { kind: 'spool' as const, id: String(scan.spool.id) }
: scan.filament
? { kind: 'filament' as const, id: scan.filament.id }
: null;
if (!hit) {
// An unknown tag has nowhere to navigate to, and silently ignoring it
// would look like the tap failed. Say what was read and where to link
// it — repeats coalesce, and the relay already debounces a reader that
// re-reads a tag left sitting on it. Not an error: tapping a tag no
// spool claims yet is how enrolling one starts.
toasts.info(m['tags.scan.unknown']({ uid: scan.uid }));
return;
}
if (!scanner.mayNavigate(document.activeElement, ui.addModalOpen || ui.scannerOpen)) return;
// The same navigation a picked search result gets, and for the same reason:
// on the Library it merges the selection into the view you are already in,
// so a tap reveals the spool without throwing away the grouping, sort and
// filters you had set up; from anywhere else it opens the Library on just
// that spool. The inspector resolves a selection by id on its own, so the
// spool still opens when the active filters exclude it from the list behind
// it -- a scan answers "where is this spool", never "is it in this view".
openSearchResult(hit.kind, hit.id);
// Say what the tap did. The reader is often in another room from the screen,
// and a page that changes by itself with no word why reads as a glitch. The
// relay already cached the spool's filament and vendor, so naming is local.
const filament = scan.filament ?? inventory.filamentById(scan.spool?.filamentId ?? '');
const name = filament ? filamentLabel(filament, inventory.vendorById(filament.vendorId)) : '';
toasts.info(
scan.spool
? m['tags.scan.openedSpool']({ id: scan.spool.id, name })
: m['tags.scan.openedFilament']({ name })
);
onScan(scan);
});
});
// The same, for a tag tapped against this device's own NFC (Chrome on Android
// over HTTPS). Pairing is about which remote reader to follow, so it has no say
// here: the phone in your hand is always this browser's reader.
//
// Without a user gesture scan() only starts if NFC permission is already
// granted, which is the usual case on reload. Otherwise it starts the moment
// permission is granted, e.g. by reading a tag in the link-tag dialog.
$effect(() => {
if (!scanner.autoNavigate || !nfcSupported()) return;
const ac = new AbortController();
const start = () =>
watchTagUids(
ac.signal,
async (uid) => {
// A tag dialog is open and reading this very tap for itself.
if (scanner.suppressed) return;
const holder = await findTagHolder(uid, ac.signal).catch(() => undefined);
if (ac.signal.aborted) return;
onScan({
uid,
readerId: 'this-device',
spool: holder?.kind === 'spool' ? holder.spool : undefined,
filament: holder?.kind === 'filament' ? holder.filament : undefined
});
},
() => {} // no permission yet, or NFC switched off: the relay still works
);
start();
navigator.permissions
?.query({ name: 'nfc' as PermissionName })
.then((status) => {
status.addEventListener(
'change',
() => {
if (status.state === 'granted') start();
},
{ signal: ac.signal }
);
})
.catch(() => {});
return () => ac.abort();
});
function onScan(scan: TagScan) {
// Read inside the handler, never in the effect body: depending on the route
// here would tear the socket down and rebuild it on every navigation.
// A page you are configuring reacts to nothing — not even the toast, which
// during pairing would explain how to link the tag you just tapped to pair.
if (!isBrowsableRoute(page.route.id)) return;
// A tag identifies a spool or a filament, and either opens in the inspector.
const hit = scan.spool
? { kind: 'spool' as const, id: String(scan.spool.id) }
: scan.filament
? { kind: 'filament' as const, id: scan.filament.id }
: null;
if (!hit) {
// An unknown tag has nowhere to navigate to, and silently ignoring it
// would look like the tap failed. Say what was read and where to link
// it — repeats coalesce, and the relay already debounces a reader that
// re-reads a tag left sitting on it. Not an error: tapping a tag no
// spool claims yet is how enrolling one starts.
toasts.info(m['tags.scan.unknown']({ uid: scan.uid }));
return;
}
if (!scanner.mayNavigate(document.activeElement, ui.addModalOpen || ui.scannerOpen)) return;
// The same navigation a picked search result gets, and for the same reason:
// on the Library it merges the selection into the view you are already in,
// so a tap reveals the spool without throwing away the grouping, sort and
// filters you had set up; from anywhere else it opens the Library on just
// that spool. The inspector resolves a selection by id on its own, so the
// spool still opens when the active filters exclude it from the list behind
// it -- a scan answers "where is this spool", never "is it in this view".
openSearchResult(hit.kind, hit.id);
// Say what the tap did. The reader is often in another room from the screen,
// and a page that changes by itself with no word why reads as a glitch. The
// relay already cached the spool's filament and vendor, so naming is local.
const filament = scan.filament ?? inventory.filamentById(scan.spool?.filamentId ?? '');
const name = filament ? filamentLabel(filament, inventory.vendorById(filament.vendorId)) : '';
toasts.info(
scan.spool
? m['tags.scan.openedSpool']({ id: scan.spool.id, name })
: m['tags.scan.openedFilament']({ name })
);
}
</script>
<div class="app">
+9 -2
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import FilamentList from '$components/library/FilamentList.svelte';
import CatalogList from '$components/library/CatalogList.svelte';
import Inspector from '$components/library/Inspector.svelte';
import DetailPane from '$components/DetailPane.svelte';
import Splitter from '$components/Splitter.svelte';
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { clearSelection, rememberedViewHref } from '$lib/library/params';
import { clearSelection, isCatalogView, rememberedViewHref } from '$lib/library/params';
import {
listWidth,
clampListWidth,
@@ -64,7 +65,13 @@
<div class="library" style="--list-w: {width}px" bind:clientWidth={available}>
<!-- Left list: front and centre; on mobile it fills the screen. -->
<div class="list-pane">
<FilamentList libraryState={data.state} />
<!-- The filament and manufacturer catalogs are sub-views of the same page:
the URL's `view` picks the list, the inspector beside it stays the same. -->
{#if isCatalogView(data.state)}
<CatalogList libraryState={data.state} />
{:else}
<FilamentList libraryState={data.state} />
{/if}
</div>
<!-- The divider between the two panes is also the handle that moves it. -->
+29 -12
View File
@@ -8,6 +8,7 @@
import { settings } from '$lib/stores/settings.svelte';
import { fields } from '$lib/stores/fields.svelte';
import { spoolSource } from '$lib/api/spoolSource';
import { allPages } from '$lib/api/query';
import { live, type LiveEvent } from '$lib/api/live';
import { isAbortError } from '$lib/api/http';
import { mapSpool } from '$lib/api/map';
@@ -36,6 +37,8 @@
const FLIP = 160;
/** Spools fetched per request when filling (or extending) a card. */
const PAGE = 30;
/** Groups fetched per request when listing the cards. */
const GROUP_PAGE = 1000;
/** Distance from the bottom of a card's list at which the next page is fetched. */
const SCROLL_MARGIN = 120;
/** The unassigned card's key: no value for the grouped-by field. */
@@ -170,20 +173,24 @@
// controller for a later view.
const signal = pageAbort.signal;
try {
const page = await spoolSource.listGroups({
field: fieldKey,
filters: {},
sort: [{ field: 'group.title', dir: 'asc' }],
limit: 1000,
offset: 0,
lowThreshold: settings.lowThreshold,
signal
});
// Every group gets a card, so page through all of them rather than stopping at
// the first page; a group past it would never show up at all.
const groups = await allPages(GROUP_PAGE, (offset) =>
spoolSource.listGroups({
field: fieldKey,
filters: {},
sort: [{ field: 'group.title', dir: 'asc' }],
limit: GROUP_PAGE,
offset,
lowThreshold: settings.lowThreshold,
signal
})
);
// A NULL value and an empty-string one are distinct rows to the database but the
// same "unassigned" card here, so counts are summed by key.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const totals = new Map<string, number>();
for (const g of page.items) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
for (const g of groups) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
for (const [key, total] of totals) {
const b = bucket(key);
@@ -1012,8 +1019,13 @@
* scrolling it to the bottom fetches the next page. */
.card-body {
/* Single source of truth for the height cap: the reserved min-height (set inline from
* the spool count) is clamped to this too, so it never exceeds max-height. */
--card-body-cap: min(52vh, 420px);
* the spool count) is clamped to this too, so it never exceeds max-height.
*
* A single row of cards reaches down to the footer (#1242). The 1000px ceiling is
* not cosmetic: the next page only loads when the body is scrolled, so the body
* must stay shorter than one page of chips (PAGE × CHIP_H, about 1500px) or a tall
* screen would show the first page with no scrollbar and never fetch the rest. */
--card-body-cap: clamp(min(52vh, 420px), 100dvh - 240px, 1000px);
display: flex;
flex-direction: column;
gap: 6px;
@@ -1127,6 +1139,11 @@
.ctrl-label {
display: none;
}
/* Cards stack on a touch screen, and a body nearly as tall as the screen would
* swallow the swipes meant for scrolling the page past it. */
.card-body {
--card-body-cap: min(52vh, 420px);
}
}
/* Phone: keep the controls together on the first row (title · group-by · new group)
and give the hint the full width of the row below them. */
-1
View File
@@ -24,7 +24,6 @@ dependencies = [
"httpx~=0.28",
"hishel~=0.1",
"aiomysql[rsa]~=0.3",
"influxdb-client>=1.50.0",
]
requires-python = ">=3.10"
+32 -36
View File
@@ -2,51 +2,47 @@
# uv export --no-dev -o requirements.txt --no-annotate --no-hashes
aiomysql==0.3.2
aiosqlite==0.22.1
alembic==1.18.5
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.14.1
alembic==1.20.0
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
anysqlite==0.0.5
async-timeout==5.0.1 ; python_full_version < '3.11'
asyncpg==0.31.0
certifi==2026.6.17
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
click==8.4.2
colorama==0.4.6 ; sys_platform == 'win32'
cryptography==50.0.0
asyncpg==0.32.0
certifi==2026.7.22
cffi==2.1.1 ; platform_python_implementation != 'PyPy'
click==8.5.0
cryptography==50.0.2
exceptiongroup==1.3.1 ; python_full_version < '3.11'
fastapi==0.139.0
greenlet==3.5.3
fastapi==0.143.0
greenlet==3.5.6
h11==0.16.0
hishel==0.1.5
httpcore==1.0.9
httptools==0.8.0 ; platform_machine != 'armv7l'
httptools==0.9.0 ; platform_machine != 'armv7l'
httpx==0.28.1
idna==3.18
influxdb-client==1.50.0
mako==1.3.12
markupsafe==3.0.3
msgpack==1.2.1
platformdirs==4.10.0
prometheus-client==0.25.0
psycopg2-binary==2.9.12
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
pydantic==2.13.4
pydantic-core==2.46.4
pymysql==1.2.0
python-dateutil==2.9.0.post0
reactivex==5.1.0
idna==3.20
mako==1.4.3
markupsafe==3.0.4
msgpack==1.2.3
opentelemetry-api==1.45.1
platformdirs==4.12.4 ; python_full_version < '3.11'
platformdirs==4.13.0 ; python_full_version >= '3.11'
prometheus-client==0.26.0
psycopg2-binary==2.9.13
pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
pydantic==2.14.0
pydantic-core==2.50.0
pymysql==1.2.3
scheduler==0.8.11
setuptools==83.0.0
six==1.17.0
sqlalchemy==2.0.51
sqlalchemy==2.0.54
sqlalchemy-cockroachdb==2.0.4
starlette==1.3.1
tomli==2.4.1 ; python_full_version < '3.11'
typeguard==4.5.2
starlette==1.7.0
tomli==2.5.0 ; python_full_version < '3.11'
typeguard==4.6.0
typing-extensions==4.16.0
typing-inspection==0.4.2
urllib3==2.7.0
uvicorn==0.50.1
uvloop==0.22.1 ; platform_machine != 'armv7l' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
typing-inspection==0.4.4
uvicorn==0.54.0
uvloop==0.23.0 ; platform_machine != 'armv7l' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
websockets==15.0.1
+70
View File
@@ -481,6 +481,39 @@ class Spool(BaseModel):
)
class SpoolGroupPreviewSpool(BaseModel):
"""One spool in a filament group's preview: just enough to draw a fill-level pip."""
id: int = Field(description="Spool ID.", examples=[3])
remaining_weight: float | None = Field(
None,
description="Remaining filament weight in grams, as on the spool. Null when the initial weight is unknown.",
examples=[750.0],
)
initial_weight: float | None = Field(
None,
description="Initial filament weight in grams, falling back to the filament's weight. Null when unknown.",
examples=[1000.0],
)
class SpoolGroupPreviewFilament(BaseModel):
"""One filament in a vendor group's preview: just enough to draw a color swatch."""
id: int = Field(description="Filament ID.", examples=[7])
color_hex: str | None = Field(None, description="Hexadecimal color code of the filament.", examples=["FF0000"])
multi_color_hexes: str | None = Field(
None,
description="Comma-separated color codes of a multi-color filament.",
examples=["FF0000,00FF00"],
)
multi_color_direction: MultiColorDirection | None = Field(
None,
description="Type of multi-color filament. Only set if multi_color_hexes is set.",
examples=["coaxial"],
)
class SpoolGroup(BaseModel):
"""A group of spools with server-computed aggregates.
@@ -524,6 +557,30 @@ class SpoolGroup(BaseModel):
None,
description="The vendor, embedded for group_by=vendor.",
)
filament_count: int | None = Field(
None,
description=(
"Only for group_by=vendor: the number of distinct filaments in the group. With "
"include_empty=true that is every matching filament of the manufacturer, including "
"those with no spools; otherwise it is the number of distinct filaments among the "
"matching spools."
),
examples=[4],
)
spools: list[SpoolGroupPreviewSpool] | None = Field(
None,
description=(
"Only with preview=true and group_by=filament: up to 5 of the group's matching spools, by "
"ascending ID. They are exactly the spools counted in spool_count."
),
)
filaments: list[SpoolGroupPreviewFilament] | None = Field(
None,
description=(
"Only with preview=true and group_by=vendor: up to 4 of the manufacturer's filaments that "
"match the filament filters, by name then ID, whether or not they have matching spools."
),
)
class SearchResultSpool(BaseModel):
@@ -732,6 +789,19 @@ class Info(BaseModel):
)
git_commit: str | None = Field(None, examples=["a1b2c3d"])
build_date: SpoolmanDateTime | None = Field(None, examples=["2021-01-01T00:00:00Z"])
latest_version: str | None = Field(
None,
description=(
"Latest Spoolman release on GitHub. Null until the server has checked, "
"or if the check is disabled via SPOOLMAN_UPDATE_CHECK."
),
examples=["0.28.0"],
)
update_available: bool = Field(
default=False,
description="True if latest_version is newer than the running version.",
examples=[False],
)
class HealthCheck(BaseModel):
+1 -1
View File
@@ -131,7 +131,7 @@ class RenameLocationBody(BaseModel):
@router.patch(
"/location/{location}",
"/location/{location:path}",
name="Rename location",
description="Rename a spool location. All spools in this location will be moved to the new location.",
response_model_exclude_none=True,
+3 -1
View File
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
from starlette.requests import Request
from starlette.responses import Response
from spoolman import env
from spoolman import env, update_check
from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.externaldb import get_external_db_name
@@ -59,6 +59,8 @@ async def info() -> models.Info:
external_db_name=get_external_db_name(),
git_commit=env.get_commit_hash(),
build_date=env.get_build_date(),
latest_version=update_check.latest_version,
update_available=update_check.is_update_available(),
)
+38 -12
View File
@@ -451,7 +451,8 @@ async def notify_any(
"in-use count, total remaining weight and most recent usage. Pagination is over groups, so "
"a group is never split and its aggregates are always complete. Uses the same filters as "
"the spool search endpoint. The total number of matching groups is returned in the "
"x-total-count header."
"x-total-count header, and the spool count and remaining weight (grams) summed over all "
"matching groups in the x-total-spools and x-total-remaining-weight headers."
),
response_model_exclude_none=True,
responses={
@@ -548,12 +549,24 @@ async def find_groups(
Query(
title="Include Empty",
description=(
"Also return matching filaments that hold no matching spools, as groups of zero, "
"instead of omitting them. Only valid together with group_by=filament: every "
"other axis is keyed by a value read off the spools themselves and so has no "
"empty groups to list. Filters on the spools themselves (location, lot_nr, the "
"date filters, spool extra fields) are rejected in combination with it, since a "
"filament with no spools has no value for them."
"Also return matching filaments (group_by=filament) or manufacturers "
"(group_by=vendor) that hold no matching spools, as groups of zero, instead of "
"omitting them. In vendor mode the group of filaments with no manufacturer is not "
"listed, since this mode lists manufacturers. Every other axis is keyed by a value "
"read off the spools themselves and so has no empty groups to list. Filters on the "
"spools themselves (location, lot_nr, the date filters, spool extra fields) are "
"rejected in combination with it, since a filament with no spools has no value for them."
),
),
] = False,
preview: Annotated[
bool,
Query(
title="Preview",
description=(
"Attach a few members to each group: up to 5 spools (id, remaining_weight, "
"initial_weight) for group_by=filament, up to 4 filaments (id and colors) for "
"group_by=vendor. Ignored for other axes."
),
),
] = False,
@@ -566,8 +579,13 @@ async def find_groups(
title="Sort",
description=(
'Sort the groups by the given field. Comma-separated "field:direction" items. '
"Available fields: group.title, group.total_remaining, group.last_used, "
"group.spool_count, group.in_use_count."
"Available fields: group.title, group.vendor_name (manufacturer name, groups with "
"no manufacturer last), group.total_remaining, group.last_used, "
"group.spool_count, group.in_use_count, group.filament_count, any filament column "
"as filament.<column> (e.g. filament.material) or manufacturer column as "
"filament.vendor.<column> (e.g. filament.vendor.name), and extra fields as "
"filament.extra.<key> or filament.vendor.extra.<key>. Empty values sort last; unknown "
"fields are ignored."
),
examples=["group.last_used:desc"],
),
@@ -592,7 +610,7 @@ async def find_groups(
spool_extra, filament_extra, vendor_extra = _parse_extra_field_filters(request.query_params)
try:
groups, total_count = await spool.find_groups(
page = await spool.find_groups(
db=db,
group_by=group_by,
filament_name=filament_name,
@@ -614,6 +632,7 @@ async def find_groups(
limit=limit,
offset=offset,
include_empty=include_empty,
preview=preview,
)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
@@ -628,12 +647,19 @@ async def find_groups(
last_used=group.last_used,
filament=Filament.from_db(group.filament) if group.filament is not None else None,
vendor=Vendor.from_db(group.vendor) if group.vendor is not None else None,
filament_count=group.filament_count,
spools=group.spools,
filaments=group.filaments,
)
for group in groups
for group in page.groups
]
return JSONResponse(
content=jsonable_encoder(content, exclude_none=True),
headers={"x-total-count": str(total_count)},
headers={
"x-total-count": str(page.total_count),
"x-total-spools": str(page.total_spools),
"x-total-remaining-weight": str(page.total_remaining_weight),
},
)
+34 -6
View File
@@ -1,10 +1,12 @@
"""SQLAlchemy database setup."""
import asyncio
import datetime
import filecmp
import logging
import shutil
import sqlite3
import threading
import time
from collections.abc import AsyncGenerator
from contextlib import closing
@@ -30,6 +32,10 @@ MIN_SECONDS_BETWEEN_ROTATIONS = 5 * 60
# Name of the newest backup. Older ones get a .1 ... .N suffix.
BACKUP_NAME = "spoolman.db"
# sqlite3's backup() retries forever while the source is locked. Give up after this long, so a lock
# that never clears fails the backup loudly instead of hanging it (#1191).
BACKUP_LOCK_TIMEOUT_SECONDS = 60
def _unicode_lower(value: str | None) -> str | None:
"""Lowercase SQLite text using Python's Unicode support."""
@@ -97,6 +103,7 @@ class Database:
# Monotonic timestamp of the last rotation, for the rate limit. Process-local, which is
# fine: Spoolman is single-instance, and a restart erring towards one extra backup is safe.
self._last_rotation: float | None = None
self._backup_lock = threading.Lock()
def is_file_based_sqlite(self) -> bool:
"""Return True if the database is file based."""
@@ -134,8 +141,21 @@ class Database:
logger.info("Backing up SQLite database to %s", target_path)
def progress(_: int, remaining: int, total: int) -> None:
logger.info("Copied %d of %d pages.", total - remaining, total)
busy_since: float | None = None
def progress(status: int, remaining: int, total: int) -> None:
nonlocal busy_since
if status not in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED):
busy_since = None
logger.debug("Copied %d of %d pages.", total - remaining, total)
return
busy_since = busy_since or time.monotonic()
if time.monotonic() - busy_since > BACKUP_LOCK_TIMEOUT_SECONDS:
raise sqlite3.OperationalError(
f"Database {self.connection_url.database} stayed locked for over "
f"{BACKUP_LOCK_TIMEOUT_SECONDS} seconds, giving up on the backup. "
"Is another program holding it open?",
)
if self.connection_url.database == target_path:
raise ValueError("Cannot backup database to itself.")
@@ -146,9 +166,12 @@ class Database:
# transaction manager that commits on exit, it does not close the connection. Leaving the
# handles open leaks them on every backup, and on Windows it makes the caller's unlink/move
# of the file we just wrote fail with "used by another process" (POSIX unlink hides this).
# nolock=1: the target is a scratch file nothing else opens, so it needs no file locks. Backup
# folders on network shares often refuse SQLite's locks (CIFS without nobrl, NFS without
# lockd), which failed every backup with a "locked" database that was not locked at all (#1246).
with (
closing(sqlite3.connect(self.connection_url.database)) as src,
closing(sqlite3.connect(target_path)) as dst,
closing(sqlite3.connect(f"{Path(target_path).absolute().as_uri()}?nolock=1", uri=True)) as dst,
):
src.backup(dst, pages=1, progress=progress)
@@ -191,7 +214,11 @@ class Database:
logger.info("Skipping backup as the database is not SQLite.")
return BackupResult(None, created=False)
backup_folder = Path(backup_folder)
# Backups run in worker threads, so a manual one can overlap the scheduled one.
with self._backup_lock:
return self._backup_and_rotate(Path(backup_folder), num_backups)
def _backup_and_rotate(self, backup_folder: Path, num_backups: int) -> BackupResult:
backup_folder.mkdir(parents=True, exist_ok=True)
newest = backup_folder.joinpath(BACKUP_NAME)
@@ -250,7 +277,8 @@ async def backup_global_db(num_backups: int = 5) -> BackupResult:
"""
if __db is None:
raise RuntimeError("DB is not setup.")
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=num_backups)
# Off the event loop: the copy is blocking, and can wait on a lock held by our own connection.
return await asyncio.to_thread(__db.backup_and_rotate, env.get_backups_dir(), num_backups=num_backups)
async def _backup_task() -> BackupResult:
@@ -258,7 +286,7 @@ async def _backup_task() -> BackupResult:
logger.info("Performing scheduled database backup.")
if __db is None:
raise RuntimeError("DB is not setup.")
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=5)
return await asyncio.to_thread(__db.backup_and_rotate, env.get_backups_dir(), num_backups=5)
async def _metrics() -> None:
+14 -10
View File
@@ -516,6 +516,19 @@ async def find_extra_field_values(
return sorted(values)
def extra_field_sort_expr(value: ColumnElement, field_type: ExtraFieldType) -> ColumnElement:
"""Turn a stored extra-field value into the expression to order by, typed so numbers sort numerically."""
if field_type == ExtraFieldType.integer:
return sqlalchemy.cast(value, sqlalchemy.Integer)
if field_type == ExtraFieldType.float:
return sqlalchemy.cast(value, sqlalchemy.Float)
if field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range):
cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float
# Use dialect-specific JSON first-element extraction, then cast to numeric.
return sqlalchemy.cast(_JsonArrayFirstElement(value), cast_type)
return value
def add_order_by_extra_field(
stmt: Select,
base_obj: type[models.Base],
@@ -540,16 +553,7 @@ def add_order_by_extra_field(
.correlate(base_obj)
)
if field_type == ExtraFieldType.integer:
sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Integer)
elif field_type == ExtraFieldType.float:
sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Float)
elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range):
cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float
# Use dialect-specific JSON first-element extraction, then cast to numeric.
sort_expr = sqlalchemy.cast(_JsonArrayFirstElement(value_subq), cast_type)
else:
sort_expr = value_subq
sort_expr = extra_field_sort_expr(value_subq, field_type)
# A spool that has no value for the field yields NULL here, and "not filled in" belongs at
# the bottom of the list in both directions -- see order_by_clauses.
+406 -69
View File
@@ -5,6 +5,7 @@ import logging
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import sqlalchemy
from sqlalchemy import ColumnElement, case, func
@@ -13,13 +14,22 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager, joinedload
from sqlalchemy.sql.functions import coalesce
from spoolman.api.v1.models import EventType, Spool, SpoolEvent
from spoolman.api.v1.models import (
EventType,
Spool,
SpoolEvent,
SpoolGroupPreviewFilament,
SpoolGroupPreviewSpool,
_sanitize_color_hex,
_sanitize_multi_color_hexes,
)
from spoolman.database import filament, models
from spoolman.database.extra_field_query import (
ExtraFieldJoin,
apply_extra_field_filters_and_sort,
apply_spool_related_extra_filters,
extra_field_join,
extra_field_sort_expr,
extra_field_value_text,
)
from spoolman.database.utils import (
@@ -258,6 +268,13 @@ ENTITY_GROUP_BY_TITLES = {
"vendor": models.Vendor.name,
}
# The group key and title of the two axes include_empty can list, which select FROM the entity
# itself (see _entities_with_their_spools_stmt) rather than reading it off the spools.
EMPTY_GROUP_COLUMNS = {
"filament": (models.Filament.id, models.Filament.name),
"vendor": (models.Vendor.id, models.Vendor.name),
}
# Prefix marking a reference to one of the spool's extra fields rather than a built-in column.
EXTRA_FIELD_PREFIX = "extra."
@@ -288,6 +305,7 @@ def _apply_filament_filters(
stmt: sqlalchemy.Select,
*,
filament_id_column: ColumnElement,
vendor_id_column: ColumnElement = models.Filament.vendor_id,
filament_name: str | None = None,
filament_id: int | Sequence[int] | None = None,
filament_material: str | None = None,
@@ -301,11 +319,14 @@ def _apply_filament_filters(
its own, with no spool involved -- which is exactly what the query behind include_empty needs
(see find_groups). `filament_id_column` names whichever column holds the filament's id in the
query at hand: Spool.filament_id when selecting spools, Filament.id when selecting filaments.
`vendor_id_column` likewise: the vendor-listing query selects FROM Vendor, where a vendor with
no filaments has a NULL Filament.vendor_id, so a `vendor.id` filter must test Vendor.id there
or it would silently drop the very manufacturers that mode exists to list.
Assumes Filament and Vendor are already joined.
"""
stmt = add_where_clause_int(stmt, filament_id_column, filament_id)
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
stmt = add_where_clause_int_opt(stmt, vendor_id_column, vendor_id)
stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name)
stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material)
@@ -439,6 +460,37 @@ class SpoolGroupResult:
last_used: datetime | None
filament: models.Filament | None
vendor: models.Vendor | None
# Only for group_by=vendor: the number of distinct filaments behind the group.
filament_count: int | None = None
# Only with `preview` (see find_groups): a few of the group's members, for the list to draw.
spools: list[SpoolGroupPreviewSpool] | None = None
filaments: list[SpoolGroupPreviewFilament] | None = None
def _axis_columns(group_by: str) -> tuple[ColumnElement, ...]:
"""Extra columns only some axes select, so every other axis' SQL stays exactly as it was."""
if group_by == "vendor":
return (func.count(sqlalchemy.distinct(models.Filament.id)).label("filament_count"),)
return ()
@dataclass
class SpoolGroupsPage:
"""One page of groups plus the totals over ALL matching groups (before pagination)."""
groups: list[SpoolGroupResult]
total_count: int
# Sums of the per-group aggregates across every matching group, not just this page, so a list
# can show "N spools, X g" for the whole result without fetching it.
total_spools: int
total_remaining_weight: float
# How many members a group's `preview` carries. The list draws a row of pips / swatches per group,
# and a few are enough to recognise the group by; the full set is one spool/filament search away.
# Capped here (in Python, after one query) rather than in SQL, which would need a window function.
PREVIEW_SPOOL_LIMIT = 5
PREVIEW_FILAMENT_LIMIT = 4
def _group_aggregates() -> tuple[ColumnElement, ColumnElement, ColumnElement, ColumnElement]:
@@ -486,15 +538,15 @@ SPOOL_SCOPED_FILTERS = ("location", "lot_nr", "first_used", "last_used", "regist
def _reject_spool_scoped_filters(group_by: str, filters: dict[str, object]) -> None:
"""Refuse an include_empty query that also filters on the spools themselves.
"""Refuse an include_empty query on an unsupported axis or with filters on the spools themselves.
"Which of these filaments are on Shelf A" has no answer for a filament that is nowhere: it
would either drop every empty group (making the flag a no-op) or return the whole catalogue
as empty. Both are worse than saying so.
as empty. Both are worse than saying so. The same holds for a manufacturer.
"""
if group_by != "filament":
if group_by not in ("filament", "vendor"):
raise ValueError(
f"include_empty is only supported when grouping by filament, not by '{group_by}'.",
f"include_empty is only supported when grouping by filament or vendor, not by '{group_by}'.",
)
named = [name for name in SPOOL_SCOPED_FILTERS if filters.get(name) is not None]
if filters.get("extra_field_filters"):
@@ -506,9 +558,10 @@ def _reject_spool_scoped_filters(group_by: str, filters: dict[str, object]) -> N
)
def _filaments_with_their_spools_stmt(
def _entities_with_their_spools_stmt(
*,
aggregates: tuple[ColumnElement, ...],
group_by: str,
columns: tuple[ColumnElement, ...],
allow_archived: bool,
filament_name: str | None,
filament_id: int | Sequence[int] | None,
@@ -517,12 +570,12 @@ def _filaments_with_their_spools_stmt(
vendor_name: str | None,
vendor_id: int | Sequence[int] | None,
) -> sqlalchemy.Select:
"""Select every matching filament with its spools outer-joined on, aggregates and all.
"""Select every matching filament (or vendor) with its spools outer-joined on, aggregates and all.
The inverse of the usual grouping query: it reads its rows from the filaments rather than
from the spools, so a filament with no spools still produces one (see find_groups'
include_empty). Only the FROM differs -- the caller groups, orders and pages it exactly like
the spool-driven query.
The inverse of the usual grouping query: it reads its rows from the filaments (or the
vendors) rather than from the spools, so an entity with no spools still produces one (see
find_groups' include_empty). Only the FROM differs -- the caller groups, orders and pages it
exactly like the spool-driven query.
"""
# The archived rule is the one spool-level condition that reaches this query, and it has to
# ride in the JOIN rather than the WHERE: hiding a filament's archived spools must leave the
@@ -530,15 +583,30 @@ def _filaments_with_their_spools_stmt(
join_cond = [models.Spool.filament_id == models.Filament.id]
if not allow_archived:
join_cond.append(_not_archived())
stmt = (
sqlalchemy.select(models.Filament.id.label("group_key"), *aggregates)
.select_from(models.Filament)
.outerjoin(models.Spool, sqlalchemy.and_(*join_cond))
.join(models.Filament.vendor, isouter=True)
)
if group_by == "vendor":
# Vendor first, filaments and spools hanging off it by outer joins: a manufacturer with no
# filaments is a row of NULLs for both, which the aggregates score as zero. No FULL OUTER
# JOIN needed, and none available on MySQL. Filament filters then land in the WHERE and so
# drop a vendor with no matching filament -- "the manufacturers of these filaments".
stmt = (
sqlalchemy.select(models.Vendor.id.label("group_key"), *columns)
.select_from(models.Vendor)
.outerjoin(models.Filament, models.Filament.vendor_id == models.Vendor.id)
.outerjoin(models.Spool, sqlalchemy.and_(*join_cond))
)
filament_id_column, vendor_id_column = models.Filament.id, models.Vendor.id
else:
stmt = (
sqlalchemy.select(models.Filament.id.label("group_key"), *columns)
.select_from(models.Filament)
.outerjoin(models.Spool, sqlalchemy.and_(*join_cond))
.join(models.Filament.vendor, isouter=True)
)
filament_id_column, vendor_id_column = models.Filament.id, models.Filament.vendor_id
return _apply_filament_filters(
stmt,
filament_id_column=models.Filament.id,
filament_id_column=filament_id_column,
vendor_id_column=vendor_id_column,
filament_name=filament_name,
filament_id=filament_id,
filament_material=filament_material,
@@ -548,6 +616,80 @@ def _filaments_with_their_spools_stmt(
)
def _order_groups(
stmt: sqlalchemy.Select,
order_exprs: dict[str, list[ColumnElement]],
sort_by: dict[str, SortOrder] | None,
*,
default_expr: ColumnElement,
group_col: ColumnElement,
) -> sqlalchemy.Select:
"""Order the groups by the requested fields, defaulting to the title, with a total-order tiebreak."""
applied_sort = False
if sort_by:
for fieldstr, order in sort_by.items():
exprs = order_exprs.get(fieldstr)
if exprs is None:
continue
stmt = stmt.order_by(*order_by_clauses(exprs, order))
applied_sort = True
if not applied_sort:
stmt = stmt.order_by(*order_by_clauses([default_expr], SortOrder.ASC))
# Break ties on the grouped column itself, so paging partitions the groups instead of
# dropping or repeating one. Every ordering above can tie -- two filaments share a name, two
# groups hold the same number of spools, and every empty group ties on all four aggregates --
# and without a total order the database may answer "the first 20" and "the next 20" from two
# different arrangements of the same rows.
return stmt.order_by(group_col.asc())
_EXTRA_SORT_PREFIXES = (
("filament.vendor.extra.", EntityType.vendor),
("filament.extra.", EntityType.filament),
)
async def _entity_sort_exprs(
db: AsyncSession,
stmt: sqlalchemy.Select,
sort_by: dict[str, SortOrder] | None,
) -> tuple[sqlalchemy.Select, dict[str, list[ColumnElement]]]:
"""Resolve `filament.*` / `filament.vendor.*` sort keys to aggregate expressions.
Columns are wrapped in MIN() so they stay valid aggregates on every axis (the value is constant
within a filament group, and a vendor column within a vendor group). Extra fields are
outer-joined (one row per entity and key, so no aggregate changes) and ordered typed like the
entity endpoints do. Anything that doesn't resolve -- a key off the filament, a column or extra
field that doesn't exist -- is skipped, the way find_groups has always ignored a sort key it
doesn't know: the endpoint is released, and rejecting what it used to accept would break callers.
"""
exprs: dict[str, list[ColumnElement]] = {}
definitions: dict[EntityType, dict[str, ExtraField]] = {}
for key in sort_by or {}:
if key.startswith("group."):
continue
extra = next(((p, t) for p, t in _EXTRA_SORT_PREFIXES if key.startswith(p)), None)
if extra is None:
if not key.startswith("filament."):
continue
try:
exprs[key] = [func.min(parse_nested_field(models.Spool, key))]
except ValueError:
continue
continue
prefix, entity_type = extra
extra_key = key.removeprefix(prefix)
if entity_type not in definitions:
definitions[entity_type] = {f.key: f for f in await get_extra_fields(db, entity_type)}
field = definitions[entity_type].get(extra_key)
if field is None:
continue
join = extra_field_join(entity_type, extra_key)
stmt = join.apply(stmt, models.Vendor.id if entity_type == EntityType.vendor else models.Filament.id)
exprs[key] = [func.min(extra_field_sort_expr(join.value, field.field_type))]
return stmt, exprs
async def find_groups(
*,
db: AsyncSession,
@@ -571,7 +713,8 @@ async def find_groups(
limit: int | None = None,
offset: int = 0,
include_empty: bool = False,
) -> tuple[list[SpoolGroupResult], int]:
preview: bool = False,
) -> SpoolGroupsPage:
"""Group matching spools by one axis and return per-group aggregates.
Aggregation, group ordering and pagination happen in the database. Pagination is over
@@ -583,13 +726,38 @@ async def find_groups(
The flag turns the query around to select FROM the filaments and outer-join the spools onto
them; a filament with none still yields its row, and the aggregates above are written to come
out as zero for it. Everything past this point -- ordering, paging, hydration -- is the same
code either way. Only `group_by="filament"` supports it, and only without spool-level filters
(see _reject_spool_scoped_filters).
code either way. Only `group_by="filament"` and `"vendor"` support it, and only without
spool-level filters (see _reject_spool_scoped_filters). For vendor it lists the manufacturers,
so the group of filaments with NO manufacturer (key null) does not appear in that mode.
Returns a tuple of the requested page of groups and the total number of matching groups.
`group_by="vendor"` groups also carry `filament_count`, the distinct filaments behind the
group: with include_empty every matching filament of the manufacturer, otherwise only those
that have a matching spool.
`preview` attaches a few members to each group on the filament and vendor axes (see
_attach_previews); other axes ignore it.
Returns the requested page of groups plus the totals over all matching groups.
"""
aggregates = _group_aggregates()
spool_count, in_use_count, total_remaining, last_used_agg = aggregates
columns = (*aggregates, *_axis_columns(group_by))
# The filters the spool-driven query applies, kept in one place because the preview query has
# to apply exactly the same ones.
spool_filters: dict[str, Any] = {
"filament_name": filament_name,
"filament_id": filament_id,
"filament_material": filament_material,
"filament_multi_color_direction": filament_multi_color_direction,
"vendor_name": vendor_name,
"vendor_id": vendor_id,
"location": location,
"lot_nr": lot_nr,
"allow_archived": allow_archived,
"first_used": first_used,
"last_used": last_used,
"registered": registered,
}
if include_empty:
_reject_spool_scoped_filters(
@@ -603,9 +771,10 @@ async def find_groups(
"extra_field_filters": extra_field_filters,
},
)
group_col, title_col = models.Filament.id, models.Filament.name
stmt = _filaments_with_their_spools_stmt(
aggregates=aggregates,
group_col, title_col = EMPTY_GROUP_COLUMNS[group_by]
stmt = _entities_with_their_spools_stmt(
group_by=group_by,
columns=columns,
allow_archived=allow_archived,
filament_name=filament_name,
filament_id=filament_id,
@@ -617,19 +786,8 @@ async def find_groups(
else:
group_col, title_col, extra_join = await _resolve_group_by(db, group_by)
stmt = _apply_spool_filters(
sqlalchemy.select(group_col.label("group_key"), *aggregates),
filament_name=filament_name,
filament_id=filament_id,
filament_material=filament_material,
filament_multi_color_direction=filament_multi_color_direction,
vendor_name=vendor_name,
vendor_id=vendor_id,
location=location,
lot_nr=lot_nr,
allow_archived=allow_archived,
first_used=first_used,
last_used=last_used,
registered=registered,
sqlalchemy.select(group_col.label("group_key"), *columns),
**spool_filters,
)
if extra_join is not None:
stmt = extra_join.apply(stmt, models.Spool.id)
@@ -643,7 +801,9 @@ async def find_groups(
)
# A filament's (or its vendor's) extra fields describe the filament, so they apply either way
# -- reached through the spool in one query and off the filament directly in the other.
# -- reached through the spool in one query and off the filament directly in the other. In the
# vendor listing that makes them WHERE conditions on the outer-joined filament, so a vendor
# filter on an extra field also drops a filament-less vendor (its Filament.id is NULL).
stmt = await apply_spool_related_extra_filters(
db=db,
stmt=stmt,
@@ -653,38 +813,41 @@ async def find_groups(
)
stmt = stmt.group_by(group_col)
# Total number of matching groups (before pagination).
count_stmt = sqlalchemy.select(func.count()).select_from(stmt.order_by(None).subquery())
total_count = (await db.execute(count_stmt)).scalar_one()
# Total number of matching groups (before pagination), and the sums of two aggregates over
# them, all from the one query over the grouped subquery. SUM of no rows is NULL, folded to zero
# in Python rather than with COALESCE: CockroachDB sums an INT column to DECIMAL and rejects
# an int or float literal as the COALESCE fallback. The sums come back as DECIMAL on
# PostgreSQL and CockroachDB, hence the conversions below.
grouped = stmt.order_by(None).subquery()
count_stmt = sqlalchemy.select(
func.count(),
func.sum(grouped.c.spool_count),
func.sum(grouped.c.total_remaining_weight),
).select_from(grouped)
total_count, total_spools, total_remaining_sum = (await db.execute(count_stmt)).one()
# Group ordering. Every option is an aggregate (or the grouped column), so no non-grouped
# bare column is referenced — portable across SQLite, PostgreSQL, MySQL and CockroachDB.
# Ordering by `group.last_used` ranks each group by its most recently used spool, which is
# what a library sorted on "last used" is expected to show; groups holding nothing but
# unused spools aggregate to NULL and belong at the bottom, hence order_by_clauses.
# Each option is a list: `group.vendor_name` is the manufacturer's name and then its id, so two
# vendors sharing a name do not interleave across pages. Aggregates, like the title, because the
# filament axis groups by filament and a bare Vendor column would not be grouped; a filament
# with no manufacturer is NULL on both and so goes last (order_by_clauses). It exists for paging
# filament groups in manufacturer sections.
order_exprs = {
"group.spool_count": spool_count,
"group.in_use_count": in_use_count,
"group.total_remaining": total_remaining,
"group.last_used": last_used_agg,
"group.title": func.min(title_col),
"group.spool_count": [spool_count],
"group.in_use_count": [in_use_count],
"group.total_remaining": [total_remaining],
"group.last_used": [last_used_agg],
"group.title": [func.min(title_col)],
"group.vendor_name": [func.min(models.Vendor.name), func.min(models.Vendor.id)],
"group.filament_count": [func.count(sqlalchemy.distinct(models.Filament.id))],
}
applied_sort = False
if sort_by:
for fieldstr, order in sort_by.items():
expr = order_exprs.get(fieldstr)
if expr is None:
continue
stmt = stmt.order_by(*order_by_clauses([expr], order))
applied_sort = True
if not applied_sort:
stmt = stmt.order_by(*order_by_clauses([func.min(title_col)], SortOrder.ASC))
# Break ties on the grouped column itself, so paging partitions the groups instead of
# dropping or repeating one. Every ordering above can tie -- two filaments share a name, two
# groups hold the same number of spools, and every empty group ties on all four aggregates --
# and without a total order the database may answer "the first 20" and "the next 20" from two
# different arrangements of the same rows.
stmt = stmt.order_by(group_col.asc())
stmt, entity_exprs = await _entity_sort_exprs(db, stmt, sort_by)
order_exprs.update(entity_exprs)
stmt = _order_groups(stmt, order_exprs, sort_by, default_expr=func.min(title_col), group_col=group_col)
if limit is not None:
stmt = stmt.offset(offset).limit(limit)
@@ -707,7 +870,7 @@ async def find_groups(
vstmt = sqlalchemy.select(models.Vendor).where(models.Vendor.id.in_(keys))
vendor_map = {v.id: v for v in (await db.execute(vstmt)).unique().scalars().all()}
return [
results = [
SpoolGroupResult(
key=row.group_key,
spool_count=int(row.spool_count or 0),
@@ -716,9 +879,181 @@ async def find_groups(
last_used=row.last_used,
filament=filament_map.get(row.group_key) if group_by == "filament" else None,
vendor=vendor_map.get(row.group_key) if group_by == "vendor" else None,
filament_count=int(row.filament_count or 0) if group_by == "vendor" else None,
)
for row in rows
], total_count
]
if preview:
await _attach_previews(
db=db,
group_by=group_by,
results=results,
keys=keys,
spool_filters=spool_filters,
extra_field_filters=extra_field_filters,
filament_extra_field_filters=filament_extra_field_filters,
vendor_extra_field_filters=vendor_extra_field_filters,
)
return SpoolGroupsPage(
groups=results,
total_count=total_count,
total_spools=int(total_spools or 0),
total_remaining_weight=float(total_remaining_sum or 0),
)
async def _attach_previews(
*,
db: AsyncSession,
group_by: str,
results: list[SpoolGroupResult],
keys: list[int],
spool_filters: dict[str, Any],
extra_field_filters: dict[str, str] | None,
filament_extra_field_filters: dict[str, str] | None,
vendor_extra_field_filters: dict[str, str] | None,
) -> None:
"""Give each group a few of its members, from ONE query for the whole page.
The members are sliced per group in Python: the alternative, a top-N-per-group, needs a window
function (or a LATERAL join) that is not worth it for at most a page of groups. Only the
filament and vendor axes have members to show; the rest are left untouched.
"""
if not keys:
return
if group_by == "filament":
await _attach_spool_previews(
db=db,
results=results,
keys=keys,
spool_filters=spool_filters,
extra_field_filters=extra_field_filters,
filament_extra_field_filters=filament_extra_field_filters,
vendor_extra_field_filters=vendor_extra_field_filters,
)
elif group_by == "vendor":
await _attach_filament_previews(
db=db,
results=results,
keys=keys,
spool_filters=spool_filters,
filament_extra_field_filters=filament_extra_field_filters,
vendor_extra_field_filters=vendor_extra_field_filters,
)
async def _attach_spool_previews(
*,
db: AsyncSession,
results: list[SpoolGroupResult],
keys: list[int],
spool_filters: dict[str, Any],
extra_field_filters: dict[str, str] | None,
filament_extra_field_filters: dict[str, str] | None,
vendor_extra_field_filters: dict[str, str] | None,
) -> None:
"""Fill filament groups' `spools`."""
# The very same filter builder the group's aggregates went through, so a pip can never
# disagree with `spool_count`: whatever the group counted is what it may show. Only the
# columns the preview needs are selected, not whole ORM spools.
stmt = _apply_spool_filters(
sqlalchemy.select(
models.Spool.id,
models.Spool.filament_id,
models.Spool.initial_weight,
models.Spool.used_weight,
models.Filament.weight,
),
**spool_filters,
).where(models.Spool.filament_id.in_(keys))
stmt = await apply_extra_field_filters_and_sort(
db=db,
stmt=stmt,
base_obj=models.Spool,
entity_type=EntityType.spool,
extra_field_filters=extra_field_filters,
sort_by=None,
)
stmt = await apply_spool_related_extra_filters(
db=db,
stmt=stmt,
filament_filters=filament_extra_field_filters,
vendor_filters=vendor_extra_field_filters,
link_column=models.Spool.filament_id,
)
by_filament: dict[int, list[SpoolGroupPreviewSpool]] = {key: [] for key in keys}
for row in (await db.execute(stmt.order_by(models.Spool.id.asc()))).all():
members = by_filament[row.filament_id]
if len(members) >= PREVIEW_SPOOL_LIMIT:
continue
# The same fallback and clamp as Spool.from_db, so a pip matches the spool endpoint.
initial = row.initial_weight if row.initial_weight is not None else row.weight
members.append(
SpoolGroupPreviewSpool(
id=row.id,
initial_weight=initial,
remaining_weight=None if initial is None else max(initial - row.used_weight, 0),
),
)
for group in results:
if group.key is not None:
group.spools = by_filament[group.key]
async def _attach_filament_previews(
*,
db: AsyncSession,
results: list[SpoolGroupResult],
keys: list[int],
spool_filters: dict[str, Any],
filament_extra_field_filters: dict[str, str] | None,
vendor_extra_field_filters: dict[str, str] | None,
) -> None:
"""Fill vendor groups' `filaments`."""
# Vendor groups preview the manufacturer's filaments that match the filament-level filters.
# In the spool-driven mode that is any such filament of the vendor, not only those with a
# matching spool: the preview is a swatch strip for the manufacturer, and `filament_count`
# is the number that says how many actually hold stock.
stmt = _apply_filament_filters(
sqlalchemy.select(
models.Filament.id,
models.Filament.vendor_id,
models.Filament.color_hex,
models.Filament.multi_color_hexes,
models.Filament.multi_color_direction,
).join(models.Filament.vendor, isouter=True),
filament_id_column=models.Filament.id,
filament_name=spool_filters["filament_name"],
filament_id=spool_filters["filament_id"],
filament_material=spool_filters["filament_material"],
filament_multi_color_direction=spool_filters["filament_multi_color_direction"],
vendor_name=spool_filters["vendor_name"],
vendor_id=spool_filters["vendor_id"],
).where(models.Filament.vendor_id.in_(keys))
stmt = await apply_spool_related_extra_filters(
db=db,
stmt=stmt,
filament_filters=filament_extra_field_filters,
vendor_filters=vendor_extra_field_filters,
link_column=models.Filament.id,
)
by_vendor: dict[int, list[SpoolGroupPreviewFilament]] = {key: [] for key in keys}
for row in (
await db.execute(stmt.order_by(*order_by_clauses([models.Filament.name], SortOrder.ASC), models.Filament.id))
).all():
members = by_vendor[row.vendor_id]
if len(members) < PREVIEW_FILAMENT_LIMIT:
members.append(
SpoolGroupPreviewFilament(
id=row.id,
color_hex=_sanitize_color_hex(row.color_hex),
multi_color_hexes=_sanitize_multi_color_hexes(row.multi_color_hexes),
multi_color_direction=row.multi_color_direction,
),
)
for group in results:
if group.key is not None:
group.filaments = by_vendor[group.key]
async def update(
@@ -897,7 +1232,9 @@ async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spoo
initial_weight = spool_info[0]
spool_weight = spool_info[2]
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# A tare of 0 is a real value (a refill wound on no spool), so only a missing one is looked
# up further down the chain.
if initial_weight is None or initial_weight == 0 or spool_weight is None:
# Get filament weight and spool_weight, and the vendor's empty spool weight as the
# level below that. The vendor is joined with an outer join on purpose: a filament
# without a vendor still has to return its row.
@@ -916,7 +1253,7 @@ async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spoo
except NoResultFound as exc:
raise ItemNotFoundError("Filament not found for spool.") from exc
if spool_weight is None or spool_weight == 0:
if spool_weight is None:
# The filament's tare, and failing that its vendor's. A filament's tare is only a
# copy of the vendor's, taken when the filament was created, so a tare the vendor
# was given later never reached it - the same order create() resolves it in.
+16 -3
View File
@@ -4,6 +4,7 @@ import logging
import os
import subprocess
import sys
import tempfile
from datetime import datetime
from enum import Enum
from pathlib import Path
@@ -332,6 +333,18 @@ def is_automatic_backup_enabled() -> bool:
)
def is_update_check_enabled() -> bool:
"""Get whether Spoolman should check GitHub for newer releases. Defaults to True."""
update_check = os.getenv("SPOOLMAN_UPDATE_CHECK", "TRUE").upper()
if update_check in {"FALSE", "0"}:
return False
if update_check in {"TRUE", "1"}:
return True
raise ValueError(
f"Failed to parse SPOOLMAN_UPDATE_CHECK variable: Unknown value '{update_check}'.",
)
def get_data_dir() -> Path:
"""Get the data directory.
@@ -446,9 +459,9 @@ def get_build_date() -> datetime | None:
def can_write_to_data_dir() -> bool:
"""Check if the data directory is writable."""
try:
test_file = get_data_dir().joinpath("test.txt")
test_file.touch()
test_file.unlink()
# A uniquely named file, so the check can never clobber one of the user's own.
with tempfile.NamedTemporaryFile(dir=get_data_dir()):
pass
except: # noqa: E722
return False
return True
+3 -2
View File
@@ -84,14 +84,15 @@ def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None:
if not isinstance(data, str):
raise ValueError("Value is not a string.")
elif field.field_type == ExtraFieldType.integer:
if not isinstance(data, int):
# Python bool is a subclass of int, but JSON true/false are not integer values.
if not isinstance(data, int) or isinstance(data, bool):
raise ValueError("Value is not an integer.")
elif field.field_type == ExtraFieldType.integer_range:
if not isinstance(data, list):
raise ValueError("Value is not a list.")
if len(data) != 2: # noqa: PLR2004
raise ValueError("Value list must have exactly two values.")
if not all(isinstance(item, int) or item is None for item in data):
if not all((isinstance(item, int) and not isinstance(item, bool)) or item is None for item in data):
raise ValueError("Value list must contain only integers or null.")
elif field.field_type == ExtraFieldType.float:
if not isinstance(data, (float, int)) or isinstance(data, bool):
+3 -2
View File
@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from prometheus_client import generate_latest
from scheduler.asyncio.scheduler import Scheduler
from spoolman import env, externaldb, security
from spoolman import env, externaldb, security, update_check
from spoolman.api.v1.router import app as v1_app
from spoolman.client import SinglePageApplication, render_config_js
from spoolman.database import database
@@ -177,7 +177,7 @@ def add_cors_middleware() -> None:
allow_credentials=allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Total-Count"],
expose_headers=["X-Total-Count", "X-Total-Spools", "X-Total-Remaining-Weight"],
)
@@ -237,6 +237,7 @@ async def startup() -> None:
schedule = Scheduler()
database.schedule_tasks(schedule)
externaldb.schedule_tasks(schedule)
update_check.schedule_tasks(schedule)
logger.info("Startup complete.")
+40 -13
View File
@@ -41,6 +41,24 @@ def make_metrics_app() -> Callable:
metrics_app = make_asgi_app()
# The label sets each gauge was last given, so the next refresh knows which ones are gone.
_published: dict[Gauge, set[tuple]] = {}
def _publish(gauge: Gauge, values: dict[tuple, float]) -> None:
"""Make a gauge hold exactly these label sets, dropping any left from an earlier refresh.
Without the drop, an archived or deleted spool (or a deleted filament) kept being exported
until restart. The new values are set before the stale ones are removed rather than
clearing first: /metrics is served from a worker thread, so a scrape can land mid-refresh,
and this way it never sees a series that is still live go missing.
"""
for labels, value in values.items():
gauge.labels(*labels).set(value)
for labels in _published.get(gauge, set()) - values.keys():
gauge.remove(*labels)
_published[gauge] = set(values)
async def spool_metrics(db: AsyncSession) -> None:
"""Get metrics by Spools from DB and write to prometheus.
@@ -57,12 +75,19 @@ async def spool_metrics(db: AsyncSession) -> None:
)
rows = await db.execute(stmt)
result = list(rows.unique().scalars().all())
price: dict[tuple, float] = {}
initial_weight: dict[tuple, float] = {}
used_weight: dict[tuple, float] = {}
for row in result:
labels = (str(row.id), str(row.filament_id))
if row.price is not None:
SPOOL_PRICE.labels(str(row.id), str(row.filament_id)).set(row.price)
price[labels] = row.price
if row.initial_weight is not None:
SPOOL_INITIAL_WEIGHT.labels(str(row.id), str(row.filament_id)).set(row.initial_weight)
SPOOL_USED_WEIGHT.labels(str(row.id), str(row.filament_id)).set(row.used_weight)
initial_weight[labels] = row.initial_weight
used_weight[labels] = row.used_weight
_publish(SPOOL_PRICE, price)
_publish(SPOOL_INITIAL_WEIGHT, initial_weight)
_publish(SPOOL_USED_WEIGHT, used_weight)
async def filament_metrics(db: AsyncSession) -> None:
@@ -79,18 +104,20 @@ async def filament_metrics(db: AsyncSession) -> None:
)
rows = await db.execute(stmt)
result = list(rows.unique().scalars().all())
info: dict[tuple, float] = {}
density: dict[tuple, float] = {}
diameter: dict[tuple, float] = {}
weight: dict[tuple, float] = {}
for row in result:
vendor_name = "-"
if row.vendor is not None:
vendor_name = row.vendor.name
FILAMENT_INFO.labels(
str(row.id),
vendor_name,
row.name,
row.material,
row.color_hex,
).set(1)
FILAMENT_DENSITY.labels(str(row.id)).set(row.density)
FILAMENT_DIAMETER.labels(str(row.id)).set(row.diameter)
info[(str(row.id), vendor_name, row.name, row.material, row.color_hex)] = 1
density[(str(row.id),)] = row.density
diameter[(str(row.id),)] = row.diameter
if row.weight is not None:
FILAMENT_WEIGHT.labels(str(row.id)).set(row.weight)
weight[(str(row.id),)] = row.weight
_publish(FILAMENT_INFO, info)
_publish(FILAMENT_DENSITY, density)
_publish(FILAMENT_DIAMETER, diameter)
_publish(FILAMENT_WEIGHT, weight)
+2
View File
@@ -84,6 +84,8 @@ def normalize_uid(uid: str) -> str:
raise ValueError(f'"{uid}" is not a valid tag UID. A UID must be hexadecimal.')
if len(normalized) > UID_MAX_LENGTH:
raise ValueError(f"A tag UID can be at most {UID_MAX_LENGTH} hex characters.")
if len(normalized) % 2:
raise ValueError("A tag UID must contain a whole number of bytes (an even number of hex characters).")
return normalized
+67
View File
@@ -0,0 +1,67 @@
"""Periodically check GitHub for a newer Spoolman release."""
import datetime
import logging
import httpx
from scheduler.asyncio.scheduler import Scheduler
from spoolman import env
logger = logging.getLogger(__name__)
LATEST_RELEASE_URL = "https://api.github.com/repos/Donkie/Spoolman/releases/latest"
CHECK_INTERVAL = datetime.timedelta(hours=12)
# Latest released version seen on GitHub, None until a check has succeeded.
latest_version: str | None = None
def _parse_version(version: str) -> tuple[int, ...] | None:
"""Parse "v1.2.3" or "1.2.3" into (1, 2, 3). Returns None for anything non-numeric."""
try:
return tuple(int(part) for part in version.strip().removeprefix("v").split("."))
except ValueError:
return None
def is_newer(candidate: str, current: str) -> bool:
"""Return True if candidate is a strictly newer version than current."""
cand, cur = _parse_version(candidate), _parse_version(current)
return cand is not None and cur is not None and cand > cur
def is_update_available() -> bool:
"""Return True if a newer release than the running version has been seen."""
return latest_version is not None and is_newer(latest_version, env.get_version())
async def _check() -> None:
global latest_version # noqa: PLW0603
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(LATEST_RELEASE_URL, headers={"Accept": "application/vnd.github+json"})
response.raise_for_status()
version = str(response.json()["tag_name"]).removeprefix("v")
except (httpx.HTTPError, KeyError, ValueError) as exc:
logger.info("Could not check for Spoolman updates: %s", exc)
return
if version == latest_version:
return
latest_version = version
if is_update_available():
logger.warning(
"A new version of Spoolman is available: v%s (running v%s). https://github.com/Donkie/Spoolman/releases",
version,
env.get_version(),
)
def schedule_tasks(scheduler: Scheduler) -> None:
"""Schedule the update check: once in the background at startup, then periodically."""
if not env.is_update_check_enabled():
logger.info("Update check disabled.")
return
scheduler.once(datetime.timedelta(seconds=0), _check) # type: ignore[arg-type]
scheduler.cyclic(CHECK_INTERVAL, _check) # type: ignore[arg-type]
+3 -1
View File
@@ -39,7 +39,9 @@ class SubscriptionTree:
def remove(self, path: tuple[str, ...], websocket: WebSocket) -> None:
"""Remove a websocket from the subscription tree."""
if len(path) == 0:
self.subscribers.remove(websocket)
# A failed broadcast can already have dropped this socket before its
# handler receives the disconnect event. Cleanup must be idempotent.
self.subscribers.discard(websocket)
elif path[0] in self.children:
self.children[path[0]].remove(path[1:], websocket)
+47
View File
@@ -7,6 +7,7 @@ cross-origin form post.
"""
import sqlite3
from contextlib import closing
from pathlib import Path
import pytest
@@ -140,3 +141,49 @@ def test_non_sqlite_database_is_skipped(backups: Path):
result = db.backup_and_rotate(backups)
assert result == database.BackupResult(None, created=False)
def test_backup_gives_up_on_a_lock_that_never_clears(
db: Database,
db_path: Path,
backups: Path,
monkeypatch: pytest.MonkeyPatch,
):
"""sqlite3's backup() retries a locked source forever, which hung the server (#1191)."""
monkeypatch.setattr(database, "BACKUP_LOCK_TIMEOUT_SECONDS", 0.5)
with closing(sqlite3.connect(db_path, isolation_level=None)) as holder:
holder.execute("BEGIN EXCLUSIVE")
with pytest.raises(sqlite3.OperationalError, match="locked"):
db.backup_and_rotate(backups)
holder.execute("ROLLBACK")
assert not (backups / f"{BACKUP_NAME}.pending").exists()
def test_backup_needs_no_lock_on_the_target(db: Database, backups: Path, monkeypatch: pytest.MonkeyPatch):
"""Backup folders on network shares often refuse file locks, which failed every backup (#1246).
Simulate that by holding an exclusive lock on the target from the moment it is created.
"""
monkeypatch.setattr(database, "BACKUP_LOCK_TIMEOUT_SECONDS", 0.5)
pending = backups / f"{BACKUP_NAME}.pending"
real_connect = sqlite3.connect
holders: list[sqlite3.Connection] = []
def connect(*args: object, **kwargs: object) -> sqlite3.Connection:
conn = real_connect(*args, **kwargs)
if pending.exists() and not holders:
holders.append(real_connect(pending, isolation_level=None))
holders[0].execute("BEGIN EXCLUSIVE")
return conn
monkeypatch.setattr(sqlite3, "connect", connect)
try:
result = db.backup_and_rotate(backups)
finally:
for holder in holders:
holder.close()
assert holders, "the target was never locked, so this test proves nothing"
assert result.created is True
with closing(real_connect(backups / BACKUP_NAME)) as conn:
assert conn.execute("SELECT note FROM spool").fetchall() == [("original",)]
+23
View File
@@ -1,5 +1,8 @@
"""Tests for environment variable parsing."""
import os
from pathlib import Path
import pytest
from spoolman import env
@@ -46,3 +49,23 @@ def test_get_cors_origin_drops_empty_and_duplicate_entries(monkeypatch: pytest.M
def test_get_cors_origin_raw_is_unparsed(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("SPOOLMAN_CORS_ORIGIN", " https://a.local, https://b.local ")
assert env.get_cors_origin_raw() == " https://a.local, https://b.local "
def test_can_write_to_data_dir_leaves_existing_files_alone(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
"""The write check must not touch a file of the user's, even one named like a probe file."""
monkeypatch.setenv("SPOOLMAN_DIR_DATA", str(tmp_path))
(tmp_path / "test.txt").write_text("mine")
assert env.can_write_to_data_dir() is True
assert [p.name for p in tmp_path.iterdir()] == ["test.txt"]
assert (tmp_path / "test.txt").read_text() == "mine"
def test_can_write_to_data_dir_read_only(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
if os.name == "nt" or os.geteuid() == 0:
pytest.skip("directory permissions are not enforced here")
monkeypatch.setenv("SPOOLMAN_DIR_DATA", str(tmp_path))
tmp_path.chmod(0o500)
try:
assert env.can_write_to_data_dir() is False
finally:
tmp_path.chmod(0o700)
+44
View File
@@ -0,0 +1,44 @@
"""Tests for how measure() resolves a spool's empty spool (tare) weight."""
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import StaticPool
from spoolman.database import models, spool
@pytest.mark.asyncio
@pytest.mark.parametrize(
("spool_weight", "expected_used"),
[
(0, 600), # A refill on no spool: its own 0 is used, the 400 g reading is all filament.
(None, 850), # No tare of its own: the filament's 250 g is subtracted from the reading.
],
)
async def test_measure_tare(spool_weight: float | None, expected_used: float) -> None:
"""An explicit 0 tare sticks; only a missing one falls back to the filament's."""
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
async with engine.begin() as conn:
await conn.run_sync(models.Base.metadata.create_all)
async with AsyncSession(engine, expire_on_commit=False) as db:
now = datetime(2026, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None) # stored naive, as UTC
filament = models.Filament(registered=now, density=1.24, diameter=1.75, weight=1000, spool_weight=250)
item = models.Spool(
filament=filament,
registered=now,
initial_weight=1000,
spool_weight=spool_weight,
used_weight=0,
)
db.add(item)
await db.commit()
# A fresh session, as each API request gets.
async with AsyncSession(engine, expire_on_commit=False) as db:
result = await spool.measure(db, item.id, 400)
assert result.used_weight == pytest.approx(expected_used)
await engine.dispose()
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the Prometheus metrics refresh dropping spools and filaments that are gone."""
from datetime import datetime, timezone
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.pool import StaticPool
from spoolman.database import models
from spoolman.prometheus.metrics import filament_metrics, registry, spool_metrics
async def refresh(engine: AsyncEngine) -> None:
"""Run one metrics refresh in a fresh session, the way the scheduler does."""
async with AsyncSession(engine) as db:
await filament_metrics(db)
await spool_metrics(db)
def used(spool_id: int, filament_id: int) -> float | None:
"""Return a spool's exported used weight, or None if it is not exported."""
labels = {"spool_id": str(spool_id), "filament_id": str(filament_id)}
return registry.get_sample_value("spoolman_spool_weight_used", labels)
def density(filament_id: int) -> float | None:
"""Return a filament's exported density, or None if it is not exported."""
return registry.get_sample_value("spoolman_filament_density", {"filament_id": str(filament_id)})
def info(filament_id: int, name: str) -> float | None:
"""Return a vendorless filament's exported info sample, or None if it is not exported."""
labels = {"filament_id": str(filament_id), "vendor": "-", "name": name, "material": "None", "color": "None"}
return registry.get_sample_value("spoolman_filament_info", labels)
@pytest.mark.asyncio
async def test_refresh_drops_archived_and_deleted() -> None:
"""Archived and deleted spools, and deleted filaments, stop being exported on the next refresh."""
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
async with engine.begin() as conn:
await conn.run_sync(models.Base.metadata.create_all)
now = datetime(2026, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None) # stored naive, as UTC
async with AsyncSession(engine, expire_on_commit=False) as db:
kept = models.Filament(registered=now, name="Kept", density=1.24, diameter=1.75)
gone = models.Filament(registered=now, name="Gone", density=1.27, diameter=1.75)
spools = [models.Spool(filament=kept, registered=now, used_weight=w) for w in (10, 20, 30)]
db.add_all([kept, gone, *spools])
await db.commit()
await refresh(engine)
assert [used(s.id, kept.id) for s in spools] == [10, 20, 30]
assert density(gone.id) == 1.27
assert info(gone.id, "Gone") == 1
async with AsyncSession(engine) as db:
(await db.get(models.Spool, spools[1].id)).archived = True
await db.delete(await db.get(models.Spool, spools[2].id))
await db.delete(await db.get(models.Filament, gone.id))
await db.commit()
await refresh(engine)
assert [used(s.id, kept.id) for s in spools] == [10, None, None]
assert density(kept.id) == 1.24
assert density(gone.id) is None
assert info(kept.id, "Kept") == 1
assert info(gone.id, "Gone") is None
await engine.dispose()
+14
View File
@@ -0,0 +1,14 @@
"""Tests for the release version comparison used by the update check."""
from spoolman.update_check import is_newer
def test_is_newer() -> None:
assert is_newer("0.28.0", "0.27.0")
assert is_newer("v0.27.1", "0.27.0")
assert is_newer("1.0.0", "0.99.9")
assert is_newer("0.10.0", "0.9.0") # numeric, not string, compare
assert not is_newer("0.27.0", "0.27.0")
assert not is_newer("0.26.5", "0.27.0")
assert not is_newer("0.28.0", "unknown")
assert not is_newer("0.28.0-beta", "0.27.0")
+1
View File
@@ -2,3 +2,4 @@ node_modules/
test-results/
playwright-report/
playwright/.cache/
screenshots/
+1136
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -10,6 +10,7 @@
"report": "playwright show-report"
},
"devDependencies": {
"@argos-ci/playwright": "^7.6.0",
"@playwright/test": "^1.62.0",
"@types/node": "^22.20.1"
}
+21 -1
View File
@@ -20,7 +20,15 @@ export default defineConfig({
// The tests share one Spoolman instance, so keep them serial to avoid
// cross-test interference in the shared database.
workers: 1,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
// Argos uploads only from the CI screenshots step; any other run would upload
// an empty build and show every screen as removed.
reporter: process.env.CI
? [
["list"],
["html", { open: "never" }],
["@argos-ci/playwright/reporter", { uploadToArgos: !!process.env.ARGOS_UPLOAD }],
]
: [["list"]],
use: {
baseURL,
// Pin the browser locale so date/number formatting is deterministic. The
@@ -30,9 +38,21 @@ export default defineConfig({
screenshot: "only-on-failure",
video: "on-first-retry",
},
// The screenshots project seeds a fixed dataset and needs an empty database,
// so CI runs it first on its own; see tests/screenshots.spec.ts.
projects: [
{
name: "screenshots",
testMatch: "screenshots.spec.ts",
use: {
...devices["Desktop Chrome"],
// Argos-recommended flags for stable font rendering across runs.
launchOptions: { args: ["--disable-lcd-text", "--font-render-hinting=none"] },
},
},
{
name: "chromium",
testIgnore: "screenshots.spec.ts",
use: { ...devices["Desktop Chrome"] },
},
],
+48
View File
@@ -169,3 +169,51 @@ test("a filament's extra fields can be filled in while creating it", async ({ pa
await expect(page.getByText(fieldKey, { exact: true })).toHaveCount(0);
}
});
test("a typed zero tare overrides the filament's spool weight", async ({ page, request }) => {
const API_BASE =
process.env.SPOOLMAN_API_URL ??
`${process.env.SPOOLMAN_BASE_URL ?? "http://localhost:8001"}/api/v1`;
const filamentName = unique("ZeroTare");
const created = await request.post(`${API_BASE}/filament`, {
data: {
name: filamentName,
material: "PLA",
density: 1.24,
diameter: 1.75,
weight: 1000,
spool_weight: 200,
price: 20,
},
});
expect(created.ok()).toBeTruthy();
const filament = await created.json();
let spoolId: number | undefined;
try {
await openApp(page);
const dialog = await openAddSpoolModal(page);
await dialog.locator("input.search-big").fill(filamentName);
await dialog.locator(".res-item").filter({ hasText: filamentName }).first().click();
await numberField(dialog, "Spool Weight").fill("0");
await numberField(dialog, "Price").fill("0");
const saved = page.waitForResponse(
(r) => r.request().method() === "POST" && new URL(r.url()).pathname.endsWith("/spool"),
);
await dialog.getByRole("button", { name: "Add 1 spool", exact: true }).click();
const response = await saved;
expect(response.ok()).toBeTruthy();
spoolId = (await response.json()).id;
await expect(dialog).toBeHidden();
const stored = await request.get(`${API_BASE}/spool/${spoolId}`);
expect(stored.ok()).toBeTruthy();
expect(await stored.json()).toMatchObject({
spool_weight: 0,
price: 0,
});
} finally {
if (spoolId !== undefined) await request.delete(`${API_BASE}/spool/${spoolId}`);
await request.delete(`${API_BASE}/filament/${filament.id}`);
}
});
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "./fixtures";
import { unique } from "./helpers";
/**
* A dashboard card shows its spools a page at a time and fetches the next page
* when its list is scrolled to the bottom. The card's height cap has to stay
* below one page of spools for that to work: on a tall screen a card that fits
* its whole first page has no scrollbar, so the rest would never load (#1242).
*/
const API_BASE =
process.env.SPOOLMAN_API_URL ?? `${process.env.SPOOLMAN_BASE_URL ?? "http://localhost:8001"}/api/v1`;
test.use({ viewport: { width: 1920, height: 2160 } });
test("a big group's card loads every spool on a tall screen", async ({ page, request }) => {
const location = unique("Rack");
const res = await request.post(`${API_BASE}/filament`, {
data: { name: unique("Filament"), density: 1.24, diameter: 1.75 },
});
expect(res.ok()).toBeTruthy();
const filamentId = (await res.json()).id;
// More than one page of spools (30).
const ids: number[] = [];
for (let i = 0; i < 40; i++) {
const spool = await request.post(`${API_BASE}/spool`, { data: { filament_id: filamentId, location } });
expect(spool.ok()).toBeTruthy();
ids.push((await spool.json()).id);
}
try {
await page.goto("/dashboard?by=location");
const card = page.locator(".card").filter({ has: page.getByText(location, { exact: true }) });
const body = card.locator(".card-body");
await expect(body.locator(".chip")).toHaveCount(30);
await body.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(body.locator(".chip")).toHaveCount(40);
} finally {
// Leave the shared database as the other specs expect it.
for (const id of ids) await request.delete(`${API_BASE}/spool/${id}`);
await request.delete(`${API_BASE}/filament/${filamentId}`);
}
});
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more