Compare 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
Donkie bed3f14ee7 Bump version to 0.27.0 2026-09-25 13:35:18 +00:00
Donkie 27ea256e2d Merge pull request #1078 from romasku/fix/sqlite-unicode-search
fix: make SQLite searches Unicode case-insensitive
2026-09-25 15:33:02 +02:00
Donkie 7b82f2afb2 Merge pull request #1164 from Niko11111/fix-1117-vendor-tare-on-measure
Resolve the empty spool weight up to the vendor when measuring
2026-09-25 15:24:21 +02:00
Donkie 45cc8246d3 Merge pull request #1185 from Donkie/fix-1169-location-comma
fix(api): keep quoted string filter terms whole when they contain commas
2026-09-25 15:23:57 +02:00
Donkie 0c4caa527e fix(api): keep quoted string filter terms whole when they contain commas
String filters split their value on every comma before checking for quotes, so an exact
match like location="Top shelf, Rack 3" became two fuzzy terms that matched nothing.
The dashboard and library filters always send quoted values, so a location with a comma
showed as empty. Split with a quote-aware helper shared by the built-in string filters
and extra-field filters. Unquoted values split exactly as before.

Fixes #1169
2026-09-25 15:14:26 +02:00
Donkie 2ab68cdff9 Merge pull request #1168 from Donkie/dependabot/uv/anyio-4.14.2
build(deps): bump anyio from 4.14.1 to 4.14.2
2026-09-25 15:10:29 +02:00
Donkie fc6aa55d27 fix(labels): let the print dialog pick sheet orientation, clamp migrated margins
Two more problems from #1149.

Sheet mode set @page size to the paper's exact dimensions. A 62x34 page makes
the browser send it as landscape, and Chromium hides the orientation picker
when the page CSS sets a size. Label printer drivers disagree on which way
round a roll is: a Brother QL needed landscape, a Niimbot B1 needed portrait,
so any fixed choice prints rotated for someone. Sheet mode now uses size: auto
like v1 did, and the page is still laid out in mm. Label mode keeps its exact
size.

The v1 migration derived the label size from the raw margins. v1 users paired
-20mm margins with a 1.5mm printer margin to pull an oversized cell back onto a
62x34 page, which migrated as a 102x74 label. Margins are now clamped at
migration; the safe-zone alone gives the same content inset (max(safe, margin)
in both versions), and the label comes out at the paper size.
2026-09-25 15:08:36 +02:00
Donkie 92e093b16d fix(labels): keep sheet-printed labels at the right size and shape
Two bugs in the sheet-mode safe-zone inset, both reported in #1149.

The label raster has a fixed aspect ratio, but labelImg sized its width and
height independently, so a safe-zone squashed the label (1.5mm top/bottom on a
34mm label is a 9% vertical squeeze). v1 padded a box of reflowing HTML, where
that was harmless; a raster has to scale uniformly, so the inset box now gets
object-fit: contain.

Negative page margins made it much worse. v1 accepted them and used them to size
the grid cells; in v2 the label size comes from the design and the margin only
positions an absolutely placed grid inside an overflow:hidden page, so a negative
margin can only clip. It also inflated overSafe (safe - margin), which turned a
1.5mm safe-zone into a 21.5mm inset: at margin -20 the label rendered 22x12mm in
the corner of a 62x34mm page, and sheetGrid invented a second row off the paper.

Margins are now clamped to zero on read, so existing presets carrying v1's
negative values are fixed without the user editing them, and the margin inputs
refuse new negative values.
2026-09-25 15:08:36 +02:00
Donkie 55d11d0dc4 Merge pull request #1166 from Donkie/dependabot/npm_and_yarn/client_v2/devalue-5.9.2
build(deps): bump devalue from 5.9.0 to 5.9.2 in /client_v2
2026-09-25 15:08:27 +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
github-actions[bot] 058490a3c6 Merge pull request #1183 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-24 10:57:40 +00:00
Julia 9131454aa2 Translated using Weblate (Polish)
Currently translated at 100.0% (609 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-09-24 10:51:34 +00:00
github-actions[bot] c73bce4301 Merge pull request #1182 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-22 23:56:58 +00:00
Юрій «Zloj Kashtan» Скоріянтов 1c0f668c16 Translated using Weblate (Ukrainian)
Currently translated at 22.4% (137 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/uk/
2026-09-23 01:51:31 +02:00
github-actions[bot] de81990a29 Merge pull request #1177 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-21 18:04:23 +00:00
klim e4a78bb352 Translated using Weblate (French)
Currently translated at 100.0% (609 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/fr/
2026-09-21 17:57:26 +00:00
Steam Flow 196ebd66d0 Translated using Weblate (Czech)
Currently translated at 75.8% (462 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/cs/
2026-09-21 17:57:25 +00:00
github-actions[bot] d1f3401b37 Merge pull request #1176 from Donkie/weblate
Translations update
2026-09-21 17:57:12 +00:00
klim d848ddef97 Translated using Weblate (French)
Currently translated at 99.3% (291 of 293 strings)

Co-authored-by: klim <klim@free.fr>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fr/
Translation: Spoolman/Web Client LEGACY
2026-09-21 17:51:33 +00:00
github-actions[bot] 5c65cb5495 Merge pull request #1175 from Donkie/weblate
Translations update
2026-09-20 13:57:15 +00:00
github-actions[bot] 72d2cbe986 Merge pull request #1174 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-20 13:57:12 +00:00
Steam Flow 3ecdf5dfc6 Translated using Weblate (Czech)
Currently translated at 99.6% (292 of 293 strings)

Co-authored-by: Steam Flow <black23@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/cs/
Translation: Spoolman/Web Client LEGACY
2026-09-20 15:51:31 +02:00
Steam Flow d5a49e3340 Translated using Weblate (Czech)
Currently translated at 74.2% (452 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/cs/
2026-09-20 13:51:30 +00:00
github-actions[bot] 83897b9744 Merge pull request #1172 from Donkie/weblate
Translations update
2026-09-19 06:45:17 +00:00
github-actions[bot] c386ac34e2 Merge pull request #1170 from Donkie/weblate
Translations update
2026-09-19 06:40:03 +00:00
Dr_Perry_Coke 205d8fb813 Translated using Weblate (Russian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/
Translation: Spoolman/Web Client LEGACY
2026-09-19 06:39:59 +00:00
dimquea c74f77b478 Translated using Weblate (Russian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: dimquea <dimquea@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/
Translation: Spoolman/Web Client LEGACY
2026-09-19 06:39:59 +00:00
github-actions[bot] a40b20df8c Merge pull request #1171 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-19 06:39:45 +00:00
Dr_Perry_Coke dc56305a97 Translated using Weblate (Russian)
Currently translated at 100.0% (609 of 609 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/ru/
2026-09-19 08:34:33 +02:00
Dr_Perry_Coke ae2319a215 Translated using Weblate (Russian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/
Translation: Spoolman/Web Client LEGACY
2026-09-19 08:34:23 +02:00
dimquea 9f90c2238d Translated using Weblate (Russian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: dimquea <dimquea@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/
Translation: Spoolman/Web Client LEGACY
2026-09-19 08:34:23 +02:00
dependabot[bot] c157946eca build(deps): bump anyio from 4.14.1 to 4.14.2
Bumps [anyio](https://github.com/agronholm/anyio) from 4.14.1 to 4.14.2.
- [Release notes](https://github.com/agronholm/anyio/releases)
- [Commits](https://github.com/agronholm/anyio/compare/4.14.1...4.14.2)

---
updated-dependencies:
- dependency-name: anyio
  dependency-version: 4.14.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 18:07:53 +00:00
dependabot[bot] 0966ecada9 build(deps): bump devalue from 5.9.0 to 5.9.2 in /client_v2
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.0 to 5.9.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.9.0...v5.9.2)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.9.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 05:22:31 +00:00
Niko11111 9ae9827f4c Resolve the empty spool weight up to the vendor when measuring
measure() walked the tare from the spool to its filament and then fell back to
zero, so a spool whose tare only exists on its vendor was recorded as if it had
no core at all. A filament copies its vendor's empty spool weight in create(),
which makes that inheritance a snapshot: a tare given to a vendor later never
reaches the filaments that already exist, and the value is then known one level
up while measure() treats it as absent.

With a vendor tare of 250 g, a 1000 g filament and a 704 g reading, the spool
came back with 704 g remaining and 296 g used instead of 454 g and 546 g.

The vendor is now selected alongside the filament in the query that already
runs, and its tare is the level below the filament's, in the same order
create() resolves the value in. The join is an outer join on purpose:
filament.vendor_id is nullable, and a filament without a vendor still has to
return its row, or measuring it fails with "Filament not found for spool".

Fixes #1117
2026-09-16 01:54:14 +02:00
Donkie e9e76a088f Merge pull request #1163 from Donkie/fix-1155-label-spool-pages
fix(labels): page the print tab's spool list
2026-09-14 19:29:21 +02:00
Donkie a4c749ad14 fix(labels): page the print tab's spool list
The print tab only listed the newest 50 spools, so printing labels for a whole collection meant searching for them one by one. The list is now paged with the same pager as the library, 100 spools per page by default.

All and Recent add to the selection instead of replacing it, so a collection can be selected page by page. Recent has its own newest-first loader, so it still covers every spool added in the last 24 hours on any page and in either sort order.

Fixes #1155
2026-09-14 19:23:55 +02:00
Donkie 994e5d422b Merge pull request #1162 from Donkie/dependabot/npm_and_yarn/client/react-router-7.18.3
build(deps): bump react-router from 7.18.1 to 7.18.3 in /client
2026-09-13 22:13:25 +02:00
dependabot[bot] 1db2ed3851 build(deps): bump react-router from 7.18.1 to 7.18.3 in /client
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.18.1 to 7.18.3.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router@7.18.3/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.18.3/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.18.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-13 10:59:46 +00:00
Donkie b9b5784108 Merge pull request #1153 from Donkie/dependabot/npm_and_yarn/client_v2/multi-d0c2d048a7
build(deps): bump @vitest/mocker and vitest in /client_v2
2026-09-13 12:58:55 +02:00
Donkie c01342d182 Merge pull request #1159 from Donkie/dependabot/npm_and_yarn/client/js-yaml-3.15.2
build(deps-dev): bump js-yaml from 3.15.1 to 3.15.2 in /client
2026-09-13 12:58:38 +02:00
Donkie 0388fb60bf Merge pull request #1161 from Donkie/feat-1136-paginated-external-search
Page through SpoolmanDB search results in the filament pickers
2026-09-13 12:57:12 +02:00
Donkie feb67ab500 feat(external): page through SpoolmanDB search results
The filament pickers only ever showed the first 8 SpoolmanDB matches, so entries further down a broad search could not be reached. They now load 20 at a time and fetch the next page when the result list is scrolled to the bottom.

GET /external/filament/search takes an optional offset and returns the total match count in x-total-count, like the other list endpoints. Requests without offset behave as before. The lowercased search text is built once per catalog load instead of on every search, since paging means one scan per page.

Fixes #1136
2026-09-13 12:19:50 +02:00
Donkie ed946223d9 Merge pull request #1160 from Donkie/fix-1124-weight-wrap
Keep the spool weight unit on one line in the library list
2026-09-13 12:12:09 +02:00
Donkie c405739b1d fix(library): keep the spool weight unit on one line
The remaining weight cell was a fixed 44px, so values like 987.5 g or 10.2 kg wrapped the unit onto a second line. Make the width a minimum and stop the text from wrapping.

Fixes #1124
2026-09-13 11:52:12 +02:00
Donkie e744049343 Show filament and manufacturer IDs in their inspectors
The spool inspector shows its #id next to the title, but filaments and
manufacturers had no visible ID anywhere, which makes them hard to reference
from integrations or the API. Both headers now carry the same muted #id badge.
2026-09-13 10:36:09 +02:00
Donkie 179fc0a117 Show a toast when a tag scan navigates
With auto-navigate on, a tapped tag moves the browser to its spool or filament.
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, so the navigation now says what it
opened: "Tag scanned: opened spool #12, Prusament Galaxy Black."

It uses the info toast, which stays up long enough to be read after walking
back from the reader, and only fires when the scan actually navigates. Scans
that are ignored (settings pages, open dialogs, a focused input) stay silent,
and unknown tags keep their existing message.

filamentLabel now accepts an absent vendor, so a filament with no manufacturer
is named by itself rather than every caller guarding for it.

The Playwright tag specs assert the toast for both a spool and a filament.
2026-09-13 10:19:59 +02:00
Donkie 6520ce6d1b Let NFC tags identify filaments as well as spools
A tag can now be linked to a filament type, not just to one spool of it. The
tag table already carried filament_id and target_type for exactly this, so
there is no migration.

API, all additive:

  POST   /filament/{id}/tag        link; 409 if anything else holds the UID
  DELETE /filament/{id}/tag/{uid}  unlink
  GET    /filament?tag=<uid>       exact lookup on the normalized UID
  Filament.tags                    also inside every spool's embedded filament
  TagScan.matched_filament_id      always present, like matched_spool_id
  TagConflictMessage.filament_id   names a filament holder on a 409

One UID still identifies one thing across both kinds: a spool's tag cannot be
linked to a filament or the reverse, and neither ?tag= lookup answers with the
other kind. The scan debounce key now includes the kind, since spool 7 and
filament 7 are different answers.

SpoolTag and SpoolTagParameters are renamed Tag and TagLinkParameters while
they are unreleased, so the schema does not describe filament tags as spool
tags forever.

client_v2: the filament inspector gets the Tags section, the link dialog names
and moves a tag held by either kind, and scanning a filament's tag opens that
filament when auto-navigate is on.

Tag having two delete-orphan parents was checked on SQLAlchemy 2.0.51: new tags
flush, unlinking deletes the row, and deleting either parent cascades.

Proven on sqlite, postgres, mariadb and cockroachdb: 558 integration tests
each. Unit tests, svelte-check and the Playwright tag specs pass.
2026-09-13 10:10:13 +02:00
dependabot[bot] 83b0238bb4 build(deps-dev): bump js-yaml from 3.15.1 to 3.15.2 in /client
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.15.1 to 3.15.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/3.15.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.15.1...3.15.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-13 05:13:28 +00:00
Donkie f55188e346 chore: update sponsors image 2026-09-10 07:42:55 +00:00
dependabot[bot] 4fee8db323 build(deps): bump @vitest/mocker and vitest in /client_v2
Bumps [@vitest/mocker](https://github.com/vitest-dev/vitest/tree/HEAD/packages/mocker) to 4.1.11 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `@vitest/mocker` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/mocker)

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/mocker"
  dependency-version: 4.1.11
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-10 06:35:14 +00:00
github-actions[bot] 81636f253e Merge pull request #1143 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-04 20:56:09 +00:00
DrLous 0f705e80c0 Translated using Weblate (French)
Currently translated at 29.0% (174 of 600 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/fr/
2026-09-04 22:51:26 +02:00
github-actions[bot] 3c7f68e2bd Merge pull request #1140 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-03 14:58:52 +00:00
Micha 64b29a561b Translated using Weblate (German)
Currently translated at 100.0% (600 of 600 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-09-03 16:52:28 +02:00
Donkie 8f9c214dac Merge pull request #1135 from Donkie/dependabot/npm_and_yarn/client/fast-uri-3.1.7
build(deps): bump fast-uri from 3.1.5 to 3.1.7 in /client
2026-09-02 18:58:43 +02:00
Donkie 1305857efb Merge pull request #1134 from Donkie/dependabot/npm_and_yarn/client/sanitize-html-2.17.7
build(deps-dev): bump sanitize-html from 2.17.5 to 2.17.7 in /client
2026-09-02 18:27:23 +02:00
Donkie dd5a0a89dc Merge pull request #1133 from Donkie/dependabot/npm_and_yarn/client/browserslist-4.28.8
build(deps): bump browserslist from 4.28.4 to 4.28.8 in /client
2026-09-02 18:14:15 +02:00
dependabot[bot] 1eeb2948b2 build(deps): bump fast-uri from 3.1.5 to 3.1.7 in /client
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 16:00:29 +00:00
Donkie 348499f469 Tag issues opened by sponsors
The Supporter tier promises that issues a sponsor opens get an exclusive tag
so they surface first.

The sponsorship lookup is viewer-relative, so it runs on the PAT rather than
GITHUB_TOKEN, whose viewer is the Actions bot that nobody sponsors. Reading
privacyLevel needs that token's read:user scope.

Private sponsors are skipped. The label is public and applying it would
disclose a sponsorship they chose to keep hidden.
2026-09-02 17:59:16 +02:00
Donkie d34fb88b31 chore: update sponsors image 2026-09-02 15:48:39 +00:00
dependabot[bot] a63af285f5 build(deps-dev): bump sanitize-html from 2.17.5 to 2.17.7 in /client
Bumps [sanitize-html](https://github.com/apostrophecms/apostrophe/tree/HEAD/packages/sanitize-html) from 2.17.5 to 2.17.7.
- [Changelog](https://github.com/apostrophecms/apostrophe/blob/main/packages/sanitize-html/CHANGELOG.md)
- [Commits](https://github.com/apostrophecms/apostrophe/commits/HEAD/packages/sanitize-html)

---
updated-dependencies:
- dependency-name: sanitize-html
  dependency-version: 2.17.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 03:33:01 +00:00
Donkie 798d8b1ded chore: update sponsors image 2026-09-01 20:32:50 +00:00
Donkie f8ffaa0837 Push the sponsors image with the PAT 2026-09-01 22:32:29 +02:00
dependabot[bot] 09fea9f668 build(deps): bump browserslist from 4.28.4 to 4.28.8 in /client
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.4 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.4...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 19:23:02 +00:00
Donkie 789b7fd88f Render a sponsors board in the README
A nightly workflow runs sponsorkit against the GitHub Sponsors API and commits
.github/media/sponsors.svg when it changes, so the board stays current without
any manual work.

Ko-fi has no API to query, so tips that arrive there are kept in a small
hand-maintained list in the config and fed in through a custom provider. A tip
counts toward a tier for 30 days and then drops to Past Sponsors on its own,
which is how sponsorkit treats real Ko-fi tips and how a one-time GitHub
sponsorship ages out. marko-p's $10 tip is the first entry.
2026-09-01 21:21:20 +02:00
github-actions[bot] 5424a638b1 Merge pull request #1132 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-09-01 16:07:57 +00:00
Samuel Wang c281114c6f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (600 of 600 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/zh_Hans/
2026-09-01 18:03:07 +02:00
Julia 9566f7848d Translated using Weblate (Polish)
Currently translated at 100.0% (600 of 600 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-09-01 18:03:05 +02:00
Donkie 4f9cc1b75d Merge pull request #1120 from Niko11111/feat/last-uid-per-reader
Offer the reader's last UID in the Add tag dialog
2026-08-29 18:34:56 +02:00
github-actions[bot] 42721c7ebc Merge pull request #1125 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-27 05:51:16 +00:00
github-actions[bot] d1dc40b3cc Merge pull request #1126 from Donkie/weblate
Translations update
2026-08-27 05:51:07 +00:00
Samuel Wang 92be2975b8 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Samuel Wang <imhsaw@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hans/
Translation: Spoolman/Web Client LEGACY
2026-08-27 07:46:37 +02:00
darkk-k1 a62e4ce6c1 Translated using Weblate (Polish)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: darkk-k1 <softdev@darkabo.eu>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/pl/
Translation: Spoolman/Web Client LEGACY
2026-08-27 07:46:37 +02:00
Julia 0a3404cefe Translated using Weblate (Polish)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Julia <juliazero2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/pl/
Translation: Spoolman/Web Client LEGACY
2026-08-27 07:46:37 +02:00
Samuel Wang c2002e43f7 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (598 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/zh_Hans/
2026-08-27 07:46:19 +02:00
github-actions[bot] 85e3ac7163 Merge pull request #1123 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-26 16:16:46 +00:00
Samuel Wang 33aa3de515 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 28.4% (170 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/zh_Hans/
2026-08-26 17:51:02 +02:00
Julia e238592e4d Translated using Weblate (Polish)
Currently translated at 100.0% (598 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-08-26 17:51:00 +02:00
Micha 107a134da8 Translated using Weblate (German)
Currently translated at 100.0% (598 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-26 17:50:58 +02:00
github-actions[bot] 3cf7a9fa61 Merge pull request #1121 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-26 09:38:35 +00:00
Zack zhou (WeAPoN.Z) 1e5b28ab7d Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 26.0% (156 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/zh_Hans/
2026-08-26 11:33:46 +02:00
Niko11111 035922b49f style(tags): give the last-seen offer the weight of an action
It sat in the same quiet link style as the phone reader, which is an
alternative route rather than the thing to press. Same 600 as a UID gets
elsewhere in the tag UI, and no wrapping between verb and object.
2026-08-26 08:40:39 +02:00
Niko11111 eced7f22db feat(tags): offer what the reader already has in the Add tag dialog
The dialog waits for a fresh tap, which is right for a sticker in your hand and
awkward for a reader that already holds the tag. On a scale the spool is on the
pad to be weighed, so filling in the field means lifting it off and putting it
back; from the user's side that looks like the dialog not working.

With the last UID now in the registry, the dialog can simply say what the reader
it listens to last read, and offer it. Paired to one reader, that reader answers
or nothing does. Listening to all of them, the most recently seen one wins,
which is the one the person most likely just used. The offer disappears once the
field already holds that UID, so it never sits there as a no-op.

Nothing changes for the tap: it still fills the field the moment it arrives.
2026-08-26 07:34:17 +02:00
Niko11111 4c2febbba6 feat(tags): remember the last UID each reader reported
The Add tag dialog waits for a fresh tap. That is fine when the tag is a
sticker in your hand, and awkward when the reader is a scale: the spool is
sitting on the pad to be weighed, and filling in the field means lifting it
and putting it back. A reader also cannot help here, because it has no reason
to report a tag that has not moved.

The registry already tracks each reader by id, name and last_seen, all in
memory. Keeping the last UID beside them is enough for a client to offer what
is on the reader right now, with no back channel to the device, no protocol
change, and nothing to update on the agent side - every existing ESPHome
reader gets this for free.

One UID, not a history: this answers "what is on the reader", not "what has
been". It lives and dies with the registry entry, so it is gone on restart
like the rest of it, and no scan is written anywhere.
2026-08-26 07:34:17 +02:00
github-actions[bot] e37045dcd4 Merge pull request #1119 from Donkie/weblate
Translations update
2026-08-25 21:07:10 +00:00
Micha f83b9d9ea4 Translated using Weblate (German)
Currently translated at 97.9% (287 of 293 strings)

Co-authored-by: Micha <hoagman@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/de/
Translation: Spoolman/Web Client LEGACY
2026-08-25 23:00:17 +02:00
github-actions[bot] 0b3d7ba140 Merge pull request #1118 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-25 21:00:03 +00:00
Micha b6217c4b30 Translated using Weblate (German)
Currently translated at 93.9% (562 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-25 22:55:33 +02:00
Donkie 09953643dc Merge pull request #1116 from Donkie/fix-1115-scan-debounce-match
Key the tag scan debounce on the resolved match
2026-08-25 19:53:03 +02:00
Donkie a6512da598 Key the tag scan debounce on the resolved match
The relay debounced on (uid, reader_id) and time alone, so a scan whose match
had changed looked like a duplicate of one that resolved to nothing. An agent
that taps an unlinked tag, links it in its own UI, then re-reports the scan had
its correction swallowed for the whole 3 s window, leaving every subscriber that
trusts the broadcast still showing an unknown tag.

The key now includes matched_spool_id, so a repeat is only a repeat while the
scan still resolves to the same thing. Repeats of a still-unlinked tag keep
collapsing on (uid, reader, None), which is the reason the debounce exists.
should_broadcast takes the match as a required argument rather than an optional
one so a future call site cannot quietly drop it again.

Fixes #1115
2026-08-25 17:23:50 +02:00
Donkie 6da0902c8e Merge pull request #1113 from itCarl/fix/dashboard-spool-initial-weight
fix: dashboard total weight displayes filament weight instead of actual spool weight
2026-08-25 16:33:29 +02:00
github-actions[bot] 6bcda3d307 Merge pull request #1114 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-24 18:11:23 +00:00
Micha 9dd0f73be1 Translated using Weblate (German)
Currently translated at 83.7% (501 of 598 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-24 20:06:20 +02:00
Maximilian Mewes 5171330c44 fix: dashboard total weight displayes filament weight instead of spool weight 2026-08-24 09:56:10 +02:00
Donkie 009f9e3bae Merge pull request #1111 from Donkie/github-sponsors-button
Replace Ko-fi buttons with GitHub Sponsors
2026-08-23 18:09:36 +02:00
Donkie a039095360 Replace Ko-fi buttons with GitHub Sponsors
Both frontends and the README now point at github.com/sponsors/Donkie.

The GitHub-suggested iframe embed is not usable here: Spoolman is often
run on a LAN with no internet access, so it would render as an empty box,
and it cannot be translated or themed. The existing buttons keep their
shell and get an inlined Octicon heart plus a translated label instead.

Only the en locale source is touched; the new "sponsor" key goes out to
the other locales via Weblate.
2026-08-23 17:38:31 +02:00
Donkie 205de5f0ce Update FUNDING.yml 2026-08-23 17:19:51 +02:00
Donkie 05a1b12e33 Merge pull request #1110 from Donkie/dependabot/npm_and_yarn/client/multi-e855e31deb
build(deps): bump brace-expansion in /client
2026-08-23 17:19:02 +02:00
dependabot[bot] f458995104 build(deps): bump brace-expansion in /client
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 2.1.1 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.1...v2.1.4)

Updates `brace-expansion` from 1.1.15 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.1...v2.1.4)

Updates `brace-expansion` from 5.0.7 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.1...v2.1.4)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-23 10:17:19 +00:00
Donkie 9f048a34c8 Merge pull request #1096 from Donkie/worktree-nfc-design-docs
Identify spools by NFC/RFID tags
2026-08-23 12:14:03 +02:00
Donkie ba891d5830 client_v2: let a scanned tag keep the view you were in
Auto-navigate rewrote the URL to a bare `/?sel=spool:N`, which since the filter
menu landed means a tap throws away the grouping, sort and filter chips you had
set up -- and lands on the shipped default view rather than the remembered one.

openSearchResult is the navigation a picked search result already gets, and a
tag scan asks the same question: on the Library it merges the selection into
the current view, elsewhere 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.
2026-08-23 10:07:02 +02:00
Donkie f841da6561 Point SpoolTag.from_db at the class the table actually has
d43552b renamed the ORM class from SpoolTag to Tag and missed this annotation.
It is evaluated when the class body runs, so importing spoolman.api.v1.models
raised AttributeError -- the server could not start and every test in the tree
failed to collect.
2026-08-23 10:07:02 +02:00
Donkie 931e3c9b3b Merge remote-tracking branch 'origin/master' into worktree-nfc-design-docs
Master split filter application in two: the filters a filament can answer on
its own (_apply_filament_filters) now serve both the spool query and the new
include_empty query, which selects FROM the filaments so that a filament with
no spools still produces a group.

The tag filter stays where it was, in _apply_spool_filters, and the comment
there now says why it cannot move: a tag is linked to a spool, so a filament
on its own can no more answer "which tag is on you" than it can answer "which
shelf are you on". Nothing routes a tag filter into the filament-driven query
-- it never calls this builder, and the group endpoint takes no `tag` -- so
there is no silently-dropped filter to guard against.
2026-08-23 10:06:30 +02:00
Donkie 9775c2c42e Merge pull request #1097 from Donkie/client-v2-issue-1080-rest
client_v2: sort direction, out-of-stock filaments and truncation tooltips
2026-08-23 09:04:47 +02:00
github-actions[bot] a9e50559bb Merge pull request #1109 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-22 17:08:31 +00:00
Micha 91d5dc8f42 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:36 +02:00
cedrik-s 8528847957 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:36 +02:00
cedrik-s 5799fac1a6 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:36 +02:00
cedrik-s ce1c781ae7 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:36 +02:00
cedrik-s dc72f3d5eb Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:36 +02:00
cedrik-s aa5821f005 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:36 +02:00
cedrik-s 13f7667bf4 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:35 +02:00
cedrik-s ed732170d1 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:35 +02:00
Micha 8465481ab3 Translated using Weblate (German)
Currently translated at 70.9% (384 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-22 19:04:35 +02:00
github-actions[bot] 9935d74886 Merge pull request #1107 from Donkie/weblate
Translations update
2026-08-21 19:59:59 +00:00
rupefelo 35d10f899d Translated using Weblate (Portuguese)
Currently translated at 99.6% (292 of 293 strings)

Co-authored-by: rupefelo <rupefelo@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/pt/
Translation: Spoolman/Web Client LEGACY
2026-08-21 19:56:01 +00:00
github-actions[bot] 785984e685 Merge pull request #1106 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-21 19:55:49 +00:00
cedrik-s 8b71fb9345 Translated using Weblate (German)
Currently translated at 57.8% (313 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-21 21:51:23 +02:00
Micha 9bb8973af2 Translated using Weblate (German)
Currently translated at 57.8% (313 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-21 21:51:22 +02:00
Donkie 332b3e0fe4 Revert "Give phase 2 somewhere to record a tag's instance identity"
This reverts 9aea271.

The column bought one thing, that phase 2 would need no migration, and
cost something worse: it commits the schema to a shape nobody has run a
decoder against. Width, and above all the choice not to make it unique,
were read off a design document rather than off working code, and
goeland86's codec in #880 computes an effective_instance_uuid that may
not mean the same thing. Getting that wrong leaves us altering a
populated column on four databases, which is the exact operation the
"settle the shape before release" rule exists to avoid.

That rule earns its keep for target_type, target_value and filament_id,
where retrofitting a discriminator or a nullability change is painful.
Appending one nullable column later is not painful, so phase 2 can add
it when there is something to write into it.
2026-08-20 19:15:35 +02:00
Donkie 8d9eb7395d Merge pull request #1105 from jpapiez/upstream-chore-prettier-eol
chore(client): let prettier respect the working tree's line endings
2026-08-20 19:12:15 +02:00
Donkie 413254b79a Merge pull request #1104 from jpapiez/upstream-fix-backup-connections
fix(database): close the sqlite connections used to write a backup
2026-08-20 19:11:56 +02:00
Jeff PapiezandCopilot App 21c7b98844 Close the sqlite connections used to write a backup
A sqlite3.Connection used as a context manager is a transaction manager: it
commits or rolls back on exit, it does not close the connection. Both handles
stayed open past the `with` block in Database.backup().

backup_and_rotate() then unlinks and moves the .pending file it just wrote.
POSIX allows unlinking a file with open handles, so this was invisible on
Linux, but on Windows it raises WinError 32 and breaks both the scheduled
midnight backup and the manual backup endpoint. It also leaked a pair of
connections per backup on every platform.

Wrap both connections in contextlib.closing instead. Connection.backup()
commits on the destination internally, so nothing is lost by dropping the
transaction-managing context manager.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-19 21:50:54 -07:00
Jeff PapiezandCopilot App 97758f2c4c Let prettier respect the working tree's line endings
The root .gitattributes sets `* text=auto`, so a Windows checkout gets CRLF in
the working tree. Prettier defaults to `endOfLine: "lf"` and therefore reports
every text file as mis-formatted -- 163 files in client_v2 alone -- which makes
`npm run lint` and `lefthook run ci` unusable locally. The pre-commit hook is
worse: it runs `prettier --write .` with stage_fixed, so any commit made from
Windows rewrites and stages the whole client tree.

Set `endOfLine: "auto"` in both client configs. Git still normalizes to LF in
the index and in the repo thanks to `text=auto`, and CI runs on ubuntu-latest,
so LF in version control is unaffected; prettier just stops fighting the
checkout. This is Prettier's documented fix for this scenario.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-19 21:50:44 -07:00
Donkie 9aea271ee5 Give phase 2 somewhere to record a tag's instance identity
OpenPrintTag derives an instance_uuid from the brand uuid plus the tag's
hardware UID, and phase 2 needs it stored so that rescanning a tag updates
the spool it already created instead of making a second one.

Added now, into the migration that has shipped to nobody, because the
table is the expensive half to change once it is populated on four
databases. Nothing writes the column yet, which is the same position
filament_id is already in.

Indexed for that lookup and deliberately not unique. uid already enforces
that one physical tag identifies one thing, and copying a tag's payload
onto a second sticker can legitimately put one instance identity on two
rows pointing at the same spool, which a unique constraint would reject.
2026-08-19 22:47:37 +02:00
Donkie 6ab045cde8 Merge remote-tracking branch 'origin/master' into client-v2-issue-1080-rest
# Conflicts:
#	client_v2/src/lib/components/library/ListToolbar.svelte
2026-08-19 20:38:38 +02:00
Donkie 90492ff75d client_v2: make the no-spools filaments an opt-in view
Listing every filament you own no spools of turned out to be the wrong default.
Finding a spool to print with is the daily job and those rows cannot serve it,
and how many of them there are scales with the size of a user's catalogue -- a
no-op for one library and a tripling of another. Off by default, one click to
turn on, remembered afterwards: a missed feature beats a primary view that is
permanently worse until you find the switch.

The toggle sits beside Show Archived and shows a dismissible chip when on, and
is offered only while grouping by filament, since there is nowhere else to put
the empty groups.

Remembered through viewPrefs, which until now deliberately stored nothing that
hides rows -- a hidden filter restored days later reads as missing data. This
one only ever adds rows, so it cannot cause that confusion; the comment there
now says which side of the line it falls on and why. Entries written before the
flag existed keep working: a missing field means off rather than a corrupt entry
that would throw away the grouping the user picked.

The badge now reads "No spools" rather than "Out of stock". Spoolman knows what
you have recorded, not what is on your shelf.
2026-08-19 17:20:26 +02:00
github-actions[bot] 1f2670cf81 Merge pull request #1102 from Donkie/weblate
Translations update
2026-08-19 09:44:00 +00:00
github-actions[bot] 565abec742 Merge pull request #1101 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-19 09:38:59 +00:00
Julia afb3094c1b Translated using Weblate (Polish)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Julia <juliazero2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/pl/
Translation: Spoolman/Web Client LEGACY
2026-08-19 11:35:03 +02:00
Julia 96065fbd9f Translated using Weblate (Polish)
Currently translated at 100.0% (541 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pl/
2026-08-19 11:34:54 +02:00
github-actions[bot] 8d65e53e7c Merge pull request #1100 from Donkie/weblate
Translations update
2026-08-18 19:56:58 +00:00
Icezaza Ch 48221c270a Translated using Weblate (Thai)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Icezaza Ch <icezaza.jar@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/th/
Translation: Spoolman/Web Client LEGACY
2026-08-18 19:51:27 +00:00
github-actions[bot] f9c2c3838a Merge pull request #1098 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-18 17:56:20 +00:00
Icezaza Ch bd877c1ec7 Translated using Weblate (Thai)
Currently translated at 100.0% (541 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/th/
2026-08-18 19:51:40 +02:00
Liudas Ališauskas 328c508c39 Translated using Weblate (Lithuanian)
Currently translated at 34.7% (188 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/lt/
2026-08-18 19:51:30 +02:00
Micha 052d3e9eb9 Translated using Weblate (German)
Currently translated at 51.0% (276 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-18 19:51:25 +02:00
cedrik-s 9a98e8ba5d Translated using Weblate (German)
Currently translated at 51.0% (276 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/de/
2026-08-18 19:51:24 +02:00
Donkie 6dce8717a0 Fold the empty-group query back into find_groups
The first cut of include_empty grew a second 177-line function next to
find_groups that repeated its filters, ordering, pagination and hydration, so
every future change to grouping had to be made twice or silently diverge.

Only the FROM ever differed. Writing the aggregates so they hold for a group
with no spools in it -- COUNT over a column instead of COUNT(*), one explicit
zero for the one SUM -- removes the need for a second set, and with it the
COALESCE that CockroachDB rejected and the CAST that worked around it. What is
left is one query with a branch over how its rows are selected, and one shared
tail.

Spool-level filters are now refused alongside include_empty rather than quietly
producing a contradiction (every filament as empty, or no empty groups at all).
That also leaves the archived rule as the only spool-level condition the
filament-driven query has to carry, in its JOIN.

Group ordering now breaks ties on the grouped column. Every ordering can tie,
and empty groups tie on all four aggregates, so paging could otherwise repeat or
drop a group between pages.
2026-08-18 18:04:34 +02:00
Donkie 5b480f7db7 tests_frontend_v2: catch the tag spec up with the renamed strings
38b77a9 renamed the auto-navigate toggle to "Auto-navigate" and reworded
the pairing prompt to "Tap any tag...", but the spec still selected on the
old wording, so three tests could not find the control at all.

Checked every string the spec selects on against en/common.json rather
than the two CI happened to reach first, since fixing one stale selector
would have moved the failure to the next one.
2026-08-18 17:53:55 +02:00
Donkie 1b4beb9593 Merge pull request #1094 from Donkie/client-v2-filter-menu
client_v2: filter menu colours, materials, checkmarks and multi-select
2026-08-18 17:49:33 +02:00
Donkie 33ad8d7173 Show filaments you have no spools of
Grouping by filament aggregates over spools, so a filament you own no spools of
could never produce a group and simply vanished from the library. A filtered
view then looked fully stocked when it was the opposite, and the thing you
needed to re-order was the one thing not on screen (#1092).

GET /api/v1/spool/group takes include_empty, which inverts the query: spools are
aggregated per filament in a subquery that the filament list is LEFT JOINed
against, so filaments with no matching spools survive with their aggregates
folded to zero. Which side of that join a filter goes is the whole subtlety, and
the docstring spells it out - spool-scoped conditions have to stay in the
subquery or they delete the very rows the flag exists to keep.

The client asks for it while grouping by filament and every active filter is one
a filament can answer on its own. "Which of these are on Shelf A" has no answer
for a filament that is nowhere, and including them there would bury the shelf
under the whole catalogue. Such a group renders as an out-of-stock row: no
disclosure, no fetch, and a marker in place of the zeroes it would otherwise
report.

The group header also picks up the truncation tooltip added in the previous
commit. Its title sits under a link stretched across the whole row, so the
pointer never lands on the text itself and the tooltip has to hang off the row.
2026-08-18 17:33:29 +02:00
Donkie 713cb51f09 client_v2: reveal truncated text on hover
Text cut off with an ellipsis gave no way to read the value behind it, and that
value — a filament name, a location, a manufacturer — is usually the whole
reason the row is on screen (#1093).

A shared action measures the element and gives it a title only while the text
really is cut off, so rows that fit stay quiet instead of popping a redundant
tooltip under every pass of the pointer. It re-measures on resize and on text
changes alike: the list pane is resizable, and a live update can lengthen a name
without the column moving at all.

Applied to the spool rows, the unused-spool rows, the search results and the
dashboard chips.
2026-08-18 17:32:01 +02:00
Donkie fa39c6d08e client_v2: flip the sort direction from the chip's own arrow
The sort chip showed the direction but opened the menu wherever you clicked it,
so reversing an order meant reopening the dropdown and finding the field that
was already selected (#1091).

Split it into two controls sharing one frame: the field label still opens the
menu, the arrow only flips the direction. A divider and a hover of its own mark
the arrow as separate, and both halves stretch to fill the chip so the 44px
mobile tap target survives the split.
2026-08-18 17:31:38 +02:00
Donkie 61e2c0c27c client_v2: keep the frontend suite in step with the filter menu
Turning filter values into checkboxes that leave the menu open broke the
`addFilter` helper in two ways, and with it the grouping test that uses it
to pin the list down to its own spools.

An explicit `role="menuitemcheckbox"` replaces a button's implicit role, so
the helper's `getByRole("button")` no longer matched a value row at all;
and having picked one, the helper handed back a toolbar with the menu still
covering it, which the next call could not open through. It now asks for
the role the row actually has, and closes the menu itself, restoring the
postcondition every caller was already relying on.

The row's accessible name needed a real space rather than the margin that
had been standing in for one: without it a filament read as one
run-together word, "Galaxy BlackPLA".
2026-08-18 17:29:07 +02:00
Donkie 83ea5001fd Keep the NFC design notes out of the source tree
They are working notes and a wiki draft, not something the repo needs to
carry. The user-facing half is now published at
https://github.com/Donkie/Spoolman/wiki/Tag-scanners, with the diagram
source alongside it in the wiki repository.
2026-08-17 21:55:06 +02:00
github-actions[bot] 6fd64f9ed4 Merge pull request #1095 from Donkie/weblate
Translations update
2026-08-17 19:44:29 +00:00
Icezaza Ch 2edd96dbb0 Translated using Weblate (Thai)
Currently translated at 96.5% (283 of 293 strings)

Co-authored-by: Icezaza Ch <icezaza.jar@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/th/
Translation: Spoolman/Web Client LEGACY
2026-08-17 21:40:22 +02:00
Liudas Ališauskas 61e1b62a38 Translated using Weblate (Lithuanian)
Currently translated at 99.3% (291 of 293 strings)

Co-authored-by: Liudas Ališauskas <liu2kas3@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/lt/
Translation: Spoolman/Web Client LEGACY
2026-08-17 21:40:22 +02:00
Donkie 7e2e98b30a design: correct two claims the shipped relay made false
The phase 1 doc still said 'not yet implemented', and pointed at a
TagsSection path the frontend did not use.

More worth fixing: both documents offered an unguessable reader_id as a
weak secret for people who care. The reader registry that shipped
alongside undoes that entirely, since GET /api/v1/tag/reader hands every
recently-seen reader id to any caller by design, so the picker can list
them. Offering obscurity that the same API gives away is worse than
offering none.
2026-08-17 21:36:48 +02:00
Donkie 6d97cd34b3 design: rename the diagram boxes to what they are
Tag Reader, Spoolman Server and Web Browser, rather than Reader,
Spoolman and Your browser. The websocket edge says what it does.
2026-08-17 21:36:40 +02:00
Donkie 5624a0a605 client_v2: pick several filter values without reopening the menu
Narrowing a library to three filaments meant opening the filter menu,
stepping into Filament, picking one, and doing all of it again twice
more. The menu closed on every pick, and nothing in it said which values
were already on — the chips that say so sit in the toolbar, behind the
menu that covers them.

A value row now carries a checkbox and its state, the same way the "Show
archived" row already does, and picking one leaves the menu open. The two
go together: once the values are marked, the list reads as a multi-select,
and closing after a single pick is the surprising half. Escape and a click
outside still close it, and picking a filter property or a date range still
does too, since neither is a set.

Nothing has to be preserved by hand for this to work. Each toggle is a
query-only navigation that keeps the component mounted, and it already
asked to keep focus and not to scroll, so the search box stays focused and
the list stays where it was.

The Ctrl-modifier the issue suggested is deliberately not implemented: a
checkbox list that stays open needs no hint text to explain itself.

Closes #1090
Closes #1089
2026-08-17 21:00:17 +02:00
Donkie 6e289d76ca client_v2: show colors and material in the filament filter
The filament filter listed nothing but a label, which is the manufacturer
and the name. That is not enough to pick from: the colour is the thing
you actually recognise a filament by, and two filaments may legitimately
carry the same name in different materials, leaving the menu offering two
entries that read identically.

Each option now carries whatever it has beyond its label, so the menu
draws a swatch and the material where they exist and stays a plain list
of names where they don't (locations, lot numbers). The material sits
with the label rather than off at the row's right edge, since it is there
to qualify the name it follows. None of it costs a request: the option
list is built from whole filaments already fetched.

The search box narrows on the material too. It is on screen, so a query
that visibly matches a row has to keep it — and since terms match in any
order, "prusament petg" now finds one filament out of two same-named
ones.

Closes #1087
Closes #1088
2026-08-17 20:59:29 +02:00
Donkie 5b39b2848f design: give the diagram boxes a title and a subtitle
Each box is now a container whose label is the title, pulled inside with
label.near, over a borderless child holding the subtitle.

The obvious route, an |md| label, is a trap and the header comment says
so at length. d2 clips md text to a computed box in both dimensions and
no width or height will grow the clip region, so subtitles lost their
tails silently: "which spool has this tag UID?" rendered as "which spool
has this". It hides well, too, because the clipping is done by the
browser against a <foreignObject> and the full string stays in the SVG,
so grepping the file finds text that nobody can read.

The tag stays a plain node. It has nothing to put under its title, and
container padding is not adjustable, so opening one would have cost a
third of the height for no content.
2026-08-17 20:02:19 +02:00
github-actions[bot] 99932ceda8 Merge pull request #1084 from Donkie/weblate
Translations update
2026-08-16 16:57:36 +00:00
github-actions[bot] fbaa83eead Merge pull request #1083 from Donkie/weblate
Translations update
2026-08-16 16:53:33 +00:00
Dr_Perry_Coke d2db504cc5 Translated using Weblate (Russian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/
Translation: Spoolman/Web Client LEGACY
2026-08-16 18:53:29 +02:00
github-actions[bot] 74a86683b3 Merge pull request #1082 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-16 16:53:14 +00:00
Dr_Perry_Coke 2434b424db Translated using Weblate (Russian)
Currently translated at 100.0% (541 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/ru/
2026-08-16 18:48:22 +02:00
Dr_Perry_Coke 678213f67e Translated using Weblate (Russian)
Currently translated at 100.0% (293 of 293 strings)

Co-authored-by: Dr_Perry_Coke <dr.perrycoke@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ru/
Translation: Spoolman/Web Client LEGACY
2026-08-16 18:48:06 +02:00
Donkie 21e0b71efe design: draw the tag scan flow instead of spelling it in ASCII
The picture now also shows the reply to the POST, which the ASCII block
had no room for and which is the half that drives an LED.

Kept theme-independent on purpose. d2's --dark-theme bakes a
prefers-color-scheme query into the SVG, but it only survives if whatever
embeds the file leaves the stylesheet intact, and a stripped one would
leave dark arrows on a dark page. Every colour is pinned instead, over a
white canvas, so it renders the same wherever it lands.
2026-08-15 11:36:03 +02:00
Donkie 6a786bac88 design: draft the Tag scanners wiki page
The client already links Settings to the wiki page at
github.com/Donkie/Spoolman/wiki/Tag-scanners, which does not exist yet.
This is its source, kept in the tree so it can be reviewed and corrected
alongside the code it documents.

Everything in it was checked against a running instance rather than read
off the source: the scan/link/unlink/lookup responses and status codes,
the two websocket pools and their isolation from the entity streams, the
debounce, and the derived reader id. Both ESPHome examples compile for
ESP32 on ESPHome 2026.7.4.
2026-08-15 11:10:37 +02:00
Donkie 38b77a9b14 Updated some strings 2026-08-15 10:55:36 +02:00
Donkie fb1567a9b0 client_v2: say what the scanner settings actually do
Nothing in this section sets a scanner up -- that is hardware, and the link
above it goes to the wiki for it. Every control here answers one question:
when a tag is scanned, does this screen jump to its spool, and which reader is
it listening to. "Tag scanner" as a heading promised the former and delivered
the latter.

So the section is "Scanned tags", the reader row talks about which scans open
spools here rather than about listening in the abstract, and "Listen to all"
becomes "Use any reader" -- the same thing said in terms of the outcome rather
than of the plumbing. The auto-navigate description drops its first sentence,
which only repeated the label it sits under, and spends the room on the limits
instead.

The e2e assertions that named this copy now match on the clause that
distinguishes the two states rather than on whole sentences, since prose gets
reworded and the assertion is about which state is showing.
2026-08-15 10:25:40 +02:00
Donkie 01c7ff65be client_v2: stop keeping a second copy of a reader's name
Pairing by tapping a tag showed "desk" where pairing with the same reader from
the recently-seen list showed "Desk reader". Not a display bug: the client was
persisting its own copy of the reader's name into localStorage at pairing time,
so there were two sources of truth for something the server owns and that
changes without us.

They drift by design. The server's registry keeps the last name each reader
gave (scanrelay.py:98, deliberately, so a trimmed-down agent doesn't blank a
label). A scan event carries only the name *that* scan sent. Pair from the list
and you read the registry's remembered name; pair by tapping a scan that
carried none and you stored null and fell back to the id. Same reader, two
answers.

Only the reader id is persisted now -- that is the durable half, and the only
thing pooling actually depends on. Names are read: merged from the registry and
from scans as they arrive, held in memory because they mirror state the server
also holds in memory, and falling back to the id when nobody knows a name,
which is the honest answer after either end restarts.

Verified against the reported scenario -- a reader that sent a name once and
then stopped -- pairing both ways and comparing the label.
2026-08-15 09:50:31 +02:00
Donkie d43552bd26 Generalise the tag table beyond spools before it ships
A tag maps a UID to "the thing to bring up", and only some of those things are
spools -- or rows at all. Locations are the case that settles the shape: a
location in Spoolman is a String(64) on Spool, not a table, so a tag meaning
"show me Shelf A" can never be a foreign key.

Getting this wrong is asymmetrically expensive. Renaming the table, dropping
spool_id's NOT NULL and adding a discriminator all mean altering a populated
table on four databases; adding an endpoint or a target kind later is additive
and free. The migration has shipped to nobody -- it is not on master and
v0.26.1 predates it -- so the schema is settled now and the unshipped revision
is rewritten in place rather than chased with a second one.

spool_tag becomes tag:

  uid           unchanged, still globally unique -- one physical tag, one thing
  target_type   which kind of target is in force, rather than leaving readers
                to infer it from whichever column is non-null
  spool_id      nullable now; targets that ARE rows keep real foreign keys, so
  filament_id   the database still cascades deletes and refuses orphans
  target_value  targets that are not rows; a new kind of this sort costs no
                migration at all, only a new target_type string

Exactly-one-target is enforced in database/tag.py rather than by a CHECK: no
migration in this tree uses one, and MySQL below 8.0.16 parses and silently
ignores them, so a constraint here would be real on three databases out of
four. Single write path beats a constraint that is only sometimes a constraint
-- the same argument, in the same module, as normalize_uid.

filament_id gets a column and a foreign key but no ORM relationship: nothing
writes filament tags, and a selectin collection on Filament would add a query
to every filament listing for rows that cannot exist. ON DELETE CASCADE holds
integrity until the feature that needs the relationship arrives.

No API surface changes. POST /spool/{id}/tag, ?tag=, the spool response's tags
and matched_spool_id are all exactly as they were; the reads are just explicit
now about wanting a spool rather than assuming the tag they found is one.
TagConflictError's spool_id becomes optional for the same reason, which the
client already handled -- it only offers "move it here" when there is an id.

Proven on sqlite, postgres, mariadb and cockroachdb: 526 integration tests each.
2026-08-15 09:35:13 +02:00
Donkie ff2e3ec31a client_v2: keep scans off the pages you are configuring
Pairing a reader is done by tapping a tag on it, and that tap is delivered to
the auto-navigate subscription as well as to the settings page waiting for it.
So setting a scanner up navigated you away from the screen you were setting it
up on, the instant it worked -- and the same would happen to anyone mid-edit
when someone taps a tag in the next room.

Scans now only act on the Library and Dashboard: the pages where being moved to
a spool is what you wanted. Settings and the label designer are work in
progress, and neither navigates nor toasts. Matched on route id rather than
pathname, since an instance under SPOOLMAN_BASE_PATH serves the library from
somewhere that is not "/".

Also: the paired reader is now named in a chip beside the buttons instead of
inline in a sentence. Ids are routinely a bare "desk" or an "ip-192-168-1-50"
derived from the address, and "Listening to desk only." reads as a typo rather
than as the name of a device. The raw id stays on hover for when a friendly
name hides it.

The scanner section's explanatory paragraph is replaced by a link to the wiki.
How a reader reaches Spoolman needs wiring and firmware, which is an article,
not a caption.
2026-08-15 09:16:34 +02:00
Donkie 5144b64a8a scripts: add a fake tag reader
The reader-to-Spoolman contract is one ordinary HTTP POST, which means a
complete conforming reader fits in a hundred lines of stdlib and needs no NFC
hardware at all. That is worth having as a tool: pairing, auto-navigate and
filling the link dialog are otherwise only reachable by someone who owns an
ESP32 and has already flashed it.

Interactive by default, because the thing you want while clicking around the
UI is to tap the same tag again, or a new one, without retyping a command.
Blank input re-taps the last UID -- which is also how to watch the server's
de-duplication behave, since it answers every repeat while broadcasting only
the first, exactly as it does for a real reader re-reading a tag that is
sitting on it.

Writes nothing and deletes nothing, unlike its neighbours in this directory:
a scan is an event, not a record.
2026-08-15 09:05:17 +02:00
Donkie 9d77b08114 design: record what the NFC frontend decided, and two traps it hit
The handoff's four open questions are answered by the code now, so it says
which way each went and why rather than leaving the next reader to infer it
from the diff.

Also two corrections worth not rediscovering: Playwright for client_v2 lives
in tests_frontend_v2/, not the client_v2/e2e/ the design doc names (that
directory is only the a11y audit); and incrementing a $state counter from
inside an $effect makes the effect depend on its own write, which is how the
tag dialog's suppression hold first came out -- a reactive loop that presented
as a flood of "WebSocket ... Insufficient resources".
2026-08-15 08:39:18 +02:00
Donkie b4292398f5 tests_frontend_v2: cover tags, and stop assuming a filament is on page 1
Six specs for the tag feature. They are a reader: the contract a scanner
implements is one ordinary POST, so linking, tap-to-fill, moving a tag off
another spool, auto-navigate, the unknown-tag report and pairing are all
drivable without NFC hardware. Web NFC is the one path with no automated
cover -- there is no way to present a physical tag to headless Chromium --
so what is asserted instead is that its control is absent where it cannot
work, which is the failure mode that would otherwise reach a desktop user.

Two things the specs have to be careful about, both documented in place: the
server broadcasts an identical (uid, reader_id) only once every few seconds,
so polling invents a new reader id per attempt rather than having its retries
swallowed; and tags never expire, so UIDs are minted per run rather than
hard-coded, since the specs turn on whether a UID is free or taken.

The translucency spec found its filament by looking for its group header in
the library, which only works while the instance holds fewer than the 20
groups a page shows. Every spec adds a filament, so that assumption was going
to expire on its own -- these six merely get it there sooner. It now goes
through search, like the specs that already had to.
2026-08-15 08:39:09 +02:00
Donkie 9ec5181d4c client_v2: link NFC tags to spools, and act on a reader's scans
The client half of design/nfc-phase-1-tag-identity.md, against the API that
landed in de533bc.

A spool's inspector grows a Tags section listing what is linked to it, with
adding as a quiet section action rather than another button in a header that
already carries the spool's primary actions. The dialog behind it takes a UID
from whichever source is to hand -- a reader tapping the tag, this phone over
Web NFC, or typing -- and resolves it as it settles, so "already on spool #12"
arrives before you commit rather than as a rejected submit. That case is
offered as one button that moves the tag, because the alternative is sending
the user to find the other spool and unlink it by hand.

The relay client is deliberately not part of live.ts. That module is one socket
per entity resource, each feeding the inventory cache, and a scan is not an
entity: it has no id, nothing to cache it under, and its URL carries a reader
id where live.ts's carries a resource name. The connection behaviour is worth
sharing, so the backoff, the wake-on-online/visible listeners, the keepalive
and the 401 probe are copied rather than generalized over both.

Which reader a browser listens to is settled by walking over and tapping a tag
on it: subscribe to every reader, take the id off the first scan, narrow to
that one. No codes, no server state, and the binding is self-evidently right
because the user made it with their hands.

Auto-navigate is off by default and subscribes only while it is on, so a
browser not using NFC holds no relay socket at all. Exactly one subscriber, in
the root layout, since two components reacting to one tap is how you get a
double navigation. It stands down while a dialog is open, while the tag dialog
holds it, and while a field is being typed in -- a tag tapped in the next room
must not take a half-typed value with it.

Web NFC is progressive enhancement: absent, not disabled, on desktop, iOS,
Firefox and every plain-HTTP install. A tag's serial is readable without
decoding its contents, so phase 1 needs no NDEF parsing.

Verified with six Playwright specs driving the relay over plain POSTs, which is
what the reader contract being ordinary HTTP buys -- the whole feature is
exercised without NFC hardware. Web NFC itself has no automated cover and
cannot; what is asserted is that its control is absent where it cannot work.
2026-08-15 08:38:34 +02:00
Donkie 91dec46936 client_v2: give toasts a neutral kind
Every message was either a green tick or a red warning, which leaves nowhere
to put the ones that are neither. A tag scanned that no spool claims is the
ordinary first step of enrolling one: reporting it as an error puts a warning
on a working flow, and as a success ticks a check at nothing having happened.

The neutral kind needs no styling of its own -- the base toast is already
neutral -- so it is an icon and a longer timer. The timer now follows from
whether a message is a confirmation rather than from whether it is an error:
a confirmation is read at a glance, an instruction has to be read properly.
2026-08-15 08:37:30 +02:00
Donkie d67a2afbe4 Merge remote-tracking branch 'origin/master' into worktree-nfc-design-docs 2026-08-14 23:14:49 +02:00
Donkie 64e70076f1 Make BackupResponse.path required, as it already always is
The field is typed str with default=None, which is a default the type does not
allow. Nothing relied on it: the backup endpoint returns 500 when there is no
path and only constructs the model with one. Dropping the default makes the
documented response schema match what is actually sent.
2026-08-14 23:09:45 +02:00
Donkie ac7865ecbc Trim the tag endpoint's docstring and parameter descriptions
These descriptions ship in the public OpenAPI docs, where an integrator wants
the rule rather than the reasoning. Says the UID is case insensitive and that
separators are optional, and shows both spellings as examples, instead of
explaining why normalization exists.
2026-08-14 23:09:45 +02:00
Donkie 419a4763d5 design: revise NFC phase 1 with the scanner contract and build plan
Replaces the thin "client work" section with what a scanner actually is: the
full device contract, what the hardware must do and what to buy, complete
ESPHome and nfcpy reference firmware, and a table of the projects that already
POST somewhere on a tag read and would get shorter by switching to this.

Adds an implementation architecture section covering where each piece of code
goes and why there, on both sides.

Softens the host-USB-reader stance from "explicitly rejected, permanently" to
"not in phase 1, and nothing here forecloses it". The scan endpoint is public
HTTP rather than an internal hook, so a first-party host reader can arrive
later as an optional extra, a sidecar, or a standalone agent without any
architectural change.

Drops #884 and the QR-carries-the-tag-id cheap win, leaving #783.
2026-08-14 23:09:36 +02:00
Donkie 3f1d3b4c7b Fix mixed naive/aware clocks in the scan relay, and hand off the frontend
register() kept datetime.utcnow() while should_broadcast() and readers() moved
to datetime.now(tz=utc), so the naive last_seen it stored was compared against
an aware cutoff during pruning: GET /tag/reader raised "can't compare
offset-naive and offset-aware datetimes" as soon as any reader had scanned.

The existing unit tests could not catch it because they inject `now` into every
call. Added one that exercises each method's own clock instead.

Also adds design/nfc-phase-1-frontend-handoff.md: the API shapes the client
will consume, the behaviours it has to get right, two stale paths in the phase
1 doc's frontend plan, and the open UI decisions.
2026-08-14 23:03:05 +02:00
Roman Skurikhin 1507f9b3b8 fix: make SQLite searches Unicode case-insensitive 2026-08-14 20:48:28 +03:00
github-actions[bot] 7f42bd6f02 Merge pull request #1077 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-14 06:50:21 +00:00
KnightArch fd9b06d33c Translated using Weblate (Portuguese (Brazil))
Currently translated at 31.2% (169 of 541 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/pt_BR/
2026-08-14 08:46:28 +02:00
Donkie de533bc74d NFC phase 1 backend: tag identity, lookup, and the scan relay
Implements the backend half of design/nfc-phase-1-tag-identity.md. Format
agnostic: it keys only on a tag's hardware UID, so it works with blank NTAG
stickers, Prusa NFC-V tags, and the Bambu/Creality/Qidi MIFARE tags whose
contents Spoolman can never decrypt.

Answers the question the whole external ecosystem has been working around:
which spool is this physical tag? Every project so far invented its own way to
smuggle a Spoolman id into the tag contents, which fails the moment the tag is
a vendor tag you cannot write to.

Data model: new spool_tag table, additive only, with a unique index on uid.
Many tags per spool, since copying a vendor tag onto a blank sticker leaves one
spool with two UIDs. UIDs are normalized to uppercase hex with separators
stripped, in the database layer rather than a Pydantic validator, so the unique
constraint holds for callers that never arrive over HTTP.

API, all additive:
  GET    /spool?tag=<uid>          exact match on the normalized UID
  POST   /spool/{id}/tag           201, 409 with the conflicting spool id
  DELETE /spool/{id}/tag/{uid}     204
  POST   /tag/scan                 broadcast and return the match
  GET    /tag/reader               readers seen recently
  WS     /tag/scan[/{reader_id}]   the relay

Link and unlink emit the existing spool event, so clients need no new wiring.
Spool responses gain a tags array.

The relay decouples where the reader is from where the user is looking: an
agent POSTs a scan, a paired browser reacts. Scans are ephemeral, never
persisted, and go through a SECOND WebsocketManager instance rather than the
shared one. The shared tree propagates to subscribers at every level, and the
root /api/v1/ pool means "any change", so putting scans there would inject a
novel resource into every existing consumer's stream. An integration test pins
that they never reach the entity websockets.

Repeated identical (uid, reader_id) scans are debounced before the broadcast
only, since readers re-read a tag that is sitting still. The response always
carries the match, so a de-duplicated scan never looks to a device like a
failed lookup.

Migration creates the table and its indexes in one revision, following the
extra-fields tables rather than the doc's suggested split. The DDL/DML split
cockroachdb needs does not apply when there is no data migration, and the 4-DB
suite confirms it.

Verified: 506 integration tests on sqlite, postgres, mariadb and cockroachdb;
263 unit tests; ruff clean.

Frontend, docs, and the #783 action links are not included.
2026-08-13 18:56:35 +02:00
github-actions[bot] 8979f80e74 Merge pull request #1074 from Donkie/weblate
Translations update
2026-08-13 16:56:18 +00:00
Donkie f261dee61b Fix one dead websocket subscriber starving a whole pool
SubscriptionTree.send let a per-socket failure escape the broadcast loop, so
every subscriber after the failing one silently received nothing. A socket can
start closing between the CONNECTED state check and the send, and starlette
then raises "connection is closing"; on the shared tree that means one browser
tab closing could stop Moonraker hearing about a spool.

Each subscriber is now isolated: a write failure drops that socket and the
broadcast continues. Iterate a snapshot too, since dropping mutates the set.

Dropping also had to stop going through remove(path, ...). By the time send()
is walking a node, path is the part of the route still to be traversed, not the
pool the socket subscribed with, so remove() descended into a child and looked
in the wrong set: the dead socket stayed put to fail again on the next event.

Observed in the integration suite as a subscriber missing an event it was
subscribed to, alongside "Failed to send websocket message" on an unrelated
filament broadcast.
2026-08-13 18:56:16 +02:00
Alessandro “Alessandrol” L 422657d18f Translated using Weblate (French)
Currently translated at 99.3% (291 of 293 strings)

Co-authored-by: Alessandro “Alessandrol” L <alessandrolond@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fr/
Translation: Spoolman/Web Client LEGACY
2026-08-13 18:51:25 +02:00
github-actions[bot] 01be8ddd5b Merge pull request #1073 from Donkie/weblatev2
Translations update from Hosted Weblate
2026-08-13 16:27:07 +00:00
Alessandro “Alessandrol” L bf265c12c3 Translated using Weblate (French)
Currently translated at 25.4% (137 of 539 strings)

Translation: Spoolman/Spoolman Web UI v2
Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/fr/
2026-08-13 18:22:34 +02:00
Donkie c90cc3acab Merge pull request #1072 from Donkie/client-v2-new-filament-on-change
client_v2: create a filament while changing what a spool holds
2026-08-13 18:22:18 +02:00
Donkie 9423acbc9e client_v2: create a filament while changing what a spool holds
The change-filament dialog could only pick a filament that already existed,
in the catalog or in SpoolmanDB. But the only way to create one anywhere in
client_v2 is the add-spools flow, which always mints at least one spool — so
refilling a shelf slot with a roll nobody had catalogued meant adding a
throwaway spool and deleting it again, handing out exactly the new spool id
the feature exists to avoid (issue #1010).

The dialog now offers the same third path the add-spools flow does. Picking
it swaps the search for the new-filament form and applies as one action:
the filament (and its manufacturer) are created, then the spool is repointed
at it. The spool keeps its number, location, dates and usage history.

The form itself moves to NewFilamentCards, shared with AddSpoolModal so both
dialogs keep the per-record card layout from #1038, with its rules and its
mapping onto the API in lib/filament/draft.ts. The one difference is the
weight/spool weight/price trio: the add-spools flow writes those to the spool
it creates as well, and so keeps showing them in its spool block, while here
they belong to the filament alone.
2026-08-13 18:11:56 +02:00
Donkie 05628eb593 Merge pull request #1071 from Donkie/client-v2-form-required-fields
client_v2: point at missing required fields instead of disabling the add button
2026-08-12 22:27:21 +02:00
Donkie f02ebbc93e design: NFC/RFID tag support, phase 1 and phase 2
Phase 1: format-agnostic tag identity (spool_tag table, indexed UID
lookup, link/unlink API) plus the scan relay and scanner-to-browser
pairing. Phase 2: OpenPrintTag codec, import and export.

Based on an investigation of 9 open issues, 3 discussions, PR #880 and
the surrounding tag-standard ecosystem.
2026-08-09 22:39:22 +02:00
193 changed files with 23454 additions and 6253 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/
+2 -2
View File
@@ -1,9 +1,9 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: Donkie # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: donkie
ko_fi:
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+67
View File
@@ -0,0 +1,67 @@
<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 {
font-weight: 300;
font-size: 14px;
fill: #777777;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.sponsorkit-link {
cursor: pointer;
}
.sponsorkit-tier-title {
font-weight: 500;
font-size: 20px;
}
</style>
<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="340" y="65" width="120" height="120" rx="60" ry="60" />
</clipPath>
<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="280" width="50" height="50" rx="25" ry="25" />
</clipPath>
<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/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="280" width="50" height="50" rx="25" ry="25" />
</clipPath>
<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>

After

Width:  |  Height:  |  Size: 11 KiB

+3
View File
@@ -0,0 +1,3 @@
node_modules
.env
.cache.json
+852
View File
@@ -0,0 +1,852 @@
{
"name": "spoolman-sponsorkit",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spoolman-sponsorkit",
"devDependencies": {
"sponsorkit": "18.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"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,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"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"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"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.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"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"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"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"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"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"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"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"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
"integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"quansync": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ansis": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
"integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/cac": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
"integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-url": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz",
"integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ofetch": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"destr": "^2.0.5",
"node-fetch-native": "^1.6.7",
"ufo": "^1.6.1"
}
},
"node_modules/p-limit": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",
"integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quansync": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
"integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"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": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@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": {
"optional": true
}
}
},
"node_modules/sponsorkit": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/sponsorkit/-/sponsorkit-18.0.0.tgz",
"integrity": "sha512-9Jr+n4SjU8Qtldq8i0UlwwqBEAVy5JFbIQNXRUIs1N+LPTCnX2/of5oyHYftCLi3KxAODVMmyEdWqkJMTagR6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansis": "^4.3.1",
"cac": "^7.0.0",
"consola": "^3.4.2",
"d3-hierarchy": "^3.1.2",
"normalize-url": "^9.0.1",
"ofetch": "^1.5.1",
"p-limit": "^7.3.1",
"sharp": "^0.35.3",
"unconfig": "^7.5.0"
},
"bin": {
"sponsorkit": "dist/cli.mjs"
},
"engines": {
"node": "^22.18.0 || ^24.11.0 || >=26.0.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/unconfig": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz",
"integrity": "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@quansync/fs": "^1.0.0",
"defu": "^6.1.4",
"jiti": "^2.6.1",
"quansync": "^1.0.0",
"unconfig-core": "7.5.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/unconfig-core": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz",
"integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@quansync/fs": "^1.0.0",
"quansync": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "spoolman-sponsorkit",
"private": true,
"description": "Generates the sponsors image rendered in the README. Run by .github/workflows/sponsors.yml.",
"type": "module",
"devDependencies": {
"sponsorkit": "18.0.0"
}
}
+112
View File
@@ -0,0 +1,112 @@
import { defineConfig, tierPresets } from 'sponsorkit'
import type { Provider, Sponsorship } from 'sponsorkit'
/**
* People who tipped on Ko-fi and asked to be credited on the sponsors board.
* Ko-fi has no API we can query, so these are transcribed by hand from the
* Ko-fi dashboard. A tip counts toward a tier for KOFI_TIP_EFFECTIVITY_DAYS,
* after which the entry drops to "Past Sponsors" on its own - the same way a
* one-time GitHub sponsorship ages out, so nothing here needs pruning.
*/
const kofiTips: KofiTip[] = [
{ login: 'marko-p', dollars: 10, date: '2026-09-01' },
]
/** Days a one-time tip keeps counting. Matches sponsorkit's own Ko-fi provider. */
const KOFI_TIP_EFFECTIVITY_DAYS = 30
interface KofiTip {
/** GitHub login. Drives the avatar and the link on the board. */
login: string
/** Display name, defaults to the login. */
name?: string
/** Tip amount in USD. Decides the tier, see the `tiers` list below. */
dollars: number
/** Date of the tip, YYYY-MM-DD. */
date: string
}
function kofiTipToSponsorship(tip: KofiTip): Sponsorship {
const expireAt = new Date(Date.parse(tip.date) + KOFI_TIP_EFFECTIVITY_DAYS * 24 * 60 * 60 * 1000)
return {
sponsor: {
type: 'User',
login: tip.login,
name: tip.name ?? tip.login,
avatarUrl: `https://github.com/${tip.login}.png`,
linkUrl: `https://github.com/${tip.login}`,
},
// -1 is sponsorkit's marker for a sponsorship that has run out.
monthlyDollars: expireAt > new Date() ? tip.dollars : -1,
isOneTime: true,
privacyLevel: 'PUBLIC',
tierName: 'Ko-fi',
createdAt: new Date(tip.date).toISOString(),
expireAt: expireAt.toISOString(),
}
}
const KofiManualProvider: Provider = {
name: 'kofi-manual',
async fetchSponsors() {
return kofiTips.map(kofiTipToSponsorship)
},
}
export default defineConfig({
github: {
login: 'Donkie',
type: 'user',
},
// Listing providers explicitly disables auto-detection, so GitHub has to be
// named here alongside the hand-maintained Ko-fi list.
providers: ['github', KofiManualProvider],
width: 800,
renderer: 'tiers',
formats: ['svg', 'png'],
// Relative to this directory. Lands next to the other README images.
outputDir: '../media',
name: 'sponsors',
// Resolved against outputDir; keep the fetch cache out of the media folder.
cacheFile: '../sponsorkit/.cache.json',
tiers: [
{
title: 'Backers',
preset: tierPresets.xs,
},
{
title: 'Supporters',
monthlyDollars: 10,
preset: tierPresets.medium,
},
{
title: 'Sponsors',
monthlyDollars: 25,
preset: tierPresets.large,
},
{
title: 'Silver Sponsors',
monthlyDollars: 50,
preset: tierPresets.xl,
},
{
title: 'Gold Sponsors',
monthlyDollars: 100,
preset: {
...tierPresets.xl,
avatar: { size: 120 },
boxWidth: 150,
boxHeight: 160,
name: { maxLength: 25 },
},
},
{
title: 'Past Sponsors',
monthlyDollars: -1,
preset: tierPresets.xs,
},
],
})
+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
+77
View File
@@ -0,0 +1,77 @@
name: Label sponsor issues
on:
issues:
types: [opened]
permissions:
issues: write
concurrency:
group: sponsor-label-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Label the issue if its author is a sponsor
uses: actions/github-script@v9
with:
# The sponsorship lookup is viewer-relative: it answers "is this person
# sponsoring *me*" for whoever owns the token. GITHUB_TOKEN's viewer is
# the Actions bot, which nobody sponsors, so it always comes back empty.
# It has to be the PAT, and reading privacyLevel needs its read:user scope.
github-token: ${{ secrets.PAT }}
script: |
// The Supporter tier and up. Below this, no tag was promised.
const MIN_MONTHLY_DOLLARS = 10
const LABEL = 'sponsor'
const issue = context.payload.issue
const author = issue.user.login
if (author.endsWith('[bot]')) return
const query = `query($login: String!) {
user(login: $login) {
sponsorshipForViewerAsSponsorable(activeOnly: true) {
privacyLevel
tier { monthlyPriceInDollars }
}
}
}`
let sponsorship
try {
const data = await github.graphql(query, { login: author })
sponsorship = data.user?.sponsorshipForViewerAsSponsorable
} catch (error) {
// Deleted accounts and app logins never resolve to a user.
core.info(`Could not look up ${author}: ${error.message}`)
return
}
if (!sponsorship) {
core.info(`${author} is not an active sponsor`)
return
}
const dollars = sponsorship.tier.monthlyPriceInDollars
if (dollars < MIN_MONTHLY_DOLLARS) {
core.info(`${author} sponsors at $${dollars}, below the $${MIN_MONTHLY_DOLLARS} tier`)
return
}
// A private sponsor chose not to be listed publicly. This label is
// public, so applying it would out them; skip rather than disclose.
if (sponsorship.privacyLevel === 'PRIVATE') {
core.info(`${author} sponsors privately, leaving the issue unlabelled`)
return
}
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels: [LABEL],
})
core.info(`Labelled #${issue.number}, opened by $${dollars}/month sponsor ${author}`)
+51
View File
@@ -0,0 +1,51 @@
name: Generate sponsors image
on:
schedule:
- cron: '0 3 * * *' # daily
workflow_dispatch:
permissions:
contents: write
concurrency:
group: sponsors
cancel-in-progress: false
jobs:
sponsorkit:
runs-on: ubuntu-latest
steps:
# Checks out with the PAT rather than the default GITHUB_TOKEN: the commit
# step below pushes straight to master, and the ruleset there only lets a
# bypass-capable actor through.
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: true
token: ${{ secrets.PAT }}
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: .github/sponsorkit/package-lock.json
- name: Install sponsorkit
working-directory: .github/sponsorkit
run: npm ci
- name: Generate sponsors image
working-directory: .github/sponsorkit
run: npx sponsorkit
env:
SPONSORKIT_GITHUB_TOKEN: ${{ secrets.PAT }}
# Only commits when the rendered image actually differs from what is checked in.
- name: Commit sponsors image
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: 'chore: update sponsors image'
file_pattern: '.github/media/sponsors.svg .github/media/sponsors.png'
skip_dirty_check: false
+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
+8
View File
@@ -12,6 +12,7 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo
[![Static Badge](https://img.shields.io/badge/Spoolman%20Wiki-blue?link=https%3A%2F%2Fgithub.com%2FDonkie%2FSpoolman%2Fwiki)](https://github.com/Donkie/Spoolman/wiki)
[![GitHub Release](https://img.shields.io/github/v/release/Donkie/Spoolman)](https://github.com/Donkie/Spoolman/releases)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/Donkie?logo=githubsponsors&logoColor=EA4AAA&color=EA4AAA)](https://github.com/sponsors/Donkie)
### Features
* **Filament Management**: Keep comprehensive records of filament types, manufacturers, and individual spools.
@@ -56,3 +57,10 @@ If the new client misbehaves on your setup, set `SPOOLMAN_LEGACY_CLIENT=TRUE` to
the previous one. Both ship in every release and talk to the same API, so your data is
untouched either way — but please [report the problem](https://github.com/Donkie/Spoolman/issues)
so it can be fixed.
## Sponsors
Spoolman is developed and maintained in my spare time. If it is useful to you, consider [sponsoring the project](https://github.com/sponsors/Donkie).
<a href="https://github.com/sponsors/Donkie">
<img alt="The people currently sponsoring Spoolman" src=".github/media/sponsors.svg">
</a>
+1
View File
@@ -1,5 +1,6 @@
{
"printWidth": 120,
"endOfLine": "auto",
"overrides": [
{
"files": ["public/locales/**/*.json"],
+1174 -816
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "spoolman-ui",
"version": "0.26.1",
"version": "0.27.0",
"engines": {
"node": "^20.19 || ^22.12 || >=24"
},
@@ -22,13 +22,13 @@
"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",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.1",
"react-router": "^7.11.0",
"react-router": "^7.18.3",
"react-to-print": "^3.2.0",
"uuid": "^14.0.0",
"vite-plugin-svgr": "^4.5.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

+8 -8
View File
@@ -60,8 +60,8 @@
"printing": {
"generic": {
"title": "Probíhá tisk",
"description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné štítky si to vyzkoušejte na kousku běžného papíru.",
"helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly papíru na štítky a tiskárně, jejich změna ovlivní velikost celé mřížky.",
"description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné etikety si to vyzkoušejte na listu běžného papíru.",
"helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly listu etiket a tiskárně, jejich změna ovlivní velikost celé mřížky.",
"helpPrinterMargin": "Bezpečná zóna by měla být nastavena tak, jak blízko okraje papíru může tiskárna tisknout, jejich změna neovlivní celou mřížku.",
"print": "Tisk",
"columns": "Sloupce",
@@ -102,16 +102,16 @@
"saveAsImage": "Uložit jako obrázek"
},
"qrcode": {
"button": "Tisk štítků",
"title": "Tisk štítku",
"button": "Tisk etiket",
"title": "Tisk etikety",
"spoolWeight": "Hmotnost cívky: {{weight}}",
"lotNr": "Číslo šarže: {{lot}}",
"bedTemp": "BT: {{temp}}",
"extruderTemp": "ET: {{temp}}",
"textSize": "Velikost textu štítku",
"textSize": "Velikost textu etikety",
"showSpoolmanIcon": "Zobrazit ikonu Spoolman",
"showVendor": "Výrobce",
"showContent": "Tisk štítku",
"showContent": "Tisk etikety",
"showLotNr": "Číslo šarže",
"showSpoolWeight": "Hmotnost cívky",
"showTemperatures": "Teploty",
@@ -127,7 +127,7 @@
},
"preview": "Náhled:"
},
"template": "Šablona štítku",
"template": "Šablona etikety",
"templateHelp": "Pomocí {} vložíte hodnoty objektu cívky jako text. Například, {id} bude nahrazeno ID cívky nebo {filament.material} bude nahrazeno materiálem cívky. Pokud hodnota chybí, bude nahrazena znakem „?“. K odstranění této hodnoty lze použít druhou sadu {}. Kromě toho bude odstraněn jakýkoli text mezi sadami {}, pokud hodnota chybí. Například {Lot Nr: {lot_nr}} zobrazí popisek pouze v případě, že cívka má číslo šarže. Text uzavřete dvojitou hvězdičkou **, aby byl tučný. Kliknutím na tlačítko zobrazíte seznam všech dostupných značek.",
"showQRCode": "Tisk QR kódu",
"showQRCodeMode": {
@@ -138,7 +138,7 @@
},
"spoolSelect": {
"title": "Vybrat cívky",
"description": "Vyberte cívky, pro které chcete tisknout štítky.",
"description": "Vyberte cívky, pro které chcete tisknout etikety.",
"showArchived": "Zobrazit archivované",
"noSpoolsSelected": "Nevybrali jste žádné cívky.",
"selectAll": "Vybrat/odebrat vše",
+2 -1
View File
@@ -407,5 +407,6 @@
"no_locations_help": "Diese Seite lässt Sie Ihre Spulen zu Orten zuweisen, fügen Sie ein paar Spulen hinzu um loszulegen!",
"locations": "Orte",
"new_location": "Neuer Ort"
}
},
"sponsor": "Unterstütze mich auf GitHub"
}
+1 -1
View File
@@ -46,7 +46,7 @@
"saveSuccessful": "Save successful!",
"validationError": "Validation error: {{error}}"
},
"kofi": "Tip me on Ko-fi",
"sponsor": "Sponsor me on GitHub",
"loading": "Loading",
"version": "Version",
"unknown": "Unknown",
+17 -9
View File
@@ -140,7 +140,7 @@
"registered": "Enregistré",
"first_used": "Première utilisation",
"used_weight": "Poids utilisé",
"id": "ID",
"id": "Identifiant",
"remaining_weight": "Poids restant",
"price": "Prix",
"initial_weight": "Poids initial",
@@ -238,7 +238,7 @@
"settings_bed_temp": "Température du lit",
"vendor": "Fabricant",
"density": "Densité",
"id": "ID",
"id": "Identifiant",
"color_hex": "Couleur",
"article_number": "Numéro d'article",
"spools": "Afficher les bobines",
@@ -260,7 +260,7 @@
"form": {
"filament_updated": "Ce filament a été mis à jour par quelqu'un/quelque chose depuis que vous avez ouvert cette page. Enregistrer écrasera ces changements !",
"import_external": "Importer depuis externe",
"import_external_description": "Sélectionnez un filament dans la liste pour renseigner automatiquement ses détails dans le formulaire de création de filament. Cela écrasera toutes les données que vous avez saisies dans le formulaire.<br><br>Un objet fabricant sera créé automatiquement lorsque vous cliquerez sur OK, si nécessaire."
"import_external_description": "Sélectionnez un filament dans la liste pour renseigner automatiquement ses détails dans le formulaire de création de filament. Cela écrasera toutes les données que vous avez saisies dans le formulaire.<br><br>Un objet fabricant sera créé automatiquement lorsque vous cliquerez sur Ok, si nécessaire."
},
"buttons": {
"add_spool": "Ajouter une bobine"
@@ -314,7 +314,7 @@
"registered": "Enregistré",
"comment": "Commentaire",
"name": "Nom",
"id": "ID",
"id": "Identifiant",
"empty_spool_weight": "Poids de la bobine vide",
"external_id": "ID externe"
},
@@ -352,7 +352,7 @@
"home": {
"home": "Accueil",
"welcome": "Bienvenue sur votre instance Spoolman !",
"description": ""
"description": "Il semble que vous n'ayez pas encore ajouté de bobines. Consultez la <helpPageLink>page d'aide</helpPageLink> pour bien démarrer."
},
"settings": {
"settings": "Paramètres",
@@ -385,7 +385,8 @@
"choices_missing_error": "Vous ne pouvez pas retirer les choix. Les choix suivants sont manquants : {{choices}}",
"key_not_changed": "Changer la clé pour quelque chose d'autre.",
"delete_confirm": "Supprimer le champ {{name}} ?",
"description": "<p>Ici vous pouvez ajouter des champs personnalisés supplémentaires à vos entités.</p><p>Une fois qu'un champ est ajouté, vous ne pouvez plus changer sa clé ou son type, et pour les champs de types \"Choix\", vous ne pouvez plus retirer un choix ou changer l'état des choix multiples. Si vous supprimez un champ, les données associées aux entités seront supprimées.</p><p>La clé correspond à ce que les autres programmes lisent/écrivent, donc si votre champs doit s'intégrer avec un programme tiers, assurez-vous de le configurer correctement. La valeur par défaut n'est appliquées qu'aux nouveaux éléments.</p><p>Les champs supplementaires ne peuvent pas être triés ou filtrés dans les vues de table.</p>"
"description": "<p>Ici vous pouvez ajouter des champs personnalisés supplémentaires à vos entités.</p><p>Une fois qu'un champ est ajouté, vous ne pouvez plus changer sa clé ou son type, et pour les champs de types \"Choix\", vous ne pouvez plus retirer un choix ou changer l'état des choix multiples. Si vous supprimez un champ, les données associées aux entités seront supprimées.</p><p>La clé correspond à ce que les autres programmes lisent/écrivent, donc si votre champs doit s'intégrer avec un programme tiers, assurez-vous de le configurer correctement. La valeur par défaut n'est appliquées qu'aux nouveaux éléments.</p><p>Les champs supplémentaires peuvent être affichés, triés ou filtrés dans les vues de table. Les champs de type \"Choix\" et booléen offrent également des options de filtrage, et tous les types de champs supplémentaires prennent en charge le filtrage des valeurs vides. </p>",
"choices_reorder_hint": "Glissez, ou utilisez les touches fléchées, pour réorganiser ce choix."
},
"header": "Paramètres",
"general": {
@@ -398,8 +399,8 @@
"tooltip": "La base de l'URL à utiliser lors de la génération de QR codes."
},
"round_prices": {
"label": "",
"tooltip": ""
"label": "Arrondir les prix",
"tooltip": "Arrondir les prix au nombre entier le plus proche."
}
}
},
@@ -408,5 +409,12 @@
"new_location": "Créer un emplacement",
"no_location": "Pas de localisation",
"no_locations_help": "Cette page vous permet d'organiser vos bobines par lieu, ajoutez une bobine pour commencer !"
}
},
"theme": {
"label": "Thème",
"system": "Système",
"light": "Clair",
"dark": "Sombre"
},
"sponsor": "Soutenez-moi sur GitHub"
}
+7 -1
View File
@@ -354,7 +354,8 @@
"non_unique_key_error": "Raktas turi būti unukalus.",
"key_not_changed": "Prašome pakeisti raktą į ką nors kitą.",
"delete_confirm": "Šalinti lauką {{name}}?",
"delete_confirm_description": "Tai ištrins lauką ir visus susijusius duomenis visiems objektams."
"delete_confirm_description": "Tai ištrins lauką ir visus susijusius duomenis visiems objektams.",
"choices_reorder_hint": "Temkite arba naudokite rodyklių klavišus, kad pakeistumėte eilės tvarką."
}
},
"documentTitle": {
@@ -396,5 +397,10 @@
"new_location": "Nauja vieta",
"no_location": "Be vietos",
"no_locations_help": "Šiame puslapyje galite tvarkyti rites pagal vietas, pridėkite keletą ričių, kad pradėtumėte!"
},
"theme": {
"label": "Tema",
"system": "Sistema",
"dark": "Tamsus"
}
}
+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"
}
+39 -31
View File
@@ -8,7 +8,7 @@
},
"buttons": {
"edit": "Edytuj",
"unArchive": "Odarchiwizuj",
"unArchive": "Przywróć",
"cancel": "Anuluj",
"create": "Utwórz",
"save": "Zapisz",
@@ -56,7 +56,7 @@
"first_used": "Pierwsze użycie",
"last_used": "Ostatnie użycie",
"comment": "Komentarz",
"archived": "Zarchiwizowano",
"archived": "Zarchiwizowana",
"used_weight": "Zużyta waga",
"registered": "Zarejestrowano",
"measured_weight": "Zmierzona waga",
@@ -111,7 +111,7 @@
"importProgress": "Importowanie: {{processed}}/{{total}}",
"error": "Błąd (kod statusu: {{statusCode}})",
"createError": "Wystąpił błąd podczas tworzenia {{resource}} (kod statusu: {{statusCode}})",
"saveSuccessful": "Pomyślnie zapisano!",
"saveSuccessful": "Zapisano pomyślnie!",
"validationError": "Błąd walidacji: {{error}}"
},
"loading": "Wczytywanie",
@@ -125,38 +125,38 @@
"printing": {
"generic": {
"title": "Drukowanie",
"helpMargin": "Marginesy powinny być skonfigurowane tak, aby pasowały do papieru etykietowego i drukarki, ich zmiana wpłynie na rozmiar całej siatki.",
"helpPrinterMargin": "Bezpieczna strefa powinna być ustawiona tak, aby znajdowała się jak najbliżej krawędzi papieru, na którym drukarka może drukować, zmiana tych ustawień nie wpłynie na całą siatkę.",
"helpMargin": "Marginesy powinny odpowiadać papierowi etykiet i drukarce — ich zmiana wpływa na rozmiar całej siatki.",
"helpPrinterMargin": "Strefę bezpieczną ustaw na to, jak blisko krawędzi papieru drukarka jeszcze drukuje. Zmiana tych wartości nie wpływa na całą siatkę.",
"print": "Drukuj",
"columns": "Kolumny",
"rows": "Wiersze",
"paperSize": "Rozmiar papieru",
"customSize": "Niestandardowy",
"customSize": "Własny",
"dimensions": "Wymiary",
"showBorder": "Pokaż obramowanie",
"previewScale": "Skala podglądu",
"skipItems": "Pomiń elementy",
"skipItems": "Pomiń pozycje",
"contentSettings": "Ustawienia zawartości",
"layoutSettings": "Ustawienia układu",
"verticalSpacing": "Pionowy odstęp",
"marginLeft": "Lewy margines",
"marginRight": "Prawy margines",
"marginTop": "Górny margines",
"marginBottom": "Dolny margines",
"printerMarginLeft": "Bezpieczna strefa po lewej",
"printerMarginRight": "Bezpieczna strefa po prawej",
"printerMarginTop": "Bezpieczna strefa od góry",
"verticalSpacing": "Odstęp pionowy",
"marginLeft": "Margines lewy",
"marginRight": "Margines prawy",
"marginTop": "Margines górny",
"marginBottom": "Margines dolny",
"printerMarginLeft": "Strefa bezpieczna lewo",
"printerMarginRight": "Strefa bezpieczna prawo",
"printerMarginTop": "Strefa bezpieczna góra",
"borders": {
"none": "Brak",
"border": "Obwódka",
"grid": "Siatka"
},
"description": "Dostosuj poniższe ustawienia, aby uzyskać pożądany układ wydruku. Należy pamiętać, że drukarki i system operacyjny mogą stosować własne marginesy i skalowanie, więc może być konieczne wykonanie kilku prób i błędów, zanim wszystko będzie prawidłowe. Przed wydrukowaniem etykiet przetestuj je na zwykłym papierze.",
"horizontalSpacing": "Poziomy odstęp",
"printerMarginBottom": "Bezpieczna strefa od dołu",
"description": "Dopasuj ustawienia poniżej, aby uzyskać właściwy układ wydruku. Pamiętaj, że drukarka i system mogą dodać własne marginesy i skalowanie, więc zwykle trzeba trochę poeksperymentować. Przetestuj na zwykłej kartce, zanim zujesz właściwe etykiety.",
"horizontalSpacing": "Odstęp poziomy",
"printerMarginBottom": "Strefa bezpieczna dół",
"newSetting": "Nowe",
"defaultSettings": "Domyślne",
"itemCopies": "Skopiuj element",
"itemCopies": "Kopie pozycji",
"settings": "Presety",
"saveAsImage": "Zapisz jako obraz",
"duplicateSettings": "Zduplikuj",
@@ -186,12 +186,12 @@
"template": "Wzór etykiety",
"useHTTPUrl": {
"preview": "Podgląd:",
"label": "Link kodu QR",
"label": "Link w kodzie QR",
"options": {
"default": "Domyślne",
"default": "Domyślny",
"url": "URL"
},
"tooltip": "Zostanie użyty link który będzie działał tylko gdy zostanie zeskanowany przez funkcję skanowania Spoolmana (Domyślna opcja). Link URL używa bazowego URL ustawionego w ustawieniach lub aktualnego URL strony jeżeli nie jest ustawiony w ustawieniach."
"tooltip": "Użyje zastrzeżonego linku, który zadziała tylko po zeskanowaniu za pomocą funkcji skanowania w Spoolman (domyślnie). Opcja URL używa podstawowego adresu URL określonego w ustawieniach lub, jeśli nie jest ustawiony, adresu URL bieżącej strony."
},
"showQRCode": "Drukuj kod QR",
"showQRCodeMode": {
@@ -199,7 +199,7 @@
"withIcon": "Z ikoną",
"simple": "Proste"
},
"templateHelp": "Użyj {}, aby wstawić wartości obiektu szpuli jako tekst. Na przykład {id} zostanie zastąpione identyfikatorem szpuli lub {filament.material} zostanie zastąpione materiałem szpuli. jeśli brakuje wartości, zostanie ona zastąpiona „?”. Drugi zestaw {} może być użyty do usunięcia tego. Ponadto każdy tekst między zestawami {} zostanie usunięty, jeśli brakuje wartości. Na przykład {Lot Nr: {lot_nr}} pokaże etykietę tylko wtedy, gdy szpula ma numer partii. Otocz tekst podwójną gwiazdką **, aby go pogrubić. Kliknij przycisk, aby wyświetlić listę wszystkich dostępnych tagów."
"templateHelp": "Użyj {} do wprowadzenia wartości elementu szpuli jako tekst. Na przykład, {id} zostanie zastąpione przez id szpuli lub {filament.material} zostanie zastąpione przez materiał szpuli. Jeżeli brakuje wartości, to zostanie ona zastąpiona znakiem \"?\". Drugi zestaw {} można użyć, aby usunąć brakujące wartości. Dodatkowo każdy tekst między znakami {} będzie usunięty jeżeli brakuje wartości. Na przykład {Numer partii: {lot_nr}} pokaże etykietę wtedy gdy szpula będzie miała numer partii. Otocz tekst gwiazdkami by go pogrubić. Kliknij na przycisk by zobaczyć listę dostępnych znaczników."
},
"spoolSelect": {
"title": "Wybierz szpule",
@@ -216,13 +216,13 @@
"title": "Skaner kodów QR",
"error": {
"notAllowed": "Nie zezwolono na dostęp do kamery.",
"insecureContext": "Strona nie jest dostarczana przez HTTPS.",
"streamApiNotSupported": "Przeglądarka nie obsługuje API MediaStream.",
"insecureContext": "Strona nie jest serwowana przez HTTPS.",
"streamApiNotSupported": "Przeglądarka nie obsługuje MediaStream API.",
"notReadable": "Kamera jest niedostępna do odczytu.",
"notFound": "Nie znaleziono kamery.",
"unknown": "Wystąpił nieznany błąd. ({{error}})"
},
"description": "Zeskanuj kod QR Spoolman, aby wyświetlić szczegółowe informacje o szpuli."
"description": "Zeskanuj kod QR Spoolmana, aby zobaczyć szczegóły szpuli."
},
"filament": {
"filament": "Filamenty",
@@ -248,7 +248,7 @@
"single_color": "Pojedynczy",
"multi_color": "Wielo",
"longitudinal": "Wzdłużny",
"coaxial": "Koekstrudowany"
"coaxial": "Współwytłaczany"
},
"fields_help": {
"material": "Np. PLA, ABS, PETG itp.",
@@ -348,7 +348,7 @@
"help": "Pomoc",
"description": "<title>Pomoc</title><p>Oto kilka wskazówek na początek.</p><p>Spoolman przechowuje 3 różne typy danych:</p><itemsHelp/><p>Aby dodać nową szpulę do bazy danych, zacznij od utworzenia obiektu <filamentCreateLink>Filament</filamentCreateLink>. Gdy już to zrobisz, przejdź do utworzenia obiektu <spoolCreateLink>Szpula</spoolCreateLink> dla tej konkretnej szpuli. Jeśli później zdobędziesz dodatkowe szpule tego samego filamentu, po prostu wygeneruj dodatkowe obiekty Szpula i ponownie użyj tego samego obiektu Filament.</p><p>Opcjonalnie możesz także wygenerować obiekt <vendorCreateLink>Producent</vendorCreateLink> dla firmy produkującej filament, jeśli chcesz śledzić te informacje.</p><p>Masz możliwość połączenia innych usług drukarek 3D ze Spoolmanem, takich jak Moonraker, który może automatycznie monitorować zużycie filamentu i aktualizować obiekty Szpula. Wskazówki dotyczące konfiguracji można znaleźć w <readmeLink>Spoolman README</readmeLink>.</p>"
},
"kofi": "Daj napiwek na Ko-fi",
"kofi": "Wesprzyj mnie na Ko-fi",
"home": {
"home": "Strona główna",
"welcome": "Witaj w instancji Spoolman!",
@@ -395,11 +395,12 @@
"boolean": "Wartość logiczna"
},
"non_unique_key_error": "Klucz musi być unikalny.",
"description": "<p>Tutaj możesz dodać dodatkowe pola niestandardowe do swoich podmiotów.</p><p>Po dodaniu pola nie można zmienić jego klucza ani typu, a w przypadku pól typu wyboru nie można usunąć wyborów ani zmienić stanu wielokrotnego wyboru. Jeśli usuniesz pole, powiązane dane dla wszystkich podmiotów zostaną usunięte.</p><p>Klucz jest tym, co inne programy odczytują/zapisują jako dane, więc jeśli twoje niestandardowe pole ma być zintegrowane z programem innej firmy, upewnij się, że ustawiłeś je poprawnie. Wartość domyślna jest stosowana tylko do nowych elementów.</p><p>Dodatkowych pól nie można sortować ani filtrować w widokach tabeli.</p>",
"description": "<p>Tutaj możesz dodać dodatkowe pola niestandardowe do swoich encji.</p><p>Po dodaniu pola nie można zmienić jego klucza ani typu, a w przypadku pól typu „wybór” nie można usuwać opcji ani zmieniać ustawienia wielu wyborów. Usunięcie pola spowoduje usunięcie powiązanych danych dla wszystkich encji.</p><p>Klucz jest wartością, którą inne programy odczytują i zapisują jako dane, więc jeśli Twoje pole niestandardowe ma integrować się z programem zewnętrznym, upewnij się, że jest ustawione poprawnie. Wartość domyślna jest stosowana tylko do nowych elementów.</p><p>Pola dodatkowe można wyświetlać i sortować w widokach tabeli. Pola typu „wybór” i logiczne (boolean) udostępniają tam również opcje filtrowania, a wszystkie typy pól dodatkowych obsługują filtrowanie wartości pustych.</p>",
"choices_missing_error": "Nie możesz usunąc możliwośći wyboru. Ten wybór nie istnieje: {{choices}}",
"key_not_changed": "Zmień klucz na inny.",
"delete_confirm": "Usunąć pole {{name}}?",
"delete_confirm_description": "Ta akcja usunie wszystkie pola i powiązane z nimi dane dla wszystkich obiektów."
"delete_confirm_description": "Ta akcja usunie wszystkie pola i powiązane z nimi dane dla wszystkich obiektów.",
"choices_reorder_hint": "Przeciągnij lub użyj klawiszy strzałek, aby zmienić kolejność tej opcji."
},
"header": "Ustawienia"
},
@@ -408,5 +409,12 @@
"new_location": "Nowa lokalizacja",
"no_location": "Brak lokalizacji",
"no_locations_help": "Ta strona pozwala uporządkować szpule w lokalizacjach, dodaj kilka szpul aby rozpocząć!"
}
},
"theme": {
"label": "Motyw",
"system": "System",
"light": "Jasny",
"dark": "Ciemny"
},
"sponsor": "Wesprzyj mnie na GitHubie"
}
+10 -3
View File
@@ -376,15 +376,16 @@
"boolean": "Booleano",
"choice": "Opção"
},
"key_not_changed": "Por favor, mude a chave por outra.",
"key_not_changed": "Por favor, altere a chave para algo diferente.",
"delete_confirm": "Apagar o campo {{name}}?",
"delete_confirm_description": "Isto irá apagar o campo e todos os dados associados em todas as entidades.",
"delete_confirm_description": "Isto irá apagar o campo e todos os dados associados a todas as entidades.",
"description": "<p>Aqui pode adicionar campos personalizados extras às suas entidades.</p><p>Uma vez que um campo é adicionado, não se pode alterar sua chave ou tipo, e para campos do tipo \"opção\" não se pode remover as opções ou alterar o estado da múltipla opção. Se remover um campo, os dados associados para todas as entidades serão excluídos.</p><p>A chave é como outros programas leem/gravam os dados, portanto, se o seu campo personalizado se destina a integrar com um programa de terceiros, certifique-se de defini-lo corretamente. O valor padrão só é aplicado a novos itens.</p><p>Campos extras não podem ser ordenados ou filtrados nas visualizações de tabela.</p>",
"choices_missing_error": "Não pode remover as opções. As seguintes opções serão perdidas: {{choices}}",
"tab": "Campos Extras",
"boolean_true": "Sim",
"boolean_false": "Não",
"non_unique_key_error": "A chave deve ser única."
"non_unique_key_error": "A chave deve ser única.",
"choices_reorder_hint": "Arraste ou use as setas para reordenar esta escolha."
},
"settings": "Configurações",
"header": "Configurações",
@@ -408,5 +409,11 @@
"new_location": "Novo Local",
"no_location": "Sem Local",
"no_locations_help": "Esta página permite organizar as suas bobines em locais, adicione algumas bobines para começar!"
},
"theme": {
"label": "Tema",
"system": "Sistema",
"light": "Claro",
"dark": "Escuro"
}
}
+5 -3
View File
@@ -356,7 +356,7 @@
},
"settings": {
"extra_fields": {
"description": "<p>Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.</p><p>Как только поле добавлено, вы не можете изменить его ключ или тип, а для полей типа выбора вы не можете удалить варианты или изменить состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.</p><p>Ключевым моментом является то, как другие программы считывают/записывают данные, поэтому, если предполагается, что ваше пользовательское поле интегрируется со сторонней программой, убедитесь, что оно настроено правильно. Значение по умолчанию применяется только к новым элементам.</p><p>Дополнительные поля нельзя отсортировать или отфильтровать в табличных представлениях.</p>",
"description": "<p>Здесь вы можете добавить дополнительные пользовательские поля к сущностям.</p><p>После добавления поля нельзя изменить его ключ или тип, а для полей выбора нельзя удалять варианты или изменять состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.</p><p>Ключ используется другими программами для чтения и записи данных, поэтому если ваше пользовательское поле должно интегрироваться со сторонней программой, убедитесь, что задали его правильно. Значение по умолчанию применяется только к новым элементам.</p><p>Дополнительные поля можно отображать и сортировать в табличных представлениях. Поля выбора и логические поля также предоставляют там параметры фильтрации, а все типы дополнительных полей поддерживают фильтрацию по пустым значениям.</p>",
"tab": "Дополнительные поля",
"params": {
"key": "Ключ",
@@ -384,7 +384,8 @@
"delete_confirm_description": "Это приведет к удалению поля и всех связанных с ним данных для всех объектов.",
"boolean_true": "Да",
"boolean_false": "Нет",
"choices_missing_error": "Вы не можете удалить варианты. Отсутствуют следующие варианты: {{choices}}"
"choices_missing_error": "Вы не можете удалить варианты. Отсутствуют следующие варианты: {{choices}}",
"choices_reorder_hint": "Перетащите или используйте стрелки."
},
"header": "Настройки",
"general": {
@@ -414,5 +415,6 @@
"system": "Как в системе",
"light": "Светлая",
"dark": "Тёмная"
}
},
"sponsor": "Поддержать меня на 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"
}
}
+37 -30
View File
@@ -31,7 +31,7 @@
"create": "สร้างม้วนพลาสติก",
"adjust": "ปรับการใช้ม้วนพลาสติก",
"edit": "แก้ไขม้วนพลาสติก",
"list": "รายการม้วนพลาสติก",
"list": "ม้วนพลาสติก",
"show": "แสดงม้วนพลาสติก"
},
"form": {
@@ -175,10 +175,10 @@
},
"filament": {
"fields_help": {
"spool_weight": "น้ำหนักม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักรวมของม้วนพลาสติก",
"spool_weight": "น้ำหนักของม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักที่วัดได้ของม้วนพลาสติก",
"material": "ตัวอย่างเช่น PLA, ABS, PETG, TPU, ASA, PC, PA เป็นต้น",
"article_number": "เช่น EAN, UPC, เป็นต้น",
"price": "ราคาของม้วนพลาสติกเต็ม (เส้นพลาสติกทั้งหมด)",
"price": "ราคาของม้วนพลาสติกเต็ม",
"weight": "น้ำหนักของเส้นพลาสติกในม้วนพลาสติก (สุทธิ) ไม่รวมม้วนพลาสติกเปล่า เป็นค่าน้ำหนักที่มักระบุบนบรรจุภัณฑ์",
"name": "ชื่อเส้นพลาสติก เพื่อแยกความต่างระหว่างเส้นพลาสติกแบบเดียวกันจากผู้ผลิตเดียวกัน โดยทั่วไปควรระบุสีในชื่อด้วย",
"multi_color_direction": "เส้นพลาสติกหลายสีสามารถมีหลายสีได้ 2 รูปแบบ: แบบ coextrusion (เช่น เส้นพลาสติกสองสีพร้อมกัน) หรือแบบไล่สีตามความยาว (เช่น เส้นพลาสติกเปลี่ยนสีไปเรื่อยๆ)"
@@ -193,15 +193,15 @@
"material": "วัสดุ",
"id": "ID",
"settings_bed_temp": "อุณหภูมิฐานพิมพ์",
"vendor_name": "ยี่ห้อ",
"vendor_name": "ผู้ผลิต",
"diameter": "เส้นผ่านศูนย์กลาง",
"article_number": "เลขบทความ",
"article_number": "รหัสสินค้า",
"multi_color": "หลายสี",
"external_id": "รหัสภายนอก",
"color_hex": "สี",
"price": "ราคา",
"spool_weight": "น้ำหนักม้วนพลาสติกเปล่า",
"coaxial": "เส้นหลายสี",
"coaxial": "เส้นประกบหลายสี",
"name": "ชื่อ",
"spools": "แสดงม้วนพลาสติก",
"registered": "ลงทะเบียน",
@@ -209,7 +209,7 @@
},
"titles": {
"edit": "แก้ไขเส้นพลาสติก",
"list": "รายการเส้นพลาสติก",
"list": "เส้นพลาสติก",
"show": "แสดงเส้นพลาสติก",
"clone": "ทำซ้ำเส้นพลาสติก",
"show_title": "[เส้นพลาสติก #{{id}}] {{name}}",
@@ -218,7 +218,7 @@
"form": {
"filament_updated": "เส้นพลาสติกนี้ได้รับการอัปเดตโดยผู้อื่นหลังจากที่คุณเปิดหน้านี้ การบันทึกจะเขียนทับการเปลี่ยนแปลง!",
"import_external": "นำเข้าจากภายนอก",
"import_external_description": "เลือกเส้นพลาสติกในรายการเพื่อกรอกข้อมูลอัตโนมัติในฟอร์มนี้ การดำเนินการนี้จะเขียนทับข้อมูลที่คุณกรอกไว้ อ็อบเจ็กต์ผู้ผลิตจะถูกสร้างขึ้นโดยอัตโนมัติเมื่อคุณคลิกตกลง"
"import_external_description": "เลือกเส้นพลาสติกในรายการเพื่อกรอกข้อมูลอัตโนมัติในฟอร์มนี้ การดำเนินการนี้จะเขียนทับข้อมูลที่คุณกรอกไว้<br><br>อ็อบเจ็กต์ผู้ผลิตจะถูกสร้างขึ้นโดยอัตโนมัติเมื่อคุณคลิกตกลง หากจำเป็น"
},
"buttons": {
"add_spool": "เพิ่มม้วนพลาสติก"
@@ -270,7 +270,7 @@
"locations": {
"list": "สถานที่เก็บ | Spoolman"
},
"default": "สปูลแมน"
"default": "Spoolman"
},
"notifications": {
"error": "เกิดข้อผิดพลาด (รหัสสถานะ: {{statusCode}})",
@@ -296,12 +296,12 @@
},
"vendor": {
"titles": {
"create": "สร้างผู้ผลิต/ยี่ห้อ",
"list": "รายการผู้ผลิต/ยี่ห้อ",
"clone": "ทำซ้ำผู้ผลิต/ยี่ห้อ",
"create": "สร้างผู้ผลิต",
"list": "ผู้ผลิต",
"clone": "ทำซ้ำผู้ผลิต",
"show_title": "[ผู้ผลิต #{{id}}] {{name}}",
"edit": "แก้ไขผู้ผลิต/ยี่ห้อ",
"show": "แสดงผู้ผลิต/ยี่ห้อ"
"edit": "แก้ไขผู้ผลิต",
"show": "แสดงผู้ผลิต"
},
"fields": {
"empty_spool_weight": "น้ำหนักม้วนพลาสติกเปล่า",
@@ -312,21 +312,21 @@
"name": "ชื่อ"
},
"fields_help": {
"empty_spool_weight": "น้ำหนักม้วนพลาสติกเปล่าของผู้ผลิตนี้"
"empty_spool_weight": "น้ำหนักของม้วนพลาสติกเปล่าจากผู้ผลิตนี้"
},
"form": {
"vendor_updated": "ผู้ผลิตนี้ถูกอัปเดตโดยผู้อื่นหลังจากที่คุณเปิดหน้านี้ การบันทึกจะเขียนทับการเปลี่ยนแปลง!"
"vendor_updated": "ผู้ผลิตนี้ได้รับการอัปเดตโดยผู้อื่นหลังจากที่คุณเปิดหน้านี้ การบันทึกจะเขียนทับการเปลี่ยนแปลง!"
},
"vendor": "ผู้ผลิต"
},
"help": {
"resources": {
"vendor": "บริษัทที่ผลิตเส้นพลาสติก",
"spool": "ม้วนพลาสติกจริงสำหรับเส้นพลาสติกเฉพาะ",
"filament": "แบรนด์เส้นพลาสติก มีคุณสมบัติต่างๆ เช่น ชื่อ วัสดุ สี เส้นผ่านศูนย์กลาง และอื่นๆ"
"vendor": "บริษัทที่เป็นผู้ผลิตเส้นพลาสติก",
"spool": "ม้วนพลาสติกจริงของเส้นพลาสติกเฉพาะแต่ละม้วน",
"filament": "แบรนด์ของเส้นพลาสติก ซึ่งมีคุณสมบัติต่างๆ เช่น ชื่อ วัสดุ สี เส้นผ่านศูนย์กลาง และอื่นๆ"
},
"help": "วิธีใช้",
"description": "<title>วิธีใช้</title><p>นี่คือเคล็ดลับบางส่วนเพื่อเริ่มต้น</p><p>Spoolman เก็บข้อมูลหลัก 3 ประเภท:</p><itemsHelp/><p>ในการเพิ่มม้วนพลาสติกใหม่ลงในฐานข้อมูล ให้เริ่มด้วยการสร้างอ็อบเจ็กต์ <filamentCreateLink>เส้นพลาสติก</filamentCreateLink> เมื่อเสร็จแล้วจึงสร้างอ็อบเจ็กต์ <spoolCreateLink>ม้วนพลาสติก</spoolCreateLink> สำหรับม้วนพลาสติกนั้น หากคุณได้ม้วนพลาสติกเพิ่มในภายหลัง ก็เพียงสร้างอ็อบเจ็กต์ม้วนพลาสติกใหม่และใช้วัตถุ เส้นพลาสติก เดิมได้</p><p>คุณสามารถสร้างอ็อบเจ็กต์ <vendorCreateLink>Manufacturer</vendorCreateLink> เพื่อเก็บข้อมูลบริษัทผู้ผลิตเส้นพลาสติกหากคุณต้องการติดตามข้อมูลนั้น</p><p>คุณสามารถเชื่อมต่อบริการ 3D printer อื่นๆ เข้ากับ Spoolman เช่น Moonraker เพื่อบันทึกปริมาณการใช้เส้นพลาสติกโดยอัตโนมัติและอัปเดตอ็อบเจ็กต์ม้วนพลาสติกของคุณ ดูเพิ่มเติมได้ที่ <readmeLink>Spoolman README</readmeLink></p>"
"description": "<title>วิธีใช้</title><p>นี่คือเคล็ดลับบางส่วนเพื่อเริ่มต้นใช้งาน</p><p>Spoolman เก็บข้อมูล 3 ประเภทที่แตกต่างกัน:</p><itemsHelp/><p>ในการเพิ่มม้วนพลาสติกใหม่ลงในฐานข้อมูล ให้เริ่มด้วยการสร้างอ็อบเจ็กต์ <filamentCreateLink>Filament</filamentCreateLink> เมื่อเสร็จแล้วจึงสร้างอ็อบเจ็กต์ <spoolCreateLink>Spool</spoolCreateLink> สำหรับม้วนพลาสติกนั้น หากคุณได้ม้วนพลาสติกของเส้นพลาสติกเดิมเพิ่มในภายหลัง ก็เพียงสร้างอ็อบเจ็กต์ Spool ใหม่และใช้อ็อบเจ็กต์ Filament เดิมซ้ำได้</p><p>นอกจากนี้คุณยังสามารถสร้างอ็อบเจ็กต์ <vendorCreateLink>Manufacturer</vendorCreateLink> สำหรับบริษัทที่ผลิตเส้นพลาสติกได้หากต้องการติดตามข้อมูลนั้น</p><p>คุณสามารถเชื่อมต่อบริการเครื่องพิมพ์ 3D อื่นๆ เข้ากับ Spoolman เช่น Moonraker ซึ่งสามารถตรวจสอบการใช้เส้นพลาสติกโดยอัตโนมัติและอัปเดตอ็อบเจ็กต์ Spool ให้คุณ ดูคำแนะนำในการตั้งค่าได้ที่ <readmeLink>Spoolman README</readmeLink></p>"
},
"dashboard": {
"title": "แดชบอร์ด"
@@ -340,17 +340,17 @@
"label": "สกุลเงิน"
},
"base_url": {
"label": "URLพื้นฐาน",
"tooltip": "URLพื้นฐาน ที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code"
"label": "URL พื้นฐาน",
"tooltip": "URL พื้นฐานที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code"
},
"round_prices": {
"label": "",
"tooltip": ""
"label": "ปัดเศษราคา",
"tooltip": "ปัดเศษราคาเป็นจำนวนเต็มที่ใกล้ที่สุด"
}
},
"extra_fields": {
"params": {
"multi_choice": "เลือกหลายตัว",
"multi_choice": "เลือกได้หลายรายการ",
"key": "คีย์",
"name": "ชื่อ",
"field_type": "ประเภท",
@@ -359,7 +359,7 @@
"default_value": "ค่าเริ่มต้น",
"choices": "ตัวเลือก"
},
"delete_confirm_description": "สิ่งนี้จะลบฟิลด์และข้อมูลที่เกี่ยวข้องทั้งหมดสำหรับเอนทิตี",
"delete_confirm_description": "สิ่งนี้จะลบฟิลด์และข้อมูลที่เกี่ยวข้องทั้งหมดของทุกเอนทิตี",
"field_type": {
"text": "ข้อความ",
"integer": "จำนวนเต็ม",
@@ -367,7 +367,7 @@
"float": "ทศนิยม",
"float_range": "ช่วงทศนิยม",
"datetime": "วันเวลา",
"boolean": "บูลีน(ใส่เฉพาะ ใช่/ไม่)",
"boolean": "บูลีน",
"choice": "ตัวเลือก"
},
"tab": "ฟิลด์เพิ่มเติม",
@@ -377,7 +377,8 @@
"non_unique_key_error": "คีย์ต้องไม่ซ้ำ",
"key_not_changed": "โปรดเปลี่ยนคีย์เป็นอย่างอื่น",
"delete_confirm": "ลบฟิลด์ {{name}} หรือไม่?",
"description": "<p>คุณสามารถเพิ่มฟิลด์ที่กำหนดเองเพิ่มเติมสำหรับเอนทิตีของคุณได้ที่นี่</p><p>เมื่อเพิ่มฟิลด์แล้ว คุณจะไม่สามารถเปลี่ยนคีย์หรือประเภทของฟิลด์ได้ และสำหรับฟิลด์ประเภทตัวเลือกคุณจะไม่สามารถลบตัวเลือกหรือเปลี่ยนโหมด multi choice ได้ หากคุณลบฟิลด์ ข้อมูลของเอนทิตีทั้งหมดจะถูกลบด้วย</p><p>คีย์คือสิ่งที่โปรแกรมอื่นจะใช้เพื่ออ่าน/เขียนข้อมูล ดังนั้นหากฟิลด์ที่คุณสร้างเพื่อเชื่อมต่อกับโปรแกรมอื่น กรุณาตั้งค่าให้ถูกต้อง ค่าดีฟอลต์จะถูกนำไปใช้เฉพาะกับรายการใหม่เท่านั้น</p><p>ฟิลด์เพิ่มเติมไม่สามารถเรียงหรือกรองได้ในตาราง</p>"
"description": "<p>คุณสามารถเพิ่มฟิลด์ที่กำหนดเองเพิ่มเติมสำหรับเอนทิตีของคุณได้ที่นี่</p><p>เมื่อเพิ่มฟิลด์แล้ว คุณจะไม่สามารถเปลี่ยนคีย์หรือประเภทของฟิลด์ได้ และสำหรับฟิลด์ประเภทตัวเลือกคุณจะไม่สามารถลบตัวเลือกหรือเปลี่ยนสถานะตัวเลือกหลายรายการได้ หากคุณลบฟิลด์ ข้อมูลที่เกี่ยวข้องของทุกเอนทิตีจะถูกลบไปด้วย</p><p>คีย์คือสิ่งที่โปรแกรมอื่นจะใช้เพื่ออ่าน/เขียนข้อมูล ดังนั้นหากฟิลด์ที่คุณสร้างต้องการเชื่อมต่อกับโปรแกรมภายนอก กรุณาตั้งค่าให้ถูกต้อง ค่าเริ่มต้นจะถูกนำไปใช้เฉพาะกับรายการใหม่เท่านั้น</p><p>ฟิลด์เพิ่มเติมสามารถแสดงและจัดเรียงในมุมมองตารางได้ ฟิลด์ประเภทตัวเลือกและบูลีนยังมีตัวเลือกการกรองที่นั่นด้วย และฟิลด์เพิ่มเติมทุกประเภทรองรับการกรองหาค่าว่าง</p>",
"choices_reorder_hint": "ลาก หรือใช้ปุ่มลูกศร เพื่อจัดเรียงตัวเลือกนี้"
}
},
"locations": {
@@ -388,11 +389,17 @@
},
"home": {
"home": "หน้าแรก",
"welcome": "",
"description": ""
"welcome": "ยินดีต้อนรับสู่ Spoolman ของคุณ!",
"description": "ดูเหมือนว่าคุณยังไม่ได้เพิ่มม้วนพลาสติกใดๆ ดูคำแนะนำการเริ่มต้นใช้งานได้ที่ <helpPageLink>หน้าวิธีใช้</helpPageLink>"
},
"warnWhenUnsavedChanges": "คุณแน่ใจหรือไม่ว่าต้องการออก? คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก",
"table": {
"actions": "การดำเนินการ"
},
"theme": {
"label": "ธีม",
"system": "ตามระบบ",
"light": "โหมดสว่าง",
"dark": "โหมดมืด"
}
}
+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 上贊助專案"
}
+69 -61
View File
@@ -21,10 +21,10 @@
"undo": "撤销",
"import": "导入",
"clone": "克隆",
"archive": "存档",
"unArchive": "取消存档",
"archive": "归档",
"unArchive": "解除归档",
"hideArchived": "隐藏存档",
"showArchived": "显示存档",
"showArchived": "显示已归档项",
"notAccessTitle": "您没有访问权限",
"hideColumns": "隐藏列",
"clearFilters": "清除筛选器",
@@ -34,7 +34,7 @@
"warnWhenUnsavedChanges": "您确定要离开吗?您有未保存的更改。",
"notifications": {
"success": "操作成功",
"error": "出现错误 (状态码:{{statusCode}})",
"error": "发生错误 (状态码:{{statusCode}})",
"undoable": "您有 {{seconds}} 秒可以撤销。",
"createSuccess": "成功创建 {{resource}}",
"createError": "创建 {{resource}} 时出现错误 (状态码:{{statusCode}})",
@@ -60,30 +60,30 @@
"printing": {
"generic": {
"title": "打印中",
"description": "调整以下设置以获得所需的打印布局。请记住,打印机和您的操作系统可能会应用自己的边距和缩放,因此您可能需要多次尝试后才能正确打印。在实际标签上打印之前,请在一张普通纸上进行测试。",
"helpMargin": "页边距应设置为与您的标签纸和打印机相匹配,更改这些将影响整个网格的大小。",
"helpPrinterMargin": "安全区应设置为打印机可以打印的离纸张边缘距离,更改这些不会影响整个网格。",
"description": "在下方微调设置以获取理想的打印布局。请注意,打印机驱动和操作系统可能会自动应用边距与缩放,因此您可能需要进行几次试打印。建议在正式打印前先用普通纸测试。",
"helpMargin": "页边距应与您的标签纸和打印机相匹配,修改此项会影响整个标签网格的位置。",
"helpPrinterMargin": "安全打印区域应设置为打印机允许贴近纸张边缘打印的极限距离,修改此项不会改变标签网格的位置。",
"print": "打印",
"columns": "行",
"columns": "列数",
"rows": "列",
"paperSize": "纸张尺寸",
"customSize": "自定义",
"dimensions": "尺寸标注",
"customSize": "自定义尺寸",
"dimensions": "尺寸规格",
"showBorder": "显示边框",
"previewScale": "预览比例",
"skipItems": "跳过项目",
"skipItems": "跳过初始格子数",
"contentSettings": "内容设置",
"layoutSettings": "布局设置",
"horizontalSpacing": "水平间距",
"verticalSpacing": "垂直间距",
"marginLeft": "左边界",
"marginRight": "右边界",
"marginTop": "上边界",
"marginBottom": "下边界",
"printerMarginLeft": "安全区左侧",
"printerMarginRight": "安全区右侧",
"printerMarginTop": "安全区顶部",
"printerMarginBottom": "安全区底部",
"horizontalSpacing": "列间距 (水平)",
"verticalSpacing": "行间距 (垂直)",
"marginLeft": "左边距",
"marginRight": "右边距",
"marginTop": "上边距",
"marginBottom": "下边距",
"printerMarginLeft": "左侧安全区域",
"printerMarginRight": "右侧安全区域",
"printerMarginTop": "顶部安全区域",
"printerMarginBottom": "底部安全区域",
"borders": {
"none": "无",
"border": "外边框",
@@ -97,7 +97,7 @@
"deleteSettingsConfirm": "确定删除此预设吗?",
"saveAsImage": "保存为图片",
"newSetting": "新增",
"itemCopies": "复制项目",
"itemCopies": "单个标签打印份数",
"duplicateSettings": "复制",
"deleteSettings": "删除当前预设"
},
@@ -127,11 +127,11 @@
},
"templateHelp": "使用 {} 将料盘的值插入为文本。例如,{id} 将被替换为料盘的ID,或者 {filament.material} 将被替换为料盘的材质。如果某个值缺失,将用 \"?\" 替代。可以使用第二组 {} 来删除缺失值相关的内容。此外,任意位于两组 {} 之间的文本,如果值缺失,将被一同删除。例如,{Lot Nr: {lot_nr}} 只有在料盘存在批号时才会显示标签内容。用双星号 ** 包裹文本可以将其加粗。点击按钮查看所有可用的标签列表。",
"useHTTPUrl": {
"tooltip": "将使用专有链接(默认情况下,仅能通过 **Spoolman** 的扫描功能访问)。URL 将使用设置中指定的基础 URL;如果未设置基础 URL,则使用当前页面的 URL。",
"label": "二维码链接",
"tooltip": "将使用专有链接,该链接仅在通过 Spoolman 的内置扫描功能扫描时生效(默认)。URL 将使用设置中指定的基准 URL;若未设置,则使用当前页面的 URL。",
"label": "二维码编码内容",
"options": {
"default": "默认",
"url": "URL"
"default": "默认(Spoolman 专属格式)",
"url": "标准网页 URL"
},
"preview": "预览:"
}
@@ -142,42 +142,42 @@
"showArchived": "显示存档",
"noSpoolsSelected": "您尚未选择任何料盘。",
"selectAll": "选择/取消选择全部",
"selectedTotal_other": "已选择{{count}}个料盘"
"selectedTotal_other": "已选择 {{count}} 卷耗材"
}
},
"scanner": {
"title": "扫码二维码",
"description": "扫描Spoolman二维码,查看有关料盘的详细信息。",
"title": "二维码扫描器",
"description": "扫描 Spoolman 专属二维码以快速查看该耗材卷的详细信息。",
"error": {
"notAllowed": "您不被允许访问相机。",
"insecureContext": "该页面不是通过HTTPS传输。",
"streamApiNotSupported": "浏览器不支持MediaStream API。",
"notAllowed": "您尚未授权允许使用摄像头。",
"insecureContext": "当前页面未通过安全的 HTTPS 协议提供服务。",
"streamApiNotSupported": "当前浏览器不支持 MediaStream API 视频流。",
"notReadable": "相机不可读。",
"notFound": "没有找到相机。",
"unknown": "发生未知错误。({{error}})"
"notFound": "未检测到可用摄像头。",
"unknown": "发生未知错误。({{error}})"
}
},
"spool": {
"spool": "料盘",
"spool": "耗材卷",
"fields": {
"id": "编号",
"id": "编号 (ID)",
"filament_name": "耗材",
"filament": "耗材",
"material": "材料",
"filament": "耗材规格",
"material": "材质",
"weight_to_use": "重量",
"used_weight": "已用重量",
"remaining_weight": "剩余重量",
"measured_weight": "净重",
"used_length": "已用长度",
"remaining_length": "剩余长度",
"location": "位置",
"location": "存放位置",
"lot_nr": "批号",
"first_used": "首次使用",
"last_used": "最后使用",
"registered": "登记时间",
"registered": "录入时间",
"comment": "备注",
"archived": "存档",
"price": "价格",
"archived": "已归档",
"price": "采购价格",
"spool_weight": "空料盘重量",
"initial_weight": "初始重量",
"filament_internal": "内部",
@@ -224,20 +224,20 @@
}
},
"filament": {
"filament": "耗材",
"filament": "耗材规格",
"fields": {
"id": "编号",
"id": "ID",
"vendor_name": "制造商",
"vendor": "制造商",
"vendor": "厂商",
"name": "名称",
"material": "材料",
"material": "材质",
"price": "价格",
"density": "密度",
"diameter": "直径",
"weight": "重量",
"diameter": "线径",
"weight": "耗材净重",
"spool_weight": "料盘重量",
"article_number": "产品编号",
"registered": "登记时间",
"registered": "录入时间",
"comment": "备注",
"settings_extruder_temp": "挤出头温度",
"settings_bed_temp": "热床温度",
@@ -245,8 +245,8 @@
"spools": "显示料盘",
"external_id": "外部ID",
"multi_color": "多色",
"longitudinal": "纵向",
"coaxial": "共挤出",
"longitudinal": "纵向渐变",
"coaxial": "双色共挤",
"single_color": "单色"
},
"fields_help": {
@@ -278,9 +278,9 @@
"vendor": {
"vendor": "制造商",
"fields": {
"id": "编号",
"name": "名称",
"registered": "登记时间",
"id": "ID",
"name": "厂商名称",
"registered": "录入时间",
"comment": "备注",
"external_id": "外部ID",
"empty_spool_weight": "空料盘重量"
@@ -340,8 +340,8 @@
"kofi": "在Ko-fi上给我打赏一点小费",
"home": {
"home": "主页",
"welcome": "",
"description": ""
"welcome": "欢迎使用您的 Spoolman 实例!",
"description": "看来您尚未添加任何耗材。请参阅 <helpPageLink> 帮助页面 </helpPageLink> ,了解入门指南。"
},
"help": {
"help": "帮助",
@@ -367,7 +367,7 @@
"choice": "选项"
},
"delete_confirm_description": "这将删除该字段以及所有实体的关联数据。",
"description": "<p>这里你可以为你的实体添加额外的自定义字段。</p><p>一旦添加了字段,就无法更改其键或类型;对于选择类型字段,无法删除选项或更改多选状态。如果删除字段,则所有实体的相关数据将被删除。</p><p>键是其他程序读取/写入数据的标识,因此,如果你的自定义字段需要与第三方程序集成,请确保设置正确。默认值仅适用于新项目。</p><p>额外字段在表视图中无法进行排序或过滤。</p>",
"description": "<p>您可以在此处为不同的实体对象添加自定义扩展字段。</p><p>字段一旦添加,其键名 (Key) 和数据类型将无法修改;对于单选/多选字段,已有的选项将无法删除,也无法更改其多选状态。如果您删除某个字段,所有对应实体中存储的该字段数据都将被永久抹除。</p><p>键名 (Key) 是第三方程序读取/写入数据时使用的唯一标识。如果您需要将自定义字段与第三方程序集成,请务必设置正确的键名。默认值仅应用于新建的项目。</p><p>扩展字段可以显示在表格视图中并支持排序。单选框与布尔值字段还提供相应的筛选选项,且所有扩展字段类型均支持筛选空值。</p>",
"params": {
"name": "名称",
"field_type": "类型",
@@ -383,13 +383,14 @@
"choices_missing_error": "您无法删除选择。以下选项丢失:{{choices}}",
"non_unique_key_error": "键必须是唯一的。",
"key_not_changed": "请将键改成其他的。",
"delete_confirm": "删除字段 {{name}}?"
"delete_confirm": "删除字段 {{name}}?",
"choices_reorder_hint": "拖动或使用方向键来重新排列此选项的顺序。"
},
"header": "设置",
"header": "系统设置",
"general": {
"tab": "常规",
"tab": "常规设置",
"currency": {
"label": "货币"
"label": "货币单位"
},
"base_url": {
"label": "基础URL",
@@ -406,5 +407,12 @@
"locations": "位置",
"new_location": "新位置",
"no_location": "没有位置"
},
"sponsor": "在 GitHub 上赞助项目",
"theme": {
"label": "主题",
"system": "系统",
"light": "明亮",
"dark": "暗黑"
}
}
+10 -11
View File
@@ -3,10 +3,15 @@ import { useTranslate } from "@refinedev/core";
import { Button } from "antd";
import { Footer } from "antd/es/layout/layout";
import Logo from "../icon.svg?react";
import { getBasePath } from "../utils/url";
import { Header } from "./header";
import { Version } from "./version";
const SponsorHeart = () => (
<svg viewBox="0 0 16 16" width="1.2em" height="1.2em" fill="#db61a2" aria-hidden="true">
<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z" />
</svg>
);
const SpoolmanFooter = () => {
const t = useTranslate();
@@ -26,19 +31,13 @@ const SpoolmanFooter = () => {
</div>
<div>
<Button
icon={
<img
src={getBasePath() + "/kofi_s_logo_nolabel.png"}
style={{
height: "1.6em",
}}
/>
}
icon={<SponsorHeart />}
type="text"
href="https://ko-fi.com/donkie"
href="https://github.com/sponsors/Donkie"
target="_blank"
rel="noopener noreferrer"
>
{t("kofi")}
{t("sponsor")}
</Button>
</div>
</div>
+1
View File
@@ -3,6 +3,7 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 110,
"endOfLine": "auto",
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+51 -27
View File
@@ -31,10 +31,18 @@
"add.pickColor": "Vyberte barvu",
"add.searchPlaceholder": "Prohledejte svůj katalog a {{name}}...",
"add.searching": "Hledám...",
"add.section.filament": "Filament",
"add.section.filamentNote": "Výrobek na cívce – jedna položka na každou barvu.",
"add.section.manufacturer": "Výrobce",
"add.section.manufacturerNote": "Subjekt, který filament vyrobil. Uvedeno jednou, poté se tento údaj používá pro všechny jejich filamenty.",
"add.section.spool": "Cívka",
"add.section.spoolNote": "Fyzické cívky, které se přidávají do vašeho inventáře.",
"add.section.spoolSharedNote": "Jako výchozí hodnoty nového filamentu se uloží také hmotnost, hmotnost cívky a cena.",
"add.spoolWeightPreset.cardboard": "Karton {{weight}} g",
"add.spoolWeightPreset.plastic": "Plast {{weight}} g",
"add.spoolWeightPresetsLead": "Začněte s typickou cívkou:",
"add.step2": "Krok 2 · podrobnosti o cívce",
"add.step2New": "Krok 2 · Údaje o filamentu a cívce",
"add.summary_few": "{{count}} cívky",
"add.summary_one": "{{count}} cívka",
"add.summary_other": "{{count}} cívek",
@@ -44,7 +52,9 @@
"add.vendorHint.existing": "S využitím stávajícího výrobce “{{name}}”",
"add.vendorHint.new": "Nový výrobce “{{name}}” bude vytvořen",
"add.vendorHint.optional": "Volitelné — pokud není uveden výrobce, ponechte pole prázdné",
"add.weightPresets": "Běžné rozměry rolí",
"add.yourCatalog": "Váš katalog",
"buttons.apply": "Použít",
"buttons.archive": "Archivovat",
"buttons.cancel": "Zrušit",
"buttons.clear": "Vymazat",
@@ -54,6 +64,7 @@
"buttons.filter": "Filtr",
"buttons.save": "Uložit",
"buttons.showArchived": "Zobrazit archivované",
"buttons.showNoSpools": "Zobrazit filamenty bez cívek",
"buttons.unArchive": "Zrušit archivaci",
"changeFilament.action": "Výměna",
"changeFilament.adoptWeight": "Nastavte celkovou hmotnost této cívky na {{full}}",
@@ -65,10 +76,12 @@
"changeFilament.failed": "Nepodařilo se vyměnit filament.",
"changeFilament.importNote": "Tento filament bude přidán do vašeho katalogu, jakmile změnu potvrdíte.",
"changeFilament.intro": "Cívka č. {{id}} si zachovává své číslo, umístění, data a historii použití – mění se pouze filament, který obsahuje.",
"changeFilament.newTag": "nový",
"changeFilament.next": "Změnit na",
"changeFilament.now": "V současné době",
"changeFilament.pickBelow": "Vyberte si z níže uvedených filamentů",
"changeFilament.title": "Vyměnit filament",
"changeFilament.useExisting": "Vyberte existující filament",
"changeFilament.weightFollows": "Tento filamentnemá žádnou vlastní plnou hmotnost, takže přebírá plnou hmotnost nového filamentu: {{full}}, přičemž po odečtení již spotřebovaného množství {{used}} zbývá {{remaining}}.",
"changeVendor.action": "Výměna",
"changeVendor.apply": "Změnit výrobce",
@@ -123,7 +136,7 @@
"datetime.prevMonth": "Předchozí měsíc",
"datetime.time": "Čas",
"documentTitle.dashboard.list": "Ovládací panel | Spoolman",
"documentTitle.labels.list": "Štítky | Spoolman",
"documentTitle.labels.list": "Etikety | Spoolman",
"documentTitle.library.list": "Knihovna | Spoolman",
"documentTitle.locations.list": "Umístění | Spoolman",
"documentTitle.settings.list": "Nastavení | Spoolman",
@@ -141,6 +154,7 @@
"filament.fields.material": "Materiál",
"filament.fields.multiColor": "Více",
"filament.fields.name": "Název",
"filament.fields.opacity": "Průhlednost",
"filament.fields.price": "Cena",
"filament.fields.registered": "Registrováno",
"filament.fields.removeColor": "Odstranit barvu",
@@ -202,7 +216,7 @@
"inspector.measuredHelp": "Celková hmotnost cívky + zbývajícího filamentu, jak ji ukazuje váha.",
"inspector.multi": "multi ×{{count}}",
"inspector.newGross": "Nová celková hmotnost:",
"inspector.noExtraFields": "",
"inspector.noExtraFields": "Zatím žádná — přidejte si vlastní pole, abyste mohli sledovat vše, co Spoolman nesleduje.",
"inspector.notFound": "Tuto položku se nepodařilo najít. Možná byla smazána.",
"inspector.ofRemaining": "zbývá {{weight}} · {{length}} m",
"inspector.openFilament": "Otevřený filament",
@@ -236,17 +250,20 @@
"labels.chooseField": "Vyberte pole...",
"labels.clearFill": "Průhledné vyplnění (pouze obrys)",
"labels.copies": "Kopie",
"labels.cornerRadiusMm": "",
"labels.createFirst": "",
"labels.cornerRadiusMm": "Zaoblení rohu",
"labels.createFirst": "Vytvořte svůj první šítek",
"labels.cutGuides": "Řezací vodítka",
"labels.deleteConfirm": "Chcete tento návrh štítku smazat?",
"labels.deleteConfirm": "Chcete tento návrh etikety smazat?",
"labels.deleteElement": "Odstranit prvek",
"labels.designName": "Název návrhu",
"labels.designerDesc": "",
"labels.designerDesc": "Navrhujte etikety s QR kódy s libovolnými poli pro cívky, filamenty nebo dodavatele a poté je vytiskněte na archy nebo na tiskárně etiket. Návrhy se ukládají na server.",
"labels.designerTitle": "Návrhář etiket",
"labels.dpi": "Rozlišení",
"labels.dpiHint": "",
"labels.duplicate": "Duplikát",
"labels.exportFormat": "Formát souboru",
"labels.exportFormatOptions.aml": "Formát souboru etiket AML",
"labels.exportFormatOptions.png": "Obrázek PNG",
"labels.fields.bedTemp": "Teplota podložky",
"labels.fields.diameterMm": "Průměr (mm)",
"labels.fields.groupVendor": "Dodavatel",
@@ -260,7 +277,7 @@
"labels.fitWidth": "Přizpůsobit šířku sloupcům",
"labels.fitWidthHint": "Nastavte šířku popisků tak, aby zvolený počet sloupců vyplnil celou stránku.",
"labels.fontMm": "Písmo",
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} štítků / stránka",
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} etiket / stránka",
"labels.hMm": "H",
"labels.heightMm": "Výška",
"labels.hintNowrap": "Text zůstává na jednom řádku; vše, co přesahuje šířku, se ořízne.",
@@ -268,16 +285,16 @@
"labels.hintWrap": "",
"labels.imageDesc": "",
"labels.imageZipNote": "",
"labels.insertField": "",
"labels.insertField": "Přidat pole",
"labels.kind.qr": "QR kód",
"labels.kind.rect": "Obdelník",
"labels.kind.swatch": "Vzorek",
"labels.kind.text": "Text",
"labels.labelSize": "Velikost štítku",
"labels.labelType": "Typ štítku",
"labels.labelSize": "Velikost etikety",
"labels.labelType": "Typ etikety",
"labels.layout": "Rozvržení",
"labels.modeImage": "Obrázkové soubory",
"labels.modeLabel": "Tiskárna štítků",
"labels.modeLabel": "Tiskárna etiket",
"labels.modeSheet": "",
"labels.new": "Nový",
"labels.newText": "Nový text",
@@ -289,10 +306,11 @@
"labels.preview": "Náhled",
"labels.previewWith": "Náhled pomocí",
"labels.printN_few": "Vytisknout {{count}} štítky",
"labels.printN_one": "Vytisknout {{count}} štítek",
"labels.printN_one": "Vytisknout {{count}} etiket",
"labels.printN_other": "Vytisknout {{count}} štítků",
"labels.radiusMm": "Poloměr",
"labels.sampleData": "Ukázková data",
"labels.saveAsFormat": "Uložit jako {{format}}",
"labels.saved": "Uloženo",
"labels.saving": "Ukládání...",
"labels.searchFilaments": "Vyhledávám filamenty...",
@@ -309,11 +327,11 @@
"labels.showingReal": "",
"labels.showingSpool": "",
"labels.showingTags": "",
"labels.sizeMm": "",
"labels.sizeMm": "Velikost",
"labels.skipHint": "",
"labels.sortHint": "Seřadit cívky",
"labels.sortId": "#",
"labels.sortNewest": "",
"labels.sortNewest": "Nejnovější",
"labels.stroke": "",
"labels.strokeMm": "",
"labels.tabDesign": "",
@@ -330,18 +348,23 @@
"labels.yMm": "Y",
"library.apiError": "",
"library.collapseGroup": "",
"library.dateFilter.never": "Nikdy",
"library.dateFilter.olderThan": "Starší než {{span}}",
"library.dateFilter.since": "Od {{date}}",
"library.dateFilter.to": "Do (volitelně)",
"library.emptyBody": "",
"library.emptyFiltered": "",
"library.emptyTitle": "",
"library.expandGroup": "",
"library.filterBy": "",
"library.groupBy": "",
"library.emptyTitle": "Zatím žádné cívky",
"library.expandGroup": "Zobrazit tyto cívky",
"library.filterBy": "Filtrovat podle",
"library.groupBy": "Skupina",
"library.groupNone": "",
"library.groupSpools_few": "{{count}} cívky",
"library.groupSpools_one": "{{count}} cívka",
"library.groupSpools_other": "{{count}} cívek",
"library.nUnused": "",
"library.noLocation": "bez uvedení místa",
"library.noSpools": "Žádné cívky",
"library.noValues": "Žádné hodnoty",
"library.openGroup": "",
"library.openManufacturer": "",
@@ -367,7 +390,7 @@
"locations.newLocation": "Nové umístění",
"locations.noLocation": "Žádné umístění",
"locations.noLocationsHelp": "Na této stránce můžete uspořádat své cívky podle umístění, přidejte několik cívek a začněte!",
"nav.labels": "Štítky",
"nav.labels": "Etikety",
"nav.library": "Knihovna",
"no": "Ne",
"notifications.error": "Chyba (kód chyby: {{statusCode}})",
@@ -380,10 +403,10 @@
"pagination.range": "",
"printing.generic.columns": "Sloupce",
"printing.generic.customSize": "Vlastní",
"printing.generic.description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné štítky si to vyzkoušejte na kousku běžného papíru.",
"printing.generic.description": "Níže uvedeným nastavením dosáhnete požadovaného rozložení tisku. Mějte na paměti, že tiskárny a váš operační systém mohou použít vlastní okraje a měřítka, takže možná budete muset provést několik pokusů a omylů, než bude vše správně nastaveno. Před tiskem na skutečné etikety si to vyzkoušejte na listu běžného papíru.",
"printing.generic.dimensions": "Rozměry",
"printing.generic.gridTooWide": "",
"printing.generic.helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly papíru na štítky a tiskárně, jejich změna ovlivní velikost celé mřížky.",
"printing.generic.helpMargin": "Okraje by měly být nastaveny tak, aby odpovídaly listu etiket a tiskárně, jejich změna ovlivní velikost celé mřížky.",
"printing.generic.helpPrinterMargin": "Bezpečná zóna by měla být nastavena tak, jak blízko okraje papíru může tiskárna tisknout, jejich změna neovlivní celou mřížku.",
"printing.generic.horizontalSpacing": "Vodorovná rozteč",
"printing.generic.itemCopies": "Kopie položky",
@@ -399,7 +422,7 @@
"printing.generic.saveAsImage": "Uložit jako obrázek",
"printing.generic.skipItems": "Vynechat položky",
"printing.generic.verticalSpacing": "Svislá rozteč",
"printing.qrcode.button": "Tisk štítků",
"printing.qrcode.button": "Tisk etiket",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
@@ -421,14 +444,15 @@
"scanner.error.unknown": "Došlo k neznámé chybě. ({{error}})",
"scanner.starting": "",
"scanner.title": "Skener QR kódu",
"search.colorHint": "",
"search.noResults": "",
"search.searching": "",
"search.colorHint": "Názvy barev je třeba zadávat v angličtině.",
"search.moreSpools": "+{{count}} více",
"search.noResults": "Nebyly nalezeny žádné výsledky",
"search.searching": "Vyhledávám...",
"search.section.filaments": "Filamenty",
"search.section.spools": "Cívky",
"search.section.vendors": "Výrobci",
"search.threshold": "",
"search.unknownFilament": "",
"search.threshold": "Shoda barev",
"search.unknownFilament": "Neznámý filament",
"settings.appearance.tab": "",
"settings.appearance.theme.dark": "Tmavé",
"settings.appearance.theme.desc": "Barevné téma (pouze v tomto prohlížeči)",
+366 -229
View File
@@ -50,6 +50,7 @@
"add.vendorHint.existing": "Verwende vorhandenen Hersteller \"{{name}}\"",
"add.vendorHint.new": "Neuer Hersteller \"{{name}}\" wird erstellt",
"add.vendorHint.optional": "Optional — leer lassen für keinen Hersteller",
"add.weightPresets": "häufige Spulengrößen",
"add.yourCatalog": "Dein Katalog",
"buttons.apply": "Zustimmen",
"buttons.archive": "Archiv",
@@ -61,6 +62,7 @@
"buttons.filter": "Filter",
"buttons.save": "Speichern",
"buttons.showArchived": "Archivierte anzeigen",
"buttons.showNoSpools": "Zeige Filamente die keine Spulen haben",
"buttons.unArchive": "Archivieren rückgängig machen",
"changeFilament.action": "Ändern",
"changeFilament.adoptWeight": "Setze das volle Gewicht dieser Spule auf {{full}}",
@@ -72,10 +74,12 @@
"changeFilament.failed": "Konnte das Filament nicht ändern.",
"changeFilament.importNote": "Wenn du der Änderung zustimmst, wird dieses Filament deinem Katalog hinzugefügt.",
"changeFilament.intro": "Spule #{{id}} behält ihre Nummer, Standort, Daten und Verbrauchshistorie — nur das Filament, das es hält, verändert sich.",
"changeFilament.newTag": "neu",
"changeFilament.next": "Ändere zu",
"changeFilament.now": "Momentan",
"changeFilament.pickBelow": "Wähle ein Filament von unten",
"changeFilament.title": "Ändere Filament",
"changeFilament.useExisting": "Wähle ein bestehendes Filament",
"changeFilament.weightFollows": "Diese Spule hat selbst kein volles Gewicht, deshalb wird die des Filaments verwendet: {{full}}, damit bleiben {{remaining}} nachdem {{used}} verwendet wurden.",
"changeVendor.action": "Ändern",
"changeVendor.apply": "Ändere Hersteller",
@@ -96,6 +100,8 @@
"changeVendor.now": "Momentan",
"changeVendor.pickBelow": "Wähle einen Hersteller von unten",
"changeVendor.searchPlaceholder": "Suche oder nenne einen Hersteller",
"changeVendor.tareFollows": "Dieses Filament hat kein eigenes Leergewicht der Spule, also das seines Herstellers übernommen. Nach der Änderung beträgt es {{value}}, also das Gewicht einer leeren Spule, wenn man eine wiegt.",
"changeVendor.tareGone": "Dieses Filament hat kein eigenes Spulenleergewicht und übernimmt das des Herstellers {{current}}. Nach der Änderung gibt es keins zu übernehmen, bis du dem Filament eines gibst.",
"changeVendor.title": "Ändere Hersteller",
"changeVendor.willBeCreated": "Wird erzeugt",
"common.decrement": "Verringern",
@@ -145,22 +151,24 @@
"filament.fields.material": "Material",
"filament.fields.multiColor": "Mehrfarbig",
"filament.fields.name": "Name",
"filament.fields.opacity": "Deckkraft",
"filament.fields.price": "Preis",
"filament.fields.registered": "Registriert",
"filament.fields.removeColor": "Farbe entfernen",
"filament.fields.settingsBedTemp": "Betttemperatur",
"filament.fields.settingsExtruderTemp": "Düsentemperatur",
"filament.fields.settingsExtruderTemp": "Extruder Temperatur",
"filament.fields.singleColor": "Einfarbig",
"filament.fields.spoolWeight": "Gewicht Spule",
"filament.fields.vendor": "Hersteller",
"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.",
@@ -170,199 +178,250 @@
"inspector.applying": "Zustimmen...",
"inspector.colorHex": "Farbe hex",
"inspector.consumeAmount": "Verbrauchsmenge:",
"inspector.defaultFrom.filament": "{{value}} · Standard des Filaments",
"inspector.defaultFrom.manufacturer": "{{value}} · Standard des Herstellers",
"inspector.delete.deleting": "Löschen...",
"inspector.delete.errorGone": "Es wurde bereits gelöscht.",
"inspector.delete.errorSpoolsExist": "Kann das Filament nicht löschen — Spule ist in Benutzung.",
"inspector.delete.errorUnknown": "Löschen fehlgeschlagen. Für Details, im ServerLog schauen..",
"inspector.delete.filament": "Filament löschen",
"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.delete.filamentBlockedHint": "Lösche diese Spulen zunächst. Archivieren ist nicht ausreichend.",
"inspector.delete.filamentBlockedTitle": "Dieses Filament kann nicht gelöscht werden",
"inspector.delete.filamentBlocked_one": "{{count}} Spule verwendet \"{{name}}\", inklusive Archivierter.",
"inspector.delete.filamentBlocked_other": "{{count}} Spulen verwenden \"{{name}}\", inklusive Archivierter.",
"inspector.delete.filamentBody": "“{{name}}” wird aus Deinem Katalog entfernt. Das kann nicht rückgängig gemacht werden.",
"inspector.delete.filamentDone": "Filament gelöscht.",
"inspector.delete.filamentTitle": "Dieses Filament löschen?",
"inspector.delete.spool": "Spule löschen",
"inspector.delete.spoolBody": "Spule #{{id}} von “{{name}}” wird gelöscht, gemeinsam mit ihrer Nutzungshistorie. Das kann nicht rückgängig gemacht werden.",
"inspector.delete.spoolDone": "Spule gelöscht.",
"inspector.delete.spoolRemaining": "Die Spule hat {{weight}} Filament übrig. Archiviere sie stattdessen, falls Du sie nur aus dem Weg haben möchtest.",
"inspector.delete.spoolTitle": "Diese Spule löschen?",
"inspector.delete.vendor": "Hersteller löschen",
"inspector.delete.vendorBody": "\"{{name}}\" wird gelöscht. Das kann nicht rückgängig gemacht werden.",
"inspector.delete.vendorDone": "Hersteller gelöscht.",
"inspector.delete.vendorOrphans_one": "Das {{count}} Filament wird behalten, aber es bleibt ohne Hersteller.",
"inspector.delete.vendorOrphans_other": "Die {{count}} Filamente werden behalten, aber sie bleiben ohne Hersteller.",
"inspector.delete.vendorTitle": "Diesen Hersteller löschen?",
"inspector.duplicateFilament": "Duplizieren",
"inspector.empty": "Wähle eine Spule, ein Filament oder einen Hersteller aus der Liste, um Details dazu hier anzuzeigen.",
"inspector.enterValidLength": "Gib eine valide Länge ein.",
"inspector.enterValidWeight": "Gib ein valides Gewicht ein.",
"inspector.loading": "Lade...",
"inspector.manageExtraFields": "Felder bearbeiten",
"inspector.measuredHelp": "Gesamtgewicht der Spule + übriges Filament, wie auf der Waage gemessen.",
"inspector.multi": "mehrfach x{{count}}",
"inspector.newGross": "Neues Bruttogewicht:",
"inspector.noExtraFields": "Noch keine — füge deine eigenen Felder hinzu, um alles zu verfolgen, was Spoolman nicht tut.",
"inspector.notFound": "Dieses Objekt konnte nicht gefunden werden. Es könnte gelöscht worden sein.",
"inspector.ofRemaining": "von {{weight}} übrig · {{length}} m",
"inspector.openFilament": "Filament öffnen",
"inspector.openLink": "{{url}} in einem neuen Tab öffnen",
"inspector.openManufacturer": "Hersteller öffnen",
"inspector.override.byFilament": "Überschrieben durch dieses Filament · {{value}}",
"inspector.override.bySpool": "Überschrieben durch diese Spule · {{value}}",
"inspector.override.overridesFilament": "Überschreibt Filament · {{value}}",
"inspector.override.overridesManufacturer": "Überschreibt Hersteller · {{value}}",
"inspector.specs": "Spezifikationen — Direktbearbeitung",
"inspector.spoolsCount": "Spulen · {{count}}",
"inspector.spoolsTotalRemaining": "Gesamt {{weight}}",
"inspector.used": "verwendet",
"inspector.vendorSub_one": "{{count}} Filament",
"inspector.vendorSub_other": "{{count}} Filamente",
"kofi": "Schick mir ein Trinkgeld über 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": "Hinzufügen",
"labels.addQr": "QR Code",
"labels.addRect": "Rechteck",
"labels.addSwatch": "Farbmuster",
"labels.addText": "Text / Feld",
"labels.align": "Ausrichten",
"labels.alignCenter": "Mittig",
"labels.alignLeft": "Links",
"labels.alignRight": "Rechts",
"labels.blank": "Du hast bisher kein Label Design.",
"labels.bold": "Fettgedruckt",
"labels.canvasHint": "Ziehen zum Verschieben · Klicken zum Auswählen · Eckpunkte zum Ändern der Größe.",
"labels.centerLogo": "Zentriere Spoolman Logo",
"labels.chooseField": "Wähle ein Feld aus...",
"labels.clearFill": "Lösche Füllung (nur Rahmen)",
"labels.copies": "Kopien",
"labels.cornerRadiusMm": "Eckenradius",
"labels.createFirst": "Erstelle Dein erstes Label",
"labels.cutGuides": "Schnittlinie",
"labels.deleteConfirm": "Dieses Label Design löschen?",
"labels.deleteElement": "Element löschen",
"labels.designName": "Name des Designs",
"labels.designerDesc": "Erstelle QR-Code Etiketten mit beliebigen Spulen-, Filament-, oder Herstellerfeldern und drucke sie auf Papier oder mit einem Etikettendrucker. Die Designs werden auf dem Server gespeichert.",
"labels.designerTitle": "Label Designer",
"labels.dpi": "Auflösung",
"labels.dpiHint": "Stelle deinen Drucker ein, um kleine Etiketten scharf zu halten: 203 dpi für die meisten Thermodrucker, 300 oder mehr für Laser- und Tintenstrahldrucker.",
"labels.duplicate": "Duplizieren",
"labels.exportDescAml": "Speichert für jedes ausgewählte Etikett eine .aml-Datei, jeweils {{w}}×{{h}} mm. Kann in den LPAPI-basierten Apps geöffnet werden, die mit den meisten Thermodruckern geliefert werden und die physische Größe aus der Datei lesen.",
"labels.exportDescPng": "Speichert ein PNG-Bild pro ausgewähltem Label, jeweils {{w}}×{{h}} mm.",
"labels.exportFormat": "Dateiformat",
"labels.exportFormatOptions.aml": "AML Label Datei",
"labels.exportFormatOptions.png": "PNG Bild",
"labels.fields.bedTemp": "Druckbetttemperatur",
"labels.fields.diameterMm": "Durchmesser (mm)",
"labels.fields.groupVendor": "Verkäufer",
"labels.fields.initialG": "Initial Gewicht (g)",
"labels.fields.netWeightG": "Nettogewicht (g)",
"labels.fields.remainingG": "Verbleibend (g)",
"labels.filamentsSelected_one": "{{count}} Filament ausgewählt",
"labels.filamentsSelected_other": "{{count}} Filamente ausgewählt",
"labels.fill": "Füllen",
"labels.fitWidth": "Breite an Spalten anpassen",
"labels.fitWidthHint": "Setzt die Breite der Etiketten so, dass die ausgewählte Anzahl an Spalten die Seite ausfüllt.",
"labels.fontMm": "Schriftart",
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} Etiketten / Seite",
"labels.hMm": "H",
"labels.heightMm": "Höhe",
"labels.hintNowrap": "Text bleibt in einer Zeile; alles, was über die Breite hinausgeht, wird abgeschnitten.",
"labels.hintSwatch": "Wird mit den Filamentfarbe(n) der Spule zum Druckzeitpunkt gefüllt.",
"labels.hintWrap": "Umhülle ein Feld, um es zu verbergen, wenn es leer ist:",
"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": "Mehr als {{threshold}} Etiketten werden als eine einzige zip Datei geliefert.",
"labels.insertField": "Feld hinzufügen",
"labels.kind.qr": "QR Code",
"labels.kind.rect": "Rechteck",
"labels.kind.swatch": "Farbmuster",
"labels.kind.text": "Text",
"labels.labelSize": "Größe des Etiketts",
"labels.labelType": "Art des Etiketts",
"labels.layout": "Layout",
"labels.modeImage": "Dateien",
"labels.modeLabel": "Etikettendrucker",
"labels.modeSheet": "Blatt (Kachel)",
"labels.new": "Neu",
"labels.newText": "Neuer Text",
"labels.noDesignsOption": "Noch keine Designs",
"labels.noFilaments": "Keine Filamente.",
"labels.noSpools": "Keine Spulen.",
"labels.onePerLabel": "Eine {{w}}×{{h}} mm Seite pro Etikett.",
"labels.preparing": "Bereite vor...",
"labels.preview": "Vorschau",
"labels.previewWith": "Vorschau mit",
"labels.printN_one": "{{count}} Etikett drucken",
"labels.printN_other": "{{count}} Etiketten drucken",
"labels.radiusMm": "Radius",
"labels.sampleData": "Beispieldaten",
"labels.saveAsFormat": "Speichern als {{format}}",
"labels.saved": "Gespeichert",
"labels.saving": "Speichere...",
"labels.searchFilaments": "Filamente suchen...",
"labels.searchSpools": "Spulen suchen...",
"labels.selectAllShort": "Alle",
"labels.selectAtLeastOne": "Wähle mindestens eine Spule.",
"labels.selectAtLeastOneFilament": "Wähle mindestens ein Filament.",
"labels.selectDesign": "Wähle Label Design",
"labels.selectElement": "Wähle ein Element aus, um es zu bearbeiten, oder füge eines aus der Palette hinzu.",
"labels.selectNoneShort": "Keine",
"labels.selectRecentHint": "Wähle alle Spulen, die in den letzten 24 Stunden hinzugefügt wurden",
"labels.selectRecentShort": "Kürzlich ({{count}})",
"labels.showingFilament": "Zeige Filament #{{id}}",
"labels.showingReal": "Echte Spulendaten anzeigen.",
"labels.showingSpool": "Zeige Spule #{{id}}",
"labels.showingTags": "Felder werden als Tags angezeigt, bis du oben eine Spule auswählst.",
"labels.sizeMm": "Größe",
"labels.skipHint": "Mit 'Objekt überspringen' werden so viele Zellen vor dem ersten Etikett leer gelassen, dass du ein teilweise benutztes Blatt wiederverwenden kannst: Zähle die Etiketten, die schon abgezogen wurden, von oben nach unten und von links nach rechts.",
"labels.sortHint": "Sortiere Spulen",
"labels.sortId": "#",
"labels.sortNewest": "Neueste",
"labels.stroke": "Rahmen",
"labels.strokeMm": "Rahmen",
"labels.tabDesign": "Design",
"labels.tabPrint": "Drucken",
"labels.textTemplate": "Text / Vorlage",
"labels.typeFilament": "'Filamen",
"labels.typeHint": "Filament-Etiketten lassen spool-only-Felder weg, und ihr QR-Code verlinkt zum Filament statt zu einer Spule.",
"labels.typeSpool": "Spule",
"labels.unknownFilament": "Unbekanntes Filament",
"labels.wMm": "B",
"labels.widthMm": "Breite",
"labels.wrapText": "Zeilenumbruch",
"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}}",
"library.dateFilter.customRange": "Benutzerdefinierter Bereich",
"library.dateFilter.from": "Von",
"library.dateFilter.last": "Letzte {{span}}",
"library.dateFilter.never": "Nie",
"library.dateFilter.olderThan": "Älter als {{span}}",
"library.dateFilter.since": "Seit {{date}}",
"library.dateFilter.to": "Bis (optional)",
"library.dateFilter.until": "Bis {{date}}",
"library.emptyBody": "Füge deine erste Spule hinzu, um dein Filament zu verfolgen.",
"library.emptyFiltered": "Keine {{unit}} passen zu den aktuellen Filtern.",
"library.emptyTitle": "Noch keine Spulen",
"library.expandGroup": "Zeige diese Spulen",
"library.filterBy": "Filtern nach",
"library.groupBy": "Gruppieren",
"library.groupNone": "Keine (flach)",
"library.groupSpools_one": "{{count}} Spule",
"library.groupSpools_other": "{{count}} Spulen",
"library.nUnused": "×{{count}} unbenutzt",
"library.noLocation": "kein Ort",
"library.noSpools": "Keine Spulen",
"library.noValues": "Keine Werte",
"library.openGroup": "Öffne {{name}}",
"library.openManufacturer": "Öffne Hersteller {{name}}",
"library.resizeList": "Größe der Liste ändern (doppelklicken zum Zurücksetzen)",
"library.reverseSort": "Sortierreihenfolge umkehren",
"library.section.extra": "Zusätzliche Felder",
"library.section.filament": "Filament",
"library.section.spool": "Spule",
"library.section.vendor": "Hersteller",
"library.showMore": "zeige {{count}} mehr",
"library.sort.nozzle": "Düsen Temperatur",
"library.sortBy": "Sortieren",
"library.unassigned": "nicht zugewiesen",
"library.unitGroups": "Gruppen",
"library.unitSpools": "Spulen",
"library.unused": "ungenutzt",
"library.unusedGroup": "{{count}} unbenutzt · {{weight}} g jede",
"library.unusedGroupMixed": "{{count}} unbenutzt",
"library.unusedSub": "jede {{weight}} g · {{location}}",
"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",
"locations.noLocation": "Kein Ort",
"locations.noLocationsHelp": "Diese Seite lässt Sie Ihre Spulen zu Orten zuweisen, fügen Sie ein paar Spulen hinzu um loszulegen!",
"nav.labels": "",
"nav.library": "",
"nav.labels": "Labels",
"nav.library": "Bibliothek",
"no": "Nein",
"notifications.error": "Fehler (status code: {{statusCode}})",
"notifications.saveSuccessful": "Erfolgreich gespeichert!",
"pagination.empty": "",
"pagination.next": "",
"pagination.pageSize": "",
"pagination.perPage": "",
"pagination.prev": "",
"pagination.range": "",
"pagination.empty": "Keine {{unit}}",
"pagination.next": "Nächste Seite",
"pagination.pageSize": "Seitengröße",
"pagination.perPage": "{{size}} / Seite",
"pagination.prev": "vorherige Seite",
"pagination.range": "{{from}}–{{to}} von {{total}} {{unit}}",
"printing.generic.columns": "Spalten",
"printing.generic.customSize": "Benutzerdefiniert",
"printing.generic.description": "Passe die Einstellungen unten an, um das gewünschte Drucklayout zu bekommen. Denk daran, dass Drucker und dein OS eigene Abstände und Skalierungen anwenden könnte, also musst du vielleicht ein bisschen ausprobieren, bis alles passt. Teste es auf einem normalen Blatt Papier, bevor du auf die richtigen Labels druckst.",
"printing.generic.dimensions": "Dimensionen",
"printing.generic.gridTooWide": "",
"printing.generic.gridTooWide": "Die Etiketten sind breiter als die Seite. Reduziere die Anzahl der Spalten, den Abstand oder die Ränder.",
"printing.generic.helpMargin": "Abstände sollten so konfiguriert werden, dass sie zum Papier und Drucker passen. Änderungen beeinflussen die Abmessungen des gesamten Gitters.",
"printing.generic.helpPrinterMargin": "Der Sicherheitsabstand sollte so eingestellt werden, wie dicht der Drucker am Rand des Papiers drucken kann. Änderungen beeinflussen nicht die Abmessungen des Gitters.",
"printing.generic.horizontalSpacing": "Horizontaler Abstand",
@@ -380,43 +439,49 @@
"printing.generic.skipItems": "Objekt überspringen",
"printing.generic.verticalSpacing": "Vertikaler Abstand",
"printing.qrcode.button": "Beschriftung drucken",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
"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",
"printing.qrcode.useHTTPUrl.options.custom": "",
"printing.qrcode.useHTTPUrl.options.custom": "Benutzerdefinierte URL",
"printing.qrcode.useHTTPUrl.options.default": "Standard",
"printing.qrcode.useHTTPUrl.options.url": "URL",
"printing.qrcode.useHTTPUrl.preview": "Vorschau:",
"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": "",
"scanner.error.inUse": "Die Kamera wird bereits von einer anderen Anwendung genutzt.",
"scanner.error.insecureContext": "Die Seite wird nicht über HTTPS bereitgestellt.",
"scanner.error.notAllowed": "Zugriff auf Kamera wurde nicht erlaubt.",
"scanner.error.notFound": "Keine Kamera gefunden.",
"scanner.error.streamApiNotSupported": "Der Browser unterstützt die MediaStream API nicht.",
"scanner.error.unknown": "Ein unbekannter Fehler ist aufgetreten. ({{error}})",
"scanner.starting": "",
"scanner.starting": "Starte Kamera…",
"scanner.title": "QR-Code Scanner",
"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.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…",
"search.section.filaments": "Filamente",
"search.section.spools": "Spulen",
"search.section.vendors": "Hersteller",
"search.threshold": "Farbtreffer",
"search.unknownFilament": "Unbekanntes Filament",
"settings.appearance.tab": "Darstellung",
"settings.appearance.theme.dark": "Dunkel",
"settings.appearance.theme.desc": "Farbschema (nur dieser Browser)",
"settings.appearance.theme.label": "Schema",
"settings.appearance.theme.light": "Hell",
"settings.appearance.theme.system": "System",
"settings.extraFields.addChoice": "Option hinzufügen…",
"settings.extraFields.addChoicesFirst": "Füge zuerst Optionen hinzu",
"settings.extraFields.addEntityField": "Feld {{entity}} hinzufügen",
"settings.extraFields.booleanFalse": "Nein",
"settings.extraFields.booleanTrue": "Ja",
"settings.extraFields.deleteConfirm": "Feld {{name}} des Objekts {{entity}} löschen?",
@@ -424,14 +489,14 @@
"settings.extraFields.description.intro": "Hier können Sie zusätzliche benutzerdefinierte Felder zu Ihren Elementen hinzufügen.",
"settings.extraFields.description.keyUsage": "Der Schlüssel ist derjenige, unter dem andere Programme die Daten lesen/schreiben, wenn Ihr benutzerdefiniertes Feld also mit einem Programm eines Drittanbieters integriert werden soll, stellen Sie sicher, dass Sie ihn richtig einstellen. Der Standardwert wird nur auf neue Elemente angewendet.",
"settings.extraFields.description.tableViews": "Zusätzliche Felder können in den Tabellenansichten nicht sortiert oder gefiltert werden.",
"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": "Feld {{entity}} bearbeiten",
"settings.extraFields.editorNew": "Neues Feld {{entity}}",
"settings.extraFields.errors.choiceNeeded": "Auswahlfelder brauchen mindestens eine Auswahlmöglichkeit.",
"settings.extraFields.errors.choicesRemoved": "Bestehende Optionen können nicht entfernt werden: {{choices}}.",
"settings.extraFields.errors.deleteFailed": "Feld konnte nicht gelöscht werden.",
"settings.extraFields.errors.keyFormat": "Der Schlüssel darf nur Kleinbuchstaben, Zahlen und Unterstriche enthalten.",
"settings.extraFields.errors.nameRequired": "Name wird benötigt.",
"settings.extraFields.errors.saveFailed": "Feld konnte nicht gespeichert werden.",
"settings.extraFields.fieldType.boolean": "Wahr/Falsch",
"settings.extraFields.fieldType.choice": "Auswahl",
"settings.extraFields.fieldType.datetime": "Datum und Uhrzeit",
@@ -440,13 +505,13 @@
"settings.extraFields.fieldType.integer": "Ganze Zahl",
"settings.extraFields.fieldType.integerRange": "Ganzzahlbereich",
"settings.extraFields.fieldType.text": "Text",
"settings.extraFields.max": "",
"settings.extraFields.min": "",
"settings.extraFields.multiSuffix": "",
"settings.extraFields.multiple": "",
"settings.extraFields.namePlaceholder": "",
"settings.extraFields.max": "max",
"settings.extraFields.min": "min",
"settings.extraFields.multiSuffix": " ·· multi",
"settings.extraFields.multiple": "Mehrere",
"settings.extraFields.namePlaceholder": "Anzeigename",
"settings.extraFields.nonUniqueKeyError": "Der Schlüssel muss einzigartig sein.",
"settings.extraFields.none": "",
"settings.extraFields.none": "Noch keine zusätzlichen {{entity}} Felder definiert.",
"settings.extraFields.params.choices": "Auswahlmöglichkeiten",
"settings.extraFields.params.defaultValue": "Standardwert",
"settings.extraFields.params.fieldType": "Typ",
@@ -454,21 +519,37 @@
"settings.extraFields.params.name": "Name",
"settings.extraFields.params.order": "Reihenfolge",
"settings.extraFields.params.unit": "Einheit",
"settings.extraFields.saveField": "",
"settings.extraFields.reorderChoice": "Ziehen oder benutze die Pfeiltasten, um diese Auswahl neu zu ordnen.",
"settings.extraFields.saveField": "Feld speichern",
"settings.extraFields.tab": "Extra Felder",
"settings.general.baseUrl.label": "Basis URL",
"settings.general.baseUrl.tooltip": "Die basis URL für die Generierung von Funktionen wie QR Codes.",
"settings.general.currency.desc": "",
"settings.general.currency.desc": "Dreistelliger ISO Code (z.B. EUR, USD) der für Preise genutzt wird",
"settings.general.currency.label": "Währung",
"settings.general.roundPrices.label": "Gerundeter Preis",
"settings.general.roundPrices.tooltip": "Rundet die Preise auf die nächsthöhere ganze Zahl.",
"settings.general.tab": "Allgemein",
"settings.header": "Einstellungen",
"settings.language.desc": "",
"settings.language.label": "",
"settings.library.lowThreshold.desc": "",
"settings.library.lowThreshold.label": "",
"settings.library.tab": "",
"settings.language.desc": "Benutzeroberflächensprache (nur für diesen Browser)",
"settings.language.label": "Sprache",
"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 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 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",
"settings.scanner.recentNone": "Kein Leser im Speicher, richte einen Tag Leser ein und lese dann einen beliebigen Tag, damit er hier erscheint.",
"settings.scanner.tab": "Tag Scannen",
"settings.scanner.unpair": "Benutze irgendeinen Leser",
"settings.scanner.use": "Benutze diesen",
"settings.scanner.waiting": "Tippe auf einen beliebigen Tag auf dem Leser, den du möchtest...",
"sponsor": "Unterstütze mich auf GitHub",
"spool.fields.archived": "Archiviert",
"spool.fields.comment": "Kommentar",
"spool.fields.filament": "Filament",
@@ -482,26 +563,81 @@
"spool.fields.price": "Preis",
"spool.fields.registered": "Registriert",
"spool.fields.remainingWeight": "Verbleibendes Gewicht",
"spool.fields.spoolWeight": "Spulengewicht",
"spool.fields.usedWeight": "Verbrauchtes Gewicht",
"spool.fields.weight": "Gewicht",
"spool.fieldsHelp.location": "Ort, an dem die Spule gelagert wird.",
"spool.fieldsHelp.lotNr": "Chargennummer des Herstellers. Kann verwendet werden um gleichmäßige Farben zu garantieren, wenn mehrere Spulen verwendet werden.",
"spool.fieldsHelp.measuredWeight": "Gesamtgewicht von Spule und Filament.",
"spool.fieldsHelp.price": "Preis einer vollen Spule. Falls nicht ausgewählt, wird der Preis des Filaments geschätzt.",
"spool.fieldsHelp.remainingWeight": "Wie viel Filament noch auf der Spule ist. Entspricht dem auf der Spule angegeben Nettogewicht, wenn die Spule neu ist.",
"spool.fieldsHelp.spoolWeight": "Was diese Spule wiegt, wenn sie leer ist. Lasse es leer, um das Gewicht der Spule (Filament) zu nehmen, und stelle es ein, wenn diese spezielle Spule anders ist – eine neu aufgewickelte Spule oder eine Pappspule, die gegen einen Plastikspule getauscht wurde. Das ist das, was vom gewogenen Gewicht abgezogen wird, wenn du es nach gemessenem Gewicht anpasst, also gibt ein falsches Spulengewicht einen falschen Wert, wie viel Filament noch übrig ist.",
"spool.fieldsHelp.usedWeight": "Wie viel Gewicht bereits verbraucht wurde z.B. 0g für eine neue Spule.",
"spool.fieldsHelp.weight": "Wie viel Filament diese Spule hatte, als sie voll war (Nettogewicht, ohne die Spule selbst). Lasse es leer, um das Gewicht der Spule (Filament) zu nehmen, und trage es ein, wenn diese spezielle Spule anders ist – eine partielle Spule oder dasselbe Filament auf einer größeren Spule gekauft.",
"spool.form.measurementType.length": "Länge",
"spool.form.measurementType.weight": "Gewicht",
"spool.spool": "Spulen",
"topbar.addSpools": "",
"topbar.searchPlaceholder": "",
"tags.add": "Tag hinzufügen",
"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",
"tags.modal.spoolNamed": "Spule #{{id}}, {{name}}",
"tags.modal.spoolShort": "Spule #{{id}}",
"tags.modal.takenBy": "Derzeit mit {{spool}} verbunden.",
"tags.modal.title": "Verbinde einen Tag",
"tags.modal.uidHelp": "Trennzeichen und Großschreibung machen keinen Unterschied: 04:a2:b3:c4, 04-A2-B3-C4 und 04a2b3c4 sind alle derselbe Tag.",
"tags.modal.uidLabel": "Tag UID",
"tags.modal.uidPlaceholder": "04A2B3C4",
"tags.modal.useLastSeen": "Benutze es",
"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.",
"tags.unlinking": "Verbindung aufheben…",
"topbar.addSpools": "Spulen hinzufügen",
"topbar.searchPlaceholder": "Suchen — versuche \"rot\" oder \"pla\"",
"unknown": "Unbekannt",
"validation.hexDigits": "",
"validation.maxChars": "",
"validation.mustBeGt": "",
"validation.mustBeMax": "",
"validation.mustBeMin": "",
"validation.mustBeNumber": "",
"validation.required": "",
"validation.hexDigits": "Benutze 6 oder 8 hexadezimal Stellen",
"validation.maxChars": "Höchstens {{max}} Zeichen",
"validation.mustBeGt": "Muss größer als {{value}} sein",
"validation.mustBeMax": "Muss {{value}}, oder weniger sein",
"validation.mustBeMin": "Muss {{value}}, oder mehr sein",
"validation.mustBeNumber": "Muss eine Zahl sein",
"validation.required": "Erforderlich",
"vendor.fields.comment": "Kommentar",
"vendor.fields.emptySpoolWeight": "Gewicht der leeren Spule",
"vendor.fields.externalId": "Externe ID",
@@ -509,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"
}
+94 -2
View File
@@ -62,6 +62,8 @@
"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}}",
"changeFilament.adoptWeightHelp": "It is recorded as {{current}}, from the filament it held before. Used weight ({{used}}) is kept either way, leaving {{remaining}}.",
@@ -73,10 +75,12 @@
"changeFilament.failed": "Couldn’t change the filament.",
"changeFilament.importNote": "This filament will be added to your catalog when you apply the change.",
"changeFilament.intro": "Spool #{{id}} keeps its number, location, dates and usage history — only the filament it holds changes.",
"changeFilament.newTag": "new",
"changeFilament.next": "Change to",
"changeFilament.now": "Currently",
"changeFilament.pickBelow": "Pick a filament below",
"changeFilament.title": "Change filament",
"changeFilament.useExisting": "Pick an existing filament",
"changeFilament.weightFollows": "This spool has no full weight of its own, so it takes the new filament’s: {{full}}, leaving {{remaining}} after the {{used}} already used.",
"changeVendor.action": "Change",
"changeVendor.apply": "Change manufacturer",
@@ -164,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.",
@@ -223,7 +228,6 @@
"inspector.used": "used",
"inspector.vendorSub_one": "{{count}} filament",
"inspector.vendorSub_other": "{{count}} filaments",
"kofi": "Tip me on Ko-fi",
"labels.addHead": "Add",
"labels.addQr": "QR code",
"labels.addRect": "Rectangle",
@@ -347,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.",
@@ -358,10 +374,12 @@
"library.groupSpools_other": "{{count}} spools",
"library.nUnused": "×{{count}} unused",
"library.noLocation": "no location",
"library.noSpools": "No spools",
"library.noValues": "No values",
"library.openGroup": "Open {{name}}",
"library.openManufacturer": "Open manufacturer {{name}}",
"library.resizeList": "Resize the list (double-click to reset)",
"library.reverseSort": "Reverse the sort order",
"library.section.extra": "Extra fields",
"library.section.filament": "Filament",
"library.section.spool": "Spool",
@@ -379,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",
@@ -413,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",
@@ -423,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.",
@@ -433,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...",
@@ -503,6 +528,21 @@
"settings.library.lowThreshold.desc": "Spools at or below this weight are flagged red (this browser only)",
"settings.library.lowThreshold.label": "Low-stock threshold",
"settings.library.tab": "Library behavior",
"settings.scanner.autoNavigate.desc": "Automatically navigate in this web browser to a spool or filament that gets read by a tag reader.",
"settings.scanner.autoNavigate.label": "Auto-navigate",
"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 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",
"settings.scanner.recentNone": "No reader in memory, set up a tag reader then read any tag for it to appear here.",
"settings.scanner.tab": "Tag Scanning",
"settings.scanner.unpair": "Use any reader",
"settings.scanner.use": "Use this one",
"settings.scanner.waiting": "Tap any tag on the reader you want...",
"sponsor": "Sponsor me on GitHub",
"spool.fields.archived": "Archived",
"spool.fields.comment": "Comment",
"spool.fields.filament": "Filament",
@@ -530,6 +570,57 @@
"spool.form.measurementType.length": "Length",
"spool.form.measurementType.weight": "Weight",
"spool.spool": "Spools",
"tags.add": "Add tag",
"tags.added": "Tag linked.",
"tags.addedOn": "Added {{date}}",
"tags.modal.alreadyHere": "This tag already identifies this spool.",
"tags.modal.alreadyHereFilament": "This tag already identifies this filament.",
"tags.modal.badUid": "A tag UID is hexadecimal, like 04A2B3C4.",
"tags.modal.checking": "Checking...",
"tags.modal.failed": "Could not link the tag.",
"tags.modal.filamentNamed": "filament {{name}}",
"tags.modal.free": "This tag is not linked to anything yet.",
"tags.modal.intro": "Tapping this tag on a reader will then find spool #{{id}}.",
"tags.modal.introFilament": "Tapping this tag on a reader will then find this filament.",
"tags.modal.lastSeenOn": "{{reader}} last read {{uid}}.",
"tags.modal.link": "Link tag",
"tags.modal.linking": "Linking...",
"tags.modal.move": "Move it here from #{{id}}",
"tags.modal.moveFailed": "Could not move the tag. It may now be linked to neither spool — try linking it again.",
"tags.modal.moveFailedFilament": "Could not move the tag. It may now be linked to nothing — try linking it again.",
"tags.modal.moveHere": "Move it here",
"tags.modal.moving": "Moving...",
"tags.modal.phoneReading": "Hold the tag against your phone...",
"tags.modal.phoneScan": "Read a tag with this phone",
"tags.modal.spoolNamed": "spool #{{id}}, {{name}}",
"tags.modal.spoolShort": "spool #{{id}}",
"tags.modal.takenBy": "Currently linked to {{spool}}.",
"tags.modal.title": "Link a tag",
"tags.modal.uidHelp": "Separators and capitalisation make no difference: 04:a2:b3:c4, 04-A2-B3-C4 and 04a2b3c4 are all the same tag.",
"tags.modal.uidLabel": "Tag UID",
"tags.modal.uidPlaceholder": "04A2B3C4",
"tags.modal.useLastSeen": "Use it",
"tags.modal.waitingAny": "Waiting for a tap on any reader.",
"tags.modal.waitingOn": "Waiting for a tap on {{reader}}.",
"tags.moved": "Tag moved here from spool #{{id}}.",
"tags.movedFromFilament": "Tag moved here from filament {{name}}.",
"tags.nfc.insecureContext": "Reading tags needs a secure connection. Open Spoolman over HTTPS to use your phone's NFC.",
"tags.nfc.notAllowed": "This site is not allowed to use NFC. Grant it permission in your browser's site settings and try again.",
"tags.nfc.notReadable": "The NFC reader is unavailable — another app may be using it.",
"tags.nfc.notSupported": "This phone has no NFC, or NFC is switched off in system settings.",
"tags.nfc.unknown": "Could not read a tag.",
"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 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.",
"tags.unlinkBodyFilament": "Tag {{uid}} will stop identifying this filament. Nothing is written to the tag itself, so you can link it again — here or somewhere else — whenever you like.",
"tags.unlinkFailed": "Could not unlink the tag.",
"tags.unlinkTitle": "Unlink this tag?",
"tags.unlinked": "Tag unlinked.",
"tags.unlinking": "Unlinking...",
"topbar.addSpools": "Add spools",
"topbar.searchPlaceholder": "Search — try \"red\" or \"pla\"",
"unknown": "Unknown",
@@ -546,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": "",
+498 -322
View File
@@ -1,102 +1,163 @@
{
"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": "Ajouter & nouveau",
"add.addN_many": "Ajouter {{count}} bobines",
"add.addN_one": "Ajouter {{count}} bobine",
"add.addN_other": "Ajouter {{count}} bobines",
"add.adding": "Ajout...",
"add.advanced": "Spécifications avancées",
"add.advancedNote": "densité, diamètre, températures (remplies automatiquement à partir du matériau)",
"add.change": "changer",
"add.count": "Décompte",
"add.createNew": "Créer un nouveau filament",
"add.createNewSub": "n'est pas dans votre catalogue ou {{name}}",
"add.dbUnavailable": "{{name}} indisponible",
"add.duplicate": "dupliquer",
"add.duplicateNote": "Les caractéristiques, le fabricant et le prix ont été repris. Donnez-lui un nouveau nom et une nouvelle couleur.",
"add.duplicateRename": "Il porte toujours le même nom que l'original : renommez-le pour les distinguer.",
"add.duplicateTitle": "Nouveau filament copie de “{{name}}”",
"add.filamentNamePlaceholder": "Exemple : PolyTerra Matte Sage",
"add.fill.full": "Rempli",
"add.fillLevel": "Niveau de remplissage",
"add.importNote": "Ce filament sera ajouté à votre catalogue lorsque vous ajouterez les bobines.",
"add.inCatalog": "au catalogue",
"add.locationHint": "L’endroit où vous rangez physiquement la bobine — une étagère, une caisse ou une imprimante. Laissez vide si vous les gardez toutes au même endroit.",
"add.locationPlaceholder": "ex. Étagère A, Caisson 2, Imprimante 1",
"add.manufacturerPlaceholder": "exemple : Polymaker",
"add.nameHint": "Indiquez la couleur : c’est ce qui permet de distinguer vos filaments.",
"add.newFilament": "Ajouter filament",
"add.newFilamentTitle": "Nouveau filament",
"add.noCatalog": "Aucun filament dans votre catalogue",
"add.noManufacturer": "Sans fabriquant",
"add.pickColor": "Choisissez une couleur",
"add.searchPlaceholder": "Recherchez dans votre catalogue et {{name}}…",
"add.searching": "Recherche…",
"add.section.filament": "Filament",
"add.section.filamentNote": "Le produit sur la bobine — une entrée par couleur.",
"add.section.manufacturer": "Fabriquant",
"add.section.manufacturerNote": "Qui fabrique le filament ? Ajouté une seule fois, puis réutilisé pour tous leurs filaments.",
"add.section.spool": "Bobine",
"add.section.spoolNote": "Les bobines physiques qui rejoignent votre inventaire.",
"add.section.spoolSharedNote": "Le poids, le poids de la bobine et le prix sont également enregistrés comme valeurs par défaut pour le nouveau filament.",
"add.spoolWeightPreset.cardboard": "Carton {{weight}}g",
"add.spoolWeightPreset.plastic": "Plastique {{weight}}g",
"add.spoolWeightPresetsLead": "Partez d'une bobine standard :",
"add.step2": "Étape 2 · Détails de la bobine",
"add.step2New": "Étape 2 · Détails du filament et de la bobine",
"add.summary_many": "{{count}} bobines",
"add.summary_one": "{{count}} bobine",
"add.summary_other": "{{count}} bobines",
"add.typeToSearchAll": "Tapez pour rechercher parmi des milliers de filaments",
"add.typeToSearchMatches": "Tapez pour rechercher — aucun résultat",
"add.useExisting": "utiliser un existant",
"add.vendorHint.existing": "Utilisation du fabricant existant \"{{name}}\"",
"add.vendorHint.new": "Le nouveau fabricant « {{name}} » sera créé",
"add.vendorHint.optional": "Facultatif — laissez vide pour ne pas indiquer de fabricant",
"add.weightPresets": "Tailles de bobines courantes",
"add.yourCatalog": "Votre catalogue",
"buttons.apply": "Appliquer",
"buttons.archive": "Archiver",
"buttons.cancel": "Abandonner",
"buttons.clear": "Effacer",
"buttons.close": "",
"buttons.close": "Fermer",
"buttons.delete": "Supprimer",
"buttons.edit": "Editer",
"buttons.filter": "Filtre",
"buttons.save": "Enregistrer",
"buttons.showArchived": "Afficher les archives",
"buttons.showNoSpools": "Afficher les filaments sans bobine",
"buttons.unArchive": "Désarchiver",
"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": "Changer",
"changeFilament.adoptWeight": "Fixer le poids plein de cette bobine à {{full}}",
"changeFilament.adoptWeightHelp": "Il est enregistré à {{current}}, hérité du filament précédent. Le poids consommé ({{used}}) est conservé dans les deux cas, ce qui laisse {{remaining}}.",
"changeFilament.apply": "Changer le filament",
"changeFilament.applying": "Changement en cours…",
"changeFilament.current": "actuel",
"changeFilament.done": "Filament changé.",
"changeFilament.failed": "Le changement de filament a échoué.",
"changeFilament.importNote": "Ce filament sera ajouté à votre catalogue lorsque vous validerez le changement.",
"changeFilament.intro": "La bobine n° {{id}} conserve son numéro, son emplacement, ses dates et son historique d’utilisation — seul le filament qu’elle contient change.",
"changeFilament.newTag": "nouveau",
"changeFilament.next": "Remplacer par",
"changeFilament.now": "Actuellement",
"changeFilament.pickBelow": "Choisissez un filament ci-dessous",
"changeFilament.title": "Changer le filament",
"changeFilament.useExisting": "Choisir un filament existant",
"changeFilament.weightFollows": "Cette bobine n’a pas de poids plein propre : elle reprend celui du nouveau filament, soit {{full}}, ce qui laisse {{remaining}} après les {{used}} déjà consommés.",
"changeVendor.action": "Changer",
"changeVendor.apply": "Changer le fabricant",
"changeVendor.applying": "Changement en cours…",
"changeVendor.clearHint": "Laisser ce filament sans fabricant",
"changeVendor.create": "Créer « {{name}} »",
"changeVendor.createHint": "Ne fait pas encore partie de vos fabricants — il sera ajouté",
"changeVendor.current": "actuel",
"changeVendor.done": "Fabricant changé.",
"changeVendor.emptySpool": "Bobine vide {{value}}",
"changeVendor.failed": "Le changement de fabricant a échoué.",
"changeVendor.intro": "« {{name}} » conserve ses bobines, ses caractéristiques et son historique — seul le fabricant sous lequel il est classé change.",
"changeVendor.next": "Remplacer par",
"changeVendor.noEmptySpool": "Aucun poids de bobine vide",
"changeVendor.noMatches": "Aucun fabricant ne correspond",
"changeVendor.none": "Aucun fabricant pour l’instant",
"changeVendor.noneNote": "Classé sans marque",
"changeVendor.now": "Actuellement",
"changeVendor.pickBelow": "Choisissez un fabricant ci-dessous",
"changeVendor.searchPlaceholder": "Rechercher ou nommer un fabricant",
"changeVendor.tareFollows": "Ce filament n’a pas de poids de bobine vide propre : il reprend celui de son fabricant. Après le changement, ce sera {{value}}, c’est-à-dire ce que pèse une bobine vide de ce filament lorsque vous la mesurez.",
"changeVendor.tareGone": "Ce filament n’a pas de poids de bobine vide propre et reprend les {{current}} de son fabricant. Après le changement, il n’y en aura plus à reprendre, jusqu’à ce que vous en donniez un au filament.",
"changeVendor.title": "Changer le fabricant",
"changeVendor.willBeCreated": "Sera créé",
"common.decrement": "Diminuer",
"common.increment": "Augmenter",
"common.remove": "Retirer",
"common.search": "Rechercher",
"dashboard.addFieldHint": "Ajoutez un champ texte ou à choix aux bobines dans les Réglages pour pouvoir aussi les grouper par ce champ",
"dashboard.dashboard": "Tableau de bord",
"dashboard.deleteGroup": "Supprimer le groupe",
"dashboard.dragHint": "Faites glisser une bobine vers une autre carte pour changer son champ {{field}}, ou à l’intérieur d’une carte pour la réordonner",
"dashboard.dropHere": "déposez des bobines ici",
"dashboard.emptyHelp": "Cette page regroupe vos bobines selon le champ de votre choix. Ajoutez des bobines pour commencer !",
"dashboard.errorEmptyName": "Le nom ne peut pas être vide",
"dashboard.errorExists": "Un groupe portant ce nom existe déjà",
"dashboard.errorRename": "Le renommage du groupe a échoué",
"dashboard.lastUsed": "Utilisée il y a {{time}}",
"dashboard.newGroup": "Nouveau groupe",
"dashboard.newGroupName": "Nouveau groupe {{n}}",
"dashboard.reorderGroup": "Faites glisser pour réordonner le groupe",
"dashboard.spoolCount_many": "{{count}} bobines",
"dashboard.spoolCount_one": "{{count}} bobine",
"dashboard.spoolCount_other": "{{count}} bobines",
"dashboard.unassigned": "Sans groupe",
"datetime.done": "Terminé",
"datetime.hour": "Heure",
"datetime.minute": "Minute",
"datetime.nextMonth": "Mois suivant",
"datetime.now": "Maintenant",
"datetime.pick": "Choisir la date et l’heure",
"datetime.prevMonth": "Mois précédent",
"datetime.time": "Heure",
"documentTitle.dashboard.list": "Tableau de bord | Spoolman",
"documentTitle.labels.list": "Étiquettes | Spoolman",
"documentTitle.library.list": "Bibliothèque | Spoolman",
"documentTitle.locations.list": "Emplacements | Spoolman",
"documentTitle.settings.list": "",
"filament.fields.addColor": "",
"documentTitle.settings.list": "Réglages | Spoolman",
"filament.fields.addColor": "Ajouter une couleur",
"filament.fields.articleNumber": "Numéro d'article",
"filament.fields.coaxial": "Coextrudée",
"filament.fields.colorHex": "Couleur",
"filament.fields.colorType": "",
"filament.fields.colorType": "Type de couleur",
"filament.fields.comment": "Commentaire",
"filament.fields.density": "Densité",
"filament.fields.diameter": "Diamètre",
"filament.fields.externalId": "ID externe",
"filament.fields.id": "ID",
"filament.fields.id": "Identifiant",
"filament.fields.longitudinal": "Longitudinale",
"filament.fields.material": "Matériau",
"filament.fields.multiColor": "Multi",
"filament.fields.name": "Nom",
"filament.fields.opacity": "Opacité",
"filament.fields.price": "Prix",
"filament.fields.registered": "Enregistré",
"filament.fields.removeColor": "",
"filament.fields.removeColor": "Retirer la couleur",
"filament.fields.settingsBedTemp": "Température du lit",
"filament.fields.settingsExtruderTemp": "Température de l'extrudeuse",
"filament.fields.singleColor": "Unique",
@@ -108,210 +169,253 @@
"filament.fieldsHelp.spoolWeight": "Le poids d'une bobine vide. Utilisé pour déterminer le poids d'une bobine.",
"filament.fieldsHelp.weight": "Le poids du filament d'une bobine complète (poids net). Ce poids ne doit pas inclure le poids de la bobine elle-même, mais uniquement celui du filament. C'est ce qui est généralement indiqué sur l'emballage.",
"filament.filament": "Filaments",
"footer.documentation": "",
"footer.reportIssue": "",
"footer.documentation": "Documentation",
"footer.reportIssue": "Signaler un problème",
"help.help": "Aide",
"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": "Ajouter des bobines de ce filament",
"inspector.adjustFailed": "L’ajustement du poids a échoué.",
"inspector.adjustHelp": "Une valeur positive consomme du filament, une valeur négative en rend.",
"inspector.adjustWeight": "Ajuster le poids",
"inspector.apply": "Appliquer",
"inspector.applying": "Application en cours…",
"inspector.colorHex": "Couleur hexadécimale",
"inspector.consumeAmount": "Quantité à consommer :",
"inspector.defaultFrom.filament": "{{value}} — valeur par défaut du filament",
"inspector.defaultFrom.manufacturer": "{{value}} — valeur par défaut du fabricant",
"inspector.delete.deleting": "Suppression en cours…",
"inspector.delete.errorGone": "L’élément avait déjà été supprimé.",
"inspector.delete.errorSpoolsExist": "Impossible de supprimer ce filament — des bobines l’utilisent encore.",
"inspector.delete.errorUnknown": "La suppression a échoué. Consultez les journaux du serveur pour en savoir plus.",
"inspector.delete.filament": "Supprimer le filament",
"inspector.delete.filamentBlockedHint": "Supprimez d’abord ces bobines. Les archiver ne suffit pas.",
"inspector.delete.filamentBlockedTitle": "Ce filament ne peut pas être supprimé",
"inspector.delete.filamentBlocked_many": "{{count}} bobines utilisent encore « {{name}} », archives comprises.",
"inspector.delete.filamentBlocked_one": "{{count}} bobine utilise encore « {{name}} », archives comprises.",
"inspector.delete.filamentBlocked_other": "{{count}} bobines utilisent encore « {{name}} », archives comprises.",
"inspector.delete.filamentBody": "« {{name}} » sera retiré de votre catalogue. Cette action est irréversible.",
"inspector.delete.filamentDone": "Filament supprimé.",
"inspector.delete.filamentTitle": "Supprimer ce filament ?",
"inspector.delete.spool": "Supprimer la bobine",
"inspector.delete.spoolBody": "La bobine n° {{id}} de « {{name}} » sera supprimée, ainsi que son historique d’utilisation. Cette action est irréversible.",
"inspector.delete.spoolDone": "Bobine supprimée.",
"inspector.delete.spoolRemaining": "Il lui reste encore {{weight}} de filament. Archivez-la plutôt, si vous voulez simplement l’écarter.",
"inspector.delete.spoolTitle": "Supprimer cette bobine ?",
"inspector.delete.vendor": "Supprimer le fabricant",
"inspector.delete.vendorBody": "« {{name}} » sera supprimé. Cette action est irréversible.",
"inspector.delete.vendorDone": "Fabricant supprimé.",
"inspector.delete.vendorOrphans_many": "Ses {{count}} filaments sont conservés, mais se retrouvent sans fabricant.",
"inspector.delete.vendorOrphans_one": "Son {{count}} filament est conservé, mais se retrouve sans fabricant.",
"inspector.delete.vendorOrphans_other": "Ses {{count}} filaments sont conservés, mais se retrouvent sans fabricant.",
"inspector.delete.vendorTitle": "Supprimer ce fabricant ?",
"inspector.duplicateFilament": "Dupliquer",
"inspector.empty": "Sélectionnez une bobine, un filament ou un fabricant dans la liste pour en afficher le détail ici.",
"inspector.enterValidLength": "Saisissez une longueur valide.",
"inspector.enterValidWeight": "Saisissez un poids valide.",
"inspector.loading": "Chargement…",
"inspector.manageExtraFields": "Gérer les champs",
"inspector.measuredHelp": "Poids total de la bobine et du filament restant, tel que lu sur une balance.",
"inspector.multi": "multi ×{{count}}",
"inspector.newGross": "Nouveau poids brut :",
"inspector.noExtraFields": "Aucun pour l’instant — ajoutez vos propres champs pour suivre ce que Spoolman ne gère pas.",
"inspector.notFound": "Cet élément est introuvable. Il a peut-être été supprimé.",
"inspector.ofRemaining": "sur {{weight}} restants · {{length}} m",
"inspector.openFilament": "Ouvrir le filament",
"inspector.openLink": "Ouvrir {{url}} dans un nouvel onglet",
"inspector.openManufacturer": "Ouvrir le fabricant",
"inspector.override.byFilament": "Remplacé par ce filament — {{value}}",
"inspector.override.bySpool": "Remplacé par cette bobine — {{value}}",
"inspector.override.overridesFilament": "Remplace le filament — {{value}}",
"inspector.override.overridesManufacturer": "Remplace le fabricant — {{value}}",
"inspector.specs": "Caractéristiques — modifiables sur place",
"inspector.spoolsCount": "Bobines · {{count}}",
"inspector.spoolsTotalRemaining": "{{weight}} au total",
"inspector.used": "consommé",
"inspector.vendorSub_many": "{{count}} filaments",
"inspector.vendorSub_one": "{{count}} filament",
"inspector.vendorSub_other": "{{count}} filaments",
"kofi": "Me donner un pourboire sur 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": "Ajouter",
"labels.addQr": "QR code",
"labels.addRect": "Rectangle",
"labels.addSwatch": "Pastille de couleur",
"labels.addText": "Texte / champ",
"labels.align": "Alignement",
"labels.alignCenter": "Centré",
"labels.alignLeft": "À gauche",
"labels.alignRight": "À droite",
"labels.blank": "Vous n’avez encore aucun modèle d’étiquette.",
"labels.bold": "Gras",
"labels.canvasHint": "Glissez pour déplacer · cliquez pour sélectionner · poignées pour redimensionner.",
"labels.centerLogo": "Centrer le logo Spoolman",
"labels.chooseField": "Choisissez un champ…",
"labels.clearFill": "Supprimer le remplissage (contour seul)",
"labels.copies": "Exemplaires",
"labels.cornerRadiusMm": "Rayon des coins",
"labels.createFirst": "Créez votre première étiquette",
"labels.cutGuides": "Repères de coupe",
"labels.deleteConfirm": "Supprimer ce modèle d’étiquette ?",
"labels.deleteElement": "Supprimer l’élément",
"labels.designName": "Nom du modèle",
"labels.designerDesc": "Concevez des étiquettes à QR code avec n’importe quel champ de bobine, de filament ou de fabricant, puis imprimez-les sur planche ou sur une imprimante d’étiquettes. Les modèles sont enregistrés sur le serveur.",
"labels.designerTitle": "Éditeur d’étiquettes",
"labels.dpi": "Résolution",
"labels.dpiHint": "Accordez-la à votre imprimante pour que les petites étiquettes restent nettes : 203 ppp pour la plupart des imprimantes thermiques, 300 ou plus pour le laser et le jet d’encre.",
"labels.duplicate": "Dupliquer",
"labels.exportDescAml": "Enregistre un fichier d’étiquette .aml par étiquette sélectionnée, chacune de {{w}} × {{h}} mm. S’ouvre dans les applications LPAPI fournies avec la plupart des imprimantes d’étiquettes thermiques, qui lisent la taille physique dans le fichier.",
"labels.exportDescPng": "Enregistre une image PNG par étiquette sélectionnée, chacune de {{w}} × {{h}} mm.",
"labels.exportFormat": "Format de fichier",
"labels.exportFormatOptions.aml": "Fichier d’étiquette AML",
"labels.exportFormatOptions.png": "Image PNG",
"labels.fields.bedTemp": "Temp. plateau",
"labels.fields.diameterMm": "Diamètre (mm)",
"labels.fields.groupVendor": "Fabricant",
"labels.fields.initialG": "Initial (g)",
"labels.fields.netWeightG": "Poids net (g)",
"labels.fields.remainingG": "Restant (g)",
"labels.filamentsSelected_many": "{{count}} filaments sélectionnés",
"labels.filamentsSelected_one": "{{count}} filament sélectionné",
"labels.filamentsSelected_other": "{{count}} filaments sélectionnés",
"labels.fill": "Remplissage",
"labels.fitWidth": "Ajuster la largeur aux colonnes",
"labels.fitWidthHint": "Règle la largeur de l’étiquette pour que le nombre de colonnes choisi remplisse la page.",
"labels.fontMm": "Police",
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} étiquettes par page",
"labels.hMm": "H",
"labels.heightMm": "Hauteur",
"labels.hintNowrap": "Le texte reste sur une seule ligne ; ce qui dépasse la largeur est coupé.",
"labels.hintSwatch": "Rempli avec la ou les couleurs du filament de la bobine au moment de l’impression.",
"labels.hintWrap": "Encadrez un champ pour le masquer lorsqu’il est vide :",
"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": "Au-delà de {{threshold}} étiquettes, le tout est livré dans un seul fichier zip.",
"labels.insertField": "Insérer un champ",
"labels.kind.qr": "QR code",
"labels.kind.rect": "Rectangle",
"labels.kind.swatch": "Pastille",
"labels.kind.text": "Texte",
"labels.labelSize": "Taille de l’étiquette",
"labels.labelType": "Type d’étiquette",
"labels.layout": "Disposition",
"labels.modeImage": "Fichiers",
"labels.modeLabel": "Imprimante d’étiquettes",
"labels.modeSheet": "Planche (mosaïque)",
"labels.new": "Nouveau",
"labels.newText": "Nouveau texte",
"labels.noDesignsOption": "Aucun modèle pour l’instant",
"labels.noFilaments": "Aucun filament.",
"labels.noSpools": "Aucune bobine.",
"labels.onePerLabel": "Une page de {{w}} × {{h}} mm par étiquette.",
"labels.preparing": "Préparation…",
"labels.preview": "Aperçu",
"labels.previewWith": "Aperçu avec",
"labels.printN_many": "Imprimer {{count}} étiquettes",
"labels.printN_one": "Imprimer {{count}} étiquette",
"labels.printN_other": "Imprimer {{count}} étiquettes",
"labels.radiusMm": "Rayon",
"labels.sampleData": "Données d’exemple",
"labels.saveAsFormat": "Enregistrer au format {{format}}",
"labels.saved": "Enregistré",
"labels.saving": "Enregistrement…",
"labels.searchFilaments": "Rechercher des filaments…",
"labels.searchSpools": "Rechercher des bobines…",
"labels.selectAllShort": "Tout",
"labels.selectAtLeastOne": "Sélectionnez au moins une bobine.",
"labels.selectAtLeastOneFilament": "Sélectionnez au moins un filament.",
"labels.selectDesign": "Choisir un modèle d’étiquette",
"labels.selectElement": "Sélectionnez un élément pour le modifier, ou ajoutez-en un depuis la palette.",
"labels.selectNoneShort": "Aucun",
"labels.selectRecentHint": "Sélectionner les bobines ajoutées dans les dernières 24 heures",
"labels.selectRecentShort": "Récentes ({{count}})",
"labels.showingFilament": "Affichage du filament n° {{id}}",
"labels.showingReal": "Affichage des données réelles de la bobine.",
"labels.showingSpool": "Affichage de la bobine n° {{id}}",
"labels.showingTags": "Les champs s’affichent sous forme d’étiquettes tant que vous n’avez pas choisi une bobine ci-dessus.",
"labels.sizeMm": "Taille",
"labels.skipHint": "« Ignorer des cases » laisse ce nombre de cases vides avant la première étiquette, ce qui permet de réutiliser une planche entamée : comptez les étiquettes déjà décollées, de gauche à droite en partant du haut.",
"labels.sortHint": "Trier les bobines",
"labels.sortId": "N°",
"labels.sortNewest": "Plus récentes",
"labels.stroke": "Contour",
"labels.strokeMm": "Contour",
"labels.tabDesign": "Conception",
"labels.tabPrint": "Impression",
"labels.textTemplate": "Texte / modèle",
"labels.typeFilament": "Filament",
"labels.typeHint": "Les étiquettes de filament omettent les champs propres aux bobines, et leur QR code renvoie vers le filament plutôt que vers une bobine.",
"labels.typeSpool": "Bobine",
"labels.unknownFilament": "Filament inconnu",
"labels.wMm": "L",
"labels.widthMm": "Largeur",
"labels.wrapText": "Retour à la ligne",
"labels.xMm": "X",
"labels.yMm": "Y",
"library.apiError": "Impossible de joindre l’API de Spoolman. Le serveur est-il démarré ?",
"library.collapseGroup": "Masquer ces bobines",
"library.dateFilter.ago": "il y a {{span}}",
"library.dateFilter.between": "{{from}} — {{to}}",
"library.dateFilter.customRange": "Période personnalisée",
"library.dateFilter.from": "Du",
"library.dateFilter.last": "{{span}} écoulés",
"library.dateFilter.never": "Jamais",
"library.dateFilter.olderThan": "Plus ancien que {{span}}",
"library.dateFilter.since": "Depuis le {{date}}",
"library.dateFilter.to": "Au (facultatif)",
"library.dateFilter.until": "Jusqu’au {{date}}",
"library.emptyBody": "Ajoutez votre première bobine pour commencer à suivre votre filament.",
"library.emptyFiltered": "Aucune correspondance parmi les {{unit}} avec les filtres actuels.",
"library.emptyTitle": "Aucune bobine pour l’instant",
"library.expandGroup": "Afficher ces bobines",
"library.filterBy": "Filtrer par",
"library.groupBy": "Grouper",
"library.groupNone": "Aucun (liste simple)",
"library.groupSpools_many": "{{count}} bobines",
"library.groupSpools_one": "{{count}} bobine",
"library.groupSpools_other": "{{count}} bobines",
"library.nUnused": "×{{count}} neuves",
"library.noLocation": "sans emplacement",
"library.noSpools": "Aucune bobine",
"library.noValues": "Aucune valeur",
"library.openGroup": "Ouvrir {{name}}",
"library.openManufacturer": "Ouvrir le fabricant {{name}}",
"library.resizeList": "Redimensionner la liste (double-clic pour réinitialiser)",
"library.reverseSort": "Inverser l’ordre de tri",
"library.section.extra": "Champs supplémentaires",
"library.section.filament": "Filament",
"library.section.spool": "Bobine",
"library.section.vendor": "Fabricant",
"library.showMore": "afficher {{count}} de plus",
"library.sort.nozzle": "Temp. buse",
"library.sortBy": "Trier",
"library.unassigned": "non assignée",
"library.unitGroups": "groupes",
"library.unitSpools": "bobines",
"library.unused": "neuve",
"library.unusedGroup": "{{count}} neuves · {{weight}} g chacune",
"library.unusedGroupMixed": "{{count}} neuves",
"library.unusedSub": "{{weight}} g chacune · {{location}}",
"library.unusedSubMixed": "{{location}}",
"library.used": "entamée",
"library.usedAgo": "utilisée il y a {{time}}",
"loading": "Chargement",
"locations.locations": "Emplacements",
"locations.newLocation": "Créer un emplacement",
"locations.noLocation": "Pas de localisation",
"locations.noLocationsHelp": "Cette page vous permet d'organiser vos bobines par lieu, ajoutez une bobine pour commencer !",
"nav.labels": "",
"nav.library": "",
"nav.labels": "Étiquettes",
"nav.library": "Bibliothèque",
"no": "Non",
"notifications.error": "Erreur (status code : {{statusCode}})",
"notifications.saveSuccessful": "Sauvegarde réussie !",
"pagination.empty": "",
"pagination.next": "",
"pagination.pageSize": "",
"pagination.perPage": "",
"pagination.prev": "",
"pagination.range": "",
"pagination.empty": "Aucune {{unit}}",
"pagination.next": "Page suivante",
"pagination.pageSize": "Taille de page",
"pagination.perPage": "{{size}} par page",
"pagination.prev": "Page précédente",
"pagination.range": "{{from}}–{{to}} sur {{total}} {{unit}}",
"printing.generic.columns": "Colonnes",
"printing.generic.customSize": "Personnaliser",
"printing.generic.description": "Ajustez les paramètres ci-dessous pour obtenir la mise en page d'impression souhaitée. Gardez à l'esprit que les imprimantes et votre système d'exploitation peuvent appliquer leurs propres marges et mises à l'échelle, il se peut donc que vous deviez faire quelques essais avant que tout soit correct. Testez-le sur une feuille de papier ordinaire avant d'imprimer les étiquettes en elles mêmes.",
"printing.generic.dimensions": "Dimensions",
"printing.generic.gridTooWide": "",
"printing.generic.gridTooWide": "Les étiquettes sont plus larges que la page. Réduisez le nombre de colonnes, l’espacement ou les marges.",
"printing.generic.helpMargin": "Les marges doivent être configurées en fonction de votre papier à étiquettes et de votre imprimante, leur modification aura une incidence sur la taille de l'ensemble de la grille.",
"printing.generic.helpPrinterMargin": "La zone de sécurité doit être définie en fonction de la proximité du bord du papier que votre imprimante peut imprimer, la modification de ces paramètres n'affectera pas l'ensemble de la grille.",
"printing.generic.horizontalSpacing": "Espacement horizontal",
@@ -329,11 +433,11 @@
"printing.generic.skipItems": "Articles ignorés",
"printing.generic.verticalSpacing": "Espacement vertical",
"printing.qrcode.button": "Imprimer des étiquettes",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
"printing.qrcode.customUrl.caveat": "Une URL personnalisée ne ramènera pas vers Spoolman à la lecture — utilisez-la pour pointer un QR code imprimé vers une application externe ou un autre serveur.",
"printing.qrcode.customUrl.hint": "Insérez l’identifiant de la bobine où vous voulez à l’aide du marqueur",
"printing.qrcode.customUrl.label": "Modèle d’URL personnalisée",
"printing.qrcode.useHTTPUrl.label": "Lien du QR code",
"printing.qrcode.useHTTPUrl.options.custom": "",
"printing.qrcode.useHTTPUrl.options.custom": "URL personnalisée",
"printing.qrcode.useHTTPUrl.options.default": "Défaut",
"printing.qrcode.useHTTPUrl.options.url": "URL",
"printing.qrcode.useHTTPUrl.preview": "Prévisualisation :",
@@ -342,46 +446,47 @@
"printing.spoolSelect.selectedTotal_one": "{{count}} bobine sélectionnée",
"printing.spoolSelect.selectedTotal_other": "{{count}} bobines sélectionnées",
"scanner.description": "Scannez un code QR de Spoolman pour afficher les détails de la bobine.",
"scanner.error.inUse": "",
"scanner.error.inUse": "La caméra est déjà utilisée par une autre application.",
"scanner.error.insecureContext": "La page n'est pas servie par HTTPS.",
"scanner.error.notAllowed": "Vous n'avez pas autorisé l'accès à l'appareil photo.",
"scanner.error.notFound": "Aucun appareil photo n'a été trouvé.",
"scanner.error.streamApiNotSupported": "Le navigateur ne prend pas en charge l'API MediaStream.",
"scanner.error.unknown": "Une erreur inconnue s'est produite. ({{error}})",
"scanner.starting": "",
"scanner.starting": "Démarrage de la caméra…",
"scanner.title": "Scanner de code 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": "",
"search.colorHint": "Les noms de couleurs doivent être saisis en anglais.",
"search.moreSpools": "+{{count}} de plus",
"search.noResults": "Aucun résultat",
"search.searching": "Recherche en cours…",
"search.section.filaments": "Filaments",
"search.section.spools": "Bobines",
"search.section.vendors": "Fabricants",
"search.threshold": "Correspondance de couleur",
"search.unknownFilament": "Filament inconnu",
"settings.appearance.tab": "Apparence",
"settings.appearance.theme.dark": "Sombre",
"settings.appearance.theme.desc": "Thème de couleurs (ce navigateur uniquement)",
"settings.appearance.theme.label": "Thème",
"settings.appearance.theme.light": "Clair",
"settings.appearance.theme.system": "Système",
"settings.extraFields.addChoice": "ajouter un choix…",
"settings.extraFields.addChoicesFirst": "Ajoutez d’abord des choix",
"settings.extraFields.addEntityField": "Ajouter un champ {{entity}}",
"settings.extraFields.booleanFalse": "Non",
"settings.extraFields.booleanTrue": "Oui",
"settings.extraFields.deleteConfirm": "Supprimer le champ {{name}} ?",
"settings.extraFields.deleteConfirm": "Supprimer le champ {{name}} de {{entity}} ?",
"settings.extraFields.description.constraints": "Une fois qu'un champ est ajouté, vous ne pouvez plus changer sa clé ou son type, et pour les champs de types \"Choix\", vous ne pouvez plus retirer un choix ou changer l'état des choix multiples. Si vous supprimez un champ, les données associées aux entités seront supprimées.",
"settings.extraFields.description.intro": "Ici vous pouvez ajouter des champs personnalisés supplémentaires à vos entités.",
"settings.extraFields.description.keyUsage": "La clé correspond à ce que les autres programmes lisent/écrivent, donc si votre champs doit s'intégrer avec un programme tiers, assurez-vous de le configurer correctement. La valeur par défaut n'est appliquées qu'aux nouveaux éléments.",
"settings.extraFields.description.tableViews": "Les champs supplementaires ne peuvent pas être triés ou filtrés dans les vues de table.",
"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": "Modifier le champ {{entity}}",
"settings.extraFields.editorNew": "Nouveau champ {{entity}}",
"settings.extraFields.errors.choiceNeeded": "Un champ à choix nécessite au moins un choix.",
"settings.extraFields.errors.choicesRemoved": "Les choix existants ne peuvent pas être retirés : {{choices}}.",
"settings.extraFields.errors.deleteFailed": "La suppression du champ a échoué.",
"settings.extraFields.errors.keyFormat": "La clé ne doit contenir que des minuscules, des chiffres et des tirets bas.",
"settings.extraFields.errors.nameRequired": "Le nom est obligatoire.",
"settings.extraFields.errors.saveFailed": "L’enregistrement du champ a échoué.",
"settings.extraFields.fieldType.boolean": "Booléen",
"settings.extraFields.fieldType.choice": "Choix",
"settings.extraFields.fieldType.datetime": "Date et heure",
@@ -390,13 +495,13 @@
"settings.extraFields.fieldType.integer": "Entier",
"settings.extraFields.fieldType.integerRange": "Plage d'entier",
"settings.extraFields.fieldType.text": "Texte",
"settings.extraFields.max": "",
"settings.extraFields.min": "",
"settings.extraFields.multiSuffix": "",
"settings.extraFields.multiple": "",
"settings.extraFields.namePlaceholder": "",
"settings.extraFields.max": "max",
"settings.extraFields.min": "min",
"settings.extraFields.multiSuffix": " · multiple",
"settings.extraFields.multiple": "Multiple",
"settings.extraFields.namePlaceholder": "Nom affiché",
"settings.extraFields.nonUniqueKeyError": "La clé doit être unique.",
"settings.extraFields.none": "",
"settings.extraFields.none": "Aucun champ supplémentaire défini pour {{entity}}.",
"settings.extraFields.params.choices": "Choix",
"settings.extraFields.params.defaultValue": "Valeur par défaut",
"settings.extraFields.params.fieldType": "Type",
@@ -404,26 +509,42 @@
"settings.extraFields.params.name": "Nom",
"settings.extraFields.params.order": "Ordre",
"settings.extraFields.params.unit": "Unité",
"settings.extraFields.saveField": "",
"settings.extraFields.reorderChoice": "Faites glisser, ou utilisez les flèches du clavier, pour réordonner ce choix.",
"settings.extraFields.saveField": "Enregistrer le champ",
"settings.extraFields.tab": "Champs additionnel",
"settings.general.baseUrl.label": "Base de l'URL",
"settings.general.baseUrl.tooltip": "La base de l'URL à utiliser lors de la génération de QR codes.",
"settings.general.currency.desc": "",
"settings.general.currency.desc": "Code ISO à trois lettres (par exemple EUR, USD) utilisé pour les prix",
"settings.general.currency.label": "Devise",
"settings.general.roundPrices.label": "",
"settings.general.roundPrices.tooltip": "",
"settings.general.roundPrices.label": "Arrondir les prix",
"settings.general.roundPrices.tooltip": "Arrondir les prix au nombre entier le plus proche.",
"settings.general.tab": "Général",
"settings.header": "Paramètres",
"settings.language.desc": "",
"settings.language.label": "",
"settings.library.lowThreshold.desc": "",
"settings.library.lowThreshold.label": "",
"settings.library.tab": "",
"settings.language.desc": "Langue de l’interface (ce navigateur uniquement)",
"settings.language.label": "Langue",
"settings.library.lowThreshold.desc": "Les bobines à ce poids ou en dessous sont signalées en rouge (ce navigateur uniquement)",
"settings.library.lowThreshold.label": "Seuil de stock bas",
"settings.library.tab": "Comportement de la bibliothèque",
"settings.scanner.autoNavigate.desc": "Ouvrir automatiquement, dans ce navigateur, la bobine ou le filament lu par un lecteur de puces.",
"settings.scanner.autoNavigate.label": "Ouverture automatique",
"settings.scanner.docs": "Comment configurer un lecteur de puces",
"settings.scanner.lastSeen": "il y a {{when}}",
"settings.scanner.pair": "Associer par lecture",
"settings.scanner.reader.anyDesc": "Les lectures de n’importe quel lecteur ouvrent les bobines dans ce navigateur. Vous pouvez en associer un en particulier si vous souhaitez un contrôle plus fin. C’est aussi ce lecteur qui servira à ajouter de nouvelles puces aux bobines et aux filaments.",
"settings.scanner.reader.label": "Lecteur de puces",
"settings.scanner.reader.pairedDesc": "Seules les lectures de ce lecteur ouvrent les bobines dans ce navigateur.",
"settings.scanner.recent": "Lecteurs récents",
"settings.scanner.recentNone": "Aucun lecteur en mémoire. Configurez un lecteur de puces, puis lisez une puce pour le voir apparaître ici.",
"settings.scanner.tab": "Lecture de puces",
"settings.scanner.unpair": "Utiliser n’importe quel lecteur",
"settings.scanner.use": "Utiliser celui-ci",
"settings.scanner.waiting": "Présentez une puce sur le lecteur que vous souhaitez associer…",
"sponsor": "Soutenez-moi sur GitHub",
"spool.fields.archived": "Archivé",
"spool.fields.comment": "Commentaire",
"spool.fields.filament": "Filament",
"spool.fields.firstUsed": "Première utilisation",
"spool.fields.id": "ID",
"spool.fields.id": "Identifiant",
"spool.fields.lastUsed": "Dernière utilisation",
"spool.fields.location": "Localisation",
"spool.fields.lotNr": "N° de Lot",
@@ -432,30 +553,85 @@
"spool.fields.price": "Prix",
"spool.fields.registered": "Enregistré",
"spool.fields.remainingWeight": "Poids restant",
"spool.fields.spoolWeight": "Poids de la bobine",
"spool.fields.usedWeight": "Poids utilisé",
"spool.fields.weight": "Poids",
"spool.fieldsHelp.location": "L'emplacement de la bobine si vous stockez vos bobines à plusieurs endroits.",
"spool.fieldsHelp.lotNr": "Numéro de lot du fabricant. Peut être utilisé pour s'assurer que la couleur d'une impression est homogène si plusieurs bobines sont utilisées.",
"spool.fieldsHelp.measuredWeight": "Le poids du filament et de la bobine.",
"spool.fieldsHelp.price": "Prix d'une bobine complète. S'il n'est pas défini, le prix du filament sera pris en compte.",
"spool.fieldsHelp.remainingWeight": "La quantité de filament restant sur la bobine. Pour une nouvelle bobine, cela devrait correspondre au poids de la bobine.",
"spool.fieldsHelp.spoolWeight": "Ce que pèse cette bobine à vide. Laissez le champ libre pour suivre le poids de bobine du filament, et renseignez-le lorsque cette bobine précise diffère — un rouleau rembobiné, ou un mandrin carton remplacé par du plastique. C’est ce qui est soustrait de la mesure lorsque vous ajustez au poids pesé : une valeur erronée fausse aussi la quantité de filament restante.",
"spool.fieldsHelp.usedWeight": "Combien de filament a été utilisé à partir de la bobine. Une bobine neuve devrait avoir 0g utilisé.",
"spool.fieldsHelp.weight": "Quantité de filament que contenait cette bobine pleine (poids net, sans compter la bobine elle-même). Laissez le champ libre pour suivre le poids du filament, et renseignez-le lorsque cette bobine précise diffère — un rouleau entamé, ou le même filament acheté sur une bobine plus grande.",
"spool.form.measurementType.length": "Longueur",
"spool.form.measurementType.weight": "Poids",
"spool.spool": "Bobines",
"topbar.addSpools": "",
"topbar.searchPlaceholder": "",
"tags.add": "Ajouter une puce",
"tags.added": "Puce associée.",
"tags.addedOn": "Ajoutée le {{date}}",
"tags.modal.alreadyHere": "Cette puce identifie déjà cette bobine.",
"tags.modal.alreadyHereFilament": "Cette puce identifie déjà ce filament.",
"tags.modal.badUid": "L’UID d’une puce est hexadécimal, par exemple 04A2B3C4.",
"tags.modal.checking": "Vérification…",
"tags.modal.failed": "L’association de la puce a échoué.",
"tags.modal.filamentNamed": "le filament {{name}}",
"tags.modal.free": "Cette puce n’est encore associée à rien.",
"tags.modal.intro": "Présenter cette puce sur un lecteur ouvrira désormais la bobine n° {{id}}.",
"tags.modal.introFilament": "Présenter cette puce sur un lecteur ouvrira désormais ce filament.",
"tags.modal.lastSeenOn": "{{reader}} a lu {{uid}} en dernier.",
"tags.modal.link": "Associer la puce",
"tags.modal.linking": "Association en cours…",
"tags.modal.move": "La déplacer ici depuis la n° {{id}}",
"tags.modal.moveFailed": "Le déplacement de la puce a échoué. Elle peut n’être désormais associée à aucune des deux bobines — essayez de l’associer à nouveau.",
"tags.modal.moveFailedFilament": "Le déplacement de la puce a échoué. Elle peut n’être désormais associée à rien — essayez de l’associer à nouveau.",
"tags.modal.moveHere": "La déplacer ici",
"tags.modal.moving": "Déplacement en cours…",
"tags.modal.phoneReading": "Maintenez la puce contre votre téléphone…",
"tags.modal.phoneScan": "Lire une puce avec ce téléphone",
"tags.modal.spoolNamed": "la bobine n° {{id}}, {{name}}",
"tags.modal.spoolShort": "la bobine n° {{id}}",
"tags.modal.takenBy": "Actuellement associée à {{spool}}.",
"tags.modal.title": "Associer une puce",
"tags.modal.uidHelp": "Les séparateurs et la casse n’ont aucune importance : 04:a2:b3:c4, 04-A2-B3-C4 et 04a2b3c4 désignent la même puce.",
"tags.modal.uidLabel": "UID de la puce",
"tags.modal.uidPlaceholder": "04A2B3C4",
"tags.modal.useLastSeen": "L’utiliser",
"tags.modal.waitingAny": "En attente d’une lecture sur n’importe quel lecteur.",
"tags.modal.waitingOn": "En attente d’une lecture sur {{reader}}.",
"tags.moved": "Puce déplacée ici depuis la bobine n° {{id}}.",
"tags.movedFromFilament": "Puce déplacée ici depuis le filament {{name}}.",
"tags.nfc.insecureContext": "La lecture des puces nécessite une connexion sécurisée. Ouvrez Spoolman en HTTPS pour utiliser le NFC de votre téléphone.",
"tags.nfc.notAllowed": "Ce site n’est pas autorisé à utiliser le NFC. Accordez-lui la permission dans les réglages de site de votre navigateur, puis réessayez.",
"tags.nfc.notReadable": "Le lecteur NFC est indisponible — une autre application l’utilise peut-être.",
"tags.nfc.notSupported": "Ce téléphone n’a pas de NFC, ou le NFC est désactivé dans les réglages du système.",
"tags.nfc.unknown": "La lecture de la puce a échoué.",
"tags.none": "Aucune puce associée.",
"tags.scan.openedFilament": "Puce lue : filament {{name}} ouvert.",
"tags.scan.openedSpool": "Puce lue : bobine n° {{id}} ouverte, {{name}}.",
"tags.scan.unknown": "Puce inconnue {{uid}}. Ouvrez la bobine à laquelle elle appartient et utilisez « Ajouter une puce » pour l’associer.",
"tags.section": "Puces",
"tags.unlink": "Dissocier",
"tags.unlinkBody": "La puce {{uid}} cessera d’identifier cette bobine. Rien n’est écrit sur la puce elle-même : vous pourrez l’associer à nouveau — à cette bobine ou à une autre — quand vous le souhaiterez.",
"tags.unlinkBodyFilament": "La puce {{uid}} cessera d’identifier ce filament. Rien n’est écrit sur la puce elle-même : vous pourrez l’associer à nouveau — ici ou ailleurs — quand vous le souhaiterez.",
"tags.unlinkFailed": "La dissociation de la puce a échoué.",
"tags.unlinkTitle": "Dissocier cette puce ?",
"tags.unlinked": "Puce dissociée.",
"tags.unlinking": "Dissociation en cours…",
"topbar.addSpools": "Ajouter des bobines",
"topbar.searchPlaceholder": "Rechercher — essayez « red » ou « pla »",
"unknown": "Inconnu",
"validation.hexDigits": "",
"validation.maxChars": "",
"validation.mustBeGt": "",
"validation.mustBeMax": "",
"validation.mustBeMin": "",
"validation.mustBeNumber": "",
"validation.required": "",
"validation.hexDigits": "Utilisez 6 ou 8 chiffres hexadécimaux",
"validation.maxChars": "{{max}} caractères au maximum",
"validation.mustBeGt": "Doit être supérieur à {{value}}",
"validation.mustBeMax": "Doit être inférieur ou égal à {{value}}",
"validation.mustBeMin": "Doit être supérieur ou égal à {{value}}",
"validation.mustBeNumber": "Doit être un nombre",
"validation.required": "Obligatoire",
"vendor.fields.comment": "Commentaire",
"vendor.fields.emptySpoolWeight": "Poids de la bobine vide",
"vendor.fields.externalId": "ID externe",
"vendor.fields.id": "ID",
"vendor.fields.id": "Identifiant",
"vendor.fields.name": "Nom",
"vendor.fields.registered": "Enregistré",
"vendor.fieldsHelp.emptySpoolWeight": "Le poids d'une bobine vide de ce fabricant.",
+54 -54
View File
@@ -1,48 +1,48 @@
{
"add.addAndNew": "",
"add.adding": "",
"add.adding": "Pridedama...",
"add.advanced": "",
"add.advancedNote": "",
"add.change": "",
"add.advancedNote": "tankis, skersmuo, temperatūros (automatiškai užpildoma pagal medžiagą)",
"add.change": "Keisti",
"add.count": "",
"add.createNew": "",
"add.createNewSub": "",
"add.dbUnavailable": "",
"add.duplicate": "",
"add.duplicateNote": "",
"add.duplicateRename": "",
"add.createNewSub": "nėra tavo kataloge ar {{name}}",
"add.dbUnavailable": "{{name}} nepasiekiams",
"add.duplicate": "dvigubinti",
"add.duplicateNote": "Spesifikacijos, gamintojas ir kainos buvo per kopijuotos. Pervardinkite ir suteikite spalvą.",
"add.duplicateRename": "Pavadinimas nepakeistas — pervardykite, kad galėtumėte atskirti.",
"add.duplicateTitle": "",
"add.filamentNamePlaceholder": "",
"add.fill.full": "",
"add.fill.full": "Pilnas",
"add.fillLevel": "",
"add.importNote": "",
"add.inCatalog": "",
"add.locationHint": "",
"add.locationPlaceholder": "",
"add.manufacturerPlaceholder": "",
"add.locationHint": "Kur fiziškai laikote ritę – lentynoje, dėžėje ar ant spausdintuvo. Palikite lauką tuščią, jei visas jas laikote vienoje vietoje.",
"add.locationPlaceholder": "pvz. Lentyna A, Džiovinimo dėžė 2, Spausdintuvas 1",
"add.manufacturerPlaceholder": "pvz. Polymaker",
"add.nameHint": "",
"add.newFilament": "",
"add.newFilamentTitle": "",
"add.noCatalog": "",
"add.noManufacturer": "",
"add.pickColor": "",
"add.searchPlaceholder": "",
"add.searching": "",
"add.noManufacturer": "Trūksta gamintojo",
"add.pickColor": "Pasirinkite spalvą",
"add.searchPlaceholder": "Ieškokite savo kataloge ir {{name}}...",
"add.searching": "Ieškoma...",
"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.step2": "Žingsnis 2 · ritės duomenys",
"add.typeToSearchAll": "Rašykite, norėdami ieškoti tarp tūkstančių gijų",
"add.typeToSearchMatches": "Rašykite – atitikmenų nerasta",
"add.useExisting": "Naudoti esamą",
"add.vendorHint.existing": "Naudoti esamą gamintoją „{{name}}“",
"add.vendorHint.new": "Naujas gamintojas „{{name}}“ bus sukurtas",
"add.vendorHint.optional": "Nebūtina – palikite tuščią, jei gamintojo nėra",
"add.yourCatalog": "Jūsų katalogas",
"buttons.archive": "Archyvas",
"buttons.cancel": "Atšaukti",
"buttons.clear": "Išvalyti",
"buttons.close": "",
"buttons.close": "Uždaryti",
"buttons.delete": "Šalinti",
"buttons.edit": "Redaguoti",
"buttons.filter": "Filtras",
@@ -51,40 +51,40 @@
"buttons.unArchive": "Išarchyvuoti",
"common.decrement": "",
"common.increment": "",
"common.remove": "",
"common.search": "",
"dashboard.addFieldHint": "",
"common.remove": "Panaikinti",
"common.search": "Ieškoti",
"dashboard.addFieldHint": "Nustatymuose prie ritinių pridėkite teksto arba pasirinkimo lauką, kad pagal jį taip pat būtų galima grupuoti",
"dashboard.dashboard": "",
"dashboard.deleteGroup": "",
"dashboard.dragHint": "",
"dashboard.dropHere": "",
"dashboard.deleteGroup": "Ištrinti grupę",
"dashboard.dragHint": "Nutempkite ritę prie kitos kortelės, kad pakeistumėte jos {{field}}, arba ant kortelės, kad pakeistumėte tvarką",
"dashboard.dropHere": "Čia dėkite rites",
"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": "",
"dashboard.errorEmptyName": "Pavadinimo laukas negali būti tuščias",
"dashboard.errorExists": "Tokio pavadinimo grupė jau egzistuoja",
"dashboard.errorRename": "Nepavyko pervardyti grupės",
"dashboard.lastUsed": "Paskutinį kartą naudota prieš {{time}}",
"dashboard.newGroup": "Nauja grupė",
"dashboard.newGroupName": "Nauja grupė {{n}}",
"dashboard.reorderGroup": "Temkite, kad perrūšiuotumėte grupes",
"dashboard.unassigned": "Nepriskita",
"datetime.done": "Baigta",
"datetime.hour": "Valanda",
"datetime.minute": "Minutė",
"datetime.nextMonth": "Kitas mėnuo",
"datetime.now": "Dabar",
"datetime.pick": "Pasirinkite datą ir laiką",
"datetime.prevMonth": "Praeitas mėnuo",
"datetime.time": "Laikas",
"documentTitle.dashboard.list": "",
"documentTitle.labels.list": "",
"documentTitle.labels.list": "Etiketės | Spoolman",
"documentTitle.library.list": "",
"documentTitle.locations.list": "Vieta | Spoolman",
"documentTitle.settings.list": "",
"filament.fields.addColor": "",
"filament.fields.addColor": "Pridėti spalvą",
"filament.fields.articleNumber": "Gaminio numeris",
"filament.fields.coaxial": "Koekstruduotas",
"filament.fields.colorHex": "Spalva",
"filament.fields.colorType": "",
"filament.fields.colorType": "Spalvos tipas",
"filament.fields.comment": "Komentaras",
"filament.fields.density": "Tankis",
"filament.fields.diameter": "Skersmuo",
@@ -96,7 +96,7 @@
"filament.fields.name": "Pavadinimas",
"filament.fields.price": "Kaina",
"filament.fields.registered": "Registruota",
"filament.fields.removeColor": "",
"filament.fields.removeColor": "Panaikinti spalvą",
"filament.fields.settingsBedTemp": "Pado temperatūra",
"filament.fields.settingsExtruderTemp": "Ekstruderio temperatūra",
"filament.fields.singleColor": "Viena",
@@ -104,12 +104,12 @@
"filament.fields.vendor": "Gamintojas",
"filament.fields.weight": "Svoris",
"filament.fieldsHelp.articleNumber": "Pvz. EAN, UPC, ir tt.",
"filament.fieldsHelp.multiColorDirection": "Gijos turėti kelias spalvas dviem būdais: arba dėl koekstruzijos, kaip dviejų spalvų gijos su nuosekliomis daugiaspalvėmis spalvomis, arba dėl išilginių spalvų pokyčių, kaip gradientinės gijos, kurios keičia spalvas išilgai ritės.",
"filament.fieldsHelp.spoolWeight": "Tuščios ritės svoris. Naudojamas išmatuotam ritės svoriui nustatyti.",
"filament.fieldsHelp.multiColorDirection": "Gijos gali turėti kelias spalvas dviem būdais: arba dėl koekstruzijos, kaip dviejų spalvų gijos su nuosekliomis daugiaspalvėmis spalvomis, arba dėl išilginių spalvų pokyčių, kaip gradientinės gijos, kurios keičia spalvas išilgai ritės.",
"filament.fieldsHelp.spoolWeight": "Tuščios ritės svoris. Naudojamas išmatuotam ritės svoriui nustatyti. Nesi tikras? Kartoninės ritės dažniausiai sveria 130 – 150 g. ir plastikinės 180 – 250 g, — tiksliam svoriui nustatyti pasverkite ritę jai pasibaigus.",
"filament.fieldsHelp.weight": "Pilnos ritės gijos svoris (grynasis svoris). Į šį skaičių neturėtų būti įtrauktas pačios ritės svoris, tik gijos svoris. Tai paprastai nurodoma ant pakuotės.",
"filament.filament": "Gijos",
"footer.documentation": "",
"footer.reportIssue": "",
"footer.reportIssue": "Praneškite apie problemą",
"help.help": "Pagalba",
"inspector.addSpoolsOfThis": "",
"inspector.adjustFailed": "",
+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",
File diff suppressed because it is too large. Load diff
+30 -27
View File
@@ -9,28 +9,31 @@
"add.change": "alterar",
"add.count": "Quantidade",
"add.createNew": "Criar um novo filamento",
"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.createNewSub": "Ausente em seu catálogo ou no {{name}}",
"add.dbUnavailable": "{{name}} indisponível",
"add.duplicate": "duplicar",
"add.duplicateNote": "As especificações, o fabricante e o preço foram copiados. Dê a ele um novo nome e uma nova cor.",
"add.duplicateRename": "Ainda com o mesmo nome do original. Renomeie-o para diferenciá-los.",
"add.duplicateTitle": "Novo filamento · cópia de “{{name}}”",
"add.filamentNamePlaceholder": "por exemplo: PLA Dourado Silk",
"add.fill.full": "Cheio",
"add.fillLevel": "Quantidade de filamento",
"add.importNote": "Este filamento será adicionado ao seu catálogo quando você adicionar os carretéis.",
"add.inCatalog": "no catálogo",
"add.locationHint": "Onde você guarda fisicamente o carretel: em uma prateleira, em uma caixa ou na impressora. Deixe em branco se você guardar todos eles no mesmo lugar.",
"add.locationPlaceholder": "por ex.: Prateleira A, Secadora 2, Impressora 1",
"add.manufacturerPlaceholder": "por exemplo: Polymaker",
"add.nameHint": "Inclua a cor. É ela que diferencia os seus filamentos.",
"add.newFilament": "novo filamento",
"add.newFilamentTitle": "Novo filamento",
"add.noCatalog": "Não há filamentos no seu catálogo",
"add.noManufacturer": "Sem fabricante",
"add.pickColor": "Escolher cor",
"add.searchPlaceholder": "Pesquisar em seu catálogo e no {{name}}...",
"add.searching": "Pesquisando...",
"add.section.filament": "Filamento",
"add.section.manufacturer": "Fabricante",
"add.section.manufacturerNote": "Quem fabrica o filamento. É adicionado uma vez, depois é reutilizado em todos os filamentos do mesmo fabricante.",
"add.spoolWeightPreset.cardboard": "",
"add.spoolWeightPreset.plastic": "",
"add.spoolWeightPresetsLead": "",
@@ -108,7 +111,7 @@
"filament.fields.weight": "Peso",
"filament.fieldsHelp.articleNumber": "Por exemplo: EAN, UPC, etc.",
"filament.fieldsHelp.multiColorDirection": "Os filamentos podem ter múltiplas cores de duas maneiras: por meio de coextrusão, como os filamentos bicolores com as cores consistentes ao longo do carretel, ou por meio de mudanças longitudinais de cor, como os filamentos gradientes que mudam as cores ao longo do carretel.",
"filament.fieldsHelp.spoolWeight": "O peso de um carretel vazio. Usado para determinar o peso medido de um carretel.",
"filament.fieldsHelp.spoolWeight": "O peso de um carretel vazio. Usado para determinar o peso medido de um carretel. Não tem certeza? Carretéis de papelão geralmente pesam entre 130 e 150 g, e os de plástico, entre 180 e 250 g. Para obter um valor exato, coloque o carretel em uma balança quando ele estiver vazio ou consulte o fabricante.",
"filament.fieldsHelp.weight": "O peso do filamento de um carretel cheio (peso líquido). Isso não deve incluir o peso do carretel em si, apenas o filamento. É o que normalmente está escrito na embalagem.",
"filament.filament": "Filamentos",
"footer.documentation": "",
@@ -372,11 +375,11 @@
"settings.extraFields.addEntityField": "",
"settings.extraFields.booleanFalse": "Não",
"settings.extraFields.booleanTrue": "Sim",
"settings.extraFields.deleteConfirm": "Apagar o campo {{name}}?",
"settings.extraFields.description.constraints": "Depois que um campo é adicionado, você não pode alterar sua chave ou tipo, e para campos do tipo \"escolha\" você não pode remover as opções ou alterar o estado de \"múltipla de escolha\". Se você remover um campo, os dados associados a ele em todas as entidades serão excluídos.",
"settings.extraFields.deleteConfirm": "Apagar o campo {{name}} em {{entity}}?",
"settings.extraFields.description.constraints": "Depois que um campo for adicionado, você não poderá alterar sua chave ou tipo, e para campos do tipo \"opção\" você não pode remover as opções ou alterar o estado da opção de \"múltipla de escolha\". Se você remover um campo, os dados associados a ele em todas as entidades serão excluídos.",
"settings.extraFields.description.intro": "Aqui você pode adicionar campos personalizados extras às suas entidades.",
"settings.extraFields.description.keyUsage": "A chave é como outros programas leem/gravam os dados, portanto, se o seu campo personalizado for para uma integração com um programa de terceiros, certifique-se de configurá-lo corretamente. O valor padrão é aplicado somente a novos itens.",
"settings.extraFields.description.tableViews": "Campos extras não podem ser ordenados ou filtrados nas visualizações de tabela.",
"settings.extraFields.description.tableViews": "Campos extras não podem ser ordenados ou filtrados nas visualizações de tabela. Campos dos tipos \"opção\" e \"booleano\" também disponibilizam opções de filtragem nesses locais, e todos os tipos de campos extras oferecem suporte à filtragem de valores vazios.",
"settings.extraFields.editorEdit": "",
"settings.extraFields.editorNew": "",
"settings.extraFields.errors.choiceNeeded": "",
@@ -436,7 +439,7 @@
"spool.fields.registered": "Registrado",
"spool.fields.remainingWeight": "Peso Restante",
"spool.fields.usedWeight": "Peso Utilizado",
"spool.fieldsHelp.location": "Onde o carretel está localizado se você tiver vários locais onde armazena seus carretéis.",
"spool.fieldsHelp.location": "O local onde você guarda fisicamente esse carretel: uma prateleira, uma gaveta, uma secadora de filamentos ou a própria impressora em que ele está carregado. Os carretéis são agrupados por localização na sua biblioteca, é assim que você os encontra novamente.",
"spool.fieldsHelp.lotNr": "Número de lote do fabricante. Pode ser usado para garantir que uma impressão tenha cores consistentes se vários carretéis forem usados.",
"spool.fieldsHelp.measuredWeight": "Quanto pesa o filamento e o carretel.",
"spool.fieldsHelp.price": "Preço de um carretel cheio. Se não for definido, o preço do filamento será assumido.",
+218 -70
View File
@@ -31,10 +31,18 @@
"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_few": "{{count}} катушки",
"add.summary_many": "{{count}} катушек",
"add.summary_one": "{{count}} катушка",
@@ -44,7 +52,9 @@
"add.vendorHint.existing": "Используется существующий производитель «{{name}}",
"add.vendorHint.new": "Будет создан новый производитель «{{name}}",
"add.vendorHint.optional": "Необязательно — оставьте пустым, если производитель не нужен",
"add.weightPresets": "Стандартные размеры катушек",
"add.yourCatalog": "Ваш каталог",
"buttons.apply": "Применить",
"buttons.archive": "В архив",
"buttons.cancel": "Отмена",
"buttons.clear": "Очистить",
@@ -54,15 +64,48 @@
"buttons.filter": "Фильтр",
"buttons.save": "Сохранить",
"buttons.showArchived": "Показать архивные",
"buttons.showNoSpools": "Показать филамент без катушек",
"buttons.unArchive": "Из архива",
"changeFilament.action": "Изменить",
"changeFilament.adoptWeight": "Установить полный вес этой катушки на {{full}}",
"changeFilament.adoptWeightHelp": "Записано как {{current}}, из предыдущего филамента. Использованный вес ({{used}}) сохранен, оставляя {{remaining}}.",
"changeFilament.apply": "Сменить филамент",
"changeFilament.applying": "Изменение...",
"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}}, оставляя {{remaining}} после {{used}} уже использованных.",
"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": "Удалить",
@@ -111,6 +154,7 @@
"filament.fields.material": "Материал",
"filament.fields.multiColor": "Многоцветный",
"filament.fields.name": "Имя",
"filament.fields.opacity": "Непрозрачность",
"filament.fields.price": "Цена",
"filament.fields.registered": "Добавлен",
"filament.fields.removeColor": "Удалить цвет",
@@ -136,6 +180,8 @@
"inspector.applying": "Применение...",
"inspector.colorHex": "Шестнадцатеричный код цвета",
"inspector.consumeAmount": "Количество для списания:",
"inspector.defaultFrom.filament": "{{value}} · по умолчанию для филамента",
"inspector.defaultFrom.manufacturer": "{{value}} · по умолчанию для производителя",
"inspector.delete.deleting": "Удаление...",
"inspector.delete.errorGone": "Он уже был удален.",
"inspector.delete.errorSpoolsExist": "Невозможно удалить этот филамент — его используют катушки.",
@@ -177,6 +223,10 @@
"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}}",
@@ -212,6 +262,11 @@
"labels.dpi": "Разрешение",
"labels.dpiHint": "Соответствуйте параметрам принтера, чтобы мелкие этикетки были четкими: 203 dpi для большинства термопринтеров, 300 или более для лазерных и струйных.",
"labels.duplicate": "Дублировать",
"labels.exportDescAml": "Сохраняет один файл .aml для каждой этикетки размером {{w}}×{{h}} мм. Открывается в приложениях на базе LPAPI (обычно поставляются с термопринтерами), которые считывают размер из файла.",
"labels.exportDescPng": "Сохраняет одно PNG изображение для каждой этикетки размером {{w}}×{{h}} мм.",
"labels.exportFormat": "Формат файла",
"labels.exportFormatOptions.aml": "Файл этикетки AML",
"labels.exportFormatOptions.png": "Изображение PNG",
"labels.fields.bedTemp": "Температура стола",
"labels.fields.diameterMm": "Диаметр (мм)",
"labels.fields.groupVendor": "Производитель",
@@ -241,7 +296,7 @@
"labels.labelSize": "Размер этикетки",
"labels.labelType": "Тип этикетки",
"labels.layout": "Макет",
"labels.modeImage": "Файлы изображений",
"labels.modeImage": "Файлы",
"labels.modeLabel": "Принтер этикеток",
"labels.modeSheet": "Лист (плитка)",
"labels.new": "Новый",
@@ -258,6 +313,7 @@
"labels.printN_one": "Печать {{count}} этикетки",
"labels.radiusMm": "Радиус",
"labels.sampleData": "Пример данных",
"labels.saveAsFormat": "Сохранить как {{format}}",
"labels.saved": "Сохранено",
"labels.saving": "Сохранение...",
"labels.searchFilaments": "Поиск филаментов...",
@@ -275,6 +331,7 @@
"labels.showingSpool": "Отображение катушки #{{id}}",
"labels.showingTags": "Поля отображаются как теги, пока не выбрана катушка выше.",
"labels.sizeMm": "Размер",
"labels.skipHint": "Пропустить элементы — оставляет указанное количество клеток пустыми, чтобы использовать остатки листа.",
"labels.sortHint": "Сортировать катушки",
"labels.sortId": "#",
"labels.sortNewest": "Самые новые",
@@ -284,7 +341,7 @@
"labels.tabPrint": "Печать",
"labels.textTemplate": "Текст / шаблон",
"labels.typeFilament": "Филамет",
"labels.typeHint": "",
"labels.typeHint": "Этикетки для филамента исключают поля, специфичные для катушки, а QR-код ведет на страницу филамента.",
"labels.typeSpool": "Катушка",
"labels.unknownFilament": "Неизвестный филамент",
"labels.wMm": "Ш",
@@ -293,52 +350,73 @@
"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_few": "{{count}} катушки",
"library.groupSpools_many": "{{count}} катушек",
"library.groupSpools_one": "{{count}} катушка",
"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": "",
"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": "Ошибка (status code: {{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.dimensions": "Размеры",
"printing.generic.gridTooWide": "",
"printing.generic.gridTooWide": "Этикетки шире страницы. Уменьшите количество столбцов, интервалы или поля.",
"printing.generic.helpMargin": "Поля должны быть настроены в соответствии с вашей этикеточной бумагой и принтером. Их изменение повлияет на размер всей сетки.",
"printing.generic.helpPrinterMargin": "В безопасной зоне должно быть установлено, насколько близко к краю бумаги может печатать ваш принтер. Изменение этих значений не повлияет на всю сетку.",
"printing.generic.horizontalSpacing": "Горизонтальный интервал",
@@ -356,11 +434,11 @@
"printing.generic.skipItems": "Пропустить элементы",
"printing.generic.verticalSpacing": "Вертикальный интервал",
"printing.qrcode.button": "Печать этикеток",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
"printing.qrcode.customUrl.caveat": "Пользовательский URL не будет сканироваться обратно в Spoolman — используйте его для внешних приложений.",
"printing.qrcode.customUrl.hint": "Вставьте ID катушки в любое место с помощью плейсхолдера",
"printing.qrcode.customUrl.label": "Шаблон пользовательского URL",
"printing.qrcode.useHTTPUrl.label": "Ссылка на QR-код",
"printing.qrcode.useHTTPUrl.options.custom": "",
"printing.qrcode.useHTTPUrl.options.custom": "Пользовательский URL",
"printing.qrcode.useHTTPUrl.options.default": "По умолчанию",
"printing.qrcode.useHTTPUrl.options.url": "URL",
"printing.qrcode.useHTTPUrl.preview": "Предварительный просмотр:",
@@ -369,45 +447,46 @@
"printing.spoolSelect.selectedTotal_many": "{{count}} катушек выбрано",
"printing.spoolSelect.selectedTotal_one": "{{count}} катушка выбрана",
"scanner.description": "Отсканируйте QR-код Spoolman, чтобы просмотреть подробную информацию о катушке.",
"scanner.error.inUse": "",
"scanner.error.inUse": "Камера уже используется другим приложением.",
"scanner.error.insecureContext": "Страница не обслуживается по протоколу HTTPS.",
"scanner.error.notAllowed": "Вы запретили доступ к камере.",
"scanner.error.notFound": "Ни одна камера не найдена.",
"scanner.error.streamApiNotSupported": "Браузер не поддерживает API MediaStream.",
"scanner.error.unknown": "Произошла неизвестная ошибка. ({{error}})",
"scanner.starting": "",
"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": "",
"search.colorHint": "Имена цветов должны быть на английском.",
"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}} сущности {{entity}}?",
"settings.extraFields.deleteConfirm": "Удалить поле {{name}} у {{entity}}?",
"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.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": "Выбор",
@@ -419,7 +498,7 @@
"settings.extraFields.fieldType.text": "Текст",
"settings.extraFields.max": "макс",
"settings.extraFields.min": "мин",
"settings.extraFields.multiSuffix": "",
"settings.extraFields.multiSuffix": " · мульти",
"settings.extraFields.multiple": "Множественный",
"settings.extraFields.namePlaceholder": "Отображаемое название",
"settings.extraFields.nonUniqueKeyError": "Ключ должен быть уникальным.",
@@ -431,6 +510,7 @@
"settings.extraFields.params.name": "Имя",
"settings.extraFields.params.order": "Сортировка",
"settings.extraFields.params.unit": "Единица измерения",
"settings.extraFields.reorderChoice": "Перетащите или используйте стрелки для изменения порядка вариантов.",
"settings.extraFields.saveField": "Сохранить поле",
"settings.extraFields.tab": "Дополнительные поля",
"settings.general.baseUrl.label": "Базовый URL-адрес",
@@ -445,7 +525,22 @@
"settings.language.label": "Язык",
"settings.library.lowThreshold.desc": "Катушка с таким остатком или меньше, будет помечена красным (только этот браузер)",
"settings.library.lowThreshold.label": "Порог низкого остатка",
"settings.library.tab": "",
"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": "Филамент",
@@ -467,15 +562,68 @@
"spool.fieldsHelp.measuredWeight": "Сколько весит филамент и катушка.",
"spool.fieldsHelp.price": "Цена полной катушки. Если не указано, будет использоваться цена филамента.",
"spool.fieldsHelp.remainingWeight": "Сколько филамента осталось на катушке. Для новой катушки этот вес должен соответствовать весу катушки.",
"spool.fieldsHelp.spoolWeight": "Вес пустой катушки. Оставьте поле пустым, чтобы использовать значение по умолчанию для филамента, или укажите, если эта конкретная катушка отличается (например, перемотанный ролик или картонная втулка вместо пластиковой). Это значение вычитается из измеренного веса при настройке, поэтому неправильное значение приведет к неверному расчету остатка филамента.",
"spool.fieldsHelp.usedWeight": "Сколько филамента было использовано с катушки. На новой катушке должно быть использовано 0 г.",
"spool.fieldsHelp.weight": "Сколько филамента было на катушке, когда она была полной (вес нетто, без учета самой катушки). Оставьте поле пустым, чтобы использовать вес филамента, или укажите значение, если эта конкретная катушка отличается — например, неполный рулон или тот же филамент на катушке большего размера.",
"spool.form.measurementType.length": "Длина",
"spool.form.measurementType.weight": "Вес",
"spool.spool": "Катушки",
"tags.add": "Добавить метку",
"tags.added": "Метка привязана.",
"tags.addedOn": "Добавлено {{date}}",
"tags.modal.alreadyHere": "Эта метка уже идентифицирует эту катушку.",
"tags.modal.alreadyHereFilament": "Эта метка уже идентифицирует этот филамент.",
"tags.modal.badUid": "UID метки в HEX-формате, например 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": "Для считывания меток требуется защищённое соединение. Откройте Spoolman по HTTPS, чтобы использовать 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": "",
"topbar.searchPlaceholder": "Поиск — попробуйте \"red\" или \"pla\"",
"unknown": "Неизвестно",
"validation.hexDigits": "",
"validation.maxChars": "",
"validation.hexDigits": "Используйте 6 или 8 шестнадцатеричных цифр",
"validation.maxChars": "Максимум {{max}} символов",
"validation.mustBeGt": "Должно быть больше {{value}}",
"validation.mustBeMax": "Должно быть равно или меньше {{value}}",
"validation.mustBeMin": "Должно быть равно или больше {{value}}",
+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",
+424 -344
View File
@@ -1,93 +1,143 @@
{
"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.save": "บันทึก",
"buttons.showArchived": "แสดงที่เก็บถาวร",
"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}} คงเหลือ {{remaining}} หลังจากหักลบ {{used}} ที่ใช้ไปแล้ว",
"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": "",
"filament.fields.articleNumber": "เลขบทความ",
"filament.fields.coaxial": "เส้นหลายสี",
"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": "เส้นผ่านศูนย์กลาง",
@@ -97,9 +147,10 @@
"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": "สีเดียว",
@@ -108,219 +159,242 @@
"filament.fields.weight": "น้ำหนัก",
"filament.fieldsHelp.articleNumber": "เช่น EAN, UPC, เป็นต้น",
"filament.fieldsHelp.multiColorDirection": "เส้นพลาสติกหลายสีสามารถมีหลายสีได้ 2 รูปแบบ: แบบ coextrusion (เช่น เส้นพลาสติกสองสีพร้อมกัน) หรือแบบไล่สีตามความยาว (เช่น เส้นพลาสติกเปลี่ยนสีไปเรื่อยๆ)",
"filament.fieldsHelp.spoolWeight": "น้ำหนักม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักรวมของม้วนพลาสติก",
"filament.fieldsHelp.spoolWeight": "น้ำหนักของม้วนพลาสติกเปล่า ใช้เพื่อคำนวณน้ำหนักที่วัดได้ของม้วนพลาสติก หากไม่แน่ใจ ม้วนกระดาษมักหนัก 130–150 ก. และม้วนพลาสติกหนัก 180–250 ก. — หากต้องการตัวเลขที่แม่นยำ ให้นำม้วนเปล่าไปชั่งเมื่อใช้หมด",
"filament.fieldsHelp.weight": "น้ำหนักของเส้นพลาสติกในม้วนพลาสติก (สุทธิ) ไม่รวมม้วนพลาสติกเปล่า เป็นค่าน้ำหนักที่มักระบุบนบรรจุภัณฑ์",
"filament.filament": "เส้นพลาสติก",
"footer.documentation": "",
"footer.reportIssue": "",
"footer.documentation": "เอกสารคู่มือ",
"footer.reportIssue": "รายงานปัญหา",
"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": "รหัสสี (HEX)",
"inspector.consumeAmount": "ปริมาณที่ใช้ไป:",
"inspector.defaultFrom.filament": "{{value}} · ค่าเริ่มต้นของเส้นพลาสติก",
"inspector.defaultFrom.manufacturer": "{{value}} · ค่าเริ่มต้นของผู้ผลิต",
"inspector.delete.deleting": "กำลังลบ...",
"inspector.delete.errorGone": "รายการนี้ถูกลบไปแล้ว",
"inspector.delete.errorSpoolsExist": "ไม่สามารถลบเส้นพลาสติกนี้ได้ — ยังมีม้วนพลาสติกที่ใช้งานอยู่",
"inspector.delete.errorUnknown": "การลบล้มเหลว ตรวจสอบรายละเอียดได้ที่ server logs",
"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": "น้ำหนักรวมใหม่ (Gross Weight):",
"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 code",
"labels.addRect": "สี่เหลี่ยม",
"labels.addSwatch": "ตัวอย่างสี (Swatch)",
"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 code พร้อมฟิลด์ของม้วนพลาสติก เส้นพลาสติก หรือผู้ผลิต แล้วพิมพ์ลงบนแผ่นสติกเกอร์หรือเครื่องพิมพ์ฉลาก รูปแบบจะถูกบันทึกไว้บนเซิร์ฟเวอร์",
"labels.designerTitle": "ตัวออกแบบฉลาก",
"labels.dpi": "ความละเอียด",
"labels.dpiHint": "กำหนดให้ตรงกับเครื่องพิมพ์เพื่อให้ฉลากขนาดเล็กคมชัด: 203 dpi สำหรับเครื่องพิมพ์ฉลากความร้อนส่วนใหญ่, 300 ขึ้นไปสำหรับเลเซอร์และอิงค์เจ็ท",
"labels.duplicate": "ทำซ้ำ",
"labels.exportDescAml": "บันทึกไฟล์ฉลาก .aml หนึ่งไฟล์ต่อฉลากที่เลือก แต่ละฉลากขนาด {{w}}×{{h}} มม. เปิดใช้งานได้ในแอปที่ใช้ LPAPI ซึ่งมาพร้อมกับเครื่องพิมพ์ฉลากความร้อนส่วนใหญ่ โดยจะอ่านขนาดจริงจากไฟล์",
"labels.exportDescPng": "บันทึกภาพ PNG หนึ่งไฟล์ต่อฉลากที่เลือก แต่ละฉลากขนาด {{w}}×{{h}} มม.",
"labels.exportFormat": "รูปแบบไฟล์",
"labels.exportFormatOptions.aml": "ไฟล์ฉลาก AML",
"labels.exportFormatOptions.png": "รูปภาพ PNG",
"labels.fields.bedTemp": "อุณหภูมิฐานพิมพ์",
"labels.fields.diameterMm": "เส้นผ่านศูนย์กลาง (มม.)",
"labels.fields.groupVendor": "ผู้ผลิต",
"labels.fields.initialG": "เริ่มต้น (ก.)",
"labels.fields.netWeightG": "น้ำหนักสุทธิ (ก.)",
"labels.fields.remainingG": "คงเหลือ (ก.)",
"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}} รายการจะถูกส่งออกเป็นไฟล์ zipไฟล์เดียว",
"labels.insertField": "แทรกฟิลด์",
"labels.kind.qr": "QR code",
"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}} มม. ต่อหนึ่งฉลาก",
"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": "กรุณาเลือกม้วนพลาสติกอย่างน้อย 1 ม้วน",
"labels.selectAtLeastOneFilament": "กรุณาเลือกเส้นพลาสติกอย่างน้อย 1 รายการ",
"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 code จะลิงก์ไปยังเส้นพลาสติกแทนม้วนพลาสติก",
"labels.typeSpool": "ม้วนพลาสติก",
"labels.unknownFilament": "ไม่ทราบเส้นพลาสติก",
"labels.wMm": "กว้าง",
"labels.widthMm": "ความกว้าง",
"labels.wrapText": "ตัดข้อความอัตโนมัติ",
"labels.xMm": "X",
"labels.yMm": "Y",
"library.apiError": "ไม่สามารถเชื่อมต่อกับ Spoolman API ได้ Backend กำลังทำงานอยู่หรือไม่?",
"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.noValues": "ไม่มีข้อมูล",
"library.openGroup": "เปิด {{name}}",
"library.openManufacturer": "เปิดผู้ผลิต {{name}}",
"library.resizeList": "ปรับขนาดรายการ (ดับเบิลคลิกเพื่อรีเซ็ต)",
"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.dimensions": "ขนาด",
"printing.generic.gridTooWide": "",
"printing.generic.gridTooWide": "ฉลากมีความกว้างเกินหน้ากระดาษ กรุณาลดจำนวนคอลัมน์ ระยะห่าง หรือระยะขอบ",
"printing.generic.helpMargin": "การตั้งค่าขอบกระดาษควรกำหนดให้ตรงกับขนาดกระดาษและเครื่องพิมพ์ของคุณ การเปลี่ยนค่านี้จะมีผลต่อขนาดตารางทั้งหมด",
"printing.generic.helpPrinterMargin": "Safe-Zone ควรกำหนดเป็นค่าระยะห่างจากขอบกระดาษที่เครื่องพิมพ์สามารถพิมพ์ได้ การเปลี่ยนค่านี้จะไม่กระทบต่อขนาดตารางทั้งหมด",
"printing.generic.horizontalSpacing": "ระยะห่างแนวนอน",
@@ -338,72 +412,73 @@
"printing.generic.skipItems": "ข้ามรายการ",
"printing.generic.verticalSpacing": "ระยะห่างแนวตั้ง",
"printing.qrcode.button": "พิมพ์ฉลาก",
"printing.qrcode.customUrl.caveat": "",
"printing.qrcode.customUrl.hint": "",
"printing.qrcode.customUrl.label": "",
"printing.qrcode.customUrl.caveat": "URL ที่กำหนดเองจะไม่สามารถสแกนกลับเข้ามาใน Spoolman ได้ — ใช้เพื่อชี้ QR code ไปยังแอปภายนอกหรือโฮสต์อื่น",
"printing.qrcode.customUrl.hint": "แทรก spool id ตรงจุดใดก็ได้โดยใช้ตัวยึดตำแหน่ง",
"printing.qrcode.customUrl.label": "เทมเพลต URL กำหนดเอง",
"printing.qrcode.useHTTPUrl.label": "ลิงก์ QR code",
"printing.qrcode.useHTTPUrl.options.custom": "",
"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 จะใช้จาก base URL ที่กำหนดไว้ใน settings หรือใช้ URL ของหน้าปัจจุบันหากไม่ได้ตั้งค่า",
"printing.spoolSelect.selectedTotal_other": "เลือก {{count}} ม้วนพลาสติก",
"scanner.description": "สแกน Spoolman QR code เพื่อดูข้อมูลเกี่ยวกับม้วนพลาสติก",
"scanner.error.inUse": "",
"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.starting": "กำลังเปิดใช้งานกล้อง...",
"scanner.title": "เครื่องสแกน QR Code",
"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": "ชื่อสีต้องกรอกเป็นภาษาอังกฤษ",
"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": "ลบฟิลด์ {{name}} บน {{entity}} หรือไม่?",
"settings.extraFields.description.constraints": "เมื่อเพิ่มฟิลด์แล้ว คุณจะไม่สามารถเปลี่ยนคีย์หรือประเภทของฟิลด์ได้ และสำหรับฟิลด์ประเภทตัวเลือกคุณจะไม่สามารถลบตัวเลือกหรือเปลี่ยนโหมด multi choice ได้ หากคุณลบฟิลด์ ข้อมูลของเอนทิตีทั้งหมดจะถูกลบด้วย",
"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.fieldType.boolean": "บูลีน(ใส่เฉพาะ ใช่/ไม่)",
"settings.extraFields.fieldType.choice": "ตัวเลือก",
"settings.extraFields.fieldType.datetime": "วันเวลา",
"settings.extraFields.fieldType.float": "ทศนิยม",
"settings.extraFields.fieldType.floatRange": "ช่วงทศนิยม",
"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.description.tableViews": "ฟิลด์เพิ่มเติมสามารถแสดงและจัดเรียงในมุมมองตารางได้ ฟิลด์ประเภทตัวเลือกและบูลีนยังมีตัวเลือกการกรองที่นั่นด้วย และฟิลด์เพิ่มเติมทุกประเภทรองรับการกรองหาค่าว่าง",
"settings.extraFields.editorEdit": "แก้ไขฟิลด์ {{entity}}",
"settings.extraFields.editorNew": "เพิ่มฟิลด์ {{entity}} ใหม่",
"settings.extraFields.errors.choiceNeeded": "ฟิลด์ประเภทตัวเลือกต้องมีตัวเลือกอย่างน้อย 1 รายการ",
"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": "บูลีน (Boolean)",
"settings.extraFields.fieldType.choice": "ตัวเลือก (Choice)",
"settings.extraFields.fieldType.datetime": "วันเวลา (Datetime)",
"settings.extraFields.fieldType.float": "ทศนิยม (Float)",
"settings.extraFields.fieldType.floatRange": "ช่วงทศนิยม (Float Range)",
"settings.extraFields.fieldType.integer": "จำนวนเต็ม (Integer)",
"settings.extraFields.fieldType.integerRange": "ช่วงจำนวนเต็ม (Integer Range)",
"settings.extraFields.fieldType.text": "ข้อความ (Text)",
"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 +486,22 @@
"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": "URLพื้นฐาน ที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code",
"settings.general.currency.desc": "",
"settings.general.baseUrl.label": "URL พื้นฐาน",
"settings.general.baseUrl.tooltip": "URL พื้นฐานที่ใช้สำหรับสร้างฟีเจอร์ต่างๆ เช่น QR code",
"settings.general.currency.desc": "รหัส ISO 3 ตัวอักษร (เช่น THB, EUR, USD) สำหรับแสดงราคา",
"settings.general.currency.label": "สกุลเงิน",
"settings.general.roundPrices.label": "",
"settings.general.roundPrices.tooltip": "",
"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": "พฤติกรรมคลังข้อมูล",
"spool.fields.archived": "เก็บถาวร",
"spool.fields.comment": "หมายเหตุ",
"spool.fields.filament": "เส้นพลาสติก",
@@ -439,26 +515,30 @@
"spool.fields.price": "ราคา",
"spool.fields.registered": "ลงทะเบียน",
"spool.fields.remainingWeight": "น้ำหนักคงเหลือ",
"spool.fields.spoolWeight": "น้ำหนักม้วนพลาสติกเปล่า",
"spool.fields.usedWeight": "น้ำหนักที่ใช้ไป",
"spool.fieldsHelp.location": "ตำแหน่งที่เก็บม้วนพลาสติก หากคุณมีหลายที่เก็บ นี่คือที่ที่คุณจัดเก็บม้วนพลาสติก",
"spool.fields.weight": "น้ำหนัก",
"spool.fieldsHelp.location": "ตำแหน่งที่คุณจัดเก็บม้วนพลาสติกจริง — เช่น ชั้นวาง ลิ้นชัก กล่องกันชื้น หรือเครื่องพิมพ์ที่กำลังใส่อยู่ ม้วนพลาสติกจะถูกจัดกลุ่มตามสถานที่ในคลัง เพื่อให้คุณค้นหากลับมาได้ง่าย",
"spool.fieldsHelp.lotNr": "เลขล็อตของผู้ผลิต ใช้เพื่อให้มั่นใจว่า การพิมพ์จะมีสีสม่ำเสมอ หากต้องใช้พลาสติกหลายม้วน",
"spool.fieldsHelp.measuredWeight": "น้ำหนักรวมของเส้นพลาสติกและม้วนพลาสติก",
"spool.fieldsHelp.price": "ราคาของม้วนพลาสติกเต็ม หากไม่ได้ตั้งค่า จะใช้ราคาของเส้นพลาสติกแทน",
"spool.fieldsHelp.remainingWeight": "ปริมาณเส้นพลาสติกที่เหลืออยู่ในม้วนพลาสติก สำหรับม้วนพลาสติกใหม่ควรมีค่าเท่ากับน้ำหนักของม้วนพลาสติกเต็ม",
"spool.fieldsHelp.remainingWeight": "ปริมาณเส้นพลาสติกที่เหลืออยู่ในม้วน สำหรับม้วนพลาสติกใหม่ควรมีค่าเท่ากับน้ำหนักของม้วนพลาสติกเต็ม",
"spool.fieldsHelp.spoolWeight": "น้ำหนักของม้วนพลาสติกนี้เมื่อไม่มีเส้นพลาสติก (ม้วนเปล่า) เว้นว่างไว้เพื่อใช้น้ำหนักม้วนเปล่าตามเส้นพลาสติก และระบุเมื่อม้วนนี้มีความแตกต่าง — เช่น ม้วนที่กรอใหม่ หรือเปลี่ยนแกนกระดาษเป็นพลาสติก ค่าน้ำหนักนี้จะถูกนำไปลบเมื่อคุณปรับค่าน้ำหนักที่วัดได้ หากระบุผิดจะทำให้การคำนวณเส้นพลาสติกคงเหลือคลาดเคลื่อน",
"spool.fieldsHelp.usedWeight": "ปริมาณเส้นพลาสติกที่ถูกใช้ไปจากม้วนพลาสติก ม้วนพลาสติกใหม่ควรเริ่มต้นที่ 0 กรัม",
"spool.fieldsHelp.weight": "ปริมาณเส้นพลาสติกที่ม้วนนี้บรรจุได้เมื่อเต็ม (น้ำหนักสุทธิ ไม่รวมม้วนเปล่า) เว้นว่างไว้เพื่อใช้น้ำหนักตามเส้นพลาสติก และระบุเมื่อม้วนนี้แตกต่างจากปกติ — เช่น ม้วนแบ่ง หรือเส้นพลาสติกเดียวกันแต่ซื้อแบบม้วนขนาดใหญ่กว่า",
"spool.form.measurementType.length": "ความยาว",
"spool.form.measurementType.weight": "น้ำหนัก",
"spool.spool": "ม้วนพลาสติก",
"topbar.addSpools": "",
"topbar.searchPlaceholder": "",
"topbar.addSpools": "เพิ่มม้วนพลาสติก",
"topbar.searchPlaceholder": "ค้นหา — เช่น \"red\" หรือ \"pla\"",
"unknown": "ไม่ทราบ",
"validation.hexDigits": "",
"validation.maxChars": "",
"validation.mustBeGt": "",
"validation.mustBeMax": "",
"validation.mustBeMin": "",
"validation.mustBeNumber": "",
"validation.required": "",
"validation.hexDigits": "ต้องเป็นรหัสสี Hex 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": "รหัสภายนอก",
+9 -9
View File
@@ -3,24 +3,24 @@
"add.adding": "",
"add.advanced": "",
"add.advancedNote": "",
"add.change": "",
"add.change": "змінити",
"add.count": "",
"add.createNew": "",
"add.createNewSub": "",
"add.dbUnavailable": "",
"add.duplicate": "",
"add.duplicateNote": "",
"add.duplicateRename": "",
"add.duplicateTitle": "",
"add.filamentNamePlaceholder": "",
"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": "",
"add.manufacturerPlaceholder": "",
"add.nameHint": "",
"add.manufacturerPlaceholder": "наприклад, Polymaker",
"add.nameHint": "Вкажіть колір — саме за ним відрізняються ваші філаменти.",
"add.newFilament": "",
"add.newFilamentTitle": "",
"add.noCatalog": "",
+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": "是"
}
File diff suppressed because it is too large. Load diff
+489 -461
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "spoolman-client-v2",
"private": true,
"version": "0.26.1",
"version": "0.27.0",
"type": "module",
"scripts": {
"strip-empty-locales": "node scripts/strip-empty-locales.mjs",
@@ -41,7 +41,7 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.65.0",
"vite": "^8.2.0",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"dependencies": {
"@lucide/svelte": "^1.28.0",
+3 -1
View File
@@ -35,6 +35,8 @@
"ta",
"th",
"lt",
"tr"
"tr",
"sl",
"ko"
]
}
+3 -3
View File
@@ -273,7 +273,7 @@ input[type='number'] {
.seg-btn,
.tab,
.logo,
.kofi,
.sponsor,
.crumb,
.arch-toggle,
.spool-row,
@@ -289,9 +289,9 @@ input[type='number'] {
.grabber {
align-items: center;
}
/* The Ko-fi button collapses to an icon-only square under 560px; keep it a
/* The sponsor button collapses to an icon-only square under 560px; keep it a
full tap target in both dimensions. */
.kofi {
.sponsor {
min-width: 44px;
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Reveal text that has been cut off with an ellipsis (#1093).
*
* Truncated text is unreadable, and the value behind it — a filament name, a
* location, a manufacturer — is usually the whole reason the row is on screen.
* The fix is the one every platform uses: hover it and see the full string.
*
* The point of doing it here rather than with a plain `title={...}` is the word
* *cut off*. A title on text that already fits is noise: it pops a redundant
* tooltip over every row the pointer crosses. So the element is measured, and
* the tooltip appears and disappears as the layout changes — which it does
* constantly here, since the list pane is resizable (#1034) and the same row is
* truncated at one width and complete at the next.
*
* Scope: this is the pointer story. Touch has no hover, and the answer there is
* the one already in the app — tapping a row opens the inspector, which shows
* every value in full and never truncates it.
*/
/**
* Fractional layout puts scrollWidth a hair over clientWidth on text that
* visibly fits, so a bare `>` would tag half the rows in a list. A pixel of
* slack is well under the width of the ellipsis it takes to truncate.
*/
function isCut(node: HTMLElement): boolean {
return node.scrollWidth - node.clientWidth > 1;
}
/**
* Re-measure whenever either half of the question can have changed: the width of
* the box, or the text asked to fit in it.
*
* Both are needed. Resizing is the obvious one — the splitter, the window, a
* webfont arriving. But the box can also keep its size while its contents
* change, which is the whole of what a live update does to a row: same column,
* longer name. A ResizeObserver never hears about that.
*/
function watch(node: HTMLElement, measure: () => void) {
// Fires once on observe, after the first layout: that is the initial reading.
const ro = new ResizeObserver(measure);
ro.observe(node);
// Attributes are deliberately not observed — truncTitle writes one.
const mo = new MutationObserver(measure);
mo.observe(node, { subtree: true, childList: true, characterData: true });
return () => {
ro.disconnect();
mo.disconnect();
};
}
/**
* Give an element a `title` for as long as its own text is cut off.
*
* The element normally speaks for itself. Its `textContent` is put through the
* same collapse the renderer applies, so an element built from several spans
* reads back as the one line it draws rather than carrying the markup's
* newlines and indentation into the tooltip. Pass the text explicitly only where
* the visible string genuinely isn't the element's own text.
*/
export function truncTitle(node: HTMLElement, text?: string) {
let full = text;
const measure = () => {
const value = (full ?? node.textContent ?? '').replace(/\s+/g, ' ').trim();
if (value && isCut(node)) node.setAttribute('title', value);
else node.removeAttribute('title');
};
const stop = watch(node, measure);
return {
update(next?: string) {
full = next;
measure();
},
destroy: stop
};
}
/**
* Report whether an element's text is cut off, instead of titling it.
*
* For text the pointer can't reach: a group header stretches its link across the
* whole row, so the element under the cursor is that overlay and never the title
* beneath it, and a `title` there would never show. The caller takes the answer
* and hangs the tooltip on an ancestor the hover does land on.
*/
export function onTruncated(node: HTMLElement, onchange: (cut: boolean) => void) {
let cb = onchange;
let last: boolean | null = null;
const stop = watch(node, () => {
const cut = isCut(node);
if (cut === last) return;
last = cut;
cb(cut);
});
return {
update(next: (cut: boolean) => void) {
cb = next;
},
destroy: stop
};
}
@@ -0,0 +1,94 @@
import { spoolSource } from './spoolSource';
import type { ExternalFilament } from './external';
/** Rows per request. Enough to overflow a picker's result list, so there is something to scroll. */
const PAGE = 20;
/** Distance from the bottom of the result list at which the next page is fetched. */
const SCROLL_MARGIN = 120;
/**
* The external-catalog half of a filament picker's search (#1136): `search` loads the
* first page of matches, and scrolling the result list towards its end appends the next.
* Shared by the add-spools and change-filament dialogs.
*/
export class ExternalSearch {
items = $state<ExternalFilament[]>([]);
error = $state(false);
loadingMore = $state(false);
#total = 0;
#query = '';
#loading = false;
// Bumped by every new search, so a page that lands after the query changed is dropped.
#req = 0;
#ctrl: AbortController | undefined;
// The result list, remembered from its first scroll event.
#list: HTMLElement | undefined;
async search(query: string): Promise<void> {
const req = this.#restart();
this.#query = query.trim();
this.#loading = true;
this.error = false;
try {
const page = await spoolSource.searchExternalFilaments(this.#query, PAGE, 0, this.#ctrl!.signal);
if (req !== this.#req) return;
this.items = page.items;
this.#total = page.total;
// A new query starts at the top: left scrolled down, the list would fetch
// its second page straight away.
this.#list?.scrollTo(0, 0);
} catch {
if (req !== this.#req) return;
this.items = [];
this.#total = 0;
this.error = true;
} finally {
if (req === this.#req) this.#loading = false;
}
}
async more(): Promise<void> {
if (this.#loading || this.loadingMore || this.error || this.items.length >= this.#total) return;
const req = this.#req;
this.loadingMore = true;
try {
const page = await spoolSource.searchExternalFilaments(
this.#query,
PAGE,
this.items.length,
this.#ctrl!.signal
);
if (req !== this.#req) return;
// A catalog sync between two pages shifts the offsets and can hand back a row
// that is already listed; a repeated id would break the keyed list.
const fresh = page.items.filter((f) => !this.items.some((e) => e.id === f.id));
this.items = [...this.items, ...fresh];
this.#total = page.total;
} catch (e) {
if (req === this.#req) console.error('Failed to load more external filaments', e);
} finally {
if (req === this.#req) this.loadingMore = false;
}
}
/** Forget the results, dropping any request still in flight. */
clear(): void {
this.#restart();
this.items = [];
this.#total = 0;
}
/** Scroll handler for the result list. An arrow function so it can be passed as-is. */
onscroll = (e: Event) => {
const el = (this.#list = e.currentTarget as HTMLElement);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_MARGIN) void this.more();
};
#restart(): number {
this.#ctrl?.abort();
this.#ctrl = new AbortController();
this.#loading = false;
this.loadingMore = false;
return ++this.#req;
}
}
+29 -8
View File
@@ -36,7 +36,15 @@ export function isAbortError(e: unknown, signal?: AbortSignal): boolean {
export class HttpError extends Error {
constructor(
message: string,
readonly status: number
readonly status: number,
/**
* The parsed error body, when there was one. Most failures need nothing
* beyond the status, but a few carry a field the UI can act on rather than
* merely report — linking a tag another spool already holds answers 409
* with the `spool_id` that holds it, which is what lets the client offer to
* move it without first going and looking the tag up.
*/
readonly body?: Record<string, unknown>
) {
super(message);
this.name = 'HttpError';
@@ -53,13 +61,14 @@ const RELOAD_ON_401 = new Set(['GET', 'HEAD']);
async function ensureOk(res: Response, method: string, path: string): Promise<Response> {
if (!res.ok) {
if (res.status === 401 && RELOAD_ON_401.has(method)) recoverFromUnauthorized();
let detail = '';
let body: Record<string, unknown> | undefined;
try {
detail = (await res.json())?.message ?? '';
body = (await res.json()) as Record<string, unknown>;
} catch {
/* ignore */
/* no body, or not JSON */
}
throw new HttpError(`${method} ${path} → ${res.status}${detail ? `: ${detail}` : ''}`, res.status);
const detail = typeof body?.message === 'string' ? body.message : '';
throw new HttpError(`${method} ${path} → ${res.status}${detail ? `: ${detail}` : ''}`, res.status, body);
}
return res;
}
@@ -67,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
@@ -82,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> {
+28
View File
@@ -123,3 +123,31 @@ describe('mapSpool — inherited vs. own values', () => {
expect(s.initial).toBe(1000);
});
});
// `tags` is always on the wire, but `format` is omitted rather than nulled when
// unset (the API excludes nulls), so the mapping has to read absence — not null —
// as "no format". Getting this backwards would render the string "null" as a
// badge on every tag a reader didn't name.
describe('mapSpool — tags', () => {
const api = (tags: unknown) => ({ id: 1, used_weight: 0, filament: { id: 9 }, tags });
it('maps a linked tag, keeping the canonical UID the server returned', () => {
const s = mapSpool(api([{ uid: '04A2B3C4', format: 'ntag', added: '2026-08-13T10:00:00Z' }]));
expect(s.tags).toEqual([{ uid: '04A2B3C4', format: 'ntag', added: '2026-08-13T10:00:00Z' }]);
});
it('reads an omitted format as no format rather than as a value', () => {
const s = mapSpool(api([{ uid: '04A2B3C4', added: '2026-08-13T10:00:00Z' }]));
expect(s.tags[0].format).toBeUndefined();
});
it('gives an untagged spool an empty list', () => {
expect(mapSpool(api([])).tags).toEqual([]);
});
// Fixtures and a server upgraded under a long-lived tab can both produce a
// spool with no `tags` key at all; every consumer indexes into it.
it('gives a spool without the key an empty list rather than undefined', () => {
expect(mapSpool({ id: 1, used_weight: 0, filament: { id: 9 } }).tags).toEqual([]);
});
});
+29 -1
View File
@@ -1,6 +1,7 @@
import type {
Filament,
FilamentPatch,
Tag,
MultiColorDirection,
Spool,
SpoolPatch,
@@ -41,6 +42,20 @@ export function mapVendor(v: Json): Vendor {
};
}
/**
* A spool's or filament's tags. The API always sends `tags`, but an entity that
* predates a server upgrade mid-session (or a fixture) may not, so an absent one
* reads as none. `format` is omitted rather than nulled when unset — the API
* excludes nulls — which is why it maps to undefined instead of being tested for null.
*/
function mapTags(tags: unknown): Tag[] {
return ((tags ?? []) as Json[]).map((t) => ({
uid: t.uid,
format: t.format ?? undefined,
added: t.added
}));
}
export function mapFilament(f: Json): Filament {
return {
id: String(f.id),
@@ -60,6 +75,7 @@ export function mapFilament(f: Json): Filament {
comment: f.comment ?? '',
externalId: f.external_id ?? undefined,
registeredLabel: formatShortDate(f.registered),
tags: mapTags(f.tags),
extra: f.extra ?? {}
};
}
@@ -96,6 +112,7 @@ export function mapSpool(s: Json): Spool {
registeredLabel: formatShortDate(s.registered),
archived: s.archived ?? false,
comment: s.comment ?? '',
tags: mapTags(s.tags),
extra: s.extra ?? {}
};
}
@@ -155,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
}))
};
}
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it } from 'vitest';
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
// filament used to drop it entirely — the library then looked fully stocked
// (#1092). Listing them is opt-in (showEmpty), and on top of that the query
// builder has a rule worth pinning down: a filter about SPOOLS (where they are,
// when they were used) is a question an empty filament cannot answer, so the API
// refuses that combination and the client must not send it.
//
// These cases run with showEmpty on unless they say otherwise; the default-off
// behaviour is its own case below.
function state(over: Partial<LibraryState> = {}): LibraryState {
return {
view: 'spools',
selection: null,
group: 'filament',
sortKey: 'last_used',
sortAsc: false,
filters: [],
showArchived: false,
showEmpty: true,
showLotNumbers: false,
page: 1,
pageSize: 20,
...over
};
}
describe('buildGroupQuery — includeEmpty', () => {
it('asks for empty groups when grouping by filament with no filters', () => {
expect(buildGroupQuery(state()).includeEmpty).toBe(true);
});
it('does not ask unless the user turned it on', () => {
// Off by default: a filament you own none of cannot serve the daily job of
// finding something to print with, and how many of them there are depends
// on how big a catalogue the user keeps.
expect(buildGroupQuery(state({ showEmpty: false })).includeEmpty).toBe(false);
});
it('does not ask on any other grouping, which has no empty groups to list', () => {
for (const group of ['vendor', 'material', 'location'] as const) {
expect(buildGroupQuery(state({ group })).includeEmpty).toBe(false);
}
});
it('keeps asking under filters the filament itself answers', () => {
const filters = [
{ prop: 'filament', value: '4' },
{ prop: 'material', value: 'PLA' },
{ prop: 'vendor', value: 'Acme' },
{ prop: 'direction', value: 'coaxial' },
{ prop: 'filament.extra.shelf', value: '"A"' },
{ prop: 'filament.vendor.extra.tier', value: '"gold"' }
];
for (const f of filters) {
expect(buildGroupQuery(state({ filters: [f] })).includeEmpty).toBe(true);
}
});
it('stops asking under a filter about the spools themselves', () => {
const filters = [
{ prop: 'location', value: 'Shelf A' },
{ prop: 'lot', value: 'B12' },
{ prop: 'last_used', value: '7d' },
{ prop: 'first_used', value: '7d' },
{ prop: 'registered', value: '7d' },
{ prop: 'extra.opened', value: 'true' }
];
for (const f of filters) {
expect(buildGroupQuery(state({ filters: [f] })).includeEmpty).toBe(false);
}
});
it('lets one spool-scoped filter veto a set of filament-scoped ones', () => {
const filters = [
{ prop: 'material', value: 'PLA' },
{ prop: 'location', value: 'Shelf A' }
];
expect(buildGroupQuery(state({ filters })).includeEmpty).toBe(false);
});
it('still asks when only archived spools are hidden, which is the default view', () => {
// A filament whose every spool is archived has none to print with, so it
// belongs in the list for the same reason a filament with no spools does.
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]);
});
});
+113 -3
View File
@@ -1,6 +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 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
@@ -19,6 +19,34 @@ function currentFilters(state: LibraryState): Record<string, string[]> {
return filters;
}
/**
* Whether to ask for filaments that have no spools at all (#1092).
*
* Off unless the user asked for it (see params.showEmpty). Finding a spool to
* print with is the daily job, and a filament you own none of cannot serve it;
* how many such rows there would be depends on how big a catalogue you keep, so
* it is not a default that can be right for everyone. Asked for once, it is
* remembered (see viewPrefs).
*
* Even then it is only sent while every active filter is one the *filament* can
* answer.
*
* Ask "which of these are at Shelf A" and a filament with no spools has no
* answer: it isn't anywhere. Including it would flood that view with every
* filament in the catalogue instead of showing the handful actually on the
* shelf. The API says the same and rejects that combination outright, so this
* is the client keeping off a 400 rather than a preference of its own; the two
* lists of spool-scoped filters have to stay in step.
*
* Archived is not one of them: hiding archived spools is the default view, and
* a filament whose only spools are archived genuinely has none to print with.
*/
function wantsEmptyGroups(state: LibraryState): boolean {
return (
state.showEmpty && state.group === 'filament' && !state.filters.some((f) => isSpoolScopedFilter(f.prop))
);
}
/** Page-of-groups query (grouped mode). */
export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): GroupQuery {
const field = state.group as GroupField;
@@ -29,6 +57,7 @@ export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): Grou
filters: currentFilters(state),
sort: [{ field: groupField, dir }],
allowArchived: state.showArchived,
includeEmpty: wantsEmptyGroups(state),
limit: state.pageSize,
offset: (state.page - 1) * state.pageSize,
lowThreshold: settings.lowThreshold,
@@ -36,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,
@@ -77,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;
}
}
+287
View File
@@ -0,0 +1,287 @@
import { wsUrl } from './config';
import { getJson } from './http';
import { mapSpool, mapFilament, mapVendor } from './map';
import { inventory } from '$lib/stores/inventory.svelte';
import type { Filament, Spool } from '$lib/types';
// The NFC/RFID scan relay: a reader taps a tag, POSTs it to /api/v1/tag/scan,
// and the server fans that out over a websocket to whichever browsers are
// listening. This is the client for that socket.
//
// It is deliberately NOT part of live.ts. That module is one socket per entity
// resource (`spool`, `filament`, `vendor`), each feeding inventory.ingest — and a
// scan is not an entity. It has no id, nothing to cache it under, and must never
// enter the inventory cache as though it were a record. Its URL also carries a
// reader id where live.ts's carries a resource name. What IS worth sharing is the
// hard-won connection behaviour, so the backoff, the wake-on-online/visible
// listeners, the keepalive and the 401 probe below are all deliberate copies of
// live.ts rather than an attempt to generalize one module over both.
//
// Which pool you get comes from the path and nothing else:
// /tag/scan every reader
// /tag/scan/{reader_id} that reader only
// That is the whole of pairing — subscribe to the root, wait for one tap, read
// its reader_id, then resubscribe to that reader. No server state is involved and
// nothing needs to be told you paired.
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
/** One tap, as broadcast to every browser listening to the reader that saw it. */
export interface TagScan {
uid: string;
/** Always present. Readers that send none are given one derived from their
* address (`ip-192-168-1-50`), which is what makes pairing work with an
* unmodified off-the-shelf agent. */
readerId: string;
/** The reader's friendly name, when it sends one. Prefer it over `readerId`. */
name?: string;
/** Tag hardware type, e.g. "ntag". Informational. */
format?: string;
/** Raw tag contents, base64. Phase 1 does not decode it. */
payloadB64?: string;
/** The spool this tag is linked to, absent unless a spool holds it. Embedded
* in the event in full, so acting on a scan needs no follow-up request. */
spool?: Spool;
/** The filament this tag is linked to, absent unless a filament holds it. At
* most one of `spool` and `filament` is set; neither means an unknown tag. */
filament?: Filament;
}
export type ScanHandler = (scan: TagScan) => void;
/** `null` means the root pool: every reader. */
export type ReaderPool = string | null;
// See live.ts — same reasoning, same numbers, so a relay socket and an entity
// socket behave identically when the backend goes away and comes back.
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const KEEPALIVE_MS = 25000;
const AUTH_PROBE_MIN_INTERVAL_MS = 10000;
let lastAuthProbeAt = 0;
function probeAuth(): void {
const now = Date.now();
if (now - lastAuthProbeAt < AUTH_PROBE_MIN_INTERVAL_MS) return;
lastAuthProbeAt = now;
getJson('/info').catch(() => {
/* backend down, or a 401 that has already triggered the reload */
});
}
/** Pool → socket path. The reader id is a path segment, and the server constrains
* it to `[A-Za-z0-9._:-]` precisely so it can travel as one. */
function pathFor(pool: ReaderPool): string {
return pool === null ? '/tag/scan' : `/tag/scan/${encodeURIComponent(pool)}`;
}
/**
* Map a broadcast payload to a `TagScan`.
*
* The embedded spool or filament is cached on the way past, exactly as one
* arriving from any other endpoint would be: a scan that is about to open an
* inspector should find the filament and manufacturer already there. Note that
* this is the only thing a scan puts in the cache — the scan itself is an event,
* not a record.
*/
function toScan(payload: Json): TagScan {
const raw = payload.spool as Json | undefined;
let spool: Spool | undefined;
if (raw) {
if (raw.filament) {
inventory.upsertFilament(mapFilament(raw.filament));
if (raw.filament.vendor) inventory.upsertVendor(mapVendor(raw.filament.vendor));
}
spool = mapSpool(raw);
inventory.upsertSpool(spool);
}
const rawFilament = payload.filament as Json | undefined;
let filament: Filament | undefined;
if (rawFilament) {
if (rawFilament.vendor) inventory.upsertVendor(mapVendor(rawFilament.vendor));
filament = mapFilament(rawFilament);
inventory.upsertFilament(filament);
}
return {
uid: payload.uid,
readerId: payload.reader_id,
name: payload.name ?? undefined,
format: payload.format ?? undefined,
payloadB64: payload.payload_b64 ?? undefined,
spool,
filament
};
}
interface PoolSocket {
ws: WebSocket | null;
subs: Set<ScanHandler>;
reconnectTimer: ReturnType<typeof setTimeout> | null;
closed: boolean;
attempts: number;
}
class ScanRelay {
#sockets = new Map<string, PoolSocket>();
constructor() {
if (typeof window !== 'undefined') {
const wake = () => this.#reconnectAllNow();
window.addEventListener('online', wake);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) wake();
});
}
}
/**
* Listen to one reader, or to every reader with `null`. Returns an unsubscribe;
* the socket opens on the first subscriber to a pool and closes after the last
* one leaves, so a browser that is not using NFC holds no connection at all.
*/
subscribe(pool: ReaderPool, handler: ScanHandler): () => void {
const key = pathFor(pool);
const sock = this.#ensure(key);
sock.subs.add(handler);
return () => {
sock.subs.delete(handler);
if (sock.subs.size === 0) this.#teardown(key);
};
}
#ensure(key: string): PoolSocket {
let sock = this.#sockets.get(key);
if (!sock) {
sock = { ws: null, subs: new Set(), reconnectTimer: null, closed: false, attempts: 0 };
this.#sockets.set(key, sock);
this.#open(key, sock);
}
return sock;
}
#open(key: string, sock: PoolSocket) {
if (typeof WebSocket === 'undefined') return; // SSR / no WS support
let ws: WebSocket;
try {
ws = new WebSocket(wsUrl(key));
} catch {
this.#scheduleReconnect(key, sock);
return;
}
sock.ws = ws;
// Kept local as well as on `sock` so a superseded socket can clean up after
// itself without touching the shared state — see the identity guards below.
let pingTimer: ReturnType<typeof setInterval> | null = null;
let opened = false;
ws.onmessage = (ev) => {
let msg: Json;
try {
msg = JSON.parse(ev.data as string);
} catch {
return;
}
if (!msg || msg.status) return; // ignore health/ping replies
if (!msg.payload?.uid) return;
const scan = toScan(msg.payload);
// Copied so a handler that unsubscribes (the pairing flow does, on its
// first scan) doesn't mutate the set we are iterating.
for (const handler of [...sock.subs]) handler(scan);
};
ws.onopen = () => {
opened = true;
if (sock.ws !== ws) {
ws.close(); // superseded while connecting — drop it
return;
}
sock.attempts = 0;
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('ping');
}, KEEPALIVE_MS);
};
ws.onclose = () => {
if (pingTimer) clearInterval(pingTimer);
pingTimer = null;
if (sock.ws !== ws) return; // a replacement is live — leave it alone
sock.ws = null;
if (!sock.closed && sock.subs.size > 0) {
if (!opened) probeAuth();
this.#scheduleReconnect(key, sock);
}
};
ws.onerror = () => ws.close();
}
#scheduleReconnect(key: string, sock: PoolSocket) {
if (sock.reconnectTimer) return;
const exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** sock.attempts);
const delay = exp / 2 + Math.random() * (exp / 2); // full jitter
sock.attempts++;
sock.reconnectTimer = setTimeout(() => {
sock.reconnectTimer = null;
if (!sock.closed && sock.subs.size > 0) this.#open(key, sock);
}, delay);
}
#reconnectAllNow() {
for (const [key, sock] of this.#sockets) {
if (sock.closed || sock.subs.size === 0) continue;
const rs = sock.ws?.readyState;
if (rs === WebSocket.OPEN || rs === WebSocket.CONNECTING) continue;
sock.attempts = 0;
if (sock.reconnectTimer) {
clearTimeout(sock.reconnectTimer);
sock.reconnectTimer = null;
}
this.#open(key, sock);
}
}
#teardown(key: string) {
const sock = this.#sockets.get(key);
if (!sock) return;
sock.closed = true;
if (sock.reconnectTimer) clearTimeout(sock.reconnectTimer);
sock.ws?.close();
this.#sockets.delete(key);
}
}
export const scanRelay = new ScanRelay();
/** Readers the server has heard from recently. */
export interface KnownReader {
readerId: string;
name?: string;
/** ISO timestamp of the last scan this reader sent. */
lastSeen: string;
/**
* The last UID this reader reported, or undefined if it has not scanned since
* the server started. Lets a dialog offer what is already on the reader rather
* than waiting for another tap.
*/
lastUid?: string;
}
/**
* The readers the server has seen recently, most recent first.
*
* This registry is in-memory and per-process: it is **empty after a restart**
* until something scans again, which is normal and not an error. Treat it as a
* convenience for picking a reader in another room, never as the list of readers
* that exist.
*/
export async function listReaders(signal?: AbortSignal): Promise<KnownReader[]> {
const rows = await getJson<Json[]>('/tag/reader', {}, signal);
return rows.map((r) => ({
readerId: r.reader_id,
name: r.name ?? undefined,
lastSeen: r.last_seen,
lastUid: r.last_uid ?? undefined
}));
}
+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 }
);
});
});
+83 -41
View File
@@ -23,10 +23,11 @@ import {
} from './map';
import { inventory } from '$lib/stores/inventory.svelte';
import { isDateFilterProp, parseDateFilter, resolveDateFilter } from '$lib/library/dateFilter';
import { filamentLabel } from '$lib/utils/library';
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>`
@@ -166,11 +172,13 @@ class HttpSpoolSource {
sort: sortParam(query.sort),
limit: query.limit,
offset: query.offset,
allow_archived: query.allowArchived ? 'true' : undefined
allow_archived: query.allowArchived ? '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));
@@ -178,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>> {
@@ -195,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[]> {
@@ -233,12 +253,18 @@ class HttpSpoolSource {
return filaments.map((m) => m.entity).slice(0, limit);
}
async searchExternalFilaments(query: string, limit = 8): Promise<ExternalFilament[]> {
async searchExternalFilaments(
query: string,
limit: number,
offset = 0,
signal?: AbortSignal
): Promise<Page<ExternalFilament>> {
// Filtering happens server-side (/external/filament/search) so the whole catalog
// — thousands of entries — never has to be downloaded to the client.
const q = query.trim();
if (!q) return [];
return getJson<ExternalFilament[]>('/external/filament/search', { query: q, limit });
if (!q) return { items: [], total: 0 };
const { items, total } = await getList('/external/filament/search', { query: q, limit, offset }, signal);
return { items: items as ExternalFilament[], total };
}
/**
@@ -247,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]);
@@ -255,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 = {
@@ -298,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');
@@ -312,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));
@@ -328,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,
@@ -492,20 +514,30 @@ class HttpSpoolSource {
return getJson<string[]>('/material');
}
/**
* Every filament as a `{ value: id, label }` option for the "filament" filter,
* so a spool listing can be narrowed to one specific filament (e.g. a given
* colour from a given manufacturer). Filaments are cached so the chosen chip
* can render its label from the reactive store.
* Every filament as an option for the "filament" filter, so a spool listing can
* be narrowed to one specific filament (e.g. a given colour from a given
* manufacturer). Each option carries the filament's colours and material as
* well as its label, since the menu shows all three. Filaments are cached so
* the chosen chip can render its label from the reactive store.
*/
async filamentOptions(): Promise<{ value: string; label: string }[]> {
async filamentOptions(): Promise<FilterOption[]> {
const items = await getJson<Json[]>('/filament');
const filaments = items.map((f) => {
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
return mapFilament(f);
});
inventory.upsertFilaments(filaments);
// The label already carries the manufacturer, so the material is what's left
// to tell two same-named filaments apart; the colors are what the eye goes
// to first. Both are free here — this fetches whole filaments anyway.
return filaments
.map((f) => ({ value: f.id, label: filamentLabel(f, inventory.vendorOf(f)) }))
.map((f) => ({
value: f.id,
label: filamentLabel(f, inventory.vendorOf(f)),
meta: f.material,
colors: f.colors,
direction: f.multiColorDirection
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Distinct values currently stored for a scalar extra field (text/single-choice). */
@@ -515,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
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { asTagConflict, isBadUid } from './tags';
import { HttpError } from './http';
// Linking a tag something else already holds is the one failure the UI has to do
// something with rather than merely report: the 409 carries the id of the spool
// or filament that holds it, which is what lets the dialog offer to move the tag
// instead of making the user go and find it. These read that body.
describe('asTagConflict', () => {
it('reads the holding spool out of a 409', () => {
const err = new HttpError('POST /spool/2/tag → 409', 409, {
message: 'Tag 04A2B3C4 is already linked to spool 1.',
spool_id: 1,
filament_id: null
});
expect(asTagConflict(err)).toEqual({
holder: { kind: 'spool', id: 1 },
message: 'Tag 04A2B3C4 is already linked to spool 1.'
});
});
// Filament ids are strings everywhere else in the client, so the holder's is too.
it('reads the holding filament out of a 409', () => {
const err = new HttpError('POST /spool/2/tag → 409', 409, {
message: 'Tag 04A2B3C4 is already linked to filament 7.',
spool_id: null,
filament_id: 7
});
expect(asTagConflict(err)).toEqual({
holder: { kind: 'filament', id: '7' },
message: 'Tag 04A2B3C4 is already linked to filament 7.'
});
});
it('ignores failures that are not conflicts', () => {
expect(asTagConflict(new HttpError('POST /spool/2/tag → 404', 404, {}))).toBeNull();
expect(asTagConflict(new Error('network'))).toBeNull();
});
// A 409 we can't act on is worse than no offer: "move it here" with nowhere to
// move it from would fail on the click. It falls back to an ordinary error.
it('declines a 409 with no usable holder id', () => {
expect(asTagConflict(new HttpError('x', 409, { message: 'nope' }))).toBeNull();
expect(asTagConflict(new HttpError('x', 409, { spool_id: '1' }))).toBeNull();
expect(asTagConflict(new HttpError('x', 409, { filament_id: '7' }))).toBeNull();
});
});
// A UID the server won't accept comes back two different ways — 400 from the
// handler for non-hex, 422 from the model for an empty string — and both mean the
// same thing to someone who typed it, so the UI treats them as one case.
describe('isBadUid', () => {
it('accepts both shapes of "that UID is not usable"', () => {
expect(isBadUid(new HttpError('x', 400, {}))).toBe(true);
expect(isBadUid(new HttpError('x', 422, {}))).toBe(true);
});
it('leaves other failures alone', () => {
expect(isBadUid(new HttpError('x', 404, {}))).toBe(false);
expect(isBadUid(new HttpError('x', 409, {}))).toBe(false);
expect(isBadUid(new Error('network'))).toBe(false);
});
});
+159
View File
@@ -0,0 +1,159 @@
import type { Filament, Spool, Tag } from '$lib/types';
import { getList, postJson, deleteResource, HttpError } from './http';
import { mapSpool, mapFilament, mapVendor } from './map';
import { inventory } from '$lib/stores/inventory.svelte';
// Linking physical NFC/RFID tags to spools and filaments.
//
// A tag identifies exactly one spool or filament and is keyed by its hardware
// UID, which the server normalizes: `04:a2:b3`, `04-A2-B3` and `04a2b3` are one
// tag. Nothing here normalizes anything itself — the canonical spelling comes
// back in the response, and rendering that rather than what was typed is what
// keeps the UI honest about which tag was actually touched.
//
// Neither link nor unlink updates the cache. Both emit the holder's ordinary
// `updated` event, so liveSync ingests the new tag list and every open inspector
// re-renders on its own; patching local state here as well would just race it.
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
/** What a tag can identify. */
export type TagKind = 'spool' | 'filament';
/**
* A spool or filament as the tag endpoints address it. Spool ids are numbers and
* filament ids strings in the client; both go into the path as they are.
*/
export interface TagTarget {
kind: TagKind;
id: number | string;
}
/** Whatever holds a tag, loaded, so the UI can name it. */
export type TagHolder = { kind: 'spool'; spool: Spool } | { kind: 'filament'; filament: Filament };
export function holderTarget(holder: TagHolder): TagTarget {
return holder.kind === 'spool'
? { kind: 'spool', id: holder.spool.id }
: { kind: 'filament', id: holder.filament.id };
}
/**
* Tag formats the server knows the name of (`spoolman/tags.py`). Informational
* and not enforced — new tag types appear faster than releases do — so this is a
* vocabulary to spell the common ones consistently, never a validation list.
*/
export const KNOWN_FORMATS = [
'openprinttag',
'opentag3d',
'ntag',
'bambu',
'tigertag',
'qidi',
'creality',
'prusa'
] as const;
/**
* What already holds a UID, carried by the 409 from `linkTag`. It is on the
* error body so the UI can offer to move the tag without looking it up.
*/
export interface TagConflict {
holder: TagTarget;
message: string;
}
/**
* Read a 409 from `linkTag` as the conflict it describes, or null for any other
* failure. A 409 that names neither a spool nor a filament by id counts as "not a
* conflict we can act on" and falls back to being reported as an ordinary error.
*/
export function asTagConflict(err: unknown): TagConflict | null {
if (!(err instanceof HttpError) || err.status !== 409) return null;
const message = typeof err.body?.message === 'string' ? err.body.message : err.message;
const spoolId = err.body?.spool_id;
if (typeof spoolId === 'number') return { holder: { kind: 'spool', id: spoolId }, message };
const filamentId = err.body?.filament_id;
if (typeof filamentId === 'number') {
return { holder: { kind: 'filament', id: String(filamentId) }, message };
}
return null;
}
/**
* True when a failure means the UID itself was unusable rather than the request
* being wrong about anything else. The server answers a non-hex UID with 400 and
* an empty one with 422 (it fails `min_length` before the handler sees it); both
* mean the same thing to a user who typed it, so they read as one case.
*/
export function isBadUid(err: unknown): boolean {
return err instanceof HttpError && (err.status === 400 || err.status === 422);
}
function tagPath(target: TagTarget): string {
return `/${target.kind}/${target.id}/tag`;
}
/**
* Link a tag to a spool or filament. Re-linking a UID it already holds succeeds
* and changes nothing (passing a `format` refines the stored one), so this is
* safe to call without checking first.
*
* Rejects with a 409 when anything else holds the UID — see `asTagConflict`.
*/
export async function linkTag(target: TagTarget, uid: string, format?: string): Promise<Tag> {
const body: Json = { uid };
if (format) body.format = format;
const tag = await postJson<Json>(tagPath(target), body);
return { uid: tag.uid, format: tag.format ?? undefined, added: tag.added };
}
/**
* Unlink a tag from the spool or filament holding it. The UID may be in any
* spelling; the server normalizes before matching.
*/
export async function unlinkTag(target: TagTarget, uid: string): Promise<void> {
await deleteResource(`${tagPath(target)}/${encodeURIComponent(uid)}`);
}
/**
* The spool or filament a UID is linked to, or undefined when nothing holds it.
*
* Both kinds are asked at once. A UID is unique across them, so at most one
* answers, and asking in parallel keeps the lookup as quick as asking one.
*
* Archived spools are included: this answers "is this tag already spoken for",
* and a tag on an archived spool is still spoken for — leaving them out would
* report a UID as free that linking then rejects with a 409.
*
* A malformed UID is a 400 rather than an empty result, and is left to the
* caller (`isBadUid`) rather than flattened into "not found", so a typo is not
* reported to the user as an unknown tag.
*/
export async function findTagHolder(uid: string, signal?: AbortSignal): Promise<TagHolder | undefined> {
const [spools, filaments] = await Promise.all([
getList('/spool', { tag: uid, allow_archived: 'true', limit: 1 }, signal),
getList('/filament', { tag: uid, limit: 1 }, signal)
]);
const rawSpool = spools.items[0] as Json | undefined;
if (rawSpool) {
// Seed the cache the way every other spool read does, so the inspector this
// may be about to open has its filament and manufacturer already.
if (rawSpool.filament) {
inventory.upsertFilament(mapFilament(rawSpool.filament));
if (rawSpool.filament.vendor) inventory.upsertVendor(mapVendor(rawSpool.filament.vendor));
}
const spool = mapSpool(rawSpool);
inventory.upsertSpool(spool);
return { kind: 'spool', spool };
}
const rawFilament = filaments.items[0] as Json | undefined;
if (rawFilament) {
if (rawFilament.vendor) inventory.upsertVendor(mapVendor(rawFilament.vendor));
const filament = mapFilament(rawFilament);
inventory.upsertFilament(filament);
return { kind: 'filament', filament };
}
return undefined;
}
+30
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,6 +95,11 @@ export interface GroupQuery {
sort: SortField[];
/** Include archived spools in the group aggregates (default: excluded). */
allowArchived?: boolean;
/** 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;
@@ -85,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) -----------------------------------
+86 -430
View File
@@ -1,27 +1,36 @@
<script lang="ts">
import { untrack, tick } from 'svelte';
import Swatch from './Swatch.svelte';
import ColorEditor from './ColorEditor.svelte';
import Button from './Button.svelte';
import NumberInput from './NumberInput.svelte';
import Combobox from './Combobox.svelte';
import DateTimeField from './DateTimeField.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ExtraFieldsSection from './ExtraFieldsSection.svelte';
import NewFilamentCards from './NewFilamentCards.svelte';
import type { Filament, Extra, MultiColorDirection } from '$lib/types';
import { inventory } from '$lib/stores/inventory.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { serverInfo } from '$lib/stores/serverInfo.svelte';
import { spoolSource, type NewFilamentDraft } from '$lib/api/spoolSource';
import { spoolSource } from '$lib/api/spoolSource';
import { fields } from '$lib/stores/fields.svelte';
import type { EntityType } from '$lib/api/fields';
import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external';
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 { loadMaterials, type MaterialSpec } from '$lib/data/materials';
import {
FILAMENT_ADVANCED_KEYS,
FILAMENT_FIELD_ORDER,
emptyFilamentDraft,
filamentDraftErrors,
filamentDraftFrom,
filamentWeightsFrom,
toNewFilamentDraft,
type FilamentDraft
} from '$lib/filament/draft';
import * as m from '$lib/paraglide/messages';
interface Props {
@@ -42,9 +51,8 @@
let query = $state('');
let searchInput = $state<HTMLInputElement | undefined>();
let localResults = $state<Filament[]>([]);
let externalResults = $state<ExternalFilament[]>([]);
const extSearch = new ExternalSearch();
let searching = $state(false);
let extError = $state(false);
let chosen = $state<Choice | null>(null);
let creating = $state(false);
let submitting = $state(false);
@@ -53,28 +61,19 @@
// the "duplicate of X" heading, the rename nudge, and the extra-field carry-over.
let cloneSource = $state<Filament | null>(null);
// New-filament fields + lookups for the combobox / auto-fill.
let nf = $state({
vendorName: '',
name: '',
material: '',
colors: [] as string[],
multiColorDirection: undefined as MultiColorDirection | undefined,
density: '',
diameter: '1.75',
nozzleTemp: '',
bedTemp: '',
articleNumber: '',
comment: ''
});
// New-filament fields, drawn by NewFilamentCards. The weight/spool weight/price
// this flow would put on the filament are `netWeight`/`spoolWeight`/`price`
// below: they are written to the spool too, so they live in the spool block.
let nf = $state<FilamentDraft>(emptyFilamentDraft());
// Custom-field values for the filament being created. Separate from the spool's
// `extraValues` below: the two entities have their own field definitions.
let filamentExtra = $state<Extra>({});
// Same, for a manufacturer this form creates. Only ever sent when the typed name
// is a new one — linking an existing manufacturer must not edit its fields.
let vendorExtra = $state<Extra>({});
// Reported back by NewFilamentCards, which owns the manufacturer list.
let vendorIsNew = $state(false);
let showAdvanced = $state(false);
let nameInput = $state<HTMLInputElement | undefined>();
let modalEl = $state<HTMLDivElement | undefined>();
// When to *show* an error, as opposed to have one: a field is revealed once the
// user has left it, and everything is revealed once Add has been pressed. A form
@@ -83,9 +82,6 @@
// the form as it stands.
let touched = $state<Record<string, boolean>>({});
let attempted = $state(false);
let vendorNames = $state<string[]>([]);
let materialNames = $state<string[]>([]);
let materialSpecs = $state<Record<string, MaterialSpec>>({});
// --- display helpers for a chosen (existing) filament ------------------
function cName(c: Choice) {
@@ -128,17 +124,11 @@
}
async function runSearch() {
searching = true;
extError = false;
const [local, external] = await Promise.allSettled([
const [local] = await Promise.allSettled([
spoolSource.searchFilaments(query.trim()),
spoolSource.searchExternalFilaments(query.trim())
extSearch.search(query)
]);
localResults = local.status === 'fulfilled' ? local.value : [];
if (external.status === 'fulfilled') externalResults = external.value;
else {
externalResults = [];
extError = true;
}
searching = false;
}
@@ -151,17 +141,9 @@
fields.ensure('filament');
fields.ensure('vendor');
spoolSource
.locations()
.locationChoices()
.then((l) => (locations = l))
.catch(() => {});
spoolSource
.vendorNames()
.then((vn) => (vendorNames = vn))
.catch(() => {});
loadMaterials().then(({ names, specs }) => {
materialNames = names;
materialSpecs = specs;
});
if (presetFilamentId) {
const f = inventory.filamentById(presetFilamentId);
if (f) choose({ source: 'catalog', filament: f });
@@ -181,16 +163,6 @@
if (open && step === 1 && searchInput) searchInput.focus();
});
// When duplicating, the name is the one field that must change, so put the
// caret in it (at the end — the colour word is usually a suffix, and the rest
// of the name is worth keeping rather than retyping).
$effect(() => {
if (open && cloneSource && nameInput) {
nameInput.focus();
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
}
});
// --- spool form ---------------------------------------------------------
type FillMode = 'full' | 'used' | 'remaining' | 'measured';
let count = $state('1');
@@ -306,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;
}
@@ -318,19 +290,7 @@
cloneSource = null;
chosen = null;
showAdvanced = false;
nf = {
vendorName: '',
name: query.trim(),
material: '',
colors: [],
multiColorDirection: undefined,
density: '',
diameter: '1.75',
nozzleTemp: '',
bedTemp: '',
articleNumber: '',
comment: ''
};
nf = emptyFilamentDraft(query.trim());
filamentExtra = withDefaults('filament', {});
vendorExtra = withDefaults('vendor', {});
netWeight = '1000';
@@ -357,64 +317,24 @@
// Specs came from a real filament rather than a material guess, so open the
// advanced block: it's what makes the copy visibly a copy.
showAdvanced = true;
nf = {
vendorName: inventory.vendorById(f.vendorId)?.name ?? '',
name: f.name,
material: f.material,
colors: [],
multiColorDirection: undefined,
density: String(f.density),
diameter: String(f.diameter),
nozzleTemp: f.nozzleTemp ? String(f.nozzleTemp) : '',
bedTemp: f.bedTemp ? String(f.bedTemp) : '',
articleNumber: '',
comment: f.comment
};
nf = filamentDraftFrom(f, inventory.vendorById(f.vendorId)?.name ?? '');
filamentExtra = withDefaults('filament', { ...f.extra });
// Not copied from the source's manufacturer: a duplicate keeps that same
// manufacturer record, and these values only ever reach a newly created one.
vendorExtra = withDefaults('vendor', {});
netWeight = String(f.weight || 1000);
spoolWeight = f.spoolWeight ? String(f.spoolWeight) : '';
price = f.price ? String(f.price) : '';
const w = filamentWeightsFrom(f);
netWeight = w.weight;
spoolWeight = w.spoolWeight;
price = w.price;
resetSpoolForm();
step = 2;
}
// Vendor combobox: reuse an existing vendor if the name matches, else create.
let vendorTrimmed = $derived(nf.vendorName.trim());
let vendorMatch = $derived(vendorNames.find((v) => v.toLowerCase() === vendorTrimmed.toLowerCase()));
let vendorHint = $derived(
vendorTrimmed === ''
? m['add.vendorHint.optional']()
: vendorMatch
? m['add.vendorHint.existing']({ name: vendorMatch })
: m['add.vendorHint.new']({ name: vendorTrimmed })
);
// Accents the manufacturer hint when the typed name will create a second record
// alongside the filament, rather than link an existing one.
let vendorIsNew = $derived(vendorTrimmed !== '' && !vendorMatch);
// Nudge, not an error: Spoolman allows same-named filaments, but keeping the
// original's name on a duplicate is almost always an oversight.
let nameStillSource = $derived(!!cloneSource && nf.name.trim() === cloneSource.name.trim());
function onMaterial(v: string) {
nf.material = v;
const spec = materialSpecs[v.trim().toLowerCase()];
// Only prefill when the material is a known one; typing a custom material
// leaves density/temps untouched.
if (spec) {
nf.density = String(spec.density);
if (spec.nozzle != null) nf.nozzleTemp = String(spec.nozzle);
if (spec.bed != null) nf.bedTemp = String(spec.bed);
}
}
function reset() {
step = 1;
query = '';
localResults = [];
externalResults = [];
extSearch.clear();
chosen = null;
creating = false;
cloneSource = null;
@@ -444,24 +364,12 @@
// next colour of it without going back through search.
let created: Filament | null = null;
if (creating) {
const draft: NewFilamentDraft = {
name: nf.name.trim(),
vendorName: nf.vendorName.trim(),
material: nf.material.trim(),
density: Number(nf.density),
diameter: Number(nf.diameter) || 1.75,
weight: Number(netWeight) || undefined,
spoolWeight: Number(spoolWeight) || undefined,
colors: nf.colors,
multiColorDirection: nf.multiColorDirection,
nozzleTemp: nf.nozzleTemp ? Number(nf.nozzleTemp) : undefined,
bedTemp: nf.bedTemp ? Number(nf.bedTemp) : undefined,
price: parseFloat(price) || undefined,
articleNumber: nf.articleNumber.trim() || undefined,
comment: nf.comment.trim() || undefined,
extra: filamentExtra,
// Weight/spool weight/price come from the spool block: this flow writes
// each of them to both records.
const draft = {
...toNewFilamentDraft(nf, { weight: netWeight, spoolWeight, price }, filamentExtra),
// Dropped when the typed name matches an existing manufacturer —
// which is also when the inputs below aren't shown.
// which is also when the inputs for it aren't shown.
vendorExtra: vendorIsNew ? vendorExtra : undefined
};
const f = await spoolSource.createFilament(draft);
@@ -475,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;
@@ -519,43 +427,12 @@
let summary = $derived(chosen || creating ? m['add.summary']({ count: countN }) : '');
// --- validation ---------------------------------------------------------
// Mirrors the filament creation API (spoolman/api/v1/filament.py):
// density & diameter are required and must be > 0; name/material ≤ 64 chars;
// weight > 0, spool_weight/price ≥ 0; color_hex must be 6 or 8 hex chars.
// Material is the one field this form is stricter about than the API, which
// accepts a null one. Leaving it blank is nearly always an oversight, and it
// takes density down with it: density is only prefilled for a material we
// recognise, so a blank material surfaced as "Density is required" — an error
// on the field the user didn't fill in *because of* the field they did miss.
function numErr(
v: string,
{ required = false, min, max, gt }: { required?: boolean; min?: number; max?: number; gt?: number } = {}
) {
const t = v.trim();
if (t === '') return required ? m['validation.required']() : '';
const n = parseDecimal(t);
if (n === null) return m['validation.mustBeNumber']();
if (gt != null && n <= gt) return m['validation.mustBeGt']({ value: gt });
if (min != null && n < min) return m['validation.mustBeMin']({ value: min });
if (max != null && n > max) return m['validation.mustBeMax']({ value: max });
return '';
}
const HEX_RE = /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
// The filament half lives in filamentDraftErrors() — including the one rule
// this client is stricter about than the API, a required material. The spool
// fields below mirror the spool creation API (spoolman/api/v1/spool.py):
// weight > 0, spool_weight/price ≥ 0, and a fill amount that fits inside them.
let errors = $derived.by(() => {
const e: Record<string, string> = {};
if (creating) {
if (nf.name.trim().length === 0) e.name = m['validation.required']();
else if (nf.name.trim().length > 64) e.name = m['validation.maxChars']({ max: 64 });
if (nf.material.trim().length === 0) e.material = m['validation.required']();
else if (nf.material.trim().length > 64) e.material = m['validation.maxChars']({ max: 64 });
if (nf.vendorName.trim().length > 64) e.vendor = m['validation.maxChars']({ max: 64 });
e.density = numErr(nf.density, { required: true, gt: 0 });
e.diameter = numErr(nf.diameter, { required: true, gt: 0 });
e.nozzleTemp = numErr(nf.nozzleTemp, { min: 0 });
e.bedTemp = numErr(nf.bedTemp, { min: 0 });
if (nf.colors.some((c) => c.trim() && !HEX_RE.test(c.trim()))) e.colorHex = m['validation.hexDigits']();
}
const e: Record<string, string> = creating ? filamentDraftErrors(nf) : {};
e.count = numErr(count, { required: true, gt: 0 });
e.netWeight = numErr(netWeight, { gt: 0 });
e.spoolWeight = numErr(spoolWeight, { min: 0 });
@@ -564,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).
@@ -587,27 +464,13 @@
// --- pointing at what's wrong -------------------------------------------
// The fields that can carry an error, in the order they appear on the form, so
// pressing Add sends you to the first one you'd have reached by reading down.
// Anything not listed falls back to whatever the error map yields, so a new
// error can never make Add silently do nothing.
const FIELD_ORDER = [
'vendor',
'name',
'material',
'colorHex',
'density',
'diameter',
'nozzleTemp',
'bedTemp',
'count',
'netWeight',
'fillWeight',
'spoolWeight',
'price'
];
// Fields inside the collapsed "Advanced specs" block. An error in here is
// invisible until the block is opened — the one case where the form really could
// look complete and still refuse to submit — so jumping to one has to open it.
const ADVANCED_KEYS = new Set(['density', 'diameter', 'nozzleTemp', 'bedTemp']);
// The filament half comes from the shared card component; the spool fields this
// modal draws itself follow. Anything not listed falls back to whatever the
// error map yields, so a new error can never make Add silently do nothing.
const FIELD_ORDER = [...FILAMENT_FIELD_ORDER, 'count', 'netWeight', 'fillWeight', 'spoolWeight', 'price'];
// This modal draws the weight trio in its own spool block, so the only fields
// hidden behind the disclosure are the filament specs.
const ADVANCED_KEYS = new Set(FILAMENT_ADVANCED_KEYS);
let firstProblem = $derived(FIELD_ORDER.find((k) => errors[k]) ?? Object.keys(errors)[0]);
@@ -668,7 +531,7 @@
oninput={(e) => onSearch(e.currentTarget.value)}
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
/>
<div class="results">
<div class="results" onscroll={extSearch.onscroll}>
<div class="res-hdr">{m['add.yourCatalog']()}</div>
{#if searching && localResults.length === 0}
<div class="res-note">{m['add.searching']()}</div>
@@ -694,16 +557,16 @@
{/if}
<div class="res-hdr"><span class="hdr-note">{serverInfo.externalDbName}</span></div>
{#if extError}
{#if extSearch.error}
<div class="res-note">{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}</div>
{:else if searching && externalResults.length === 0}
{:else if searching && extSearch.items.length === 0}
<div class="res-note">{m['add.searching']()}</div>
{:else if externalResults.length === 0}
{:else if extSearch.items.length === 0}
<div class="res-note">
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
</div>
{:else}
{#each externalResults as ext (ext.id)}
{#each extSearch.items as ext (ext.id)}
<button class="res-item" onclick={() => choose({ source: 'external', ext })}>
<Swatch
colors={externalColors(ext)}
@@ -725,6 +588,9 @@
<span class="tag external">{serverInfo.externalDbName}</span>
</button>
{/each}
{#if extSearch.loadingMore}
<div class="res-note">{m['add.searching']()}</div>
{/if}
{/if}
</div>
@@ -744,169 +610,20 @@
plain remainder of the form, since a spool is always being created and
needs no such emphasis. -->
{#if creating}
<!-- Manufacturer: its own record, so its own card — a vendor combobox
sitting among the filament fields, quietly creating a second entity,
was the most confusing part of the flow. -->
<section class="new-section man-section">
<label class="ent-field" data-field="vendor" onfocusout={() => touch('vendor')}>
<span class="ent-label">{m['add.section.manufacturer']()}</span>
<span class="ent-note">{m['add.section.manufacturerNote']()}</span>
<Combobox
value={nf.vendorName}
options={vendorNames}
placeholder={m['add.manufacturerPlaceholder']()}
invalid={!!err('vendor')}
oninput={(v) => (nf.vendorName = v)}
/>
{#if err('vendor')}
<span class="err">{err('vendor')}</span>
{:else}
<span class="hint" class:accent={vendorIsNew}>{vendorHint}</span>
{/if}
</label>
<!-- Outside the <label> above: these are inputs of their own, and only
shown while the typed name will create a manufacturer. Linking an
existing one must not offer to rewrite its custom fields (#1055). -->
{#if vendorIsNew}
<ExtraFieldsSection entity="vendor" extra={vendorExtra} onchange={setVendorExtra} />
{/if}
</section>
<section class="new-section">
<div class="fs-head">
<span class="ent-label">{m['add.section.filament']()}</span>
<button class="fs-back" onclick={() => (step = 1)}>{m['add.useExisting']()}</button>
</div>
<div class="fs-title">
{cloneSource
? m['add.duplicateTitle']({ name: cloneSource.name })
: m['add.newFilamentTitle']()}
</div>
<div class="ent-note">
{cloneSource ? m['add.duplicateNote']() : m['add.section.filamentNote']()}
</div>
<div class="form">
<label class="wide" data-field="name" onfocusout={() => touch('name')}>
{m['filament.fields.name']()}
{@render req()}
<input
bind:this={nameInput}
bind:value={nf.name}
placeholder={m['add.filamentNamePlaceholder']()}
aria-required="true"
aria-invalid={!!err('name')}
class:invalid={!!err('name')}
/>
<!-- The rename nudge only outranks the naming hint while the copy
still carries the original's name; after that both cases get the
same advice, since color is what makes a filament name useful. -->
{#if err('name')}<span class="err">{err('name')}</span>
{:else if nameStillSource}<span class="hint accent">{m['add.duplicateRename']()}</span>
{:else}<span class="hint">{m['add.nameHint']()}</span>{/if}
</label>
<label data-field="material" onfocusout={() => touch('material')}>
{m['filament.fields.material']()}
{@render req()}
<Combobox
value={nf.material}
options={materialNames}
placeholder="PLA"
required
invalid={!!err('material')}
oninput={onMaterial}
/>
{#if err('material')}<span class="err">{err('material')}</span>{/if}
</label>
<label class="color-field wide" data-field="colorHex" onfocusout={() => touch('colorHex')}>
{m['filament.fields.colorHex']()}
<div class="color-editor-wrap">
<ColorEditor
colors={nf.colors}
direction={nf.multiColorDirection}
onchange={(v) => {
nf.colors = v.colors;
nf.multiColorDirection = v.direction;
}}
/>
</div>
{#if err('colorHex')}<span class="err">{err('colorHex')}</span>{/if}
</label>
</div>
<button class="adv-toggle" onclick={() => (showAdvanced = !showAdvanced)}>
{#if showAdvanced}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{m['add.advanced']()}
{#if !showAdvanced}<span class="adv-note">{m['add.advancedNote']()}</span>{/if}
</button>
{#if showAdvanced}
<div class="form">
<label data-field="density" onfocusout={() => touch('density')}
>{m['filament.fields.density']()}
{@render req()}
<NumberInput
bind:value={nf.density}
min={0}
step={0.01}
unit="g/cm³"
spaced
required
invalid={!!err('density')}
/>
{#if err('density')}<span class="err">{err('density')}</span>{/if}
</label>
<label data-field="diameter" onfocusout={() => touch('diameter')}
>{m['filament.fields.diameter']()}
{@render req()}
<NumberInput
bind:value={nf.diameter}
min={0}
step={0.05}
unit="mm"
spaced
required
invalid={!!err('diameter')}
/>
{#if err('diameter')}<span class="err">{err('diameter')}</span>{/if}
</label>
<label data-field="nozzleTemp" onfocusout={() => touch('nozzleTemp')}
>{m['filament.fields.settingsExtruderTemp']()}
<NumberInput
bind:value={nf.nozzleTemp}
min={0}
step={5}
unit="°C"
placeholder="—"
spaced
invalid={!!err('nozzleTemp')}
/>
{#if err('nozzleTemp')}<span class="err">{err('nozzleTemp')}</span>{/if}
</label>
<label data-field="bedTemp" onfocusout={() => touch('bedTemp')}
>{m['filament.fields.settingsBedTemp']()}
<NumberInput
bind:value={nf.bedTemp}
min={0}
step={5}
unit="°C"
placeholder="—"
spaced
invalid={!!err('bedTemp')}
/>
{#if err('bedTemp')}<span class="err">{err('bedTemp')}</span>{/if}
</label>
<label>
{m['filament.fields.articleNumber']()}
<input class="mono" bind:value={nf.articleNumber} placeholder="—" />
</label>
<label class="wide">
{m['filament.fields.comment']()}
<input bind:value={nf.comment} placeholder="—" />
</label>
</div>
{/if}
<!-- Outside the advanced block: a custom field only exists because
someone defined it, so it isn't an advanced detail to them. The
section renders nothing when no filament fields are defined. -->
<ExtraFieldsSection entity="filament" extra={filamentExtra} onchange={setFilamentExtra} />
</section>
<NewFilamentCards
bind:draft={nf}
{err}
{touch}
extra={filamentExtra}
onextra={setFilamentExtra}
{vendorExtra}
onVendorExtra={setVendorExtra}
bind:vendorIsNew
bind:showAdvanced
{cloneSource}
backLabel={m['add.useExisting']()}
onback={() => (step = 1)}
/>
{:else if chosen}
<!-- No card here: the chosen-filament row is already a self-contained
bordered block, so it only needs the heading that names the entity. -->
@@ -1009,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}
@@ -1401,24 +1118,6 @@
font-size: 11.5px;
color: var(--accent-muted-2);
}
/* One card per record this flow will create. Whether the manufacturer is a new
one or an existing one being linked is left to the accent hint under the
field: --accent-border is lighter than --border-strong in the light theme, so
swapping borders to mark "new" would read as less emphasis, not more. */
.new-section {
background: var(--surface);
border: 1px solid var(--accent-border);
border-radius: var(--radius-md);
padding: 12px 14px;
}
.man-section + .new-section {
margin-top: 10px;
}
/* The manufacturer block holds a single field, so its heading is that field's
<label> — one accessible name, no heading/label echo. */
.ent-field {
display: block;
}
.ent-label {
display: block;
font-size: 11px;
@@ -1439,24 +1138,6 @@
.ent-note.shared {
color: var(--accent-muted-2);
}
.fs-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.fs-title {
font-weight: 600;
font-size: 13px;
margin-top: 2px;
}
.fs-back {
margin-left: auto;
font-size: 12px;
color: var(--accent-link);
background: none;
border: none;
cursor: pointer;
}
/* Separates the records being created from the spool block. Deliberately
heavier than --border-soft, which was invisible against the light theme's
card backgrounds and left the two blocks looking like one run of fields. */
@@ -1465,25 +1146,6 @@
background: var(--border);
margin: 16px 0 12px;
}
.color-editor-wrap {
margin-top: 6px;
}
.adv-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
background: none;
border: none;
color: var(--accent-link);
font-size: 12px;
cursor: pointer;
font-family: inherit;
padding: 0;
}
.adv-note {
color: var(--text-faint);
}
.form {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -1535,9 +1197,6 @@
.form input:focus {
border-color: var(--accent);
}
.form input.invalid {
border-color: var(--danger);
}
.err {
display: block;
margin-top: 4px;
@@ -1595,9 +1254,6 @@
/* Flows inline in the form, so it wraps and stays inside the modal on mobile. */
max-width: 100%;
}
.hint.accent {
color: var(--accent-soft);
}
.presets {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,579 @@
<script lang="ts">
// Link a physical tag to a spool or filament, by any of the three ways a UID can
// reach a browser: a reader taps it and the relay delivers it, this phone reads it over
// Web NFC, or the user types it.
//
// All three converge on one input, deliberately. The UID is the whole of a
// tag's identity, so a scan is nothing more than a faster way to fill the same
// box — and seeing what was captured before committing is what makes an
// unfamiliar tag safe to link.
//
// The dialog looks the UID up as it settles rather than waiting for the link to
// be rejected, because "this tag is already on spool #12" is the answer that
// changes what you want to do, and learning it after pressing the button means
// pressing a second one. A 409 on submit is still handled: the lookup can be
// stale, and the server is the authority.
import Button from './Button.svelte';
import X from '@lucide/svelte/icons/x';
import Nfc from '@lucide/svelte/icons/nfc';
import Smartphone from '@lucide/svelte/icons/smartphone';
import Radio from '@lucide/svelte/icons/radio';
import History from '@lucide/svelte/icons/history';
import type { Filament, Spool } from '$lib/types';
import {
linkTag,
unlinkTag,
findTagHolder,
holderTarget,
asTagConflict,
isBadUid,
type TagHolder,
type TagKind,
type TagTarget
} from '$lib/api/tags';
import { scanRelay } from '$lib/api/scanRelay';
import { scanner } from '$lib/stores/scanner.svelte';
import { inventory } from '$lib/stores/inventory.svelte';
import { spoolSource } from '$lib/api/spoolSource';
import { toasts } from '$lib/stores/toasts.svelte';
import { isAbortError } from '$lib/api/http';
import { nfcSupported, readTagUid, NfcError, type NfcErrorReason } from '$lib/utils/nfc';
import { filamentLabel } from '$lib/utils/library';
import * as m from '$lib/paraglide/messages';
interface Props {
open: boolean;
/** What the tag is being linked to. */
kind: TagKind;
id: number | string;
onclose: () => void;
}
let { open, kind, id, onclose }: Props = $props();
let target = $derived<TagTarget>({ kind, id });
let uid = $state('');
/** Carried through from a scan; a typed UID has no format to report. */
let format = $state<string | undefined>(undefined);
let busy = $state(false);
let error = $state<string | null>(null);
/** What the UID currently in the box resolves to on the server. */
type Lookup =
| { state: 'empty' }
| { state: 'checking' }
| { state: 'free' }
| { state: 'here' }
| { state: 'taken'; holder: TagHolder }
| { state: 'bad' }
| { state: 'unknown' };
let lookup = $state<Lookup>({ state: 'empty' });
let nfcReading = $state(false);
let nfcError = $state<NfcErrorReason | null>(null);
/** Whether this phone can read tags itself. False everywhere but Chrome on
* Android over HTTPS, which is why the control is omitted rather than
* disabled — there is no advice to give a desktop user about it. */
const canReadHere = nfcSupported();
// Web NFC needs a real gesture to raise its permission prompt, so this is
// started by the button rather than on open.
let nfcAbort: AbortController | null = null;
function reset() {
uid = '';
format = undefined;
error = null;
lookup = { state: 'empty' };
nfcError = null;
stopNfc();
}
function close() {
onclose();
}
function stopNfc() {
nfcAbort?.abort();
nfcAbort = null;
nfcReading = false;
}
// Everything the dialog does starts when it opens and stops when it closes,
// including the relay subscription and the hold on auto-navigate. That hold
// matters more than it looks: the tap this dialog is waiting for is exactly the
// event that would otherwise navigate the browser somewhere else, throwing the
// dialog away mid-flow.
$effect(() => {
if (!open) return;
reset();
const release = scanner.suppress();
const off = scanRelay.subscribe(scanner.pool, (scan) => {
scanner.receive(scan);
uid = scan.uid;
format = scan.format;
nfcError = null;
stopNfc();
});
return () => {
off();
release();
stopNfc();
};
});
// Resolve the UID as it settles. Debounced because this also runs while the
// user is typing one in by hand, and aborted on every change so a slow earlier
// answer can't land on top of a newer one.
$effect(() => {
const value = uid.trim();
if (!open) return;
if (!value) {
lookup = { state: 'empty' };
return;
}
lookup = { state: 'checking' };
const ac = new AbortController();
const timer = setTimeout(async () => {
try {
const found = await findTagHolder(value, ac.signal);
if (ac.signal.aborted) return;
if (!found) lookup = { state: 'free' };
else if (isTarget(found)) lookup = { state: 'here' };
else lookup = { state: 'taken', holder: found };
} catch (err) {
if (isAbortError(err, ac.signal)) return;
// A UID that isn't hexadecimal is a 400 rather than an empty result, so
// a half-typed or mistyped one says so instead of claiming to be free.
lookup = isBadUid(err) ? { state: 'bad' } : { state: 'unknown' };
}
}, 350);
return () => {
clearTimeout(timer);
ac.abort();
};
});
async function startNfc() {
stopNfc();
nfcError = null;
const ac = new AbortController();
nfcAbort = ac;
nfcReading = true;
try {
const serial = await readTagUid(ac.signal);
uid = serial;
// Web NFC reaches NDEF tags, so "ntag" is the honest default for what it
// can read — but only as a starting guess the server may refine later.
format = 'ntag';
} catch (err) {
if (ac.signal.aborted) return; // closed, or superseded by another read
nfcError = err instanceof NfcError ? err.reason : 'unknown';
} finally {
if (nfcAbort === ac) {
nfcAbort = null;
nfcReading = false;
}
}
}
function nfcErrorMessage(reason: NfcErrorReason): string {
if (reason === 'notAllowed') return m['tags.nfc.notAllowed']();
if (reason === 'notSupported') return m['tags.nfc.notSupported']();
if (reason === 'notReadable') return m['tags.nfc.notReadable']();
if (reason === 'insecureContext') return m['tags.nfc.insecureContext']();
return m['tags.nfc.unknown']();
}
/** Whether a holder is the very spool or filament this dialog links to. */
function isTarget(holder: TagHolder): boolean {
const held = holderTarget(holder);
return held.kind === target.kind && String(held.id) === String(target.id);
}
function nameOf(filament: Filament): string {
const vendor = inventory.vendorById(filament.vendorId);
return vendor ? filamentLabel(filament, vendor) : filament.name;
}
/** Describe whatever holds the tag well enough to decide whether to take it. */
function describe(holder: TagHolder): string {
if (holder.kind === 'filament') {
return m['tags.modal.filamentNamed']({ name: nameOf(holder.filament) });
}
const other: Spool = holder.spool;
const filament = inventory.filamentById(other.filamentId);
if (!filament) return m['tags.modal.spoolShort']({ id: other.id });
return m['tags.modal.spoolNamed']({ id: other.id, name: nameOf(filament) });
}
/** Load whatever a 409 said holds the tag, so the offer to move it can name it. */
async function fetchHolder(held: TagTarget): Promise<TagHolder | undefined> {
if (held.kind === 'spool') {
const spool = await spoolSource.fetchSpool(Number(held.id));
return spool ? { kind: 'spool', spool } : undefined;
}
const filament = await spoolSource.fetchFilament(String(held.id));
return filament ? { kind: 'filament', filament } : undefined;
}
async function submit() {
const value = uid.trim();
if (!value || busy) return;
busy = true;
error = null;
try {
await linkTag(target, value, format);
toasts.success(m['tags.added']());
close();
} catch (err) {
const conflict = asTagConflict(err);
if (conflict) {
// Lost a race, or the lookup never ran. Fetch the holder so the offer to
// move the tag can name it, and fall back to the bare id if even that
// fails — the offer is still valid without a name.
const holder = await fetchHolder(conflict.holder).catch(() => undefined);
lookup = holder ? { state: 'taken', holder } : { state: 'unknown' };
error = holder ? null : conflict.message;
} else if (isBadUid(err)) {
lookup = { state: 'bad' };
} else {
error = m['tags.modal.failed']();
}
} finally {
busy = false;
}
}
/**
* Take a tag off whatever holds it and put it on this spool or filament.
*
* Two requests, in this order, because a tag identifies exactly one thing and
* the server will not accept the second until the first has happened. If the
* link then fails the tag is left on neither, which is reported rather than
* hidden: the tag still exists and can simply be linked again.
*/
async function move(from: TagHolder) {
const value = uid.trim();
if (!value || busy) return;
busy = true;
error = null;
try {
await unlinkTag(holderTarget(from), value);
await linkTag(target, value, format);
toasts.success(
from.kind === 'spool'
? m['tags.moved']({ id: from.spool.id })
: m['tags.movedFromFilament']({ name: nameOf(from.filament) })
);
close();
} catch {
// "neither spool" is only the truth when both ends are spools.
error =
from.kind === 'spool' && target.kind === 'spool'
? m['tags.modal.moveFailed']()
: m['tags.modal.moveFailedFilament']();
} finally {
busy = false;
}
}
// Naming the reader you are waiting on needs the server's registry, since the
// store keeps no copy of reader names; until it arrives the id stands in.
$effect(() => {
if (open && scanner.pool !== null) scanner.ensureReaderNames();
});
let readerLabel = $derived(scanner.pairedLabel);
// What the reader we listen to already has on it. Offered instead of asking
// for another tap, which on a scale means lifting the spool off the pad and
// putting it back. Hidden once it is what the field already holds, so it
// never sits there as a no-op.
let lastSeen = $derived(scanner.listeningLastSeen);
let lastSeenOffer = $derived(
lastSeen && lastSeen.uid.toUpperCase() !== uid.trim().toUpperCase() ? lastSeen : null
);
</script>
<svelte:window
onkeydown={(e) => {
if (open && e.key === 'Escape') close();
}}
/>
{#if open}
<div class="overlay">
<!-- Click-outside catcher: a sibling of the dialog rather than a parent, so
interactive controls aren't nested inside an interactive element. -->
<button class="backdrop" tabindex="-1" aria-hidden="true" onclick={close}></button>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="add-tag-title" tabindex="-1">
<div class="modal-head">
<span class="title" id="add-tag-title">{m['tags.modal.title']()}</span>
<button class="x" onclick={close} aria-label={m['buttons.close']()}><X size={16} /></button>
</div>
<div class="body">
<p class="intro">
{kind === 'spool' ? m['tags.modal.intro']({ id }) : m['tags.modal.introFilament']()}
</p>
<label class="uid-label" for="add-tag-uid">{m['tags.modal.uidLabel']()}</label>
<input
id="add-tag-uid"
class="uid-input mono"
bind:value={uid}
placeholder={m['tags.modal.uidPlaceholder']()}
autocomplete="off"
spellcheck="false"
onkeydown={(e) => {
if (e.key === 'Enter') submit();
}}
/>
<p class="help">{m['tags.modal.uidHelp']()}</p>
<div class="status" aria-live="polite">
{#if lookup.state === 'checking'}
<span class="muted">{m['tags.modal.checking']()}</span>
{:else if lookup.state === 'free'}
<span class="ok">{m['tags.modal.free']()}</span>
{:else if lookup.state === 'here'}
<span class="muted">
{kind === 'spool' ? m['tags.modal.alreadyHere']() : m['tags.modal.alreadyHereFilament']()}
</span>
{:else if lookup.state === 'taken'}
<span class="warn">{m['tags.modal.takenBy']({ spool: describe(lookup.holder) })}</span>
{:else if lookup.state === 'bad'}
<span class="warn">{m['tags.modal.badUid']()}</span>
{/if}
</div>
<div class="ways">
<div class="way">
<Radio size={14} class="way-ico" />
<span>
{#if readerLabel}
{m['tags.modal.waitingOn']({ reader: readerLabel })}
{:else}
{m['tags.modal.waitingAny']()}
{/if}
</span>
</div>
{#if lastSeenOffer}
<div class="way">
<History size={14} class="way-ico" />
<span>
{m['tags.modal.lastSeenOn']({
reader: lastSeenOffer.label,
uid: lastSeenOffer.uid
})}
</span>
<button class="link act" onclick={() => (uid = lastSeenOffer.uid)}>
{m['tags.modal.useLastSeen']()}
</button>
</div>
{/if}
{#if canReadHere}
<div class="way">
<Smartphone size={14} class="way-ico" />
{#if nfcReading}
<span>{m['tags.modal.phoneReading']()}</span>
<button class="link" onclick={stopNfc}>{m['buttons.cancel']()}</button>
{:else}
<button class="link" onclick={startNfc}>{m['tags.modal.phoneScan']()}</button>
{/if}
</div>
{/if}
</div>
{#if nfcError}<div class="err">{nfcErrorMessage(nfcError)}</div>{/if}
{#if error}<div class="err">{error}</div>{/if}
</div>
<div class="foot">
<Button variant="ghost" onclick={close}>{m['buttons.cancel']()}</Button>
{#if lookup.state === 'taken'}
{@const other = lookup.holder}
<Button variant="primary" disabled={busy} onclick={() => move(other)}>
<Nfc size={15} />
{#if busy}
{m['tags.modal.moving']()}
{:else if other.kind === 'spool'}
{m['tags.modal.move']({ id: other.spool.id })}
{:else}
{m['tags.modal.moveHere']()}
{/if}
</Button>
{:else}
<Button
variant="primary"
disabled={busy || !uid.trim() || lookup.state === 'here' || lookup.state === 'bad'}
onclick={submit}
>
<Nfc size={15} />
{busy ? m['tags.modal.linking']() : m['tags.modal.link']()}
</Button>
{/if}
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
/* Above the inspector's bottom sheet on mobile, same layer as the other
library dialogs. */
z-index: 60;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 8vh 16px 16px;
}
.backdrop {
position: fixed;
inset: 0;
border: none;
margin: 0;
padding: 0;
background: transparent;
cursor: default;
}
.modal {
position: relative;
z-index: 1;
width: 460px;
max-width: 100%;
max-height: 84vh;
display: flex;
flex-direction: column;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.modal-head {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px 0;
flex: none;
}
.title {
font-weight: 700;
font-size: 16px;
}
.x {
margin-left: auto;
color: var(--text-dim);
cursor: pointer;
padding: 4px 8px;
background: none;
border: none;
display: inline-flex;
}
.x:hover {
color: var(--text);
}
.body {
padding: 12px 20px 4px;
overflow-y: auto;
}
.intro {
margin: 0 0 14px;
font-size: 12.5px;
color: var(--text-muted);
}
.uid-label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
margin-bottom: 5px;
}
.uid-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
font-size: 14px;
letter-spacing: 0.04em;
color: var(--text);
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.uid-input:focus {
outline: none;
border-color: var(--accent-link);
}
.help {
margin: 6px 0 0;
font-size: 11.5px;
color: var(--text-dim);
}
.status {
/* Reserved so the dialog doesn't jump as the lookup resolves. */
min-height: 20px;
margin-top: 8px;
font-size: 12px;
}
.muted {
color: var(--text-dim);
}
.ok {
color: var(--text-muted);
}
.warn {
color: var(--danger-soft);
}
.ways {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.way {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-muted);
}
.way :global(.way-ico) {
color: var(--text-dim);
flex: none;
}
.link {
font-size: 12px;
color: var(--accent-link);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
/* The offer is the action in its row rather than an alternative route like
the phone reader, so it carries the weight a UID gets elsewhere. */
.link.act {
font-weight: 600;
white-space: nowrap;
}
.err {
margin-top: 10px;
font-size: 12px;
color: var(--danger-soft);
}
.foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px 16px;
flex: none;
}
</style>
+29 -10
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { asset } from '$app/paths';
import { getInfo, type Info } from '$lib/api/info';
import * as m from '$lib/paraglide/messages';
@@ -17,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>
@@ -24,9 +31,14 @@
<a href="https://github.com/Donkie/Spoolman/issues">{m['footer.reportIssue']()}</a>
</div>
<a class="kofi" href="https://ko-fi.com/donkie" target="_blank" rel="noopener noreferrer">
<img src={asset('/kofi_s_logo_nolabel.png')} alt="" />
{m.kofi()}
<a class="sponsor" href="https://github.com/sponsors/Donkie" target="_blank" rel="noopener noreferrer">
<!-- Octicon heart-fill, inlined so the footer needs no request to github.com. -->
<svg viewBox="0 0 16 16" aria-hidden="true">
<path
d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z"
/>
</svg>
{m.sponsor()}
</a>
</footer>
@@ -52,7 +64,12 @@
.commit {
color: var(--text-faint);
}
.kofi {
.update {
margin-left: 4px;
color: var(--accent);
font-weight: 600;
}
.sponsor {
flex: none;
display: inline-flex;
align-items: center;
@@ -67,25 +84,27 @@
text-decoration: none;
transition: border-color 0.12s;
}
.kofi:hover {
.sponsor:hover {
border-color: var(--accent);
}
.kofi img {
.sponsor svg {
height: 16px;
width: auto;
width: 16px;
fill: #db61a2;
}
@media (max-width: 560px) {
.footer {
padding: 6px 12px;
}
.kofi {
.sponsor {
font-size: 0;
gap: 0;
padding: 5px 8px;
}
.kofi img {
.sponsor svg {
height: 18px;
width: 18px;
}
}
</style>
+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>
@@ -0,0 +1,450 @@
<script lang="ts">
// The "describe a filament that doesn't exist yet" form, as a pair of cards.
//
// Two dialogs need it: the add-spools flow, where it is step 2's first half, and
// the change-filament dialog, where refilling a shelf slot with something new is
// the whole point (issue #1010). Keeping it here means the layout that makes the
// flow legible — one card per record about to be created, so it is never a
// mystery which record a field lands on (#1038) — holds in both places.
//
// The manufacturer gets its own card because it is its own record: a vendor
// combobox sitting among the filament fields, quietly creating a second entity,
// was the most confusing part of the flow.
//
// Validation is drawn here but decided by the caller: `err` says what to show for
// a field and `touch` records that it has been left, so the caller keeps its own
// policy for when an error is revealed (see AddSpoolModal's `touched`/`attempted`).
// This component only reports and displays.
import ColorEditor from './ColorEditor.svelte';
import NumberInput from './NumberInput.svelte';
import Combobox from './Combobox.svelte';
import ExtraFieldsSection from './ExtraFieldsSection.svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import type { Extra, Filament } from '$lib/types';
import type { FilamentDraft, FilamentWeights } from '$lib/filament/draft';
import { applyMaterialSpec } from '$lib/filament/draft';
import { spoolSource } from '$lib/api/spoolSource';
import { settings } from '$lib/stores/settings.svelte';
import { loadMaterials, type MaterialSpec } from '$lib/data/materials';
import * as m from '$lib/paraglide/messages';
interface Props {
draft: FilamentDraft;
/** The error to display for a field, or '' — the caller decides what is revealed. */
err: (key: string) => string;
/** Called when a field is left, so the caller can start revealing its error. */
touch: (key: string) => void;
/** Custom-field values for the filament being created. */
extra: Extra;
onextra: (key: string, json: string | undefined) => void;
/**
* Custom-field values for a manufacturer this form would create. Only drawn
* while the typed name is a new one: linking an existing manufacturer must
* never offer to rewrite its values (#1055).
*/
vendorExtra: Extra;
onVendorExtra: (key: string, json: string | undefined) => void;
/** Whether the typed manufacturer name will create a record. Read-only to callers. */
vendorIsNew?: boolean;
/** Open the specs block. Callers set it when the draft came from a real filament. */
showAdvanced: boolean;
/** The filament this draft was copied from, if any — drives the heading and nudges. */
cloneSource?: Filament | null;
/**
* Weight, spool weight and price. Only drawn — and so only worth passing —
* when `showWeights`: the add-spools flow writes each of them to the spool it
* creates as well as to the filament, so it shows them in its spool block.
*/
weights?: FilamentWeights;
showWeights?: boolean;
/** Escape hatch in the filament card's heading, e.g. back to picking an existing one. */
backLabel?: string;
onback?: () => void;
}
let {
draft = $bindable(),
err,
touch,
extra,
onextra,
vendorExtra,
onVendorExtra,
vendorIsNew = $bindable(false),
showAdvanced = $bindable(),
cloneSource = null,
weights = $bindable({ weight: '', spoolWeight: '', price: '' }),
showWeights = false,
backLabel,
onback
}: Props = $props();
let nameInput = $state<HTMLInputElement | undefined>();
let vendorNames = $state<string[]>([]);
let materialNames = $state<string[]>([]);
let materialSpecs = $state<Record<string, MaterialSpec>>({});
spoolSource
.vendorNames()
.then((vn) => (vendorNames = vn))
.catch(() => {});
loadMaterials()
.then(({ names, specs }) => {
materialNames = names;
materialSpecs = specs;
})
.catch(() => {});
// Vendor combobox: reuse an existing vendor if the name matches, else create.
let vendorTrimmed = $derived(draft.vendorName.trim());
let vendorMatch = $derived(vendorNames.find((v) => v.toLowerCase() === vendorTrimmed.toLowerCase()));
let vendorHint = $derived(
vendorTrimmed === ''
? m['add.vendorHint.optional']()
: vendorMatch
? m['add.vendorHint.existing']({ name: vendorMatch })
: m['add.vendorHint.new']({ name: vendorTrimmed })
);
// Accents the manufacturer hint when the typed name will create a second record
// alongside the filament, rather than link an existing one. Written back to the
// caller, which needs it to decide whether the vendor's fields are sent at all.
$effect(() => {
vendorIsNew = vendorTrimmed !== '' && !vendorMatch;
});
// Nudge, not an error: Spoolman allows same-named filaments, but keeping the
// original's name on a duplicate is almost always an oversight.
let nameStillSource = $derived(!!cloneSource && draft.name.trim() === cloneSource.name.trim());
// When duplicating, the name is the one field that must change, so put the
// caret in it (at the end — the colour word is usually a suffix, and the rest
// of the name is worth keeping rather than retyping).
$effect(() => {
if (cloneSource && nameInput) {
nameInput.focus();
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
}
});
</script>
<!-- The one marker for "this must be filled in", used on every required field and
on nothing else. It is decoration: the control itself carries aria-required, so
screen readers hear the requirement rather than an asterisk. -->
{#snippet req()}<span class="req" title={m['validation.required']()} aria-hidden="true">*</span>{/snippet}
<section class="new-section man-section">
<label class="ent-field" data-field="vendor" onfocusout={() => touch('vendor')}>
<span class="ent-label">{m['add.section.manufacturer']()}</span>
<span class="ent-note">{m['add.section.manufacturerNote']()}</span>
<Combobox
value={draft.vendorName}
options={vendorNames}
placeholder={m['add.manufacturerPlaceholder']()}
invalid={!!err('vendor')}
oninput={(v) => (draft.vendorName = v)}
/>
{#if err('vendor')}
<span class="err">{err('vendor')}</span>
{:else}
<span class="hint" class:accent={vendorIsNew}>{vendorHint}</span>
{/if}
</label>
<!-- Outside the <label> above: these are inputs of their own, and only shown
while the typed name will create a manufacturer. Linking an existing one
must not offer to rewrite its custom fields (#1055). -->
{#if vendorIsNew}
<ExtraFieldsSection entity="vendor" extra={vendorExtra} onchange={onVendorExtra} />
{/if}
</section>
<section class="new-section">
<div class="fs-head">
<span class="ent-label">{m['add.section.filament']()}</span>
{#if backLabel && onback}
<button class="fs-back" onclick={onback}>{backLabel}</button>
{/if}
</div>
<div class="fs-title">
{cloneSource ? m['add.duplicateTitle']({ name: cloneSource.name }) : m['add.newFilamentTitle']()}
</div>
<div class="ent-note">
{cloneSource ? m['add.duplicateNote']() : m['add.section.filamentNote']()}
</div>
<div class="form">
<label class="wide" data-field="name" onfocusout={() => touch('name')}>
{m['filament.fields.name']()}
{@render req()}
<input
bind:this={nameInput}
bind:value={draft.name}
placeholder={m['add.filamentNamePlaceholder']()}
aria-required="true"
aria-invalid={!!err('name')}
class:invalid={!!err('name')}
/>
<!-- The rename nudge only outranks the naming hint while the copy still
carries the original's name; after that both cases get the same
advice, since color is what makes a filament name useful. -->
{#if err('name')}<span class="err">{err('name')}</span>
{:else if nameStillSource}<span class="hint accent">{m['add.duplicateRename']()}</span>
{:else}<span class="hint">{m['add.nameHint']()}</span>{/if}
</label>
<label data-field="material" onfocusout={() => touch('material')}>
{m['filament.fields.material']()}
{@render req()}
<Combobox
value={draft.material}
options={materialNames}
placeholder="PLA"
required
invalid={!!err('material')}
oninput={(v) => applyMaterialSpec(draft, v, materialSpecs)}
/>
{#if err('material')}<span class="err">{err('material')}</span>{/if}
</label>
{#if showWeights}
<!-- Only shown where this form is the only thing being filled in: how much
filament a full spool holds is the number the rest of the app measures
against, so it belongs beside the name rather than under "advanced". -->
<label data-field="netWeight" onfocusout={() => touch('netWeight')}>
{m['filament.fields.weight']()}
<NumberInput bind:value={weights.weight} min={0} unit="g" spaced invalid={!!err('netWeight')} />
{#if err('netWeight')}<span class="err">{err('netWeight')}</span>{/if}
</label>
{/if}
<label class="color-field wide" data-field="colorHex" onfocusout={() => touch('colorHex')}>
{m['filament.fields.colorHex']()}
<div class="color-editor-wrap">
<ColorEditor
colors={draft.colors}
direction={draft.multiColorDirection}
onchange={(v) => {
draft.colors = v.colors;
draft.multiColorDirection = v.direction;
}}
/>
</div>
{#if err('colorHex')}<span class="err">{err('colorHex')}</span>{/if}
</label>
</div>
<button class="adv-toggle" onclick={() => (showAdvanced = !showAdvanced)}>
{#if showAdvanced}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{m['add.advanced']()}
{#if !showAdvanced}<span class="adv-note">{m['add.advancedNote']()}</span>{/if}
</button>
{#if showAdvanced}
<div class="form">
<label data-field="density" onfocusout={() => touch('density')}
>{m['filament.fields.density']()}
{@render req()}
<NumberInput
bind:value={draft.density}
min={0}
step={0.01}
unit="g/cm³"
spaced
required
invalid={!!err('density')}
/>
{#if err('density')}<span class="err">{err('density')}</span>{/if}
</label>
<label data-field="diameter" onfocusout={() => touch('diameter')}
>{m['filament.fields.diameter']()}
{@render req()}
<NumberInput
bind:value={draft.diameter}
min={0}
step={0.05}
unit="mm"
spaced
required
invalid={!!err('diameter')}
/>
{#if err('diameter')}<span class="err">{err('diameter')}</span>{/if}
</label>
<label data-field="nozzleTemp" onfocusout={() => touch('nozzleTemp')}
>{m['filament.fields.settingsExtruderTemp']()}
<NumberInput
bind:value={draft.nozzleTemp}
min={0}
step={5}
unit="°C"
placeholder="—"
spaced
invalid={!!err('nozzleTemp')}
/>
{#if err('nozzleTemp')}<span class="err">{err('nozzleTemp')}</span>{/if}
</label>
<label data-field="bedTemp" onfocusout={() => touch('bedTemp')}
>{m['filament.fields.settingsBedTemp']()}
<NumberInput
bind:value={draft.bedTemp}
min={0}
step={5}
unit="°C"
placeholder="—"
spaced
invalid={!!err('bedTemp')}
/>
{#if err('bedTemp')}<span class="err">{err('bedTemp')}</span>{/if}
</label>
{#if showWeights}
<label data-field="spoolWeight" onfocusout={() => touch('spoolWeight')}
>{m['filament.fields.spoolWeight']()}
<NumberInput
bind:value={weights.spoolWeight}
min={0}
unit="g"
placeholder="—"
spaced
invalid={!!err('spoolWeight')}
/>
{#if err('spoolWeight')}<span class="err">{err('spoolWeight')}</span>{/if}
</label>
<label data-field="price" onfocusout={() => touch('price')}
>{m['filament.fields.price']()} <span class="u">{settings.currency}</span>
<NumberInput bind:value={weights.price} min={0} placeholder="—" spaced invalid={!!err('price')} />
{#if err('price')}<span class="err">{err('price')}</span>{/if}
</label>
{/if}
<label>
{m['filament.fields.articleNumber']()}
<input class="mono" bind:value={draft.articleNumber} placeholder="—" />
</label>
<label class="wide">
{m['filament.fields.comment']()}
<input bind:value={draft.comment} placeholder="—" />
</label>
</div>
{/if}
<!-- Outside the advanced block: a custom field only exists because someone
defined it, so it isn't an advanced detail to them. The section renders
nothing when no filament fields are defined. -->
<ExtraFieldsSection entity="filament" {extra} onchange={onextra} />
</section>
<style>
/* One card per record this flow will create. Whether the manufacturer is a new
one or an existing one being linked is left to the accent hint under the
field: --accent-border is lighter than --border-strong in the light theme, so
swapping borders to mark "new" would read as less emphasis, not more. */
.new-section {
background: var(--surface);
border: 1px solid var(--accent-border);
border-radius: var(--radius-md);
padding: 12px 14px;
}
.man-section + .new-section {
margin-top: 10px;
}
/* The manufacturer block holds a single field, so its heading is that field's
<label> — one accessible name, no heading/label echo. */
.ent-field {
display: block;
}
.ent-label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.ent-note {
display: block;
font-size: 11.5px;
color: var(--text-faint);
margin: 2px 0 8px;
}
.fs-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.fs-title {
font-weight: 600;
font-size: 13px;
margin-top: 2px;
}
.fs-back {
margin-left: auto;
font-size: 12px;
color: var(--accent-link);
background: none;
border: none;
cursor: pointer;
}
.color-editor-wrap {
margin-top: 6px;
}
.adv-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
background: none;
border: none;
color: var(--accent-link);
font-size: 12px;
cursor: pointer;
font-family: inherit;
padding: 0;
}
.adv-note {
color: var(--text-faint);
}
.form {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-top: 14px;
}
.form label {
display: block;
font-size: 11.5px;
color: var(--text-muted);
}
.form label.wide {
grid-column: 1 / -1;
}
.u {
color: var(--text-faint);
}
.form input {
width: 100%;
border: 1px solid var(--border-strong);
background: none;
border-radius: 7px;
padding: 9px 12px;
color: var(--text);
font-size: 13px;
margin-top: 5px;
}
.form input:focus {
border-color: var(--accent);
}
.form input.invalid {
border-color: var(--danger);
}
.err {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--danger-soft);
}
.req {
color: var(--accent-soft);
}
.hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--text-faint);
}
.hint.accent {
color: var(--accent-soft);
}
@media (max-width: 620px) {
.form {
grid-template-columns: 1fr 1fr;
}
}
</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>
@@ -0,0 +1,180 @@
<script lang="ts">
// The NFC/RFID tags linked to a spool or filament, listed in its inspector.
//
// A tag is identified by its hardware UID and nothing else, so there is very
// little to show: the UID, what kind of tag it is when the reader said, and
// when it was linked. What matters is that the UID shown is the server's
// canonical spelling — the same physical tag reads as `04:a2:b3:c4` on one
// reader and `04-A2-B3-C4` on another, and showing whichever spelling happened
// to be typed would make one tag look like several.
//
// Adding is a quiet section action rather than another button in the inspector
// header: that header already carries the primary actions, and linking a tag is
// not one of the things you do every day.
import SectionLabel from './SectionLabel.svelte';
import AddTagModal from './AddTagModal.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Nfc from '@lucide/svelte/icons/nfc';
import X from '@lucide/svelte/icons/x';
import type { Tag } from '$lib/types';
import { unlinkTag, type TagKind } from '$lib/api/tags';
import { toasts } from '$lib/stores/toasts.svelte';
import { formatShortDate } from '$lib/utils/datetime';
import * as m from '$lib/paraglide/messages';
interface Props {
/** What the tags identify, and so where links and unlinks go. */
kind: TagKind;
id: number | string;
tags: Tag[];
}
let { kind, id, tags }: Props = $props();
let addOpen = $state(false);
let pending = $state<Tag | null>(null);
let unlinking = $state(false);
async function confirmUnlink() {
const tag = pending;
if (!tag) return;
unlinking = true;
try {
await unlinkTag({ kind, id }, tag.uid);
// No local edit: unlinking emits the holder's ordinary `updated` event, and
// the list below re-renders from the cache when it arrives.
toasts.success(m['tags.unlinked']());
pending = null;
} catch {
toasts.error(m['tags.unlinkFailed']());
} finally {
unlinking = false;
}
}
function unlinkBody(uid: string): string {
return kind === 'spool' ? m['tags.unlinkBody']({ uid }) : m['tags.unlinkBodyFilament']({ uid });
}
</script>
<SectionLabel>
{m['tags.section']()}
{#snippet right()}
<button class="link" onclick={() => (addOpen = true)}>
<Plus size={13} />
{m['tags.add']()}
</button>
{/snippet}
</SectionLabel>
{#if tags.length}
<ul class="tags">
{#each tags as tag (tag.uid)}
<li class="tag">
<Nfc size={14} class="ico" />
<span class="uid mono">{tag.uid}</span>
{#if tag.format}<span class="fmt">{tag.format}</span>{/if}
<span class="added">{m['tags.addedOn']({ date: formatShortDate(tag.added) })}</span>
<button
class="unlink"
onclick={() => (pending = tag)}
title={m['tags.unlink']()}
aria-label={m['tags.unlink']()}
>
<X size={14} />
</button>
</li>
{/each}
</ul>
{:else}
<div class="none">{m['tags.none']()}</div>
{/if}
<AddTagModal open={addOpen} {kind} {id} onclose={() => (addOpen = false)} />
<ConfirmDialog
open={pending !== null}
busy={unlinking}
title={m['tags.unlinkTitle']()}
lines={pending ? [unlinkBody(pending.uid)] : []}
confirmLabel={unlinking ? m['tags.unlinking']() : m['tags.unlink']()}
onconfirm={confirmUnlink}
onclose={() => (pending = null)}
/>
<style>
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--accent-link);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
.tags {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.tag {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.tag :global(.ico) {
color: var(--text-dim);
flex: none;
}
.uid {
font-weight: 600;
/* The UID is the whole identity of the tag, so it gets the room; everything
after it is context and may be dropped on a narrow inspector. */
overflow-wrap: anywhere;
}
.fmt {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 1px 5px;
flex: none;
}
.added {
margin-left: auto;
color: var(--text-dim);
font-size: 11px;
white-space: nowrap;
}
.unlink {
flex: none;
display: inline-flex;
color: var(--text-dim);
background: none;
border: none;
padding: 2px;
cursor: pointer;
}
.unlink:hover {
color: var(--danger);
}
.none {
font-size: 12px;
color: var(--text-dim);
}
</style>
@@ -9,6 +9,7 @@
// are dismissible on click) take pointer events.
import Check from '@lucide/svelte/icons/check';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import Info from '@lucide/svelte/icons/info';
import { fly } from 'svelte/transition';
import { toasts } from '$lib/stores/toasts.svelte';
</script>
@@ -20,8 +21,11 @@
transition:fly={{ y: 12, duration: 150 }}
onclick={() => toasts.dismiss(toast.id)}
>
<!-- `info` keeps the neutral base styling; only the icon distinguishes it. -->
{#if toast.kind === 'error'}
<TriangleAlert size={15} />
{:else if toast.kind === 'info'}
<Info size={15} />
{:else}
<Check size={15} />
{/if}
@@ -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>
@@ -1,11 +1,12 @@
<script lang="ts">
import { untrack } from 'svelte';
import Button from '$components/Button.svelte';
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, paperSize, sheetGrid } from '$lib/labels/paper';
import { PAPER_NAMES, layoutMargin, paperSize, sheetGrid } from '$lib/labels/paper';
import { printLabels, exportLabels, ZIP_THRESHOLD } from '$lib/labels/print';
import { EXPORT_FORMATS, resolveExportFormat } from '$lib/labels/export';
import { spoolSource } from '$lib/api/spoolSource';
@@ -32,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);
@@ -40,27 +41,31 @@
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 preload only the latest batch — the
// common case when printing labels for spools you just added — and let search
// pull in anything older on demand.
const INITIAL_LIMIT = 50;
// …but the latest 50 isn't enough for the "Recent" quick-select once you've
// added more than that in the last 24h, so we keep paging (newest-first) past
// the first 50 while still inside the recent window. PAGE is the batch size for
// that extension; MAX_INITIAL caps it so a huge same-day import can't recreate
// the original heavy load.
// 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;
const MAX_INITIAL = 1000;
// "Recent" must still cover every spool added in the last 24h, past the first
// page too, so it has its own newest-first loader. MAX_RECENT caps it so a huge
// same-day import can't recreate the original heavy load.
const MAX_RECENT = 1000;
// Color-similarity threshold for the search (a query like "#ff0000" or "red"
// also runs a color match); matches the value used by the library picker.
const COLOR_THRESHOLD = 20;
// IDs of the preloaded "latest" spools, and of the current search result (null
// when not searching). Both index into the reactive inventory cache, so the
// list stays live as spools are edited or removed.
let baseIds = $state<number[]>([]);
// IDs of the current page, of the recently added spools, and of the current
// 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);
let pageSize = $state(PAGE);
let total = $state(0);
let listEl = $state<HTMLElement>();
// Spools registered within this window count as "recently added" for the
// one-click quick-select.
@@ -77,80 +82,111 @@
return Number.isNaN(t) ? s.id : t;
}
// The list pages in its displayed order, so the next page of "newest" holds
// older spools and the next page of "#" higher ids.
const dir = $derived(sort === 'newest' ? 'desc' : 'asc');
$effect(() => {
void kind;
untrack(() => (pageNo = 1));
});
$effect(() => {
if (kind !== 'spool') return;
const ctrl = new AbortController();
void loadInitial(ctrl.signal);
void loadPage(kind, dir, (pageNo - 1) * pageSize, pageSize, ctrl.signal);
return () => ctrl.abort();
});
async function loadInitial(signal: AbortSignal) {
async function loadPage(
kind: LabelKind,
dir: 'asc' | 'desc',
offset: number,
limit: number,
signal: AbortSignal
) {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const seen = new Set<number>();
const ids: number[] = [];
let offset = 0;
// Load the latest INITIAL_LIMIT, then keep paging while the oldest spool
// of the last batch is still within the 24h window — so every spool added
// today lands in the list (and thus in "Recent"), not just the newest 50.
// Sorted by id desc, which is monotonic with registration, so once a batch
// reaches past the window everything below it is older too.
for (;;) {
const limit = ids.length === 0 ? INITIAL_LIMIT : PAGE;
const page = await spoolSource.listSpools({
filters: {},
sort: [{ field: 'id', dir: 'desc' }],
limit,
offset,
lowThreshold: settings.lowThreshold,
signal
});
for (const s of page.items) {
if (!seen.has(s.id)) {
seen.add(s.id);
ids.push(s.id);
}
}
offset += page.items.length;
const oldest = page.items.at(-1);
const more =
page.items.length === limit && // a full page ⇒ there may be more
ids.length < MAX_INITIAL && // safety cap
!!oldest &&
isRecent(oldest); // still inside the 24h window
if (!more) break;
}
baseIds = ids;
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;
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;
}
}
// Search hits the backend rather than filtering the preloaded slice, so spools
$effect(() => {
if (kind !== 'spool') return;
const ctrl = new AbortController();
void loadRecent(ctrl.signal);
return () => ctrl.abort();
});
async function loadRecent(signal: AbortSignal) {
try {
const ids: number[] = [];
let offset = 0;
// Page newest-first while the last spool of the batch is still within the
// 24h window. Sorted by id, which is monotonic with registration, so once a
// batch reaches past the window everything below it is older too.
for (;;) {
const page = await spoolSource.listSpools({
filters: {},
sort: [{ field: 'id', dir: 'desc' }],
limit: PAGE,
offset,
lowThreshold: settings.lowThreshold,
signal
});
ids.push(...page.items.filter(isRecent).map((s) => s.id));
offset += page.items.length;
const last = page.items.at(-1);
if (page.items.length < PAGE || offset >= MAX_RECENT || !last || !isRecent(last)) break;
}
// A live insert shifts the offsets, so batches can overlap.
recentIds = [...new Set(ids)];
} catch (e) {
if (isAbortError(e, signal)) return;
console.error('Failed to load recent spools for printing', e);
}
}
// 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
@@ -168,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;
}
@@ -184,9 +220,9 @@
return `#${s.id} · ${name}${s.location ? ' · ' + s.location : ''}`;
}
// The active id set — search results when searching, otherwise the latest batch —
// The active id set — search results when searching, otherwise the current page —
// resolved through the reactive cache (so edits/deletes reflect) and sorted.
const activeIds = $derived(resultIds ?? baseIds);
const activeIds = $derived(resultIds ?? pageIds);
const visibleSpools = $derived(
activeIds
.map((id) => inventory.spoolById(id))
@@ -194,9 +230,16 @@
.sort((a, b) => (sort === 'newest' ? recencyKey(b) - recencyKey(a) : a.id - b.id))
);
// Count of currently-visible spools added in the last 24h — drives the "Recent"
// quick-select label and whether it's actionable.
const recentCount = $derived(visibleSpools.filter(isRecent).length);
// Spools added in the last 24h (within the results, when searching) — drive the
// "Recent" quick-select label and whether it's actionable.
const recentSpools = $derived(
resultIds
? visibleSpools.filter(isRecent)
: recentIds
.map((id) => inventory.spoolById(id))
.filter((s): s is Spool => !!s && !s.archived && isRecent(s))
);
const recentCount = $derived(recentSpools.length);
function toggle(id: number) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selected` updates via reassignment below
@@ -205,11 +248,17 @@
else next.add(id);
selected = next;
}
// Quick-selects add to the selection rather than replace it, so picks carry
// across pages.
function selectAll() {
selected = new Set(visibleSpools.map((s) => s.id));
selected = new Set([...selected, ...visibleSpools.map((s) => s.id)]);
}
function selectRecent() {
selected = new Set(visibleSpools.filter(isRecent).map((s) => s.id));
selected = new Set([...selected, ...recentSpools.map((s) => s.id)]);
}
function setSort(s: 'newest' | 'id') {
sort = s;
pageNo = 1;
}
function clearAll() {
selected = new Set();
@@ -222,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);
@@ -250,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
@@ -269,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();
@@ -328,7 +354,9 @@
}
function setMargin(k: 't' | 'b' | 'l' | 'r', v: number) {
layout.margin = { ...layout.margin, [k]: v };
// Negative margins anchor the grid off the paper: the labels get clipped and
// the safe-zone inset (safe - margin) balloons past the label itself.
layout.margin = { ...layout.margin, [k]: Math.max(0, v) };
}
function setSafe(k: 't' | 'b' | 'l' | 'r', v: number) {
layout.safe = { ...layout.safe, [k]: Math.max(0, v) };
@@ -339,28 +367,39 @@
// the design (the button only touches width, per its label).
function fitLabelWidth() {
const page = paperSize(layout);
const usableW = page.w - layout.margin.l - layout.margin.r;
const margin = layoutMargin(layout);
const usableW = page.w - margin.l - margin.r;
const cols = Math.max(1, Math.round(layout.columns));
const w = (usableW - (cols - 1) * layout.spacing.h) / cols;
if (w > 0) design.label = { ...design.label, w: Math.round(w * 10) / 10 };
}
</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>
@@ -379,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
>
@@ -388,14 +427,9 @@
</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={() => (sort = 'newest')}
>{m['labels.sortNewest']()}</button
>
<button class:active={sort === 'id'} onclick={() => (sort = 'id')}>{m['labels.sortId']()}</button>
</div>
{@render sortControls()}
</div>
<div class="spool-list">
<div class="spool-list" bind:this={listEl}>
{#if loading || (searching && visibleSpools.length === 0)}
<div class="muted">{m.loading()}…</div>
{:else if visibleSpools.length === 0}
@@ -412,6 +446,19 @@
</div>
<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">
@@ -515,6 +562,7 @@
<label class="fld"
>{m['printing.generic.marginTop']()}<NumberInput
dense
min={0}
unit="mm"
value={layout.margin.t}
onchange={(v) => setMargin('t', v)}
@@ -523,6 +571,7 @@
<label class="fld"
>{m['printing.generic.marginBottom']()}<NumberInput
dense
min={0}
unit="mm"
value={layout.margin.b}
onchange={(v) => setMargin('b', v)}
@@ -531,6 +580,7 @@
<label class="fld"
>{m['printing.generic.marginLeft']()}<NumberInput
dense
min={0}
unit="mm"
value={layout.margin.l}
onchange={(v) => setMargin('l', v)}
@@ -539,6 +589,7 @@
<label class="fld"
>{m['printing.generic.marginRight']()}<NumberInput
dense
min={0}
unit="mm"
value={layout.margin.r}
onchange={(v) => setMargin('r', v)}
@@ -784,6 +835,13 @@
overflow-y: auto;
padding: 4px;
}
/* The shared pager is styled as a list-footer bar; here it sits under a bordered
box in a narrow column, so drop the bar chrome. */
.spools :global(.pager) {
padding: 0;
border-top: none;
background: none;
}
.spool-item {
display: flex;
align-items: center;
@@ -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>
@@ -7,17 +7,31 @@
// delete and re-add, because that hands them a new id. The v1 client exposed it
// as a plain filament dropdown on the spool edit form; here it gets its own
// two-step dialog so the weight consequence can be spelled out before applying.
import { tick } from 'svelte';
import Swatch from '../Swatch.svelte';
import Button from '../Button.svelte';
import NewFilamentCards from '../NewFilamentCards.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import type { Filament, Spool } from '$lib/types';
import type { Extra, Filament, Spool } from '$lib/types';
import { inventory } from '$lib/stores/inventory.svelte';
import { serverInfo } from '$lib/stores/serverInfo.svelte';
import { spoolSource } from '$lib/api/spoolSource';
import { fields } from '$lib/stores/fields.svelte';
import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external';
import { ExternalSearch } from '$lib/api/externalSearch.svelte';
import { toasts } from '$lib/stores/toasts.svelte';
import { weightAuto } from '$lib/utils/format';
import { parseDecimal } from '$lib/utils/numeric';
import {
FILAMENT_ADVANCED_KEYS,
emptyFilamentDraft,
filamentDraftErrors,
toNewFilamentDraft,
type FilamentDraft,
type FilamentWeights
} from '$lib/filament/draft';
import * as m from '$lib/paraglide/messages';
interface Props {
@@ -29,19 +43,115 @@
}
let { open, spool, current, onclose }: Props = $props();
// Same two sources as the add-spools flow: the local catalog, plus SpoolmanDB
// entries which are imported into the catalog when the change is applied.
// The same three sources as the add-spools flow: the local catalog, SpoolmanDB
// entries (imported into the catalog when the change is applied), and a filament
// described from scratch. That third one is what makes this dialog usable for
// the case it was built for — a slot refilled with a roll nobody has catalogued
// yet is the ordinary case, not the exception, and creating it through the
// add-spools flow would mint a spool this user explicitly does not want.
type Choice = { source: 'catalog'; filament: Filament } | { source: 'external'; ext: ExternalFilament };
let query = $state('');
let searchInput = $state<HTMLInputElement | undefined>();
let localResults = $state<Filament[]>([]);
let externalResults = $state<ExternalFilament[]>([]);
const extSearch = new ExternalSearch();
let searching = $state(false);
let extError = $state(false);
let chosen = $state<Choice | null>(null);
let busy = $state(false);
// --- new filament -------------------------------------------------------
let creating = $state(false);
let modalEl = $state<HTMLDivElement | undefined>();
let nf = $state<FilamentDraft>(emptyFilamentDraft());
// Unlike the add-spools flow, nothing else here claims these numbers: no spool
// is being created, so they belong to the filament alone and are shown with it.
let nfWeights = $state<FilamentWeights>({ weight: '1000', spoolWeight: '', price: '' });
let filamentExtra = $state<Extra>({});
let vendorExtra = $state<Extra>({});
let vendorIsNew = $state(false);
let showAdvanced = $state(false);
// Same reveal policy as the add-spools flow: a field's error appears once you
// have left it, and everything appears once the apply button has been pressed,
// so a form you just opened stays quiet. `errors` always describes the form.
let touched = $state<Record<string, boolean>>({});
let attempted = $state(false);
/** `current` with a default filled in for every field it doesn't already carry. */
function withDefaults(entity: 'filament' | 'vendor'): Extra {
const out: Extra = {};
for (const f of fields.get(entity)) if (f.default_value != null) out[f.key] = f.default_value;
return out;
}
function startCreate() {
creating = true;
chosen = null;
clearValidation();
showAdvanced = false;
nf = emptyFilamentDraft(query.trim());
nfWeights = { weight: '1000', spoolWeight: '', price: '' };
filamentExtra = withDefaults('filament');
vendorExtra = withDefaults('vendor');
}
function stopCreate() {
creating = false;
clearValidation();
}
function clearValidation() {
touched = {};
attempted = false;
}
function setExtraOn(current: Extra, key: string, json: string | undefined): Extra {
const next = { ...current };
if (json === undefined) delete next[key];
else next[key] = json;
return next;
}
function setFilamentExtra(key: string, json: string | undefined) {
filamentExtra = setExtraOn(filamentExtra, key, json);
}
function setVendorExtra(key: string, json: string | undefined) {
vendorExtra = setExtraOn(vendorExtra, key, json);
}
let errors = $derived(creating ? filamentDraftErrors(nf, nfWeights) : {});
// Weight sits beside the name here rather than in the spool block, and the two
// numbers that come off a product page sit under the specs disclosure with them.
const FIELD_ORDER = [
'vendor',
'name',
'material',
'netWeight',
'colorHex',
'density',
'diameter',
'nozzleTemp',
'bedTemp',
'spoolWeight',
'price'
];
const ADVANCED_KEYS = new Set([...FILAMENT_ADVANCED_KEYS, 'spoolWeight', 'price']);
let firstProblem = $derived(FIELD_ORDER.find((k) => errors[k]) ?? Object.keys(errors)[0]);
function touch(key: string) {
touched[key] = true;
}
/** The error to display for a field — empty until that field is revealed. */
function err(key: string): string {
return attempted || touched[key] ? (errors[key] ?? '') : '';
}
/** Scroll a field into view and put focus in it, opening its section if needed. */
async function focusField(key: string) {
if (ADVANCED_KEYS.has(key)) showAdvanced = true;
await tick();
const host = modalEl?.querySelector<HTMLElement>(`[data-field="${key}"]`);
if (!host) return;
host.scrollIntoView({ block: 'center', behavior: 'smooth' });
// preventScroll: the smooth scroll above is already on its way there.
host.querySelector<HTMLElement>('input, textarea, select')?.focus({ preventScroll: true });
}
function cName(c: Choice) {
return c.source === 'catalog' ? c.filament.name : c.ext.name;
}
@@ -77,17 +187,11 @@
}
async function runSearch() {
searching = true;
extError = false;
const [local, external] = await Promise.allSettled([
const [local] = await Promise.allSettled([
spoolSource.searchFilaments(query.trim()),
spoolSource.searchExternalFilaments(query.trim())
extSearch.search(query)
]);
localResults = local.status === 'fulfilled' ? local.value : [];
if (external.status === 'fulfilled') externalResults = external.value;
else {
externalResults = [];
extError = true;
}
searching = false;
}
@@ -99,16 +203,24 @@
initialized = true;
query = '';
chosen = null;
creating = false;
adoptWeight = true;
busy = false;
runSearch();
clearValidation();
// Needed only for the new-filament form, but requested on open so the
// definitions are there by the time someone reaches it.
fields.ensure('filament');
fields.ensure('vendor');
} else if (!open) {
initialized = false;
}
});
// Focus the search box whenever the picker is showing. Re-runs when the input
// remounts, i.e. on the way back from the new-filament form.
$effect(() => {
if (open && searchInput) searchInput.focus();
if (open && !creating && searchInput) searchInput.focus();
});
// --- full weight --------------------------------------------------------
@@ -125,14 +237,18 @@
// forever, with no field in this client to correct it. So when the new
// filament's weight differs, offer to take it — checked, because a spool
// holding filament X almost always holds X's amount of it.
let newWeight = $derived(chosen ? (cWeight(chosen) ?? 0) : 0);
/** The spool follows its filament's weight and will be moved by the change itself. */
let weightFollows = $derived(
chosen != null && spool.initialOverride == null && newWeight !== spool.initial
// A filament being described right here has its weight in the form rather than
// on a record, so it is read from the draft — which also means the note below
// updates as that field is typed.
let picked = $derived(creating || chosen != null);
let newWeight = $derived(
creating ? (parseDecimal(nfWeights.weight) ?? 0) : chosen ? (cWeight(chosen) ?? 0) : 0
);
/** The spool follows its filament's weight and will be moved by the change itself. */
let weightFollows = $derived(picked && spool.initialOverride == null && newWeight !== spool.initial);
/** The spool has a weight of its own that the change would leave stale. */
let weightDiffers = $derived(
chosen != null && spool.initialOverride != null && newWeight > 0 && newWeight !== spool.initial
picked && spool.initialOverride != null && newWeight > 0 && newWeight !== spool.initial
);
let adoptWeight = $state(true);
let resultingFull = $derived(weightFollows || (weightDiffers && adoptWeight) ? newWeight : spool.initial);
@@ -142,13 +258,30 @@
}
async function apply() {
if (!chosen || busy) return;
if (busy || !picked) return;
// The button stays clickable while the form is incomplete, same as the
// add-spools flow: a dead button answers "why can't I apply this?" with
// silence. Pressing it reveals every outstanding error and takes you to the
// first one, opening the specs block if that is where it is hiding.
if (firstProblem) {
attempted = true;
await focusField(firstProblem);
return;
}
busy = true;
try {
const filamentId =
chosen.source === 'external'
? (await spoolSource.importExternalFilament(chosen.ext)).id
: chosen.filament.id;
const filamentId = creating
? (
await spoolSource.createFilament({
...toNewFilamentDraft(nf, nfWeights, filamentExtra),
// Dropped when the typed name matches an existing manufacturer —
// which is also when the inputs for it aren't shown.
vendorExtra: vendorIsNew ? vendorExtra : undefined
})
).id
: chosen!.source === 'external'
? (await spoolSource.importExternalFilament(chosen!.ext)).id
: chosen!.filament.id;
await spoolSource.saveSpool(spool.id, {
filamentId,
...(weightDiffers && adoptWeight ? { initial: newWeight } : {})
@@ -175,7 +308,14 @@
nest interactive controls inside an interactive element. Keyboard close is
handled by the window Escape listener above. -->
<button class="backdrop" tabindex="-1" aria-hidden="true" onclick={close}></button>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="change-filament-title" tabindex="-1">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="change-filament-title"
tabindex="-1"
bind:this={modalEl}
>
<div class="modal-head">
<span class="title" id="change-filament-title">{m['changeFilament.title']()}</span>
<button class="x" onclick={close} aria-label={m['buttons.close']()}><X size={16} /></button>
@@ -198,7 +338,22 @@
<span class="arrow" aria-hidden="true"><ArrowRight size={16} /></span>
<div class="side">
<div class="side-label">{m['changeFilament.next']()}</div>
{#if chosen}
{#if creating}
<!-- Mirrors the form below as it is filled in, so the "from → to" pair
keeps meaning something while a filament is being described. -->
<div class="fil">
<Swatch colors={nf.colors} direction={nf.multiColorDirection} size={18} radius={5} />
<div class="fil-name">
<span class="fn">
{nf.name.trim() || m['add.newFilamentTitle']()}
<span class="tag external sm">{m['changeFilament.newTag']()}</span>
</span>
<span class="fs">
{nf.vendorName.trim() || m['add.noManufacturer']()} · {nf.material.trim() || '—'}
</span>
</div>
</div>
{:else if chosen}
<div class="fil">
<Swatch colors={cColors(chosen)} direction={cDirection(chosen)} size={18} radius={5} />
<div class="fil-name">
@@ -242,87 +397,115 @@
</span>
</label>
{/if}
{#if chosen?.source === 'external'}
<!-- Same promise either way: the filament joins the catalog as part of
applying the change, whether it came from SpoolmanDB or the form. -->
{#if creating || chosen?.source === 'external'}
<div class="note">{m['changeFilament.importNote']()}</div>
{/if}
<input
bind:this={searchInput}
class="search-big"
value={query}
oninput={(e) => onSearch(e.currentTarget.value)}
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
aria-label={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
/>
<div class="results">
<div class="res-hdr">{m['add.yourCatalog']()}</div>
{#if searching && localResults.length === 0}
<div class="res-note">{m['add.searching']()}</div>
{:else if localResults.length === 0}
<div class="res-note">{m['add.noCatalog']()}</div>
{:else}
{#each localResults as f (f.id)}
{@const isCurrent = f.id === current.id}
<button
class="res-item"
class:sel={chosen?.source === 'catalog' && chosen.filament.id === f.id}
disabled={isCurrent}
onclick={() => (chosen = { source: 'catalog', filament: f })}
>
<Swatch colors={f.colors} direction={f.multiColorDirection} size={18} radius={5} />
<div class="res-name">
<span class="rn">{f.name}</span>
<span class="rs">{vendorName(f)} · {f.material}</span>
</div>
{#if f.weight}
<span class="res-weight">{weightAuto(f.weight)}</span>
{/if}
<span class="tag" class:in-catalog={!isCurrent} class:cur={isCurrent}
>{isCurrent ? m['changeFilament.current']() : m['add.inCatalog']()}</span
{#if creating}
<NewFilamentCards
bind:draft={nf}
{err}
{touch}
extra={filamentExtra}
onextra={setFilamentExtra}
{vendorExtra}
onVendorExtra={setVendorExtra}
bind:vendorIsNew
bind:showAdvanced
bind:weights={nfWeights}
showWeights
backLabel={m['changeFilament.useExisting']()}
onback={stopCreate}
/>
{:else}
<input
bind:this={searchInput}
class="search-big"
value={query}
oninput={(e) => onSearch(e.currentTarget.value)}
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
aria-label={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
/>
<div class="results" onscroll={extSearch.onscroll}>
<div class="res-hdr">{m['add.yourCatalog']()}</div>
{#if searching && localResults.length === 0}
<div class="res-note">{m['add.searching']()}</div>
{:else if localResults.length === 0}
<div class="res-note">{m['add.noCatalog']()}</div>
{:else}
{#each localResults as f (f.id)}
{@const isCurrent = f.id === current.id}
<button
class="res-item"
class:sel={chosen?.source === 'catalog' && chosen.filament.id === f.id}
disabled={isCurrent}
onclick={() => (chosen = { source: 'catalog', filament: f })}
>
</button>
{/each}
{/if}
<Swatch colors={f.colors} direction={f.multiColorDirection} size={18} radius={5} />
<div class="res-name">
<span class="rn">{f.name}</span>
<span class="rs">{vendorName(f)} · {f.material}</span>
</div>
{#if f.weight}
<span class="res-weight">{weightAuto(f.weight)}</span>
{/if}
<span class="tag" class:in-catalog={!isCurrent} class:cur={isCurrent}
>{isCurrent ? m['changeFilament.current']() : m['add.inCatalog']()}</span
>
</button>
{/each}
{/if}
<div class="res-hdr"><span class="hdr-note">{serverInfo.externalDbName}</span></div>
{#if extError}
<div class="res-note">{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}</div>
{:else if searching && externalResults.length === 0}
<div class="res-note">{m['add.searching']()}</div>
{:else if externalResults.length === 0}
<div class="res-note">
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
</div>
{:else}
{#each externalResults as ext (ext.id)}
<button
class="res-item"
class:sel={chosen?.source === 'external' && chosen.ext.id === ext.id}
onclick={() => (chosen = { source: 'external', ext })}
>
<Swatch
colors={externalColors(ext)}
direction={externalDirection(ext)}
size={18}
radius={5}
/>
<div class="res-name">
<span class="rn">{ext.name}</span>
<span class="rs">{ext.manufacturer} · {ext.material}</span>
</div>
{#if ext.weight}
<span class="res-weight">{weightAuto(ext.weight)}</span>
{/if}
<span class="tag external">{serverInfo.externalDbName}</span>
</button>
{/each}
{/if}
</div>
<div class="res-hdr"><span class="hdr-note">{serverInfo.externalDbName}</span></div>
{#if extSearch.error}
<div class="res-note">{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}</div>
{:else if searching && extSearch.items.length === 0}
<div class="res-note">{m['add.searching']()}</div>
{:else if extSearch.items.length === 0}
<div class="res-note">
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
</div>
{:else}
{#each extSearch.items as ext (ext.id)}
<button
class="res-item"
class:sel={chosen?.source === 'external' && chosen.ext.id === ext.id}
onclick={() => (chosen = { source: 'external', ext })}
>
<Swatch
colors={externalColors(ext)}
direction={externalDirection(ext)}
size={18}
radius={5}
/>
<div class="res-name">
<span class="rn">{ext.name}</span>
<span class="rs">{ext.manufacturer} · {ext.material}</span>
</div>
{#if ext.weight}
<span class="res-weight">{weightAuto(ext.weight)}</span>
{/if}
<span class="tag external">{serverInfo.externalDbName}</span>
</button>
{/each}
{#if extSearch.loadingMore}
<div class="res-note">{m['add.searching']()}</div>
{/if}
{/if}
</div>
<button class="create-new" onclick={startCreate}>
<span class="cn-plus"><Plus size={16} /></span>
<span>{m['add.createNew']()}</span>
<span class="cn-sub">{m['add.createNewSub']({ name: serverInfo.externalDbName })}</span>
</button>
{/if}
</div>
<div class="foot">
<Button variant="outline" disabled={busy} onclick={close}>{m['buttons.cancel']()}</Button>
<Button disabled={!chosen || busy} onclick={apply}>
<Button disabled={!picked || busy} onclick={apply}>
{busy ? m['changeFilament.applying']() : m['changeFilament.apply']()}
</Button>
</div>
@@ -612,6 +795,38 @@
.tag.sm {
margin-left: 6px;
}
/* The third option, styled as it is in the add-spools flow: dashed rather than
solid, because it is an invitation to describe something rather than a row
that already exists. */
.create-new {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
margin-top: 10px;
padding: 11px 14px;
border: 1px dashed var(--accent-border);
border-radius: var(--radius-md);
background: none;
color: var(--accent-link);
cursor: pointer;
font-family: inherit;
font-size: 13px;
text-align: left;
}
.create-new:hover {
border-color: var(--accent);
background: var(--accent-wash-soft);
}
.cn-plus {
font-size: 15px;
flex: none;
}
.cn-sub {
margin-left: auto;
color: var(--text-faint);
font-size: 11.5px;
}
.foot {
display: flex;
justify-content: flex-end;
@@ -17,6 +17,7 @@
import ProgressBar from '../ProgressBar.svelte';
import SectionLabel from '../SectionLabel.svelte';
import ExtraFieldsSection from '../ExtraFieldsSection.svelte';
import TagsSection from '../TagsSection.svelte';
import Breadcrumbs from '../Breadcrumbs.svelte';
import FieldGrid from '../FieldGrid.svelte';
import Field from '../Field.svelte';
@@ -33,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';
@@ -79,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
@@ -95,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) =>
@@ -221,6 +233,7 @@
<div class="titles">
<div class="title">
{filament.name}
<span class="idmono mono">#{filament.id}</span>
{#if filament.externalId}<span
class="ext-badge"
title={serverInfo.externalDbName + ' · ' + filament.externalId}>{serverInfo.externalDbName}</span
@@ -445,6 +458,8 @@
</FieldGrid>
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={extraSaver.change} manage />
<TagsSection kind="filament" id={filament.id} tags={filament.tags} />
</div>
<div class="col">
@@ -473,6 +488,11 @@
font-weight: 700;
font-size: 16px;
}
.idmono {
font-size: 12px;
color: var(--text-muted);
font-weight: 400;
}
.ext-badge {
font-size: 10px;
font-weight: 600;
@@ -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}
@@ -7,6 +7,7 @@
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Factory from '@lucide/svelte/icons/factory';
import type { GroupHeaderInfo } from '$lib/utils/library';
import { onTruncated } from '$lib/actions/truncated';
import * as m from '$lib/paraglide/messages';
interface Props {
@@ -21,10 +22,36 @@
sticky?: boolean;
/** Whether the group's spools are folded away behind this header. */
collapsed?: boolean;
/** The group holds no spools at all — it is the header and nothing else
* (#1092). It says so in place of the aggregates, which are all zero, and
* drops the disclosure since there is nothing behind it to fold. */
noSpools?: boolean;
ontoggle?: () => void;
}
let { group, href, vendorHref, sticky = false, collapsed = false, ontoggle }: Props = $props();
let {
group,
href,
vendorHref,
sticky = false,
collapsed = false,
noSpools = false,
ontoggle
}: Props = $props();
// Whichever of the two lines is currently cut off, spelled out on hover (#1093).
// It hangs off the row rather than off the truncated span itself: the entity
// link below covers the whole header, so the span is never what the pointer is
// over. The manufacturer link and the disclosure carry titles of their own,
// which take precedence over this one where they overlap it.
let titleCut = $state(false);
let subCut = $state(false);
// Stable callbacks: an inline closure would be a new parameter on every render.
const markTitle = (cut: boolean) => (titleCut = cut);
const markSub = (cut: boolean) => (subCut = cut);
let fullText = $derived(
[titleCut ? group.title : '', subCut ? group.subtitle : ''].filter(Boolean).join(' · ') || undefined
);
</script>
<!-- The row is three controls, not one: the body opens the group's entity (where it
@@ -39,7 +66,7 @@
the two links being siblings. That is also what makes `.main:hover` mean
"anywhere on the row except the other two controls", which is the precision the
hover affordance below needs. -->
<div class="header" class:sticky class:link={href}>
<div class="header" class:sticky class:link={href} title={fullText}>
{#if href}
<!-- The entity link is an overlay covering the whole row, not a box around the
title: it has to be a sibling of the manufacturer link (anchors can't
@@ -61,7 +88,7 @@
<!-- A group header reads as a heading, and headings don't normally lead
anywhere — so the link has to say so itself, by taking the underline
and colour every link gets on hover. -->
<span class="title">{group.title}</span>
<span class="title" use:onTruncated={markTitle}>{group.title}</span>
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
</div>
{#if group.vendorName || group.subtitle}
@@ -85,7 +112,7 @@
<span class="vendor-none">{group.vendorName}</span>
{/if}
{#if group.vendorName && group.subtitle}<span class="dot" aria-hidden="true">·</span>{/if}
{#if group.subtitle}<span class="stext">{group.subtitle}</span>{/if}
{#if group.subtitle}<span class="stext" use:onTruncated={markSub}>{group.subtitle}</span>{/if}
</div>
{/if}
</div>
@@ -93,20 +120,34 @@
them: what it holds, and how much of it. The count is also what says the
rows below are ALL of this group's spools, not the first few. -->
<div class="meta">
<span class="weight">{group.meta}</span>
<span class="count">{group.count}</span>
{#if noSpools}
<!-- "0 g / 0 spools" is true but reads like any other quiet number, and the
whole point of listing this group is that it should catch the eye. It
says no spools rather than out of stock: Spoolman knows what you have
recorded, not what is on your shelf. -->
<span class="no-spools">{m['library.noSpools']()}</span>
{:else}
<span class="weight">{group.meta}</span>
<span class="count">{group.count}</span>
{/if}
</div>
<button
class="disc"
type="button"
aria-expanded={!collapsed}
aria-label={collapsed ? m['library.expandGroup']() : m['library.collapseGroup']()}
onclick={ontoggle}
>
<!-- The glyph turns, not the button: rotating the control itself would swing
its box (and hit area) out past the edge of the row. -->
<span class="chev" class:open={!collapsed}><ChevronRight size={15} /></span>
</button>
{#if !noSpools}
<button
class="disc"
type="button"
aria-expanded={!collapsed}
aria-label={collapsed ? m['library.expandGroup']() : m['library.collapseGroup']()}
onclick={ontoggle}
>
<!-- The glyph turns, not the button: rotating the control itself would swing
its box (and hit area) out past the edge of the row. -->
<span class="chev" class:open={!collapsed}><ChevronRight size={15} /></span>
</button>
{:else}
<!-- Keeps the header's right edge lined up with the ones that do have a
disclosure, so the column of aggregates doesn't jog in and out. -->
<span class="disc-spacer" aria-hidden="true"></span>
{/if}
</div>
<style>
@@ -237,6 +278,22 @@
font-size: 10.5px;
color: var(--text-faint);
}
/* Outlined rather than filled: it marks a shopping-list entry, not a failure,
and a solid danger block on every out-of-stock row would shout down the rest
of the library. */
.no-spools {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--danger-soft);
border: 1px solid var(--danger-soft);
border-radius: var(--radius-sm);
padding: 1px 5px;
}
.disc-spacer {
flex: none;
width: 34px;
}
/* Full-height so the hit target is the whole right edge of the header — far
easier to hit on a phone than the glyph, without drawing a heavier control.
Above the stretched link for the same reason the manufacturer is. */
@@ -38,6 +38,11 @@
let id = $derived(groupId(group.field, group.key));
let collapsed = $derived(collapsedGroups.has(id));
// A filament the library holds no spools of, listed so that "I have none of
// this" is visible rather than silently absent (#1092). There is nothing under
// the header, so there is nothing to fetch, fold or page through either.
let noSpools = $derived(group.spoolCount === 0);
let el: HTMLDivElement | undefined = $state();
async function toggle() {
@@ -74,7 +79,7 @@
// loaded is kept, so re-expanding paints immediately and refreshes behind it.
let reqId = 0;
$effect(() => {
if (collapsed) return;
if (collapsed || noSpools) return;
const ctrl = new AbortController();
void revision; // refetch on live events
const want = untrack(() => spools.length) || PAGE;
@@ -166,18 +171,44 @@
</script>
<div bind:this={el}>
<GroupHeader group={header} sticky href={headerHref} {vendorHref} {collapsed} ontoggle={toggle} />
{#if !collapsed}
<GroupHeader
group={header}
sticky
href={headerHref}
{vendorHref}
{collapsed}
{noSpools}
ontoggle={toggle}
/>
{#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,9 +11,16 @@
withinLast,
type DateFilterProp
} from '$lib/library/dateFilter';
import { sortDefs, filamentLabel, 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';
import { spoolSource } from '$lib/api/spoolSource';
import { inventory } from '$lib/stores/inventory.svelte';
import { fields } from '$lib/stores/fields.svelte';
@@ -32,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);
@@ -63,10 +74,6 @@
fields.ensure('vendor');
});
interface FilterOption {
value: string;
label: string;
}
// Most filters pick from a set of values the API can enumerate; a date filter
// picks a range instead, so it gets a panel of its own rather than an option
// list (see library/dateFilter for the grammar behind it).
@@ -229,6 +236,14 @@
return parsed?.kind === 'range' ? parsed : null;
});
// The values this property already filters by. A filter's chip sits in the
// toolbar behind the open menu, which is no help when the list is long and the
// menu covers it, so the list says so itself (#1090). Values only: a date
// filter holds one range at a time, so its presets are a choice, not a set.
let activeValues = $derived(
new Set(libraryState.filters.filter((f) => f.prop === filterProp).map((f) => f.value))
);
async function openProp(category: FilterCategory) {
filterProp = category.key;
// The query that found this property says nothing about its values.
@@ -255,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 }[] = [
@@ -265,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 {
@@ -275,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);
@@ -328,7 +359,21 @@
let visibleCategories = $derived(filterByQuery(filterCategories, menuQuery, (c) => c.label()));
let archivedMatches = $derived(matchesTerms(m['buttons.showArchived'](), searchTerms(menuQuery)));
let visibleOptions = $derived(filterByQuery(options, menuQuery, (o) => o.label));
// Only offered where it means something: the empty groups are filaments, so
// there is nowhere to put them in a list grouped by anything else. Folded into
// the match itself rather than tested at the row, so that a search matching
// nothing else still falls through to "no results" instead of an empty menu.
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(
filterByQuery(options, menuQuery, (o) => (o.meta ? `${o.label} ${o.meta}` : o.label))
);
let visibleSortSections = $derived(
sortSections
.map((sec) => ({ ...sec, items: filterByQuery(sec.items, menuQuery, (it) => it.labelKey()) }))
@@ -345,7 +390,6 @@
const first = visibleOptions[0];
if (!first) return;
params.toggleFilter(filterProp!, first.value);
close();
}
function chooseFirstSort() {
const first = visibleSortSections[0]?.items[0];
@@ -360,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"
>
@@ -386,17 +435,41 @@
{m['spool.fields.archived']()} <span class="x"><X size={12} /></span>
</button>
{/if}
<!-- So is showing the filaments you hold no spools of: it widens the list
rather than narrowing it, but it is still a deviation from the default
view, so it says so and can be dismissed from here (#1092). -->
{#if libraryState.showEmpty}
<button class="chip active" onclick={() => params.setShowEmpty(false)}>
{m['library.noSpools']()} <span class="x"><X size={12} /></span>
</button>
{/if}
</div>
<div class="controls">
<button class="link-btn" onclick={() => toggle('group')}
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
<ChevronDown size={13} /></button
>
<button class="chip active sort" onclick={() => toggle('sort')}>
<span class="ctrl-label">{m['library.sortBy']()}: </span>{activeSort.labelKey()}
{#if libraryState.sortAsc}<ArrowUp size={12} />{:else}<ArrowDown size={12} />{/if}
</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
hover of its own say the arrow is clickable; buttons can't nest, so the
chip itself is the wrapper and the padding lives on the two halves. -->
<div class="chip active sort">
<button class="sort-field" onclick={() => toggle('sort')}>
<span class="ctrl-label">{m['library.sortBy']()}: </span>{activeSort.labelKey()}
</button>
<button
class="sort-dir-btn"
title={m['library.reverseSort']()}
aria-label={m['library.reverseSort']()}
onclick={() => params.toggleSortDir()}
>
{#if libraryState.sortAsc}<ArrowUp size={12} />{:else}<ArrowDown size={12} />{/if}
</button>
</div>
</div>
{#if open === 'filter'}
@@ -436,7 +509,46 @@
>
<span class="mi-label">{m['buttons.showArchived']()}</span>
</button>
{:else if !visibleCategories.length}
{/if}
{#if noSpoolsMatches}
{#if visibleCategories.length && !archivedMatches}<div class="menu-sep"></div>{/if}
<button
class="menu-item"
role="menuitemcheckbox"
aria-checked={libraryState.showEmpty}
onclick={() => {
params.setShowEmpty(!libraryState.showEmpty);
close();
}}
>
<span class="mi-check"
>{#if libraryState.showEmpty}<SquareCheck size={15} />{:else}<Square size={15} />{/if}</span
>
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
</button>
{/if}
{#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}
@@ -498,15 +610,32 @@
onenter={chooseFirstOption}
/>
{/if}
<!-- A value row is a checkbox, not a command: it reads as on or off, and
picking one leaves the menu open so the next one is a click away
(#1090, #1089). Escape and a click outside still close it. -->
{#each visibleOptions as opt (opt.value)}
{@const checked = activeValues.has(opt.value)}
<button
class="menu-item"
onclick={() => {
params.toggleFilter(filterProp!, opt.value);
close();
}}
role="menuitemcheckbox"
aria-checked={checked}
onclick={() => params.toggleFilter(filterProp!, opt.value)}
>
<span class="mi-label">{opt.label}</span>
<span class="mi-check"
>{#if checked}<SquareCheck size={15} />{:else}<Square size={15} />{/if}</span
>
{#if opt.colors}<Swatch
colors={opt.colors}
direction={opt.direction}
size={16}
radius={4}
/>{/if}
<!-- The gap is a real space, not just margin: it is what separates the
two halves in the row's accessible name, which would otherwise be
read out as one run-together word. -->
<span class="mi-label"
>{opt.label}{#if opt.meta}&nbsp;<span class="mi-sub">{opt.meta}</span>{/if}</span
>
</button>
{:else}
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
@@ -518,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}
@@ -626,6 +755,45 @@
border: 1px solid var(--accent-border);
color: var(--accent-soft);
}
/* The chip is only the frame now; each half carries the padding so a click
anywhere in it still lands on the control that half belongs to. They
STRETCH to fill it, which matters on mobile: the shared `.chip` rule in
app.css grows the chip to a 44px tap target, and centred children would
leave that as a 44px frame around two 20px buttons. It also runs the
divider the full height of the chip, which is what makes the pair read as
one segmented control rather than two things that happen to be adjacent. */
.chip.sort {
padding: 0;
gap: 0;
align-items: stretch;
}
.chip.sort > button {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 9px;
background: none;
border: none;
color: inherit;
font-family: inherit;
font-size: 11.5px;
white-space: nowrap;
cursor: pointer;
}
.chip.sort > button:hover {
color: var(--accent-link);
}
.chip.sort > button:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.chip.sort > .sort-field {
padding-right: 7px;
}
.chip.sort > .sort-dir-btn {
border-left: 1px solid var(--accent-border);
padding-left: 7px;
}
.menu-sep {
height: 1px;
background: var(--border-soft);
@@ -769,6 +937,13 @@
color: var(--text-faint);
font-size: 11px;
}
/* Sits with the label rather than off at the row's right edge: it's there to
qualify the name it follows, which is the whole point when two filaments
share one. */
.mi-sub {
color: var(--text-muted);
font-size: 11px;
}
.mi-dir {
color: var(--accent-muted);
font-size: 11px;
@@ -6,13 +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';
@@ -30,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('');
@@ -99,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) => ({
@@ -133,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.
@@ -157,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;
@@ -173,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);
}
}
}
@@ -247,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>
@@ -273,8 +337,9 @@
<span class="id mono">#{spool.entity.id}</span>
<Swatch colors={filament?.colors ?? []} direction={filament?.multiColorDirection} size={20} />
<span class="text">
<span class="title">{filament?.name || m['search.unknownFilament']()}</span>
{#if spool.entity.location}<span class="sub">{spool.entity.location}</span>{/if}
<span class="title" use:truncTitle>{filament?.name || m['search.unknownFilament']()}</span>
{#if spool.entity.location}<span class="sub" use:truncTitle>{spool.entity.location}</span
>{/if}
</span>
<span class="match">{matchLabel('spool', spool.matchField)}</span>
</a>
@@ -307,12 +372,12 @@
the trailing "matched" tag which describes the search hit. Two chips
of the same shape side by side read as the same kind of fact. -->
<span class="line">
<span class="title">{filament.entity.name}</span>
<span class="title" use:truncTitle>{filament.entity.name}</span>
{#if filament.entity.material}
<MaterialBadge label={filament.entity.material} variant="outline" />
{/if}
</span>
{#if vendor?.name}<span class="sub">{vendor.name}</span>{/if}
{#if vendor?.name}<span class="sub" use:truncTitle>{vendor.name}</span>{/if}
</span>
<span class="match">{matchLabel('filament', filament.matchField)}</span>
</a>
@@ -369,12 +434,27 @@
>
<span class="vendor-icon" aria-hidden="true"><Factory size={14} /></span>
<span class="text">
<span class="title">{vendor.entity.name}</span>
<span class="title" use:truncTitle>{vendor.entity.name}</span>
</span>
<span class="match">{matchLabel('vendor', vendor.matchField)}</span>
</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}
@@ -440,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;
@@ -18,6 +18,7 @@
import DateTimeField from '../DateTimeField.svelte';
import SectionLabel from '../SectionLabel.svelte';
import ExtraFieldsSection from '../ExtraFieldsSection.svelte';
import TagsSection from '../TagsSection.svelte';
import Breadcrumbs from '../Breadcrumbs.svelte';
import FieldGrid from '../FieldGrid.svelte';
import Field from '../Field.svelte';
@@ -56,6 +57,7 @@
price: 0,
comment: '',
registeredLabel: '',
tags: [],
extra: {}
};
@@ -122,7 +124,7 @@
let serverLocations = $state<string[]>([]);
$effect(() => {
spoolSource
.locations()
.locationChoices()
.then((l) => (serverLocations = l))
.catch(() => {});
});
@@ -513,6 +515,8 @@
</FieldGrid>
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} manage />
<TagsSection kind="spool" id={spool.id} tags={spool.tags} />
</div>
<div class="col">
@@ -6,6 +6,7 @@
import ProgressBar from '../ProgressBar.svelte';
import { rowIdentity, type RowContext, type SpoolVM } from '$lib/utils/library';
import * as params from '$lib/library/params';
import { truncTitle } from '$lib/actions/truncated';
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages';
@@ -15,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>
@@ -36,7 +38,8 @@
{#if showSwatch}
<Swatch colors={vm.filament.colors} direction={vm.filament.multiColorDirection} size={22} />
{/if}
<span class="name">
<!-- Both columns fall back to a hover tooltip when they run out of room (#1093). -->
<span class="name" use:truncTitle>
{#if identity.title}<span class="title">{identity.title}</span>{/if}
{#if identity.sub}<span class="sub">{identity.sub}</span>{/if}
</span>
@@ -45,7 +48,7 @@
{/if}
<ProgressBar value={vm.pctValue} danger={vm.low} width="56px" />
<span class="rem mono" class:low={vm.low}>{vm.remLabel}</span>
<span class="right">{vm.location}</span>
<span class="right" use:truncTitle>{vm.location}</span>
</a>
<style>
@@ -110,8 +113,11 @@
.rem {
font-size: 11px;
color: var(--text-2);
width: 44px;
/* A floor, not a fixed width: "987.5 g" and "10.2 kg" run past 44px and would
wrap the unit onto its own line (#1124). */
min-width: 44px;
text-align: right;
white-space: nowrap;
flex: none;
}
.rem.low {
@@ -4,6 +4,7 @@
import SpoolRow from './SpoolRow.svelte';
import type { RowContext, SpoolVM } from '$lib/utils/library';
import { isSelected } from '$lib/library/params';
import { truncTitle } from '$lib/actions/truncated';
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages';
@@ -13,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
@@ -94,7 +96,7 @@
opacity={0.75}
/>
{/if}
<span class="label">
<span class="label" use:truncTitle>
<span class="fname">{first.filament.name}</span>
<span class="badge mono">{m['library.nUnused']({ count: unused.length })}</span>
</span>
@@ -108,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';
@@ -128,7 +129,7 @@
<div class="head">
<div class="avatar">{initials}</div>
<div class="titles">
<div class="title">{vendor.name}</div>
<div class="title">{vendor.name} <span class="idmono mono">#{vendor.id}</span></div>
<div class="subtitle">
{m['inspector.vendorSub']({
count: filaments.length
@@ -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
@@ -229,6 +238,11 @@
font-weight: 700;
font-size: 16px;
}
.idmono {
font-size: 12px;
color: var(--text-muted);
font-weight: 400;
}
.subtitle {
font-size: 12px;
color: var(--text-muted);
@@ -244,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;
@@ -0,0 +1,219 @@
<script lang="ts">
// Which tag reader this browser listens to, and whether a scan moves it.
//
// Pairing is done by walking over and tapping a tag on the reader you mean:
// the browser subscribes to every reader, takes the reader id off the first
// scan that arrives, and resubscribes to just that one. No codes to copy, no
// server state, and the binding is self-evidently right because the user made
// it with their hands.
//
// The list of recently-seen readers underneath is the fallback for pairing with
// a reader in another room. It comes from an in-memory registry that is empty
// after a server restart until something scans again — so an empty list is a
// normal state to be explained, never an error.
import Card from '../Card.svelte';
import SettingRow from './SettingRow.svelte';
import Toggle from '../Toggle.svelte';
import Button from '../Button.svelte';
import Radio from '@lucide/svelte/icons/radio';
import { scanRelay, listReaders, type KnownReader } from '$lib/api/scanRelay';
import { scanner } from '$lib/stores/scanner.svelte';
import { isAbortError } from '$lib/api/http';
import { formatDurationShort } from '$lib/utils/datetime';
import * as m from '$lib/paraglide/messages';
let pairing = $state(false);
let readers = $state<KnownReader[]>([]);
let loadingReaders = $state(false);
// While pairing, listen to the root pool — every reader — and pair with the
// first one heard from. The subscription is torn down by the effect's cleanup,
// which runs as soon as `pairing` goes false, including on the scan itself.
$effect(() => {
if (!pairing) return;
return scanRelay.subscribe(null, (scan) => {
scanner.receive(scan);
scanner.pair(scan.readerId, scan.name);
pairing = false;
});
});
// Refreshed on mount and whenever pairing starts, which is when the list is
// actually being read. A reader that scans while this page is open won't appear
// until then — acceptable for a fallback affordance, and tapping the tag is the
// path that needs no list at all.
async function loadReaders(signal?: AbortSignal) {
loadingReaders = true;
try {
readers = await listReaders(signal);
// The registry is the authority on what a reader is called; the store keeps
// no copy of its own, so this is where the paired reader's label comes from.
scanner.learnReaders(readers);
} catch (err) {
if (isAbortError(err, signal)) return; // navigated away mid-request
readers = []; // registry unreachable — the empty-state copy still applies
} finally {
loadingReaders = false;
}
}
$effect(() => {
const ac = new AbortController();
loadReaders(ac.signal);
return () => ac.abort();
});
let pairedLabel = $derived(scanner.pairedLabel);
// A reader that has scanned since the server started is described by that
// registry entry, which is fresher than whatever was stored at pairing time.
let others = $derived(readers.filter((r) => r.readerId !== scanner.pairedReaderId));
</script>
<Card divided>
<SettingRow
title={m['settings.scanner.autoNavigate.label']()}
desc={m['settings.scanner.autoNavigate.desc']()}
>
<Toggle
checked={scanner.autoNavigate}
onchange={(v) => scanner.setAutoNavigate(v)}
ariaLabel={m['settings.scanner.autoNavigate.label']()}
/>
</SettingRow>
<SettingRow
title={m['settings.scanner.reader.label']()}
desc={pairedLabel ? m['settings.scanner.reader.pairedDesc']() : m['settings.scanner.reader.anyDesc']()}
>
{#if pairing}
<span class="waiting">{m['settings.scanner.waiting']()}</span>
<Button variant="ghost" onclick={() => (pairing = false)}>{m['buttons.cancel']()}</Button>
{:else}
{#if scanner.pairedReaderId}
<!-- The reader's own name, set apart from the prose. Ids are frequently
things like `ip-192-168-1-50` or a bare `desk`, which read as a typo
mid-sentence rather than as the name of a device. The raw id stays
reachable on hover for when a friendly name hides it. -->
<span class="reader-chip mono" title={scanner.pairedReaderId}>{pairedLabel}</span>
<Button variant="ghost" onclick={() => scanner.unpair()}>{m['settings.scanner.unpair']()}</Button>
{/if}
<Button
variant="outline"
onclick={() => {
pairing = true;
loadReaders();
}}
>
<Radio size={15} />
{m['settings.scanner.pair']()}
</Button>
{/if}
</SettingRow>
</Card>
<div class="known">
<div class="known-head">{m['settings.scanner.recent']()}</div>
{#if others.length}
<ul class="readers">
{#each others as reader (reader.readerId)}
<li class="reader">
<div class="who">
<span class="rname">{reader.name ?? reader.readerId}</span>
{#if reader.name}<span class="rid mono">{reader.readerId}</span>{/if}
</div>
<span class="seen"
>{m['settings.scanner.lastSeen']({ when: formatDurationShort(reader.lastSeen) })}</span
>
<button class="link" onclick={() => scanner.pair(reader.readerId, reader.name)}>
{m['settings.scanner.use']()}
</button>
</li>
{/each}
</ul>
{:else if !loadingReaders}
<p class="empty">{m['settings.scanner.recentNone']()}</p>
{/if}
</div>
<style>
.waiting {
font-size: 12px;
color: var(--text-muted);
}
.reader-chip {
font-size: 12px;
color: var(--text);
background: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 3px 8px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.known {
margin-top: 14px;
}
.known-head {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
margin-bottom: 6px;
}
.readers {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reader {
display: flex;
align-items: center;
gap: 10px;
font-size: 12.5px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.who {
display: flex;
flex-direction: column;
min-width: 0;
}
.rname {
overflow-wrap: anywhere;
}
.rid {
font-size: 11px;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.seen {
margin-left: auto;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
.link {
font-size: 12px;
color: var(--accent-link);
background: none;
border: none;
padding: 0;
cursor: pointer;
flex: none;
}
.link:hover {
text-decoration: underline;
}
.empty {
margin: 0;
font-size: 12px;
color: var(--text-dim);
}
</style>
+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' });
});
});
+206
View File
@@ -0,0 +1,206 @@
// The new-filament form, as data.
//
// Two dialogs now let you describe a filament that exists in neither the local
// catalog nor SpoolmanDB: the add-spools flow, and the change-filament dialog
// (issue #1010 — "I ran out of slot #13 and refilled it with something new").
// Both need the same fields, the same rules and the same mapping onto the API,
// so all three live here and the components only draw them.
//
// Every value is a string, because that is what the user typed: a half-entered
// "1," has to survive a keystroke without being coerced to a number and back.
// Conversion happens once, at submit, in {@link toNewFilamentDraft}.
import type { Extra, Filament, MultiColorDirection } from '$lib/types';
import type { NewFilamentDraft } from '$lib/api/spoolSource';
import { parseDecimal } from '$lib/utils/numeric';
import { HEX_RE, numErr } from '$lib/utils/validate';
import type { MaterialSpec } from '$lib/data/materials';
import * as m from '$lib/paraglide/messages';
/** What identifies and specifies a filament, as typed into a form. */
export interface FilamentDraft {
vendorName: string;
name: string;
material: string;
colors: string[];
multiColorDirection: MultiColorDirection | undefined;
density: string;
diameter: string;
nozzleTemp: string;
bedTemp: string;
articleNumber: string;
comment: string;
}
/**
* The three numbers that describe how much filament a full spool of this holds
* and what it cost. Kept out of {@link FilamentDraft} because the add-spools flow
* writes them to the spool it creates as well as to the filament, and so shows
* them in its own spool block rather than among the filament fields.
*/
export interface FilamentWeights {
weight: string;
spoolWeight: string;
price: string;
}
/** 1.75 mm is what the overwhelming majority of spools are; 2.85 is the exception. */
const DEFAULT_DIAMETER = '1.75';
/** A blank draft, optionally seeded with a name the user has already typed elsewhere. */
export function emptyFilamentDraft(name = ''): FilamentDraft {
return {
vendorName: '',
name,
material: '',
colors: [],
multiColorDirection: undefined,
density: '',
diameter: DEFAULT_DIAMETER,
nozzleTemp: '',
bedTemp: '',
articleNumber: '',
comment: ''
};
}
/**
* A draft copied from an existing filament — the "I bought the same filament in
* another colour" case. Everything that describes the *product* carries over;
* everything that identifies the *variant* is left for the user: the colour is
* cleared and the article number (a per-colour SKU) is dropped. The name is kept
* as a starting point since it is usually one word away from the new one.
*/
export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraft {
return {
vendorName,
name: f.name,
material: f.material,
colors: [],
multiColorDirection: undefined,
density: String(f.density),
diameter: String(f.diameter),
nozzleTemp: f.nozzleTemp != null ? String(f.nozzleTemp) : '',
bedTemp: f.bedTemp != null ? String(f.bedTemp) : '',
articleNumber: '',
comment: f.comment
};
}
/** The weights of an existing filament, for a draft copied from it. */
export function filamentWeightsFrom(f: Filament): FilamentWeights {
return {
weight: String(f.weight || 1000),
spoolWeight: f.spoolWeight != null ? String(f.spoolWeight) : '',
price: f.price != null ? String(f.price) : ''
};
}
/**
* Fill in density and print temps for a material the app knows about, leaving the
* draft untouched for one it doesn't — typing a custom material must not wipe
* figures the user entered by hand.
*/
export function applyMaterialSpec(
draft: FilamentDraft,
material: string,
specs: Record<string, MaterialSpec>
): void {
draft.material = material;
const spec = specs[material.trim().toLowerCase()];
if (!spec) return;
draft.density = String(spec.density);
if (spec.nozzle != null) draft.nozzleTemp = String(spec.nozzle);
if (spec.bed != null) draft.bedTemp = String(spec.bed);
}
/**
* The filament fields that can carry an error, in the order they appear in the
* cards, so pressing submit sends you to the first one you'd have reached by
* reading down. Callers weave in the fields they draw themselves — the weight
* trio included, since where those sit differs per dialog.
*/
export const FILAMENT_FIELD_ORDER = [
'vendor',
'name',
'material',
'colorHex',
'density',
'diameter',
'nozzleTemp',
'bedTemp'
];
/**
* Filament fields inside the collapsed "Advanced specs" block. An error in here
* is invisible until the block is opened — the one case where a form really can
* look complete and still refuse to submit — so jumping to one has to open it.
* Callers add their own: a dialog that shows the weight trio in this component
* puts spoolWeight and price in there too.
*/
export const FILAMENT_ADVANCED_KEYS = ['density', 'diameter', 'nozzleTemp', 'bedTemp'];
/**
* Field errors for the filament half of a form, keyed by field name ('' entries
* dropped, so an empty object means valid).
*
* Mirrors the filament creation API (spoolman/api/v1/filament.py): density and
* diameter are required and must be > 0; name/material/vendor are capped at 64
* chars; colors must be 6 or 8 hex digits. Material is required by this client
* even though the API accepts a filament without one — a blank material would
* otherwise surface as "Density is required", an error on the field you didn't
* fill in because of the one you did miss. Pass `weights` for the callers that
* show those fields here rather than on a spool.
*/
export function filamentDraftErrors(draft: FilamentDraft, weights?: FilamentWeights): Record<string, string> {
const e: Record<string, string> = {};
if (draft.name.trim().length === 0) e.name = m['validation.required']();
else if (draft.name.trim().length > 64) e.name = m['validation.maxChars']({ max: 64 });
if (draft.material.trim().length === 0) e.material = m['validation.required']();
else if (draft.material.trim().length > 64) e.material = m['validation.maxChars']({ max: 64 });
if (draft.vendorName.trim().length > 64) e.vendor = m['validation.maxChars']({ max: 64 });
e.density = numErr(draft.density, { required: true, gt: 0 });
e.diameter = numErr(draft.diameter, { required: true, gt: 0 });
e.nozzleTemp = numErr(draft.nozzleTemp, { min: 0 });
e.bedTemp = numErr(draft.bedTemp, { min: 0 });
if (draft.colors.some((c) => c.trim() && !HEX_RE.test(c.trim()))) e.colorHex = m['validation.hexDigits']();
if (weights) {
e.netWeight = numErr(weights.weight, { gt: 0 });
e.spoolWeight = numErr(weights.spoolWeight, { min: 0 });
e.price = numErr(weights.price, { min: 0 });
}
for (const k of Object.keys(e)) if (!e[k]) delete e[k];
return e;
}
/** Number as typed, or undefined when blank/unparseable — the API omits either way. */
function num(v: string): number | undefined {
return parseDecimal(v) ?? undefined;
}
/** Turn a validated draft into the body {@link spoolSource.createFilament} expects. */
export function toNewFilamentDraft(
draft: FilamentDraft,
weights: FilamentWeights,
extra: Extra
): NewFilamentDraft {
return {
name: draft.name.trim(),
vendorName: draft.vendorName.trim(),
material: draft.material.trim(),
// 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: num(draft.diameter) ?? Number(DEFAULT_DIAMETER),
weight: num(weights.weight) || undefined,
spoolWeight: num(weights.spoolWeight),
colors: draft.colors,
multiColorDirection: draft.multiColorDirection,
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);
@@ -140,6 +140,26 @@ describe('presetToDesign', () => {
expect(d.layout.columns).toBeGreaterThanOrEqual(1);
});
it('clamps v1 negative margins so the label matches the paper (#1149)', () => {
// A real preset: -20mm margins with a 1.5mm printer margin on a 62x34 roll.
// Carried over raw, the derived label came out 102x74.
const d = presetToDesign({
labelSettings: {
printSettings: {
paperSize: 'custom',
customPaperSize: { width: 62, height: 34 },
columns: 1,
rows: 1,
margin: { top: -20, bottom: -20, left: -20, right: -20 },
printerMargin: { top: 1.5, bottom: 1.5, left: 1.5, right: 1.5 }
}
}
});
expect(d.layout.margin).toEqual({ t: 0, b: 0, l: 0, r: 0 });
expect(d.layout.safe).toEqual({ t: 1.5, b: 1.5, l: 1.5, r: 1.5 });
expect(d.label).toEqual({ w: 62, h: 34 });
});
it('omits the QR element when v1 had the QR turned off', () => {
const d = presetToDesign({ labelSettings: { showQRCodeMode: 'no' } });
expect(d.elements.some((e) => e.type === 'qr')).toBe(false);
+7 -4
View File
@@ -133,11 +133,14 @@ function mapLayout(ps: V1PrintSettings): PrintLayout {
paper,
custom,
landscape: false,
// v1 users paired negative margins with a printer margin to pull an oversized
// grid cell back onto a label-sized page. Clamped, the safe-zone alone gives the
// same inset and the label is sized to the paper instead of that cell (#1149).
margin: {
t: ps.margin?.top ?? DEFAULT_LAYOUT.margin.t,
b: ps.margin?.bottom ?? DEFAULT_LAYOUT.margin.b,
l: ps.margin?.left ?? DEFAULT_LAYOUT.margin.l,
r: ps.margin?.right ?? DEFAULT_LAYOUT.margin.r
t: Math.max(0, ps.margin?.top ?? DEFAULT_LAYOUT.margin.t),
b: Math.max(0, ps.margin?.bottom ?? DEFAULT_LAYOUT.margin.b),
l: Math.max(0, ps.margin?.left ?? DEFAULT_LAYOUT.margin.l),
r: Math.max(0, ps.margin?.right ?? DEFAULT_LAYOUT.margin.r)
},
safe: {
t: ps.printerMargin?.top ?? 0,
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { layoutMargin, sheetGrid } from './paper';
import { DEFAULT_LAYOUT, type PrintLayout } from './types';
// Issue #1149: a QL-700 preset carried over from v1, which allowed margins down
// to -20mm. Negative margins made sheetGrid think a 34mm page fit two rows, and
// made the safe-zone inset (safe - margin) larger than the label itself.
const ql700: PrintLayout = {
...DEFAULT_LAYOUT,
paper: 'custom',
custom: { w: 62, h: 34 },
columns: 1,
spacing: { h: 0, v: 0 },
margin: { t: -20, b: -20, l: -20, r: -20 },
safe: { t: 1.5, b: 1.5, l: 0, r: 0 }
};
describe('layoutMargin', () => {
it('clamps negative margins to zero', () => {
expect(layoutMargin(ql700)).toEqual({ t: 0, b: 0, l: 0, r: 0 });
});
it('leaves non-negative margins alone', () => {
expect(layoutMargin(DEFAULT_LAYOUT)).toEqual(DEFAULT_LAYOUT.margin);
});
});
describe('sheetGrid', () => {
it('fits one label per page when the page is the label', () => {
const grid = sheetGrid(ql700, { w: 62, h: 34 });
expect([grid.cols, grid.rows, grid.perPage]).toEqual([1, 1, 1]);
expect(grid.fits).toBe(true);
});
});
+14 -2
View File
@@ -17,6 +17,17 @@ export function paperSize(layout: PrintLayout): { w: number; h: number } {
return layout.landscape ? { w: base.h, h: base.w } : { w: base.w, h: base.h };
}
/**
* A layout's page margins, clamped to zero. A negative margin anchors the grid
* off the paper (so labels get clipped away) and inflates the safe-zone inset in
* print.ts — v1 presets allowed down to -20mm, so old saved layouts still carry
* negative values. Clamp on read rather than trusting the stored numbers.
*/
export function layoutMargin(layout: PrintLayout): { t: number; b: number; l: number; r: number } {
const { t, b, l, r } = layout.margin;
return { t: Math.max(0, t), b: Math.max(0, b), l: Math.max(0, l), r: Math.max(0, r) };
}
/** 1 inch = 25.4 mm. */
export const MM_PER_INCH = 25.4;
@@ -53,8 +64,9 @@ export interface SheetGrid {
*/
export function sheetGrid(layout: PrintLayout, label: { w: number; h: number }): SheetGrid {
const page = paperSize(layout);
const usableW = page.w - layout.margin.l - layout.margin.r;
const usableH = page.h - layout.margin.t - layout.margin.b;
const margin = layoutMargin(layout);
const usableW = page.w - margin.l - margin.r;
const usableH = page.h - margin.t - margin.b;
// Fall back to an auto column fit for older saved layouts that predate `columns`.
const cols =
layout.columns && layout.columns > 0
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more