mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-20 17:19:46 -05:00
Compare commits
203
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362d80e55d | ||
|
|
5fc8e6cf42 | ||
|
|
b7a282d2eb | ||
|
|
c979e63513 | ||
|
|
0e88109cc6 | ||
|
|
ed9124cb06 | ||
|
|
f399855d3b | ||
|
|
d102f9475b | ||
|
|
61e85c29bf | ||
|
|
ef3dee4728 | ||
|
|
8656e04055 | ||
|
|
744284391d | ||
|
|
f8642bedd6 | ||
|
|
8285a4da45 | ||
|
|
f6f28a8df8 | ||
|
|
abdfc73b8d | ||
|
|
90713bf5e2 | ||
|
|
636e12e737 | ||
|
|
896e473807 | ||
|
|
b01e4e5bbe | ||
|
|
c881740e69 | ||
|
|
dc18a3017f | ||
|
|
f7c3988fed | ||
|
|
8d363bbec4 | ||
|
|
57ef303c93 | ||
|
|
bcf340bdc7 | ||
|
|
69e541ce67 | ||
|
|
6746bc4fce | ||
|
|
c492cc05fe | ||
|
|
1b039bca19 | ||
|
|
bd9553342f | ||
|
|
6a9904adbb | ||
|
|
74f93b4afb | ||
|
|
073bf15108 | ||
|
|
15f0124a4c | ||
|
|
1c87bb9c0e | ||
|
|
715cd47b9f | ||
|
|
fa92ab465d | ||
|
|
a9e1802c2a | ||
|
|
72031f0ea5 | ||
|
|
a3977509eb | ||
|
|
3a8ac87b13 | ||
|
|
3e9c3b68bd | ||
|
|
9708d02498 | ||
|
|
58a01e3389 | ||
|
|
bb0bcc1b5f | ||
|
|
6b6c13e4f1 | ||
|
|
b0faab5cfd | ||
|
|
62963935eb | ||
|
|
3f8cb8f1d1 | ||
|
|
89fba3f4a8 | ||
|
|
e72b2d1821 | ||
|
|
4658f42798 | ||
|
|
abe5bd909f | ||
|
|
645ef72937 | ||
|
|
12199575de | ||
|
|
975e0cc702 | ||
|
|
15381fcf01 | ||
|
|
ca007aa561 | ||
|
|
43101b2a2d | ||
|
|
a31beee46b | ||
|
|
6447198fe3 | ||
|
|
c35983ee52 | ||
|
|
1e800ec5f8 | ||
|
|
016ca6791e | ||
|
|
e5944c94e0 | ||
|
|
a35331e50a | ||
|
|
bcc1ba1ace | ||
|
|
629b8228d8 | ||
|
|
7fbe604cc2 | ||
|
|
bff2a485e6 | ||
|
|
7e83451226 | ||
|
|
30928407a7 | ||
|
|
02f5c07c13 | ||
|
|
022285d64d | ||
|
|
6b1e01ecd8 | ||
|
|
594e14b465 | ||
|
|
bce0d8069a | ||
|
|
cc34b8ea24 | ||
|
|
82137bf35b | ||
|
|
7c4dcc7da7 | ||
|
|
dcb4618399 | ||
|
|
7a1114f8fc | ||
|
|
423cf234be | ||
|
|
2c15af00b8 | ||
|
|
ac9a32fa23 | ||
|
|
787d912dec | ||
|
|
8badd51707 | ||
|
|
53f09eb2bc | ||
|
|
b3d35d751d | ||
|
|
9f29826588 | ||
|
|
f7c53187c1 | ||
|
|
1e9d74cd6e | ||
|
|
1a04139c1d | ||
|
|
aae2b4df56 | ||
|
|
e1ad9feb30 | ||
|
|
5b93b3b37e | ||
|
|
bd4b9351aa | ||
|
|
958d5bd9ee | ||
|
|
c9b1215cb6 | ||
|
|
586299aaa6 | ||
|
|
7e98987b87 | ||
|
|
ba62681856 | ||
|
|
05e4da7a10 | ||
|
|
0543b9145b | ||
|
|
25d25e3701 | ||
|
|
a353983f85 | ||
|
|
bed3f14ee7 | ||
|
|
27ea256e2d | ||
|
|
7b82f2afb2 | ||
|
|
45cc8246d3 | ||
|
|
0c4caa527e | ||
|
|
2ab68cdff9 | ||
|
|
fc6aa55d27 | ||
|
|
92e093b16d | ||
|
|
55d11d0dc4 | ||
|
|
6c5d03d4ec | ||
|
|
058490a3c6 | ||
|
|
9131454aa2 | ||
|
|
c73bce4301 | ||
|
|
1c0f668c16 | ||
|
|
de81990a29 | ||
|
|
e4a78bb352 | ||
|
|
196ebd66d0 | ||
|
|
d1f3401b37 | ||
|
|
d848ddef97 | ||
|
|
5c65cb5495 | ||
|
|
72d2cbe986 | ||
|
|
3ecdf5dfc6 | ||
|
|
d5a49e3340 | ||
|
|
83897b9744 | ||
|
|
c386ac34e2 | ||
|
|
205d8fb813 | ||
|
|
c74f77b478 | ||
|
|
a40b20df8c | ||
|
|
dc56305a97 | ||
|
|
ae2319a215 | ||
|
|
9f90c2238d | ||
|
|
c157946eca | ||
|
|
0966ecada9 | ||
|
|
9ae9827f4c | ||
|
|
e9e76a088f | ||
|
|
a4c749ad14 | ||
|
|
994e5d422b | ||
|
|
1db2ed3851 | ||
|
|
b9b5784108 | ||
|
|
c01342d182 | ||
|
|
0388fb60bf | ||
|
|
feb67ab500 | ||
|
|
ed946223d9 | ||
|
|
c405739b1d | ||
|
|
e744049343 | ||
|
|
179fc0a117 | ||
|
|
6520ce6d1b | ||
|
|
83b0238bb4 | ||
|
|
f55188e346 | ||
|
|
4fee8db323 | ||
|
|
81636f253e | ||
|
|
0f705e80c0 | ||
|
|
3c7f68e2bd | ||
|
|
64b29a561b | ||
|
|
8f9c214dac | ||
|
|
1305857efb | ||
|
|
dd5a0a89dc | ||
|
|
1eeb2948b2 | ||
|
|
348499f469 | ||
|
|
d34fb88b31 | ||
|
|
a63af285f5 | ||
|
|
798d8b1ded | ||
|
|
f8ffaa0837 | ||
|
|
09fea9f668 | ||
|
|
789b7fd88f | ||
|
|
5424a638b1 | ||
|
|
c281114c6f | ||
|
|
9566f7848d | ||
|
|
4f9cc1b75d | ||
|
|
42721c7ebc | ||
|
|
d1dc40b3cc | ||
|
|
92be2975b8 | ||
|
|
a62e4ce6c1 | ||
|
|
0a3404cefe | ||
|
|
c2002e43f7 | ||
|
|
85e3ac7163 | ||
|
|
33aa3de515 | ||
|
|
e238592e4d | ||
|
|
107a134da8 | ||
|
|
3cf7a9fa61 | ||
|
|
1e5b28ab7d | ||
|
|
035922b49f | ||
|
|
eced7f22db | ||
|
|
4c2febbba6 | ||
|
|
e37045dcd4 | ||
|
|
f83b9d9ea4 | ||
|
|
0b3d7ba140 | ||
|
|
b6217c4b30 | ||
|
|
09953643dc | ||
|
|
a6512da598 | ||
|
|
6da0902c8e | ||
|
|
6bcda3d307 | ||
|
|
9dd0f73be1 | ||
|
|
5171330c44 | ||
|
|
009f9e3bae | ||
|
|
1507f9b3b8 |
No files matched your search
@@ -53,6 +53,10 @@ SPOOLMAN_PORT=7912
|
||||
# Default: FALSE
|
||||
#SPOOLMAN_METRICS_ENABLED=TRUE
|
||||
|
||||
# Periodically check GitHub for a newer Spoolman release, shown in the UI footer and server log
|
||||
# Default: TRUE
|
||||
#SPOOLMAN_UPDATE_CHECK=FALSE
|
||||
|
||||
# Collect items (filaments, materials, etc.) from an external database
|
||||
# Set this to a URL of an external database. Set to an empty string to disable
|
||||
# Default: https://donkie.github.io/SpoolmanDB/
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.env
|
||||
.cache.json
|
||||
Generated
+852
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -57,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>
|
||||
Generated
+1172
-814
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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"
|
||||
},
|
||||
@@ -415,5 +415,6 @@
|
||||
"system": "Système",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre"
|
||||
}
|
||||
},
|
||||
"sponsor": "Soutenez-moi sur GitHub"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -415,5 +415,6 @@
|
||||
"system": "System",
|
||||
"light": "Jasny",
|
||||
"dark": "Ciemny"
|
||||
}
|
||||
},
|
||||
"sponsor": "Wesprzyj mnie na GitHubie"
|
||||
}
|
||||
@@ -385,7 +385,7 @@
|
||||
"boolean_true": "Да",
|
||||
"boolean_false": "Нет",
|
||||
"choices_missing_error": "Вы не можете удалить варианты. Отсутствуют следующие варианты: {{choices}}",
|
||||
"choices_reorder_hint": "Перетащите или используйте клавиши со стрелками, чтобы изменить порядок этого элемента."
|
||||
"choices_reorder_hint": "Перетащите или используйте стрелки."
|
||||
},
|
||||
"header": "Настройки",
|
||||
"general": {
|
||||
@@ -415,5 +415,6 @@
|
||||
"system": "Как в системе",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная"
|
||||
}
|
||||
},
|
||||
"sponsor": "Поддержать меня на GitHub"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 上贊助專案"
|
||||
}
|
||||
@@ -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": "暗黑"
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
+252
-137
@@ -62,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}}",
|
||||
@@ -155,18 +156,19 @@
|
||||
"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.",
|
||||
@@ -198,19 +200,21 @@
|
||||
"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": "Kopie erstellen",
|
||||
"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": "",
|
||||
"inspector.multi": "mehrfach x{{count}}",
|
||||
"inspector.newGross": "Neues Bruttogewicht:",
|
||||
"inspector.noExtraFields": "",
|
||||
"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": "",
|
||||
"inspector.ofRemaining": "von {{weight}} übrig · {{length}} m",
|
||||
"inspector.openFilament": "Filament öffnen",
|
||||
"inspector.openLink": "{{url}} in einem neuen Tab öffnen",
|
||||
"inspector.openManufacturer": "Hersteller öffnen",
|
||||
@@ -220,7 +224,7 @@
|
||||
"inspector.override.overridesManufacturer": "Überschreibt Hersteller · {{value}}",
|
||||
"inspector.specs": "Spezifikationen — Direktbearbeitung",
|
||||
"inspector.spoolsCount": "Spulen · {{count}}",
|
||||
"inspector.spoolsTotalRemaining": "",
|
||||
"inspector.spoolsTotalRemaining": "Gesamt {{weight}}",
|
||||
"inspector.used": "verwendet",
|
||||
"inspector.vendorSub_one": "{{count}} Filament",
|
||||
"inspector.vendorSub_other": "{{count}} Filamente",
|
||||
@@ -229,17 +233,17 @@
|
||||
"labels.addQr": "QR Code",
|
||||
"labels.addRect": "Rechteck",
|
||||
"labels.addSwatch": "Farbmuster",
|
||||
"labels.addText": "",
|
||||
"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": "",
|
||||
"labels.centerLogo": "",
|
||||
"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": "",
|
||||
"labels.clearFill": "Lösche Füllung (nur Rahmen)",
|
||||
"labels.copies": "Kopien",
|
||||
"labels.cornerRadiusMm": "Eckenradius",
|
||||
"labels.createFirst": "Erstelle Dein erstes Label",
|
||||
@@ -248,11 +252,15 @@
|
||||
"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": "",
|
||||
"labels.designerTitle": "Label Designer",
|
||||
"labels.dpi": "Auflösung",
|
||||
"labels.dpiHint": "",
|
||||
"labels.duplicate": "",
|
||||
"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",
|
||||
@@ -261,42 +269,42 @@
|
||||
"labels.fields.remainingG": "Verbleibend (g)",
|
||||
"labels.filamentsSelected_one": "{{count}} Filament ausgewählt",
|
||||
"labels.filamentsSelected_other": "{{count}} Filamente ausgewählt",
|
||||
"labels.fill": "",
|
||||
"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": "",
|
||||
"labels.hMm": "H",
|
||||
"labels.heightMm": "Höhe",
|
||||
"labels.hintNowrap": "",
|
||||
"labels.hintSwatch": "",
|
||||
"labels.hintWrap": "",
|
||||
"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.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": "",
|
||||
"labels.kind.swatch": "Farbmuster",
|
||||
"labels.kind.text": "Text",
|
||||
"labels.labelSize": "Größe des Etiketts",
|
||||
"labels.labelType": "Art des Etiketts",
|
||||
"labels.layout": "",
|
||||
"labels.layout": "Layout",
|
||||
"labels.modeImage": "Dateien",
|
||||
"labels.modeLabel": "Etikettendrucker",
|
||||
"labels.modeSheet": "",
|
||||
"labels.modeSheet": "Blatt (Kachel)",
|
||||
"labels.new": "Neu",
|
||||
"labels.newText": "Neuer Text",
|
||||
"labels.noDesignsOption": "",
|
||||
"labels.noDesignsOption": "Noch keine Designs",
|
||||
"labels.noFilaments": "Keine Filamente.",
|
||||
"labels.noSpools": "Keine Spulen.",
|
||||
"labels.onePerLabel": "Eine {{w}}×{{h}} mm Seite pro Etikett.",
|
||||
"labels.preparing": "",
|
||||
"labels.preparing": "Bereite vor...",
|
||||
"labels.preview": "Vorschau",
|
||||
"labels.previewWith": "",
|
||||
"labels.previewWith": "Vorschau mit",
|
||||
"labels.printN_one": "{{count}} Etikett drucken",
|
||||
"labels.printN_other": "{{count}} Etiketten drucken",
|
||||
"labels.radiusMm": "Radius",
|
||||
"labels.sampleData": "",
|
||||
"labels.sampleData": "Beispieldaten",
|
||||
"labels.saveAsFormat": "Speichern als {{format}}",
|
||||
"labels.saved": "Gespeichert",
|
||||
"labels.saving": "Speichere...",
|
||||
@@ -305,86 +313,115 @@
|
||||
"labels.selectAllShort": "Alle",
|
||||
"labels.selectAtLeastOne": "Wähle mindestens eine Spule.",
|
||||
"labels.selectAtLeastOneFilament": "Wähle mindestens ein Filament.",
|
||||
"labels.selectDesign": "",
|
||||
"labels.selectElement": "",
|
||||
"labels.selectNoneShort": "",
|
||||
"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": "",
|
||||
"labels.showingFilament": "",
|
||||
"labels.showingReal": "",
|
||||
"labels.showingSpool": "",
|
||||
"labels.showingTags": "",
|
||||
"labels.sizeMm": "",
|
||||
"labels.skipHint": "",
|
||||
"labels.sortHint": "",
|
||||
"labels.sortId": "",
|
||||
"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": "",
|
||||
"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": "",
|
||||
"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": "",
|
||||
"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.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",
|
||||
@@ -402,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": "",
|
||||
"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": "",
|
||||
"settings.extraFields.addChoicesFirst": "",
|
||||
"settings.extraFields.addEntityField": "",
|
||||
"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?",
|
||||
@@ -446,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",
|
||||
@@ -462,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",
|
||||
@@ -476,7 +519,8 @@
|
||||
"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.",
|
||||
@@ -486,11 +530,26 @@
|
||||
"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",
|
||||
@@ -504,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",
|
||||
@@ -531,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"
|
||||
}
|
||||
@@ -62,6 +62,7 @@
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Save",
|
||||
"buttons.showArchived": "Show Archived",
|
||||
"buttons.showLotNumbers": "Show lot numbers",
|
||||
"buttons.showNoSpools": "Show filaments with no spools",
|
||||
"buttons.unArchive": "Unarchive",
|
||||
"changeFilament.adoptWeight": "Set this spool’s full weight to {{full}}",
|
||||
@@ -167,6 +168,7 @@
|
||||
"filament.filament": "Filaments",
|
||||
"footer.documentation": "Documentation",
|
||||
"footer.reportIssue": "Report an issue",
|
||||
"footer.updateAvailable": "v{{version}} available",
|
||||
"help.help": "Help",
|
||||
"inspector.addSpoolsOfThis": "Add spools of this",
|
||||
"inspector.adjustFailed": "Failed to adjust weight.",
|
||||
@@ -349,6 +351,18 @@
|
||||
"library.dateFilter.since": "Since {{date}}",
|
||||
"library.dateFilter.to": "To (optional)",
|
||||
"library.dateFilter.until": "Until {{date}}",
|
||||
"library.catalog.allFilaments": "All filaments",
|
||||
"library.catalog.allManufacturers": "All manufacturers",
|
||||
"library.catalog.backToSpools": "Back to spools",
|
||||
"library.catalog.filamentCount_one": "{{count}} filament",
|
||||
"library.catalog.filamentCount_other": "{{count}} filaments",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} manufacturer",
|
||||
"library.catalog.manufacturerCount_other": "{{count}} manufacturers",
|
||||
"library.catalog.noManufacturer": "No manufacturer",
|
||||
"library.catalog.notSet": "Not set",
|
||||
"library.catalog.switchList": "Switch list",
|
||||
"library.catalog.unitFilaments": "filaments",
|
||||
"library.catalog.unitManufacturers": "manufacturers",
|
||||
"library.emptyFiltered": "No {{unit}} match the current filters.",
|
||||
"library.emptyTitle": "No spools yet",
|
||||
"library.emptyBody": "Add your first spool to start tracking your filament.",
|
||||
@@ -383,6 +397,8 @@
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "used",
|
||||
"library.usedAgo": "used {{time}} ago",
|
||||
"library.viewMenu.browse": "Browse",
|
||||
"library.views": "Library views",
|
||||
"loading": "Loading",
|
||||
"nav.labels": "Labels",
|
||||
"nav.library": "Library",
|
||||
@@ -417,6 +433,7 @@
|
||||
"printing.generic.verticalSpacing": "Vertical Spacing",
|
||||
"printing.qrcode.button": "Print Labels",
|
||||
"printing.qrcode.customUrl.caveat": "A custom URL won't scan back into Spoolman — use it to point a printed QR at an external app or a different host.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Label fields work too, for example",
|
||||
"printing.qrcode.customUrl.hint": "Insert the spool id anywhere with the placeholder",
|
||||
"printing.qrcode.customUrl.label": "Custom URL template",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR code link",
|
||||
@@ -427,6 +444,8 @@
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "Default encodes a Spoolman-only code that only Spoolman's own scanner can read. URL encodes a normal web link that any scanner can open, built from the base URL in settings — or this site's address if that's unset.",
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} spool selected",
|
||||
"printing.spoolSelect.selectedTotal_other": "{{count}} spools selected",
|
||||
"scanner.camera": "Camera",
|
||||
"scanner.cameraNumbered": "Camera {{number}}",
|
||||
"scanner.description": "Scan a Spoolman QR code to view details about the spool.",
|
||||
"scanner.error.insecureContext": "The page is not served over HTTPS.",
|
||||
"scanner.error.notAllowed": "You have not allowed access to the camera.",
|
||||
@@ -437,6 +456,8 @@
|
||||
"scanner.starting": "Starting camera...",
|
||||
"scanner.title": "QR Code Scanner",
|
||||
"search.colorHint": "Color names must be entered in English.",
|
||||
"search.browseAll": "Browse all",
|
||||
"search.hint": "Search spools, filaments and manufacturers by name, color, material or any field.",
|
||||
"search.moreSpools": "+{{count}} more",
|
||||
"search.noResults": "No matches found",
|
||||
"search.searching": "Searching...",
|
||||
@@ -507,12 +528,12 @@
|
||||
"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 that gets read by a tag reader.",
|
||||
"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 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.",
|
||||
"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",
|
||||
@@ -553,15 +574,21 @@
|
||||
"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",
|
||||
@@ -572,19 +599,24 @@
|
||||
"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.unknown": "Unknown tag {{uid}}. Open the spool it belongs to and use Add tag to link it.",
|
||||
"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.",
|
||||
@@ -605,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"
|
||||
}
|
||||
@@ -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": "",
|
||||
|
||||
+493
-320
@@ -4,102 +4,160 @@
|
||||
"add.addN_one": "Ajouter {{count}} bobine",
|
||||
"add.addN_other": "Ajouter {{count}} bobines",
|
||||
"add.adding": "Ajout...",
|
||||
"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.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",
|
||||
@@ -111,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",
|
||||
@@ -332,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 :",
|
||||
@@ -345,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",
|
||||
@@ -393,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",
|
||||
@@ -407,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",
|
||||
@@ -435,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.",
|
||||
|
||||
+227
-155
@@ -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",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"buttons.filter": "Filtruj",
|
||||
"buttons.save": "Zapisz",
|
||||
"buttons.showArchived": "Pokaż zarchiwizowane",
|
||||
"buttons.showLotNumbers": "Pokaż numery partii",
|
||||
"buttons.showNoSpools": "Pokaż filamenty bez szpul",
|
||||
"buttons.unArchive": "Przywróć",
|
||||
"changeFilament.action": "Zmień",
|
||||
"changeFilament.adoptWeight": "Ustaw pełną wagę tej szpuli na {{full}}",
|
||||
@@ -74,7 +76,7 @@
|
||||
"changeFilament.done": "Filament zmieniony.",
|
||||
"changeFilament.failed": "Nie udało się zmienić filamentu.",
|
||||
"changeFilament.importNote": "Ten filament trafi do katalogu po zatwierdzeniu zmiany.",
|
||||
"changeFilament.intro": "Szpula nr {{id}} zachowuje numer, lokalizację, daty i historię zużycia — zmienia się tylko filament.",
|
||||
"changeFilament.intro": "Szpula #{{id}} zachowuje numer, lokalizację, daty i historię zużycia — zmienia się tylko filament.",
|
||||
"changeFilament.newTag": "nowy",
|
||||
"changeFilament.next": "Zmień na",
|
||||
"changeFilament.now": "Obecnie",
|
||||
@@ -118,7 +120,7 @@
|
||||
"dashboard.errorEmptyName": "Nazwa nie może być pusta",
|
||||
"dashboard.errorExists": "Grupa o tej nazwie już istnieje",
|
||||
"dashboard.errorRename": "Nie udało się zmienić nazwy grupy",
|
||||
"dashboard.lastUsed": "Ostatnio użyta {{time}} temu",
|
||||
"dashboard.lastUsed": "Ostatnio użyto {{time}} temu",
|
||||
"dashboard.newGroup": "Nowa grupa",
|
||||
"dashboard.newGroupName": "Nowa grupa {{n}}",
|
||||
"dashboard.reorderGroup": "Przeciągnij, aby zmienić kolejność grupy",
|
||||
@@ -170,6 +172,7 @@
|
||||
"filament.filament": "Filamenty",
|
||||
"footer.documentation": "Dokumentacja",
|
||||
"footer.reportIssue": "Zgłoś problem",
|
||||
"footer.updateAvailable": "Nowa wersja: v{{version}}",
|
||||
"help.help": "Pomoc",
|
||||
"inspector.addSpoolsOfThis": "Dodaj szpule tego filamentu",
|
||||
"inspector.adjustFailed": "Nie udało się skorygować wagi.",
|
||||
@@ -195,7 +198,7 @@
|
||||
"inspector.delete.filamentDone": "Filament usunięty.",
|
||||
"inspector.delete.filamentTitle": "Usunąć ten filament?",
|
||||
"inspector.delete.spool": "Usuń szpulę",
|
||||
"inspector.delete.spoolBody": "Szpula nr {{id}} „{{name}}” zostanie usunięta wraz z historią zużycia. Tej operacji nie można cofnąć.",
|
||||
"inspector.delete.spoolBody": "Szpula #{{id}} „{{name}}” zostanie usunięta wraz z historią zużycia. Tej operacji nie można cofnąć.",
|
||||
"inspector.delete.spoolDone": "Szpula usunięta.",
|
||||
"inspector.delete.spoolRemaining": "Zostało jeszcze {{weight}} filamentu. Zarchiwizuj ją, jeśli chcesz ją tylko odłożyć na bok.",
|
||||
"inspector.delete.spoolTitle": "Usunąć tę szpulę?",
|
||||
@@ -324,9 +327,9 @@
|
||||
"labels.selectNoneShort": "Żadne",
|
||||
"labels.selectRecentHint": "Zaznacz szpule dodane w ciągu ostatnich 24 godzin",
|
||||
"labels.selectRecentShort": "Ostatnie ({{count}})",
|
||||
"labels.showingFilament": "Pokazywany filament nr {{id}}",
|
||||
"labels.showingFilament": "Pokazywany filament #{{id}}",
|
||||
"labels.showingReal": "Pokazywane rzeczywiste dane szpuli.",
|
||||
"labels.showingSpool": "Pokazywana szpula nr {{id}}",
|
||||
"labels.showingSpool": "Pokazywana szpula #{{id}}",
|
||||
"labels.showingTags": "Pola widać jako tagi, dopóki nie wybierzesz szpuli powyżej.",
|
||||
"labels.sizeMm": "Rozmiar",
|
||||
"labels.skipHint": "Pomiń pozycje zostawia tyle pustych komórek przed pierwszą etykietą, żebyś mógł użyć częściowo zużytego arkusza: policz już oderwane etykiety, od lewej do prawej od góry.",
|
||||
@@ -348,6 +351,20 @@
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Nie udało się połączyć z API Spoolmana. Czy backend działa?",
|
||||
"library.catalog.allFilaments": "Wszystkie filamenty",
|
||||
"library.catalog.allManufacturers": "Wszyscy producenci",
|
||||
"library.catalog.backToSpools": "Cofnij do szpul",
|
||||
"library.catalog.filamentCount_few": "{{count}} filamenty",
|
||||
"library.catalog.filamentCount_many": "{{count}} filamentów",
|
||||
"library.catalog.filamentCount_one": "{{count}} filament",
|
||||
"library.catalog.manufacturerCount_few": "{{count}} producenci",
|
||||
"library.catalog.manufacturerCount_many": "{{count}} producentów",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} producent",
|
||||
"library.catalog.noManufacturer": "Brak producenta",
|
||||
"library.catalog.notSet": "Nie ustawiono",
|
||||
"library.catalog.switchList": "Przełącz listę",
|
||||
"library.catalog.unitFilaments": "filamenty",
|
||||
"library.catalog.unitManufacturers": "producenci",
|
||||
"library.collapseGroup": "Ukryj te szpule",
|
||||
"library.dateFilter.ago": "{{span}} temu",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
@@ -369,12 +386,14 @@
|
||||
"library.groupSpools_few": "{{count}} szpule",
|
||||
"library.groupSpools_many": "{{count}} szpul",
|
||||
"library.groupSpools_one": "{{count}} szpula",
|
||||
"library.nUnused": "×{{count}} nieużywane",
|
||||
"library.nUnused": "×{{count}} nierozpoczętych",
|
||||
"library.noLocation": "brak lokalizacji",
|
||||
"library.noSpools": "Brak szpul",
|
||||
"library.noValues": "Brak wartości",
|
||||
"library.openGroup": "Otwórz {{name}}",
|
||||
"library.openManufacturer": "Otwórz producenta {{name}}",
|
||||
"library.resizeList": "Zmień szerokość listy (podwójne kliknięcie resetuje)",
|
||||
"library.reverseSort": "Odwróć kolejność sortowania",
|
||||
"library.section.extra": "Dodatkowe pola",
|
||||
"library.section.filament": "Filament",
|
||||
"library.section.spool": "Szpula",
|
||||
@@ -385,13 +404,15 @@
|
||||
"library.unassigned": "nieprzypisane",
|
||||
"library.unitGroups": "grupy",
|
||||
"library.unitSpools": "szpule",
|
||||
"library.unused": "nieużywane",
|
||||
"library.unused": "nieużyta",
|
||||
"library.unusedGroup": "{{count}} nieużywanych · po {{weight}} g",
|
||||
"library.unusedGroupMixed": "{{count}} nieużywanych",
|
||||
"library.unusedSub": "po {{weight}} g · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "używane",
|
||||
"library.usedAgo": "użyta {{time}} temu",
|
||||
"library.used": "użyta",
|
||||
"library.usedAgo": "użyto {{time}} temu",
|
||||
"library.viewMenu.browse": "Przeglądaj",
|
||||
"library.views": "Widoki biblioteki",
|
||||
"loading": "Wczytywanie",
|
||||
"locations.locations": "Lokalizacje",
|
||||
"locations.newLocation": "Nowa lokalizacja",
|
||||
@@ -431,6 +452,7 @@
|
||||
"printing.generic.verticalSpacing": "Odstęp pionowy",
|
||||
"printing.qrcode.button": "Drukuj etykiety",
|
||||
"printing.qrcode.customUrl.caveat": "Własny URL nie wróci skanem do Spoolmana — użyj go, żeby kod QR otwierał zewnętrzną aplikację albo inny host.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Pola etykiet również działają, na przykład",
|
||||
"printing.qrcode.customUrl.hint": "Wstaw identyfikator szpuli w dowolnym miejscu za pomocą placeholdera",
|
||||
"printing.qrcode.customUrl.label": "Własny szablon URL",
|
||||
"printing.qrcode.useHTTPUrl.label": "Link w kodzie QR",
|
||||
@@ -442,6 +464,8 @@
|
||||
"printing.spoolSelect.selectedTotal_few": "Wybrano {{count}} szpule",
|
||||
"printing.spoolSelect.selectedTotal_many": "Wybrano {{count}} szpul",
|
||||
"printing.spoolSelect.selectedTotal_one": "Wybrano {{count}} szpulę",
|
||||
"scanner.camera": "Kamera",
|
||||
"scanner.cameraNumbered": "Kamera {{number}}",
|
||||
"scanner.description": "Zeskanuj kod QR Spoolmana, aby zobaczyć szczegóły szpuli.",
|
||||
"scanner.error.inUse": "Kamera jest już używana przez inną aplikację.",
|
||||
"scanner.error.insecureContext": "Strona nie jest serwowana przez HTTPS.",
|
||||
@@ -451,7 +475,9 @@
|
||||
"scanner.error.unknown": "Wystąpił nieznany błąd. ({{error}})",
|
||||
"scanner.starting": "Uruchamianie kamery...",
|
||||
"scanner.title": "Skaner kodów QR",
|
||||
"search.browseAll": "Przeglądaj wszystko",
|
||||
"search.colorHint": "Nazwy kolorów trzeba wpisywać po angielsku.",
|
||||
"search.hint": "Wyszukuj szpule, filamenty i producentów według nazwy, koloru, materiału lub dowolnego pola.",
|
||||
"search.moreSpools": "+{{count}} więcej",
|
||||
"search.noResults": "Brak wyników",
|
||||
"search.searching": "Szukanie...",
|
||||
@@ -522,6 +548,21 @@
|
||||
"settings.library.lowThreshold.desc": "Szpule o wadze równej lub niższej są oznaczane na czerwono (tylko ta przeglądarka)",
|
||||
"settings.library.lowThreshold.label": "Próg niskiego stanu",
|
||||
"settings.library.tab": "Zachowanie biblioteki",
|
||||
"settings.scanner.autoNavigate.desc": "Automatycznie przechodź w tej przeglądarce do szpuli lub filamentu odczytanego przez czytnik tagów.",
|
||||
"settings.scanner.autoNavigate.label": "Automatyczna nawigacja",
|
||||
"settings.scanner.docs": "Jak skonfigurować czytnik tagów",
|
||||
"settings.scanner.lastSeen": "{{when}} temu",
|
||||
"settings.scanner.pair": "Sparuj przez przyłożenie tagu",
|
||||
"settings.scanner.reader.anyDesc": "Skanowanie z dowolnego czytnika otwiera szpule lub filamenty w tej przeglądarce. Możesz sparować konkretny czytnik, aby uzyskać większą kontrolę. Ten czytnik będzie również używany do dodawania nowych tagów do szpul i filamentów.",
|
||||
"settings.scanner.reader.label": "Czytnik tagów",
|
||||
"settings.scanner.reader.pairedDesc": "Tylko odczyty z tego czytnika otwierają szpule w tej przeglądarce.",
|
||||
"settings.scanner.recent": "Ostatnio używane czytniki",
|
||||
"settings.scanner.recentNone": "Brak zapamiętanych czytników. Skonfiguruj czytnik tagów i odczytaj dowolny tag, aby pojawił się tutaj.",
|
||||
"settings.scanner.tab": "Skanowanie tagów",
|
||||
"settings.scanner.unpair": "Używaj dowolnego czytnika",
|
||||
"settings.scanner.use": "Użyj tego czytnika",
|
||||
"settings.scanner.waiting": "Przyłóż dowolny tag do wybranego czytnika...",
|
||||
"sponsor": "Wesprzyj mnie na GitHubie",
|
||||
"spool.fields.archived": "Zarchiwizowana",
|
||||
"spool.fields.comment": "Komentarz",
|
||||
"spool.fields.filament": "Filament",
|
||||
@@ -549,6 +590,57 @@
|
||||
"spool.form.measurementType.length": "Długość",
|
||||
"spool.form.measurementType.weight": "Waga",
|
||||
"spool.spool": "Szpule",
|
||||
"tags.add": "Dodaj tag",
|
||||
"tags.added": "Tag został połączony.",
|
||||
"tags.addedOn": "Dodano {{date}}",
|
||||
"tags.modal.alreadyHere": "Ten tag już identyfikuje tę szpulę.",
|
||||
"tags.modal.alreadyHereFilament": "Ten tag już identyfikuje ten filament.",
|
||||
"tags.modal.badUid": "UID tagu jest zapisem szesnastkowym, np. 04A2B3C4.",
|
||||
"tags.modal.checking": "Sprawdzanie...",
|
||||
"tags.modal.failed": "Nie udało się połączyć tagu.",
|
||||
"tags.modal.filamentNamed": "filament {{name}}",
|
||||
"tags.modal.free": "Ten tag nie jest jeszcze z niczym połączony.",
|
||||
"tags.modal.intro": "Po przyłożeniu tego tagu do czytnika zostanie znaleziona szpula #{{id}}.",
|
||||
"tags.modal.introFilament": "Dotknięcie tego tagu czytnikiem spowoduje znalezienie tego filamentu.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} ostatnio odczytał {{uid}}.",
|
||||
"tags.modal.link": "Połącz tag",
|
||||
"tags.modal.linking": "Łączenie...",
|
||||
"tags.modal.move": "Przenieś go tutaj ze szpuli #{{id}}",
|
||||
"tags.modal.moveFailed": "Nie udało się przenieść tagu. Może on teraz nie być połączony z żadną szpulą — spróbuj połączyć go ponownie.",
|
||||
"tags.modal.moveFailedFilament": "Nie udało się przenieść tagu. Może być teraz niepowiązany z żadnym elementem — spróbuj połączyć go ponownie.",
|
||||
"tags.modal.moveHere": "Przenieś tutaj",
|
||||
"tags.modal.moving": "Przenoszenie...",
|
||||
"tags.modal.phoneReading": "Przyłóż tag do telefonu...",
|
||||
"tags.modal.phoneScan": "Odczytaj tag tym telefonem",
|
||||
"tags.modal.spoolNamed": "szpula #{{id}}, {{name}}",
|
||||
"tags.modal.spoolShort": "szpula #{{id}}",
|
||||
"tags.modal.takenBy": "Obecnie połączony z: {{spool}}.",
|
||||
"tags.modal.title": "Połącz tag",
|
||||
"tags.modal.uidHelp": "Separatory i wielkość liter nie mają znaczenia: 04:a2:b3:c4, 04-A2-B3-C4 oraz 04a2b3c4 oznaczają ten sam tag.",
|
||||
"tags.modal.uidLabel": "UID tagu",
|
||||
"tags.modal.uidPlaceholder": "04A2B3C4",
|
||||
"tags.modal.useLastSeen": "Użyj",
|
||||
"tags.modal.waitingAny": "Oczekiwanie na przyłożenie tagu do dowolnego czytnika.",
|
||||
"tags.modal.waitingOn": "Oczekiwanie na przyłożenie tagu do: {{reader}}.",
|
||||
"tags.moved": "Tag przeniesiono tutaj ze szpuli #{{id}}.",
|
||||
"tags.movedFromFilament": "Tag przeniesiony tutaj z filamentu {{name}}.",
|
||||
"tags.nfc.insecureContext": "Odczytywanie tagów wymaga bezpiecznego połączenia. Otwórz Spoolmana przez HTTPS, aby używać NFC telefonu.",
|
||||
"tags.nfc.notAllowed": "Ta witryna nie ma uprawnień do korzystania z NFC. Nadaj jej uprawnienie w ustawieniach witryny przeglądarki i spróbuj ponownie.",
|
||||
"tags.nfc.notReadable": "Czytnik NFC jest niedostępny — może go używać inna aplikacja.",
|
||||
"tags.nfc.notSupported": "Ten telefon nie obsługuje NFC albo funkcja NFC jest wyłączona w ustawieniach systemu.",
|
||||
"tags.nfc.unknown": "Nie udało się odczytać tagu.",
|
||||
"tags.none": "Brak połączonych tagów.",
|
||||
"tags.scan.openedFilament": "Zeskanowano tag: otwarto filament {{name}}.",
|
||||
"tags.scan.openedSpool": "Zeskanowano tag: otwarto szpulę nr {{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Nieznany tag {{uid}}. Otwórz szpulę lub filament, do którego należy, i użyj opcji „Dodaj tag”, aby go przypisać.",
|
||||
"tags.section": "Tagi",
|
||||
"tags.unlink": "Odłącz",
|
||||
"tags.unlinkBody": "Tag {{uid}} przestanie identyfikować tę szpulę. Na samym tagu nie są zapisywane żadne dane, więc możesz go ponownie połączyć z tą lub inną szpulą, kiedy tylko zechcesz.",
|
||||
"tags.unlinkBodyFilament": "Tag {{uid}} przestanie identyfikować ten filament. Na samym tagu nic nie jest zapisywane, więc możesz połączyć go ponownie — tutaj lub gdzie indziej — w dowolnym momencie.",
|
||||
"tags.unlinkFailed": "Nie udało się odłączyć tagu.",
|
||||
"tags.unlinkTitle": "Odłączyć ten tag?",
|
||||
"tags.unlinked": "Tag został odłączony.",
|
||||
"tags.unlinking": "Odłączanie...",
|
||||
"topbar.addSpools": "Dodaj szpule",
|
||||
"topbar.searchPlaceholder": "Szukaj — spróbuj \"red\" albo \"pla\"",
|
||||
"unknown": "Nieznane",
|
||||
@@ -566,5 +658,6 @@
|
||||
"vendor.fields.name": "Nazwa",
|
||||
"vendor.fields.registered": "Zarejestrowano",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "Waga pustej szpuli tego producenta.",
|
||||
"vendor.showAsList": "Pokaż jako listę",
|
||||
"yes": "Tak"
|
||||
}
|
||||
@@ -64,6 +64,7 @@
|
||||
"buttons.filter": "Фильтр",
|
||||
"buttons.save": "Сохранить",
|
||||
"buttons.showArchived": "Показать архивные",
|
||||
"buttons.showNoSpools": "Показать филамент без катушек",
|
||||
"buttons.unArchive": "Из архива",
|
||||
"changeFilament.action": "Изменить",
|
||||
"changeFilament.adoptWeight": "Установить полный вес этой катушки на {{full}}",
|
||||
@@ -372,10 +373,12 @@
|
||||
"library.groupSpools_one": "{{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": "Катушка",
|
||||
@@ -523,6 +526,21 @@
|
||||
"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": "Филамент",
|
||||
@@ -550,6 +568,57 @@
|
||||
"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": "Поиск — попробуйте \"red\" или \"pla\"",
|
||||
"unknown": "Неизвестно",
|
||||
|
||||
+465
-316
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "是"
|
||||
}
|
||||
+568
-428
File diff suppressed because it is too large.
Load diff
Generated
+489
-461
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"ta",
|
||||
"th",
|
||||
"lt",
|
||||
"tr"
|
||||
"tr",
|
||||
"sl",
|
||||
"ko"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,15 @@ async function ensureOk(res: Response, method: string, path: string): Promise<Re
|
||||
export interface RawPage {
|
||||
items: unknown[];
|
||||
total: number;
|
||||
/** The group endpoint's totals across every page, when it sends them. */
|
||||
totalSpools?: number;
|
||||
totalRemaining?: number;
|
||||
}
|
||||
|
||||
function numberHeader(res: Response, name: string): number | undefined {
|
||||
const header = res.headers.get(name);
|
||||
const n = header != null && header !== '' ? Number(header) : NaN;
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Reads take an optional AbortSignal so a view can cancel what it no longer needs
|
||||
@@ -91,9 +100,12 @@ export async function getList(
|
||||
): Promise<RawPage> {
|
||||
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
|
||||
const items = (await res.json()) as unknown[];
|
||||
const header = res.headers.get('x-total-count');
|
||||
const total = header != null && header !== '' ? Number(header) : items.length;
|
||||
return { items, total: Number.isNaN(total) ? items.length : total };
|
||||
return {
|
||||
items,
|
||||
total: numberHeader(res, 'x-total-count') ?? items.length,
|
||||
totalSpools: numberHeader(res, 'x-total-spools'),
|
||||
totalRemaining: numberHeader(res, 'x-total-remaining-weight')
|
||||
};
|
||||
}
|
||||
|
||||
export async function getJson<T = unknown>(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,15 +112,7 @@ export function mapSpool(s: Json): Spool {
|
||||
registeredLabel: formatShortDate(s.registered),
|
||||
archived: s.archived ?? false,
|
||||
comment: s.comment ?? '',
|
||||
// The API always sends `tags`, but a spool 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.
|
||||
tags: ((s.tags ?? []) as Json[]).map((t) => ({
|
||||
uid: t.uid,
|
||||
format: t.format ?? undefined,
|
||||
added: t.added
|
||||
})),
|
||||
tags: mapTags(s.tags),
|
||||
extra: s.extra ?? {}
|
||||
};
|
||||
}
|
||||
@@ -164,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
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildGroupQuery } from './query';
|
||||
import { allPages, buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query';
|
||||
import type { LibraryState } from '$lib/library/params';
|
||||
|
||||
// A filament with no spools is the one you need to re-order, and grouping by
|
||||
@@ -14,6 +14,7 @@ import type { LibraryState } from '$lib/library/params';
|
||||
|
||||
function state(over: Partial<LibraryState> = {}): LibraryState {
|
||||
return {
|
||||
view: 'spools',
|
||||
selection: null,
|
||||
group: 'filament',
|
||||
sortKey: 'last_used',
|
||||
@@ -21,6 +22,7 @@ function state(over: Partial<LibraryState> = {}): LibraryState {
|
||||
filters: [],
|
||||
showArchived: false,
|
||||
showEmpty: true,
|
||||
showLotNumbers: false,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...over
|
||||
@@ -87,3 +89,118 @@ describe('buildGroupQuery — includeEmpty', () => {
|
||||
expect(buildGroupQuery(state({ showArchived: false })).includeEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The catalog sub-views page the group endpoint too, always including the empty
|
||||
// groups. The order is what holds the filament list's manufacturer sections
|
||||
// together across pages, so it is pinned down here.
|
||||
describe('buildCatalogQuery', () => {
|
||||
const catalog = (over: Partial<LibraryState> = {}) =>
|
||||
state({ view: 'filaments', group: 'vendor', sortKey: 'name', sortAsc: true, showEmpty: false, ...over });
|
||||
|
||||
it('lists every filament, manufacturer first, then by name', () => {
|
||||
const q = buildCatalogQuery(catalog({ page: 3, pageSize: 50 }));
|
||||
expect(q).toMatchObject({ field: 'filament', includeEmpty: true, preview: true, limit: 50, offset: 100 });
|
||||
expect(q.sort).toEqual([
|
||||
{ field: 'group.vendor_name', dir: 'asc' },
|
||||
{ field: 'group.title', dir: 'asc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the manufacturer lead when the list is flat', () => {
|
||||
expect(buildCatalogQuery(catalog({ group: 'none', sortAsc: false })).sort).toEqual([
|
||||
{ field: 'group.title', dir: 'desc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks weight and recency ties by name', () => {
|
||||
expect(buildCatalogQuery(catalog({ sortKey: 'remaining_weight', sortAsc: false })).sort).toEqual([
|
||||
{ field: 'group.vendor_name', dir: 'asc' },
|
||||
{ field: 'group.total_remaining', dir: 'desc' },
|
||||
{ field: 'group.title', dir: 'asc' }
|
||||
]);
|
||||
expect(buildCatalogQuery(catalog({ group: 'none', sortKey: 'last_used' })).sort).toEqual([
|
||||
{ field: 'group.last_used', dir: 'asc' },
|
||||
{ field: 'group.title', dir: 'asc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('pages manufacturers in the manufacturer view', () => {
|
||||
const q = buildCatalogQuery(catalog({ view: 'manufacturers', group: 'none' }));
|
||||
expect(q).toMatchObject({ field: 'vendor', includeEmpty: true, preview: true });
|
||||
expect(q.sort).toEqual([{ field: 'group.title', dir: 'asc' }]);
|
||||
});
|
||||
|
||||
it('orders by the fields of the filament and manufacturer, custom ones too', () => {
|
||||
const sortOf = (sortKey: string, view: LibraryState['view'] = 'filaments') =>
|
||||
buildCatalogQuery(catalog({ view, group: 'none', sortKey, sortAsc: false })).sort[0];
|
||||
expect(sortOf('filament.settings_extruder_temp')).toEqual({
|
||||
field: 'filament.settings_extruder_temp',
|
||||
dir: 'desc'
|
||||
});
|
||||
expect(sortOf('filament.extra.shelf')).toEqual({ field: 'filament.extra.shelf', dir: 'desc' });
|
||||
expect(sortOf('filament.vendor.extra.country', 'manufacturers')).toEqual({
|
||||
field: 'filament.vendor.extra.country',
|
||||
dir: 'desc'
|
||||
});
|
||||
expect(sortOf('filament_count', 'manufacturers')).toEqual({ field: 'group.filament_count', dir: 'desc' });
|
||||
// A filament's custom field means nothing to a manufacturer, nor a spool's to either.
|
||||
expect(sortOf('filament.extra.shelf', 'manufacturers').field).toBe('group.title');
|
||||
expect(sortOf('location').field).toBe('group.title');
|
||||
});
|
||||
|
||||
it('takes any filter, leaving out the empty ones under a filter about spools', () => {
|
||||
// "Which filaments do I have on Shelf A" is a fair question of the catalog,
|
||||
// but a filament with no spools isn't anywhere, and the API refuses to say.
|
||||
const filters = [{ prop: 'location', value: 'Shelf A' }];
|
||||
expect(buildCatalogQuery(catalog({ filters }))).toMatchObject({ includeEmpty: false });
|
||||
expect(buildVendorTotalsQuery(catalog({ filters }), ['1'])).toMatchObject({ includeEmpty: false });
|
||||
expect(buildCatalogQuery(catalog({ filters: [{ prop: 'material', value: 'PLA' }] }))).toMatchObject({
|
||||
includeEmpty: true
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the section totals to the manufacturers on the page', () => {
|
||||
const filters = [{ prop: 'material', value: 'PLA' }];
|
||||
const q = buildVendorTotalsQuery(catalog({ filters, showArchived: true }), ['1', '4']);
|
||||
expect(q).toMatchObject({
|
||||
field: 'vendor',
|
||||
includeEmpty: true,
|
||||
allowArchived: true,
|
||||
limit: 2,
|
||||
offset: 0
|
||||
});
|
||||
expect(q.filters).toEqual({ material: ['PLA'], vendorId: ['1', '4'] });
|
||||
});
|
||||
});
|
||||
|
||||
// The dashboard and the filament inspector used to stop at their first page
|
||||
// (1000 groups, 100 spools) and show the rest as if it didn't exist.
|
||||
describe('allPages', () => {
|
||||
const rows = Array.from({ length: 25 }, (_, i) => i);
|
||||
const pager = (size: number) => {
|
||||
const offsets: number[] = [];
|
||||
const fetchPage = async (offset: number) => {
|
||||
offsets.push(offset);
|
||||
return { items: rows.slice(offset, offset + size), total: rows.length };
|
||||
};
|
||||
return { offsets, fetchPage };
|
||||
};
|
||||
|
||||
it('keeps fetching until it has every row', async () => {
|
||||
const { offsets, fetchPage } = pager(10);
|
||||
expect(await allPages(10, fetchPage)).toEqual(rows);
|
||||
expect(offsets).toEqual([0, 10, 20]);
|
||||
});
|
||||
|
||||
it('stops after one request when the first page holds everything', async () => {
|
||||
const { offsets, fetchPage } = pager(100);
|
||||
expect(await allPages(100, fetchPage)).toEqual(rows);
|
||||
expect(offsets).toEqual([0]);
|
||||
});
|
||||
|
||||
it('stops on an exact multiple without asking for an empty page', async () => {
|
||||
const { offsets, fetchPage } = pager(5);
|
||||
expect(await allPages(5, fetchPage)).toHaveLength(25);
|
||||
expect(offsets).toEqual([0, 5, 10, 15, 20]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { GroupField, GroupQuery, GroupSummary, SortField, SpoolQuery } from './types';
|
||||
import type { LibraryState } from '$lib/library/params';
|
||||
import { resolveSortField, resolveGroupSortField } from '$lib/utils/library';
|
||||
import { isDateFilterProp } from '$lib/library/dateFilter';
|
||||
import type { GroupField, GroupQuery, GroupSummary, Page, SortField, SpoolQuery } from './types';
|
||||
import { isSpoolScopedFilter, type CatalogView, type LibraryState } from '$lib/library/params';
|
||||
import { resolveSortField, resolveGroupSortField, catalogSortField } from '$lib/utils/library';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
|
||||
// Translates the URL-borne LibraryState into concrete /spool and /spool/group
|
||||
@@ -20,22 +19,6 @@ function currentFilters(state: LibraryState): Record<string, string[]> {
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter props that describe a SPOOL rather than the filament it is of.
|
||||
*
|
||||
* The distinction only matters for empty filament groups (see includeEmpty
|
||||
* below). Everything not listed here — filament, material, vendor, direction,
|
||||
* and the `filament.extra.` / `filament.vendor.extra.` fields — is a fact about
|
||||
* the filament, which a filament with no spools still has.
|
||||
*/
|
||||
function isSpoolScopedFilter(prop: string): boolean {
|
||||
if (prop === 'location' || prop === 'lot') return true;
|
||||
if (isDateFilterProp(prop)) return true;
|
||||
// `extra.<key>` is the spool's own custom field; the filament's and the
|
||||
// vendor's are prefixed, so a bare `extra.` is the spool-scoped one.
|
||||
return prop.startsWith('extra.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to ask for filaments that have no spools at all (#1092).
|
||||
*
|
||||
@@ -82,6 +65,70 @@ export function buildGroupQuery(state: LibraryState, signal?: AbortSignal): Grou
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a catalog page lists the filaments (or manufacturers) with no spools
|
||||
* too. It does unless a filter asks about the spools themselves: a filament with
|
||||
* none can't be on Shelf A, and the API refuses the pairing (see wantsEmptyGroups).
|
||||
*/
|
||||
function catalogIncludesEmpty(state: LibraryState): boolean {
|
||||
return !state.filters.some((f) => isSpoolScopedFilter(f.prop));
|
||||
}
|
||||
|
||||
/**
|
||||
* Page of a catalog sub-view: one group per filament (or manufacturer), those
|
||||
* with no spools included (see catalogIncludesEmpty), with the preview the row
|
||||
* draws from.
|
||||
*/
|
||||
export function buildCatalogQuery(state: LibraryState, signal?: AbortSignal): GroupQuery {
|
||||
const field = catalogSortField(state.view as CatalogView, state.sortKey) ?? 'group.title';
|
||||
const sort: SortField[] = [{ field, dir: state.sortAsc ? 'asc' : 'desc' }];
|
||||
// Manufacturer sections are runs of consecutive rows (see library/catalog), so
|
||||
// the manufacturer has to lead the order or a section would break apart.
|
||||
if (state.view === 'filaments' && state.group === 'vendor') {
|
||||
sort.unshift({ field: 'group.vendor_name', dir: 'asc' });
|
||||
}
|
||||
// Most orders tie a lot (every filament with no spools has 0 g and was never
|
||||
// used; many share a material), so the name settles ties instead of the
|
||||
// database's whim.
|
||||
if (field !== 'group.title') sort.push({ field: 'group.title', dir: 'asc' });
|
||||
return {
|
||||
field: state.view === 'manufacturers' ? 'vendor' : 'filament',
|
||||
filters: currentFilters(state),
|
||||
sort,
|
||||
allowArchived: state.showArchived,
|
||||
includeEmpty: catalogIncludesEmpty(state),
|
||||
preview: true,
|
||||
limit: state.pageSize,
|
||||
offset: (state.page - 1) * state.pageSize,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The aggregates behind the manufacturer section headers on one page of the
|
||||
* filament catalog: the same filters, narrowed to the manufacturers on screen.
|
||||
* Including the empty ones whenever the page does is what makes the filament
|
||||
* count cover the same filaments as the rows under the header.
|
||||
*/
|
||||
export function buildVendorTotalsQuery(
|
||||
state: LibraryState,
|
||||
vendorIds: string[],
|
||||
signal?: AbortSignal
|
||||
): GroupQuery {
|
||||
return {
|
||||
field: 'vendor',
|
||||
filters: { ...currentFilters(state), vendorId: vendorIds },
|
||||
sort: [],
|
||||
allowArchived: state.showArchived,
|
||||
includeEmpty: catalogIncludesEmpty(state),
|
||||
limit: vendorIds.length,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
};
|
||||
}
|
||||
|
||||
/** Spools of one group, ordered by the chosen Sort. */
|
||||
export function buildScopedSpoolQuery(
|
||||
state: LibraryState,
|
||||
@@ -123,3 +170,20 @@ export function buildFlatSpoolQuery(state: LibraryState, signal?: AbortSignal):
|
||||
signal
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row of a paged list, fetched `size` at a time: for views that need the
|
||||
* whole set (every dashboard card, every spool of one filament) without asking
|
||||
* the server for all of it in one response.
|
||||
*/
|
||||
export async function allPages<T>(
|
||||
size: number,
|
||||
fetchPage: (offset: number) => Promise<Page<T>>
|
||||
): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
for (;;) {
|
||||
const page = await fetchPage(all.length);
|
||||
all.push(...page.items);
|
||||
if (page.items.length < size || all.length >= page.total) return all;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { wsUrl } from './config';
|
||||
import { getJson } from './http';
|
||||
import { mapSpool, mapFilament, mapVendor } from './map';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import type { Spool } from '$lib/types';
|
||||
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
|
||||
@@ -40,9 +40,12 @@ export interface TagScan {
|
||||
format?: string;
|
||||
/** Raw tag contents, base64. Phase 1 does not decode it. */
|
||||
payloadB64?: string;
|
||||
/** The spool this tag is linked to, absent when the tag is unknown. Embedded
|
||||
/** 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;
|
||||
@@ -77,10 +80,11 @@ function pathFor(pool: ReaderPool): string {
|
||||
/**
|
||||
* Map a broadcast payload to a `TagScan`.
|
||||
*
|
||||
* The embedded spool is cached on the way past, exactly as a spool 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.
|
||||
* 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;
|
||||
@@ -93,13 +97,21 @@ function toScan(payload: Json): TagScan {
|
||||
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
|
||||
spool,
|
||||
filament
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,6 +260,12 @@ export interface KnownReader {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +281,7 @@ export async function listReaders(signal?: AbortSignal): Promise<KnownReader[]>
|
||||
return rows.map((r) => ({
|
||||
readerId: r.reader_id,
|
||||
name: r.name ?? undefined,
|
||||
lastSeen: r.last_seen
|
||||
lastSeen: r.last_seen,
|
||||
lastUid: r.last_uid ?? undefined
|
||||
}));
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { filamentLabel, type FilterOption } from '$lib/utils/library';
|
||||
import type { EntityType } from './fields';
|
||||
import { type ExternalFilament } from './external';
|
||||
import { searchAll } from './search';
|
||||
import { loadLayout } from '$lib/dashboard/layout';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Json = Record<string, any>;
|
||||
@@ -64,7 +65,11 @@ const FILTER_PARAM: Record<string, string> = {
|
||||
vendor: 'filament.vendor.name',
|
||||
direction: 'filament.multi_color_direction',
|
||||
location: 'location',
|
||||
lot: 'lot_nr'
|
||||
lot: 'lot_nr',
|
||||
// Not in the filter menu, which picks manufacturers by name: the manufacturer
|
||||
// inspector's "Show as a list" filters by id, so two of the same name stay
|
||||
// apart, and the filament list narrows its section totals the same way.
|
||||
vendorId: 'filament.vendor.id'
|
||||
};
|
||||
|
||||
// Filters whose values are passed through verbatim rather than double-quoted for
|
||||
@@ -72,7 +77,8 @@ const FILTER_PARAM: Record<string, string> = {
|
||||
// an int and would reject if quoted. `direction` is passed verbatim so its empty
|
||||
// value (single-color = no direction) hits the backend's NULL-match branch, which
|
||||
// quoting would turn into an exact match on the literal empty string instead.
|
||||
const UNQUOTED_FILTERS = new Set(['filament', 'direction']);
|
||||
// `vendorId` is numeric like `filament`.
|
||||
const UNQUOTED_FILTERS = new Set(['filament', 'direction', 'vendorId']);
|
||||
|
||||
// A filter prop is either one of the fixed categories above or an extra-field
|
||||
// filter whose prop is already the query param the backend expects — `extra.<key>`
|
||||
@@ -167,11 +173,12 @@ class HttpSpoolSource {
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
allow_archived: query.allowArchived ? 'true' : undefined,
|
||||
include_empty: query.includeEmpty ? 'true' : undefined
|
||||
include_empty: query.includeEmpty ? 'true' : undefined,
|
||||
preview: query.preview ? 'true' : undefined
|
||||
};
|
||||
applyFilters(params, query.filters);
|
||||
|
||||
const { items, total } = await getList('/spool/group', params, query.signal);
|
||||
const { items, total, totalSpools, totalRemaining } = await getList('/spool/group', params, query.signal);
|
||||
for (const g of items as Json[]) {
|
||||
if (g.filament) {
|
||||
inventory.upsertFilament(mapFilament(g.filament));
|
||||
@@ -179,7 +186,7 @@ class HttpSpoolSource {
|
||||
}
|
||||
if (g.vendor) inventory.upsertVendor(mapVendor(g.vendor));
|
||||
}
|
||||
return { items: (items as Json[]).map(mapGroup), total };
|
||||
return { items: (items as Json[]).map(mapGroup), total, totalSpools, totalRemaining };
|
||||
}
|
||||
|
||||
async listSpools(query: SpoolQuery): Promise<Page<Spool>> {
|
||||
@@ -196,20 +203,32 @@ class HttpSpoolSource {
|
||||
return { items: cacheSpools(items), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a batch of filaments for the label picker/preview, newest first, and
|
||||
* cache them. Filament catalogs are far smaller than spool inventories, so a
|
||||
* single generous page covers virtually every library; the label picker also
|
||||
* has a search box for anything past the cap.
|
||||
*/
|
||||
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
|
||||
const items = await getJson<Json[]>('/filament', { limit, sort: 'id:desc' }, signal);
|
||||
const filaments = items.map((f) => {
|
||||
/** Load and cache one page of filaments, including the catalog total. */
|
||||
async listFilamentPage(
|
||||
query: Pick<SpoolQuery, 'sort' | 'limit' | 'offset' | 'signal'>
|
||||
): Promise<Page<Filament>> {
|
||||
const { items, total } = await getList(
|
||||
'/filament',
|
||||
{ sort: sortParam(query.sort), limit: query.limit, offset: query.offset },
|
||||
query.signal
|
||||
);
|
||||
const filaments = (items as Json[]).map((f) => {
|
||||
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
||||
return mapFilament(f);
|
||||
});
|
||||
inventory.upsertFilaments(filaments);
|
||||
return filaments;
|
||||
return { items: filaments, total };
|
||||
}
|
||||
|
||||
/** Load a batch for label previews, newest first. */
|
||||
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
|
||||
const { items } = await this.listFilamentPage({
|
||||
sort: [{ field: 'id', dir: 'desc' }],
|
||||
limit,
|
||||
offset: 0,
|
||||
signal
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
async searchFilaments(query: string, limit = 8): Promise<Filament[]> {
|
||||
@@ -234,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,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]);
|
||||
@@ -256,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 = {
|
||||
@@ -299,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');
|
||||
@@ -313,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));
|
||||
@@ -329,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,
|
||||
@@ -526,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
|
||||
|
||||
@@ -2,22 +2,36 @@ import { describe, expect, it } from 'vitest';
|
||||
import { asTagConflict, isBadUid } from './tags';
|
||||
import { HttpError } from './http';
|
||||
|
||||
// Linking a tag another spool already holds is the one failure the UI has to do
|
||||
// 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
|
||||
// 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.
|
||||
// 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
|
||||
spool_id: 1,
|
||||
filament_id: null
|
||||
});
|
||||
expect(asTagConflict(err)).toEqual({
|
||||
spoolId: 1,
|
||||
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();
|
||||
@@ -25,9 +39,10 @@ describe('asTagConflict', () => {
|
||||
|
||||
// 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 spool id', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import type { Spool, SpoolTag } from '$lib/types';
|
||||
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.
|
||||
// Linking physical NFC/RFID tags to spools and filaments.
|
||||
//
|
||||
// A tag belongs to exactly one spool 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.
|
||||
// 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 ordinary spool
|
||||
// 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
|
||||
@@ -35,27 +56,29 @@ export const KNOWN_FORMATS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The spool that 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.
|
||||
* 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 {
|
||||
spoolId: number;
|
||||
holder: TagTarget;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 409 from `linkTag` as the conflict it describes, or null for any other
|
||||
* failure. A 409 without a usable `spool_id` counts as "not a conflict we can
|
||||
* act on" and falls back to being reported as an ordinary error.
|
||||
* 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 null;
|
||||
return {
|
||||
spoolId,
|
||||
message: typeof err.body?.message === 'string' ? err.body.message : err.message
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,30 +91,37 @@ 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. Re-linking a UID the spool already holds succeeds and
|
||||
* changes nothing (passing a `format` refines the stored one), so this is safe
|
||||
* to call without checking first.
|
||||
* 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 another spool holds the UID — see `asTagConflict`.
|
||||
* Rejects with a 409 when anything else holds the UID — see `asTagConflict`.
|
||||
*/
|
||||
export async function linkTag(spoolId: number, uid: string, format?: string): Promise<SpoolTag> {
|
||||
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>(`/spool/${spoolId}/tag`, body);
|
||||
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 holding it. The UID may be in any spelling; the
|
||||
* server normalizes before matching.
|
||||
* 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(spoolId: number, uid: string): Promise<void> {
|
||||
await deleteResource(`/spool/${spoolId}/tag/${encodeURIComponent(uid)}`);
|
||||
export async function unlinkTag(target: TagTarget, uid: string): Promise<void> {
|
||||
await deleteResource(`${tagPath(target)}/${encodeURIComponent(uid)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spool a UID is linked to, or undefined when no spool holds it.
|
||||
* 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
|
||||
@@ -101,17 +131,29 @@ export async function unlinkTag(spoolId: number, uid: string): Promise<void> {
|
||||
* caller (`isBadUid`) rather than flattened into "not found", so a typo is not
|
||||
* reported to the user as an unknown tag.
|
||||
*/
|
||||
export async function findSpoolByTag(uid: string, signal?: AbortSignal): Promise<Spool | undefined> {
|
||||
const { items } = await getList('/spool', { tag: uid, allow_archived: 'true', limit: 1 }, signal);
|
||||
const raw = items[0] as Json | undefined;
|
||||
if (!raw) return undefined;
|
||||
// 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 (raw.filament) {
|
||||
inventory.upsertFilament(mapFilament(raw.filament));
|
||||
if (raw.filament.vendor) inventory.upsertVendor(mapVendor(raw.filament.vendor));
|
||||
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 spool = mapSpool(raw);
|
||||
inventory.upsertSpool(spool);
|
||||
return 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;
|
||||
}
|
||||
@@ -45,6 +45,27 @@ export interface GroupSummary {
|
||||
lastUsedLabel: string;
|
||||
/** Numeric recency for group ordering (higher = more recent). */
|
||||
lastUsedSort: number;
|
||||
/** Vendor groups only: how many filaments the group spans — every matching
|
||||
* filament of the manufacturer when includeEmpty, else those with spools. */
|
||||
filamentCount?: number;
|
||||
/** Filament groups, with GroupQuery.preview: the first few spools (by id). */
|
||||
previewSpools?: SpoolPreview[];
|
||||
/** Vendor groups, with GroupQuery.preview: the first few filaments (by name). */
|
||||
previewFilaments?: FilamentPreview[];
|
||||
}
|
||||
|
||||
/** Just enough of a spool to draw its fill pip. Grams; null when unknown. */
|
||||
export interface SpoolPreview {
|
||||
id: string;
|
||||
remaining: number | null;
|
||||
initial: number | null;
|
||||
}
|
||||
|
||||
/** Just enough of a filament to draw its swatch. */
|
||||
export interface FilamentPreview {
|
||||
id: string;
|
||||
colors: string[];
|
||||
direction?: MultiColorDirection;
|
||||
}
|
||||
|
||||
export interface GroupScope {
|
||||
@@ -74,9 +95,11 @@ export interface GroupQuery {
|
||||
sort: SortField[];
|
||||
/** Include archived spools in the group aggregates (default: excluded). */
|
||||
allowArchived?: boolean;
|
||||
/** List matching filaments that hold no matching spools as groups of zero
|
||||
* (filament grouping only; see buildGroupQuery). */
|
||||
/** List matching filaments (filament grouping) or manufacturers (vendor
|
||||
* grouping) that hold no matching spools, as groups of zero. */
|
||||
includeEmpty?: boolean;
|
||||
/** Attach previewSpools / previewFilaments to filament / vendor groups. */
|
||||
preview?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
lowThreshold: number;
|
||||
@@ -88,6 +111,10 @@ export interface Page<T> {
|
||||
items: T[];
|
||||
/** Total matching rows across all pages (from X-Total-Count). */
|
||||
total: number;
|
||||
/** Group pages only: spools and remaining grams summed over ALL matching
|
||||
* groups, not just this page (X-Total-Spools / X-Total-Remaining-Weight). */
|
||||
totalSpools?: number;
|
||||
totalRemaining?: number;
|
||||
}
|
||||
|
||||
// --- cross-entity search (GET /search) -----------------------------------
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
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 {
|
||||
FILAMENT_ADVANCED_KEYS,
|
||||
FILAMENT_FIELD_ORDER,
|
||||
@@ -49,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);
|
||||
@@ -123,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;
|
||||
}
|
||||
|
||||
@@ -146,7 +141,7 @@
|
||||
fields.ensure('filament');
|
||||
fields.ensure('vendor');
|
||||
spoolSource
|
||||
.locations()
|
||||
.locationChoices()
|
||||
.then((l) => (locations = l))
|
||||
.catch(() => {});
|
||||
if (presetFilamentId) {
|
||||
@@ -283,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;
|
||||
}
|
||||
@@ -339,7 +334,7 @@
|
||||
step = 1;
|
||||
query = '';
|
||||
localResults = [];
|
||||
externalResults = [];
|
||||
extSearch.clear();
|
||||
chosen = null;
|
||||
creating = false;
|
||||
cloneSource = null;
|
||||
@@ -388,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;
|
||||
@@ -446,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).
|
||||
@@ -536,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>
|
||||
@@ -562,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)}
|
||||
@@ -593,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>
|
||||
|
||||
@@ -728,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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
// Link a physical tag to a spool, 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
|
||||
// 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
|
||||
@@ -18,8 +18,19 @@
|
||||
import Nfc from '@lucide/svelte/icons/nfc';
|
||||
import Smartphone from '@lucide/svelte/icons/smartphone';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import type { Spool } from '$lib/types';
|
||||
import { linkTag, unlinkTag, findSpoolByTag, asTagConflict, isBadUid } from '$lib/api/tags';
|
||||
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';
|
||||
@@ -32,10 +43,13 @@
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
spool: Spool;
|
||||
/** What the tag is being linked to. */
|
||||
kind: TagKind;
|
||||
id: number | string;
|
||||
onclose: () => void;
|
||||
}
|
||||
let { open, spool, onclose }: Props = $props();
|
||||
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. */
|
||||
@@ -49,7 +63,7 @@
|
||||
| { state: 'checking' }
|
||||
| { state: 'free' }
|
||||
| { state: 'here' }
|
||||
| { state: 'taken'; spool: Spool }
|
||||
| { state: 'taken'; holder: TagHolder }
|
||||
| { state: 'bad' }
|
||||
| { state: 'unknown' };
|
||||
let lookup = $state<Lookup>({ state: 'empty' });
|
||||
@@ -121,11 +135,11 @@
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const found = await findSpoolByTag(value, ac.signal);
|
||||
const found = await findTagHolder(value, ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
if (!found) lookup = { state: 'free' };
|
||||
else if (found.id === spool.id) lookup = { state: 'here' };
|
||||
else lookup = { state: 'taken', spool: found };
|
||||
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
|
||||
@@ -170,15 +184,36 @@
|
||||
return m['tags.nfc.unknown']();
|
||||
}
|
||||
|
||||
/** Describe another spool well enough to decide whether to take its tag. */
|
||||
function describe(other: Spool): string {
|
||||
/** 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);
|
||||
const vendor = filament ? inventory.vendorById(filament.vendorId) : undefined;
|
||||
if (!filament) return m['tags.modal.spoolShort']({ id: other.id });
|
||||
return m['tags.modal.spoolNamed']({
|
||||
id: other.id,
|
||||
name: vendor ? filamentLabel(filament, vendor) : filament.name
|
||||
});
|
||||
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() {
|
||||
@@ -187,7 +222,7 @@
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await linkTag(spool.id, value, format);
|
||||
await linkTag(target, value, format);
|
||||
toasts.success(m['tags.added']());
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -196,8 +231,8 @@
|
||||
// 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 spoolSource.fetchSpool(conflict.spoolId).catch(() => undefined);
|
||||
lookup = holder ? { state: 'taken', spool: holder } : { state: 'unknown' };
|
||||
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' };
|
||||
@@ -210,25 +245,33 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a tag off the spool that holds it and put it on this one.
|
||||
* Take a tag off whatever holds it and put it on this spool or filament.
|
||||
*
|
||||
* Two requests, in this order, because a tag belongs to exactly one spool and
|
||||
* 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: Spool) {
|
||||
async function move(from: TagHolder) {
|
||||
const value = uid.trim();
|
||||
if (!value || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await unlinkTag(from.id, value);
|
||||
await linkTag(spool.id, value, format);
|
||||
toasts.success(m['tags.moved']({ id: from.id }));
|
||||
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 {
|
||||
error = m['tags.modal.moveFailed']();
|
||||
// "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;
|
||||
}
|
||||
@@ -241,6 +284,14 @@
|
||||
});
|
||||
|
||||
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
|
||||
@@ -261,7 +312,9 @@
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<p class="intro">{m['tags.modal.intro']({ id: spool.id })}</p>
|
||||
<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
|
||||
@@ -283,9 +336,11 @@
|
||||
{:else if lookup.state === 'free'}
|
||||
<span class="ok">{m['tags.modal.free']()}</span>
|
||||
{:else if lookup.state === 'here'}
|
||||
<span class="muted">{m['tags.modal.alreadyHere']()}</span>
|
||||
<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.spool) })}</span>
|
||||
<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}
|
||||
@@ -302,6 +357,20 @@
|
||||
{/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" />
|
||||
@@ -322,10 +391,16 @@
|
||||
<div class="foot">
|
||||
<Button variant="ghost" onclick={close}>{m['buttons.cancel']()}</Button>
|
||||
{#if lookup.state === 'taken'}
|
||||
{@const other = lookup.spool}
|
||||
{@const other = lookup.holder}
|
||||
<Button variant="primary" disabled={busy} onclick={() => move(other)}>
|
||||
<Nfc size={15} />
|
||||
{busy ? m['tags.modal.moving']() : m['tags.modal.move']({ id: other.id })}
|
||||
{#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
|
||||
@@ -483,6 +558,12 @@
|
||||
.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;
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
Spoolman
|
||||
{#if info}
|
||||
v{info.version}{#if info.git_commit}<span class="commit"> ({info.git_commit})</span>{/if}
|
||||
{#if info.update_available && info.latest_version}
|
||||
<a
|
||||
class="update"
|
||||
href="https://github.com/Donkie/Spoolman/releases/tag/v{info.latest_version}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{m['footer.updateAvailable']({ version: info.latest_version })}</a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
·
|
||||
<a href="https://github.com/Donkie/Spoolman">{m['footer.documentation']()}</a>
|
||||
@@ -56,6 +64,11 @@
|
||||
.commit {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.update {
|
||||
margin-left: 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sponsor {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
// The NFC/RFID tags linked to a spool, listed in its inspector.
|
||||
// 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
|
||||
@@ -9,24 +9,30 @@
|
||||
// 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 spool's primary actions, and linking
|
||||
// a tag is not one of the things you do to a spool every day.
|
||||
// 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 { Spool, SpoolTag } from '$lib/types';
|
||||
import { unlinkTag } from '$lib/api/tags';
|
||||
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';
|
||||
|
||||
let { spool }: { spool: Spool } = $props();
|
||||
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<SpoolTag | null>(null);
|
||||
let pending = $state<Tag | null>(null);
|
||||
let unlinking = $state(false);
|
||||
|
||||
async function confirmUnlink() {
|
||||
@@ -34,8 +40,8 @@
|
||||
if (!tag) return;
|
||||
unlinking = true;
|
||||
try {
|
||||
await unlinkTag(spool.id, tag.uid);
|
||||
// No local edit: unlinking emits the spool's ordinary `updated` event, and
|
||||
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;
|
||||
@@ -45,6 +51,10 @@
|
||||
unlinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function unlinkBody(uid: string): string {
|
||||
return kind === 'spool' ? m['tags.unlinkBody']({ uid }) : m['tags.unlinkBodyFilament']({ uid });
|
||||
}
|
||||
</script>
|
||||
|
||||
<SectionLabel>
|
||||
@@ -57,9 +67,9 @@
|
||||
{/snippet}
|
||||
</SectionLabel>
|
||||
|
||||
{#if spool.tags.length}
|
||||
{#if tags.length}
|
||||
<ul class="tags">
|
||||
{#each spool.tags as tag (tag.uid)}
|
||||
{#each tags as tag (tag.uid)}
|
||||
<li class="tag">
|
||||
<Nfc size={14} class="ico" />
|
||||
<span class="uid mono">{tag.uid}</span>
|
||||
@@ -80,13 +90,13 @@
|
||||
<div class="none">{m['tags.none']()}</div>
|
||||
{/if}
|
||||
|
||||
<AddTagModal open={addOpen} {spool} onclose={() => (addOpen = false)} />
|
||||
<AddTagModal open={addOpen} {kind} {id} onclose={() => (addOpen = false)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={pending !== null}
|
||||
busy={unlinking}
|
||||
title={m['tags.unlinkTitle']()}
|
||||
lines={pending ? [m['tags.unlinkBody']({ uid: pending.uid })] : []}
|
||||
lines={pending ? [unlinkBody(pending.uid)] : []}
|
||||
confirmLabel={unlinking ? m['tags.unlinking']() : m['tags.unlink']()}
|
||||
onconfirm={confirmUnlink}
|
||||
onclose={() => (pending = null)}
|
||||
|
||||
@@ -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>
|
||||
@@ -20,6 +20,7 @@
|
||||
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';
|
||||
@@ -53,9 +54,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 busy = $state(false);
|
||||
|
||||
@@ -187,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;
|
||||
}
|
||||
|
||||
@@ -434,7 +428,7 @@
|
||||
placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })}
|
||||
aria-label={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>
|
||||
@@ -465,16 +459,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"
|
||||
class:sel={chosen?.source === 'external' && chosen.ext.id === ext.id}
|
||||
@@ -496,6 +490,9 @@
|
||||
<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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -182,15 +182,33 @@
|
||||
/>
|
||||
{#if !collapsed && !noSpools}
|
||||
{#each inUse as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{vm}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/each}
|
||||
{#each unusedByFilament as unused (unused[0].spool.id)}
|
||||
{#if unused.length === 1}
|
||||
<!-- A lone unused spool gains nothing from a collapsing header — it just
|
||||
adds a click and a row — so render it inline like the used spools. -->
|
||||
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
vm={unused[0]}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{:else}
|
||||
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
|
||||
<UnusedRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{unused}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if moreCount > 0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { asset } from '$app/paths';
|
||||
import SpoolInspector from './SpoolInspector.svelte';
|
||||
import FilamentInspector from './FilamentInspector.svelte';
|
||||
@@ -6,7 +7,6 @@
|
||||
import type { Selection } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { selection }: { selection: Selection | null } = $props();
|
||||
@@ -30,17 +30,24 @@
|
||||
// across runs to stop a genuine miss / 404 from re-fetching every tick.
|
||||
let attempted = '';
|
||||
let loading = $state(false);
|
||||
// The effect follows the selection by value, not by object. Arriving on the
|
||||
// Library rewrites the URL to restore the remembered view (see +page.svelte),
|
||||
// which parses the same selection into a new object; following the object
|
||||
// re-ran the effect mid-fetch and its cleanup aborted the request (#1243).
|
||||
let key = $derived(sel ? `${sel.kind}:${sel.id}` : '');
|
||||
$effect(() => {
|
||||
const s = sel;
|
||||
if (!s || found) return;
|
||||
const key = `${s.kind}:${s.id}`;
|
||||
if (attempted === key) return;
|
||||
attempted = key;
|
||||
const k = key;
|
||||
if (!k || found) return;
|
||||
if (attempted === k) return;
|
||||
attempted = k;
|
||||
loading = true;
|
||||
|
||||
const s = untrack(() => sel)!;
|
||||
const ctrl = new AbortController();
|
||||
let pending = true;
|
||||
const done = (err?: unknown) => {
|
||||
if (isAbortError(err, ctrl.signal)) return; // superseded selection; leave loading for the next run
|
||||
if (ctrl.signal.aborted) return; // superseded; the run that aborted it owns `loading`
|
||||
pending = false;
|
||||
if (err) console.warn('deep-link fetch failed', err);
|
||||
loading = false;
|
||||
};
|
||||
@@ -52,7 +59,12 @@
|
||||
: spoolSource.fetchVendor(s.id, ctrl.signal);
|
||||
p.then(() => done()).catch(done);
|
||||
|
||||
return () => ctrl.abort();
|
||||
return () => {
|
||||
// Cancelled before it answered: forget the attempt, so whichever run
|
||||
// comes next asks again instead of waiting on a request that is gone.
|
||||
if (pending) attempted = '';
|
||||
ctrl.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
withinLast,
|
||||
type DateFilterProp
|
||||
} from '$lib/library/dateFilter';
|
||||
import { sortDefs, filamentLabel, type FilterOption, type SortDef } from '$lib/utils/library';
|
||||
import {
|
||||
sortDefs,
|
||||
catalogSortDefs,
|
||||
filamentLabel,
|
||||
type FilterOption,
|
||||
type SortDef
|
||||
} from '$lib/utils/library';
|
||||
import { filterByQuery, matchesTerms, searchTerms } from '$lib/utils/match';
|
||||
import MenuSearch from '../MenuSearch.svelte';
|
||||
import Swatch from '../Swatch.svelte';
|
||||
@@ -33,6 +39,10 @@
|
||||
// Named libraryState, not `state`, to avoid shadowing the $state rune.
|
||||
let { libraryState }: { libraryState: LibraryState } = $props();
|
||||
|
||||
// The catalog sub-views (filaments, manufacturers) reuse this toolbar with the
|
||||
// few groupings, orders and filters a list of filaments can answer.
|
||||
let catalog = $derived(params.isCatalogView(libraryState));
|
||||
|
||||
type Menu = 'filter' | 'group' | 'sort' | null;
|
||||
let open = $state<Menu>(null);
|
||||
|
||||
@@ -260,7 +270,11 @@
|
||||
close();
|
||||
}
|
||||
|
||||
let sorts = $derived(sortDefs(fields.get('spool')));
|
||||
let sorts = $derived<SortDef[]>(
|
||||
libraryState.view === 'spools'
|
||||
? sortDefs(fields.get('spool'))
|
||||
: catalogSortDefs(libraryState.view, fields.get('filament'), fields.get('vendor'))
|
||||
);
|
||||
let activeSort = $derived(sorts.find((s) => s.key === libraryState.sortKey) ?? sorts[0]);
|
||||
|
||||
const groupOptions: { key: GroupMode; labelKey: () => string }[] = [
|
||||
@@ -270,8 +284,15 @@
|
||||
{ key: 'location', labelKey: m['spool.fields.location'] },
|
||||
{ key: 'none', labelKey: m['library.groupNone'] }
|
||||
];
|
||||
// The filament catalog sections by manufacturer or not at all; the manufacturer
|
||||
// catalog has nothing to group by and shows no Group control.
|
||||
const catalogGroupOptions: typeof groupOptions = [
|
||||
{ key: 'vendor', labelKey: m['filament.fields.vendor'] },
|
||||
{ key: 'none', labelKey: m['library.groupNone'] }
|
||||
];
|
||||
let groupChoices = $derived(catalog ? catalogGroupOptions : groupOptions);
|
||||
let groupLabel = $derived(
|
||||
groupOptions.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
|
||||
groupChoices.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
|
||||
);
|
||||
|
||||
function chipLabel(prop: string, value: string): string {
|
||||
@@ -280,6 +301,11 @@
|
||||
// Date filters hold a range, whose value is grammar rather than something to
|
||||
// show ("-24h.." reads as "Last 24 hours").
|
||||
if (isDateFilterProp(prop)) return `${label}: ${rangeLabel(value)}`;
|
||||
// A manufacturer picked by id (the inspector's "Show as a list"): its name,
|
||||
// so the chip reads like the by-name Manufacturer chip the menu makes.
|
||||
if (prop === 'vendorId') {
|
||||
return `${m['filament.fields.vendor']()}: ${inventory.vendorById(value)?.name ?? '#' + value}`;
|
||||
}
|
||||
// Filament filters store the numeric id; show the filament's name instead.
|
||||
if (prop === 'filament') {
|
||||
const fil = inventory.filamentById(value);
|
||||
@@ -340,6 +366,9 @@
|
||||
let noSpoolsMatches = $derived(
|
||||
libraryState.group === 'filament' && matchesTerms(m['buttons.showNoSpools'](), searchTerms(menuQuery))
|
||||
);
|
||||
let lotNumbersMatches = $derived(
|
||||
libraryState.view === 'spools' && matchesTerms(m['buttons.showLotNumbers'](), searchTerms(menuQuery))
|
||||
);
|
||||
// An option's secondary text is on screen, so it has to be searchable: typing
|
||||
// "petg" against a list that visibly says PETG must not come back empty.
|
||||
let visibleOptions = $derived(
|
||||
@@ -375,7 +404,12 @@
|
||||
<div
|
||||
class="toolbar"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation();
|
||||
// A menu opened by a click leaves focus on its button, out here rather than
|
||||
// in the menu, so this is where Escape has to close it.
|
||||
if (e.key === 'Escape' && open) close();
|
||||
}}
|
||||
role="toolbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
@@ -412,10 +446,12 @@
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="link-btn" onclick={() => toggle('group')}
|
||||
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
|
||||
<ChevronDown size={13} /></button
|
||||
>
|
||||
{#if libraryState.view !== 'manufacturers'}
|
||||
<button class="link-btn" onclick={() => toggle('group')}
|
||||
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
|
||||
<ChevronDown size={13} /></button
|
||||
>
|
||||
{/if}
|
||||
<!-- One chip, two controls (#1091): the field opens the menu, the arrow only
|
||||
flips the direction. Reversing an order used to mean reopening the menu
|
||||
and hunting down the field that was already selected. A divider and a
|
||||
@@ -491,7 +527,28 @@
|
||||
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !visibleCategories.length}
|
||||
{#if lotNumbersMatches}
|
||||
{#if visibleCategories.length && !archivedMatches && !noSpoolsMatches}<div
|
||||
class="menu-sep"
|
||||
></div>{/if}
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={libraryState.showLotNumbers}
|
||||
onclick={() => {
|
||||
params.setShowLotNumbers(!libraryState.showLotNumbers);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span class="mi-check"
|
||||
>{#if libraryState.showLotNumbers}<SquareCheck size={15} />{:else}<Square
|
||||
size={15}
|
||||
/>{/if}</span
|
||||
>
|
||||
<span class="mi-label">{m['buttons.showLotNumbers']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !lotNumbersMatches && !visibleCategories.length}
|
||||
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -590,7 +647,7 @@
|
||||
|
||||
{#if open === 'group'}
|
||||
<div class="menu group-menu">
|
||||
{#each groupOptions as g (g.key)}
|
||||
{#each groupChoices as g (g.key)}
|
||||
<button
|
||||
class="menu-item"
|
||||
class:sel={libraryState.group === g.key}
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import { goto } from '$app/navigation';
|
||||
import { searchAll } from '$lib/api/search';
|
||||
import type { SearchResults } from '$lib/api/types';
|
||||
import { openSearchResult, searchResultHref } from '$lib/library/params';
|
||||
import { catalogHref, openSearchResult, searchResultHref } from '$lib/library/params';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
import { page } from '$app/state';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import type { EntityKind } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -31,8 +34,9 @@
|
||||
|
||||
function loadThreshold(): number {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_THRESHOLD;
|
||||
const n = Number(localStorage.getItem(THRESHOLD_KEY));
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRESHOLD;
|
||||
// 0 is a real choice on the slider (exact colour only), so keep it.
|
||||
const n = parseDecimal(localStorage.getItem(THRESHOLD_KEY));
|
||||
return n !== null && n >= 0 ? n : DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
let query = $state('');
|
||||
@@ -100,14 +104,30 @@
|
||||
});
|
||||
|
||||
interface FlatItem {
|
||||
kind: EntityKind;
|
||||
/** 'browse' rows are plain links to a catalog list, not entities. */
|
||||
kind: EntityKind | 'browse';
|
||||
id: string;
|
||||
href?: string;
|
||||
/** Unique per row. A spool can be both its own result and a pill under a
|
||||
* filament, so kind+id alone would highlight two rows at once. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
let flat = $derived<FlatItem[]>(
|
||||
const browseFilaments = {
|
||||
kind: 'browse',
|
||||
id: 'filaments',
|
||||
href: catalogHref('filaments'),
|
||||
key: 'browse:filaments'
|
||||
} as const;
|
||||
const browseManufacturers = {
|
||||
kind: 'browse',
|
||||
id: 'manufacturers',
|
||||
href: catalogHref('manufacturers'),
|
||||
key: 'browse:manufacturers'
|
||||
} as const;
|
||||
let emptyQuery = $derived(query.trim().length === 0);
|
||||
|
||||
let resultItems = $derived<FlatItem[]>(
|
||||
results
|
||||
? [
|
||||
...results.spools.map((m) => ({
|
||||
@@ -134,8 +154,18 @@
|
||||
: []
|
||||
);
|
||||
|
||||
let hasResults = $derived(flat.length > 0);
|
||||
let showPanel = $derived(open && query.trim().length > 0);
|
||||
let hasResults = $derived(resultItems.length > 0);
|
||||
// Browse links sit last so they never displace the first result under Enter,
|
||||
// and are reachable by the same arrow keys.
|
||||
let flat = $derived<FlatItem[]>(
|
||||
emptyQuery
|
||||
? [browseFilaments, browseManufacturers]
|
||||
: hasResults
|
||||
? [...resultItems, browseFilaments, browseManufacturers]
|
||||
: []
|
||||
);
|
||||
// Focused-but-empty shows the hint and the browse links instead of nothing.
|
||||
let showPanel = $derived(open);
|
||||
|
||||
// Each result is a real `<a href>` (see searchResultHref) so it opens in a new
|
||||
// tab / copies its address like any link. Close and clear the panel afterwards.
|
||||
@@ -158,6 +188,13 @@
|
||||
dismiss();
|
||||
}
|
||||
|
||||
// Arrowing past the panel's fold must bring the row along; the browse links
|
||||
// sit last, so without this they'd be picked while out of sight.
|
||||
$effect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
wrapper?.querySelector('.panel .active')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
open = false;
|
||||
@@ -174,7 +211,10 @@
|
||||
if (activeIndex >= 0 && activeIndex < flat.length) {
|
||||
e.preventDefault();
|
||||
const item = flat[activeIndex];
|
||||
chooseByKey(item.kind, item.id);
|
||||
if (item.kind === 'browse') {
|
||||
goto(item.href!);
|
||||
dismiss();
|
||||
} else chooseByKey(item.kind, item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +288,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && !results}
|
||||
{#if emptyQuery}
|
||||
<p class="empty-hint">{m['search.hint']()}</p>
|
||||
<div class="section-label">{m['library.viewMenu.browse']()}</div>
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf(browseFilaments.key)}
|
||||
href={browseFilaments.href}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><List size={14} /></span>
|
||||
<span class="text"><span class="title">{m['library.catalog.allFilaments']()}</span></span>
|
||||
</a>
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf(browseManufacturers.key)}
|
||||
href={browseManufacturers.href}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><Factory size={14} /></span>
|
||||
<span class="text"><span class="title">{m['library.catalog.allManufacturers']()}</span></span>
|
||||
</a>
|
||||
{:else if loading && !results}
|
||||
<div class="msg">{m['search.searching']()}</div>
|
||||
{:else if errored}
|
||||
<div class="msg">{m['library.apiError']()}</div>
|
||||
@@ -377,6 +440,21 @@
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
{m['search.browseAll']()}
|
||||
<a
|
||||
href={browseFilaments.href}
|
||||
class:active={activeIndex === indexOf(browseFilaments.key)}
|
||||
onclick={onResultClick}>{m['library.catalog.unitFilaments']()}</a
|
||||
>
|
||||
·
|
||||
<a
|
||||
href={browseManufacturers.href}
|
||||
class:active={activeIndex === indexOf(browseManufacturers.key)}
|
||||
onclick={onResultClick}>{m['library.catalog.unitManufacturers']()}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -442,6 +520,31 @@
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot {
|
||||
margin-top: 4px;
|
||||
padding: 8px 12px 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
padding: 2px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.foot a:hover,
|
||||
.foot a.active {
|
||||
text-decoration: underline;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.msg {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
price: 0,
|
||||
comment: '',
|
||||
registeredLabel: '',
|
||||
tags: [],
|
||||
extra: {}
|
||||
};
|
||||
|
||||
@@ -123,7 +124,7 @@
|
||||
let serverLocations = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
spoolSource
|
||||
.locations()
|
||||
.locationChoices()
|
||||
.then((l) => (serverLocations = l))
|
||||
.catch(() => {});
|
||||
});
|
||||
@@ -515,7 +516,7 @@
|
||||
|
||||
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} manage />
|
||||
|
||||
<TagsSection {spool} />
|
||||
<TagsSection kind="spool" id={spool.id} tags={spool.tags} />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
indent?: number;
|
||||
/** Listing context — drops whatever the enclosing group already implies. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
let identity = $derived(rowIdentity(vm, context));
|
||||
let identity = $derived(rowIdentity(vm, context, showLotNumbers));
|
||||
let selected = $derived(params.isSelected(page.url.searchParams, 'spool', String(vm.spool.id)));
|
||||
</script>
|
||||
|
||||
@@ -112,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 {
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
indent?: number;
|
||||
/** Listing context, forwarded to the expanded per-instance rows. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
// Unused spools (never drawn from — used_weight 0) of one filament are
|
||||
// collapsed into a single summary row. Clicking can't know *which* instance
|
||||
@@ -109,7 +110,7 @@
|
||||
{#each unused as vm (vm.spool.id)}
|
||||
<!-- Divider-mode instances align with the sibling used rows (same indent)
|
||||
so the divider reads as the header for the section beneath it. -->
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} />
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} {showLotNumbers} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import ConfirmDialog from '../ConfirmDialog.svelte';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import EditableField from '../EditableField.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
@@ -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,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' });
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,8 @@ export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraf
|
||||
multiColorDirection: undefined,
|
||||
density: String(f.density),
|
||||
diameter: String(f.diameter),
|
||||
nozzleTemp: f.nozzleTemp ? String(f.nozzleTemp) : '',
|
||||
bedTemp: f.bedTemp ? String(f.bedTemp) : '',
|
||||
nozzleTemp: f.nozzleTemp != null ? String(f.nozzleTemp) : '',
|
||||
bedTemp: f.bedTemp != null ? String(f.bedTemp) : '',
|
||||
articleNumber: '',
|
||||
comment: f.comment
|
||||
};
|
||||
@@ -91,8 +91,8 @@ export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraf
|
||||
export function filamentWeightsFrom(f: Filament): FilamentWeights {
|
||||
return {
|
||||
weight: String(f.weight || 1000),
|
||||
spoolWeight: f.spoolWeight ? String(f.spoolWeight) : '',
|
||||
price: f.price ? String(f.price) : ''
|
||||
spoolWeight: f.spoolWeight != null ? String(f.spoolWeight) : '',
|
||||
price: f.price != null ? String(f.price) : ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,15 +178,6 @@ function num(v: string): number | undefined {
|
||||
return parseDecimal(v) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same, but treating zero as "not given". The weight/price trio is optional on a
|
||||
* filament and a recorded zero says nothing a missing value doesn't, so a blank
|
||||
* and a typed 0 are sent identically rather than pinning the record to 0.
|
||||
*/
|
||||
function numPos(v: string): number | undefined {
|
||||
return num(v) || undefined;
|
||||
}
|
||||
|
||||
/** Turn a validated draft into the body {@link spoolSource.createFilament} expects. */
|
||||
export function toNewFilamentDraft(
|
||||
draft: FilamentDraft,
|
||||
@@ -200,14 +191,14 @@ export function toNewFilamentDraft(
|
||||
// Both are required and > 0 by the time this runs, so the fallbacks below are
|
||||
// only there to keep the types honest.
|
||||
density: num(draft.density) ?? 0,
|
||||
diameter: numPos(draft.diameter) ?? Number(DEFAULT_DIAMETER),
|
||||
weight: numPos(weights.weight),
|
||||
spoolWeight: numPos(weights.spoolWeight),
|
||||
diameter: num(draft.diameter) ?? Number(DEFAULT_DIAMETER),
|
||||
weight: num(weights.weight) || undefined,
|
||||
spoolWeight: num(weights.spoolWeight),
|
||||
colors: draft.colors,
|
||||
multiColorDirection: draft.multiColorDirection,
|
||||
nozzleTemp: numPos(draft.nozzleTemp),
|
||||
bedTemp: numPos(draft.bedTemp),
|
||||
price: numPos(weights.price),
|
||||
nozzleTemp: num(draft.nozzleTemp),
|
||||
bedTemp: num(draft.bedTemp),
|
||||
price: num(weights.price),
|
||||
articleNumber: draft.articleNumber.trim() || undefined,
|
||||
comment: draft.comment.trim() || undefined,
|
||||
extra
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ import { labelKind, type LabelDesign, type PrintLayout } from './types';
|
||||
import type { LabelBinding } from './template';
|
||||
import { elementToShape, qrLogoBox } from './render';
|
||||
import { getLogoImage } from './logo';
|
||||
import { paperSize, sheetGrid, pxPerMmForDpi } from './paper';
|
||||
import { layoutMargin, paperSize, sheetGrid, pxPerMmForDpi } from './paper';
|
||||
import { resolveExportFormat, type ExportedFile, type RenderedLabel } from './export';
|
||||
|
||||
// Renders a design to high-DPI raster images and lays them out for printing.
|
||||
@@ -141,8 +141,10 @@ export async function printLabels({ design, bindings, layout, baseUrl }: PrintJo
|
||||
style.textContent = pageCss(`${design.label.w}mm ${design.label.h}mm`);
|
||||
} else {
|
||||
buildSheetPages(root, items, design, layout);
|
||||
const page = paperSize(layout);
|
||||
style.textContent = pageCss(`${page.w}mm ${page.h}mm`);
|
||||
// `size: auto`, as v1 did. A fixed size makes the browser pick the orientation
|
||||
// and (in Chromium) hide the picker, and label printer drivers disagree on which
|
||||
// way round a roll is, so any fixed choice prints rotated for someone (#1149).
|
||||
style.textContent = pageCss('auto');
|
||||
}
|
||||
|
||||
document.body.appendChild(style);
|
||||
@@ -259,7 +261,14 @@ function labelImg(url: string, w: number, h: number, inset?: Inset): HTMLImageEl
|
||||
const l = inset?.l ?? 0;
|
||||
// Shrink and offset the raster to keep the whole label inside the printable
|
||||
// area of an edge cell; interior cells get no inset and render at full size.
|
||||
img.style.cssText = `width:${w - l - r}mm;height:${h - t - b}mm;margin:${t}mm ${r}mm ${b}mm ${l}mm;display:block;`;
|
||||
// The raster has a fixed aspect ratio, so the inset box gets `object-fit:
|
||||
// contain` — sizing width and height independently would squash the label
|
||||
// (a 1.5mm top/bottom safe-zone on a 34mm label is a 9% vertical squeeze).
|
||||
// Clamped so a box that an over-large inset drove negative just disappears
|
||||
// instead of falling back to the intrinsic size.
|
||||
const boxW = Math.max(0, w - l - r);
|
||||
const boxH = Math.max(0, h - t - b);
|
||||
img.style.cssText = `width:${boxW}mm;height:${boxH}mm;object-fit:contain;margin:${t}mm ${r}mm ${b}mm ${l}mm;display:block;`;
|
||||
return img;
|
||||
}
|
||||
|
||||
@@ -282,6 +291,7 @@ function buildSheetPages(
|
||||
) {
|
||||
const grid = sheetGrid(layout, design.label);
|
||||
const page = paperSize(layout);
|
||||
const margin = layoutMargin(layout);
|
||||
const { w: lw, h: lh } = design.label;
|
||||
|
||||
// The label size is fixed by the design, so we place those fixed-size labels on
|
||||
@@ -309,13 +319,13 @@ function buildSheetPages(
|
||||
if (!url) continue;
|
||||
const col = i % grid.cols;
|
||||
const row = Math.floor(i / grid.cols);
|
||||
const x = layout.margin.l + col * (lw + layout.spacing.h);
|
||||
const y = layout.margin.t + row * (lh + layout.spacing.v);
|
||||
const x = margin.l + col * (lw + layout.spacing.h);
|
||||
const y = margin.t + row * (lh + layout.spacing.v);
|
||||
const inset: Inset = {
|
||||
l: col === 0 ? overSafe(layout.safe.l, layout.margin.l) : 0,
|
||||
r: col === lastCol ? overSafe(layout.safe.r, layout.margin.r) : 0,
|
||||
t: row === 0 ? overSafe(layout.safe.t, layout.margin.t) : 0,
|
||||
b: row === lastRow ? overSafe(layout.safe.b, layout.margin.b) : 0
|
||||
l: col === 0 ? overSafe(layout.safe.l, margin.l) : 0,
|
||||
r: col === lastCol ? overSafe(layout.safe.r, margin.r) : 0,
|
||||
t: row === 0 ? overSafe(layout.safe.t, margin.t) : 0,
|
||||
b: row === lastRow ? overSafe(layout.safe.b, margin.b) : 0
|
||||
};
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText =
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { qrContent } from './qr';
|
||||
import type { QrElement } from './types';
|
||||
import type { LabelBinding } from './template';
|
||||
|
||||
const qr = (urlTemplate: string): QrElement => ({
|
||||
id: 'q',
|
||||
type: 'qr',
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 10,
|
||||
ec: 'H',
|
||||
encoding: 'custom',
|
||||
urlTemplate,
|
||||
logo: false
|
||||
});
|
||||
|
||||
describe('qrContent custom template', () => {
|
||||
const binding = { spool: { id: 7, lot: 'L1' }, filament: { id: 42 } } as unknown as LabelBinding;
|
||||
|
||||
it('resolves label fields so a spool label can link to its filament (#1152)', () => {
|
||||
const el = qr('http://host/filament/show/{filament.id}?spool={id}&lot={spool.lot}');
|
||||
expect(qrContent(el, 7, { baseUrl: '' }, binding)).toBe('http://host/filament/show/42?spool=7&lot=L1');
|
||||
});
|
||||
|
||||
it('still substitutes {id} without a binding', () => {
|
||||
expect(qrContent(qr('x/{id}'), 7, { baseUrl: '' })).toBe('x/7');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import QRCode from 'qrcode';
|
||||
import type { QrElement, LabelKind } from './types';
|
||||
import { resolveTemplate, type LabelBinding } from './template';
|
||||
|
||||
// QR content + geometry. The QR encodes a link back to the label's subject — a
|
||||
// spool for spool labels, a filament for filament labels. The first two forms are
|
||||
// understood by Spoolman's scanner ($lib/utils/spoolCode.ts):
|
||||
// scheme → WEB+SPOOLMAN:S-<id> / F-<id> (compact custom URI)
|
||||
// url → <base_url>/spool/show/<id> or /filament/show/<id> (opens in a browser)
|
||||
// custom → the element's urlTemplate (user-supplied, {id} substituted)
|
||||
// custom → the element's urlTemplate (user-supplied, {id} and
|
||||
// label fields like {filament.id} substituted)
|
||||
// A custom target is meant for a third-party app or host and won't scan back
|
||||
// into Spoolman.
|
||||
|
||||
@@ -17,8 +19,16 @@ export interface QrContext {
|
||||
}
|
||||
|
||||
/** Build the string encoded in a QR element for a given subject id. */
|
||||
export function qrContent(el: QrElement, id: number | string, ctx: QrContext): string {
|
||||
return qrTemplate(el, ctx).replace('{id}', String(id));
|
||||
export function qrContent(
|
||||
el: QrElement,
|
||||
id: number | string,
|
||||
ctx: QrContext,
|
||||
binding?: LabelBinding
|
||||
): string {
|
||||
const content = qrTemplate(el, ctx).replace('{id}', String(id));
|
||||
// A custom target can also reference label fields, e.g. a spool label whose QR
|
||||
// opens the filament page via {filament.id} (#1152).
|
||||
return el.encoding === 'custom' && binding ? resolveTemplate(content, binding) : content;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,7 +91,7 @@ export function elementToShape(el: LabelElement, ctx: RenderContext): ShapeSpec
|
||||
// encoding — a long URL/custom target is denser than the compact scheme.
|
||||
const content =
|
||||
subjectId !== undefined
|
||||
? qrContent(el, subjectId, qrCtx)
|
||||
? qrContent(el, subjectId, qrCtx, ctx.binding)
|
||||
: qrTemplate(el, qrCtx).replace('{id}', '0');
|
||||
return {
|
||||
kind: 'qr',
|
||||
|
||||
@@ -43,8 +43,9 @@ export interface QrElement extends BaseElement {
|
||||
*/
|
||||
encoding: 'scheme' | 'url' | 'custom';
|
||||
/**
|
||||
* Template used when `encoding === 'custom'`. Must contain an `{id}`
|
||||
* placeholder, which is replaced with the spool id. Ignored otherwise.
|
||||
* Template used when `encoding === 'custom'`. `{id}` is replaced with the
|
||||
* label's subject id, and label fields like `{filament.id}` are resolved too.
|
||||
* Ignored otherwise.
|
||||
*/
|
||||
urlTemplate?: string;
|
||||
/** Draw the Spoolman logo in the centre of the code. */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { catalogSections, MIN_PIP_FILL, spoolPips } from './catalog';
|
||||
import type { GroupSummary } from '$lib/api/types';
|
||||
|
||||
function filament(key: string, vendorId?: string): GroupSummary {
|
||||
return {
|
||||
field: 'filament',
|
||||
key,
|
||||
title: `F${key}`,
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
vendorId,
|
||||
vendorName: vendorId ? `V${vendorId}` : undefined,
|
||||
colors: [],
|
||||
spoolCount: 0,
|
||||
inUseCount: 0,
|
||||
unusedCount: 0,
|
||||
totalRemaining: 0,
|
||||
hasStock: false,
|
||||
lastUsedLabel: '',
|
||||
lastUsedSort: 0
|
||||
};
|
||||
}
|
||||
|
||||
describe('catalogSections', () => {
|
||||
it('groups consecutive rows of one manufacturer, keeping the server order', () => {
|
||||
const page = [filament('1', '7'), filament('2', '7'), filament('3', '9'), filament('4')];
|
||||
const sections = catalogSections(page);
|
||||
expect(sections.map((s) => [s.vendorId, s.vendorName, s.rows.map((r) => r.key)])).toEqual([
|
||||
['7', 'V7', ['1', '2']],
|
||||
['9', 'V9', ['3']],
|
||||
['', undefined, ['4']]
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no sections for an empty page', () => {
|
||||
expect(catalogSections([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spoolPips', () => {
|
||||
it('fills by remaining over initial and flags low stock at the threshold', () => {
|
||||
const pips = spoolPips(
|
||||
[
|
||||
{ id: '1', remaining: 500, initial: 1000 },
|
||||
{ id: '2', remaining: 150, initial: 1000 },
|
||||
{ id: '3', remaining: 1000, initial: 1000 }
|
||||
],
|
||||
150
|
||||
);
|
||||
expect(pips).toEqual([
|
||||
{ fill: 0.5, low: false },
|
||||
{ fill: 0.15, low: true },
|
||||
{ fill: 1, low: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a sliver visible on an empty spool and caps an overfull one', () => {
|
||||
const [empty, over] = spoolPips(
|
||||
[
|
||||
{ id: '1', remaining: 0, initial: 1000 },
|
||||
{ id: '2', remaining: 1200, initial: 1000 }
|
||||
],
|
||||
150
|
||||
);
|
||||
expect(empty.fill).toBe(MIN_PIP_FILL);
|
||||
expect(over.fill).toBe(1);
|
||||
});
|
||||
|
||||
it('marks the fill unknown when the spool size is', () => {
|
||||
const pips = spoolPips(
|
||||
[
|
||||
{ id: '1', remaining: 80, initial: null },
|
||||
{ id: '2', remaining: null, initial: null },
|
||||
{ id: '3', remaining: 400, initial: 0 }
|
||||
],
|
||||
150
|
||||
);
|
||||
expect(pips).toEqual([
|
||||
{ fill: null, low: true },
|
||||
{ fill: null, low: false },
|
||||
{ fill: null, low: false }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { GroupSummary, SpoolPreview } from '$lib/api/types';
|
||||
|
||||
// Pure helpers behind the catalog sub-views' rows: how a page of filament groups
|
||||
// splits into manufacturer sections, and how a filament's preview spools draw as
|
||||
// fill pips.
|
||||
|
||||
/** A run of filaments sharing one manufacturer, under one section header. */
|
||||
export interface CatalogSection {
|
||||
/** Empty for the filaments with no manufacturer set. */
|
||||
vendorId: string;
|
||||
vendorName?: string;
|
||||
rows: GroupSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a page of filament groups into manufacturer sections.
|
||||
*
|
||||
* The page arrives sorted by manufacturer first (group.vendor_name), so a
|
||||
* section is simply a run of consecutive rows: no regrouping, and the server's
|
||||
* order within a section is kept. A section cut by a page boundary starts the
|
||||
* next page under a repeat of its header, which is what tells you whose
|
||||
* filaments you are still looking at.
|
||||
*/
|
||||
export function catalogSections(groups: GroupSummary[]): CatalogSection[] {
|
||||
const sections: CatalogSection[] = [];
|
||||
for (const g of groups) {
|
||||
const vendorId = g.vendorId ?? '';
|
||||
const last = sections.at(-1);
|
||||
if (last && last.vendorId === vendorId) last.rows.push(g);
|
||||
else sections.push({ vendorId, vendorName: g.vendorName, rows: [g] });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export interface Pip {
|
||||
/** Share of the spool left, 0–1; null when the spool's size isn't known. */
|
||||
fill: number | null;
|
||||
low: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pip this nearly empty still shows a sliver, so a spool that is all but
|
||||
* gone reads as "almost out" rather than as a blank slot.
|
||||
*/
|
||||
export const MIN_PIP_FILL = 0.06;
|
||||
|
||||
/** One pip per preview spool, filled by remaining ÷ initial. */
|
||||
export function spoolPips(spools: SpoolPreview[], lowThreshold: number): Pip[] {
|
||||
return spools.map(({ remaining, initial }) => ({
|
||||
fill:
|
||||
remaining == null || !initial || initial <= 0
|
||||
? null
|
||||
: Math.max(MIN_PIP_FILL, Math.min(1, remaining / initial)),
|
||||
// At or under the threshold, the same line settings.isLow draws everywhere else.
|
||||
low: remaining != null && remaining <= lowThreshold
|
||||
}));
|
||||
}
|
||||
@@ -131,3 +131,126 @@ describe('parseLibraryState', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The filament and manufacturer lists are sub-views of the Library, picked by
|
||||
// `view`. They are entered fresh every time (the Library tab always lands on
|
||||
// spools), so the spool view's remembered layout must never leak into them, and
|
||||
// each has its own defaults that the URL omits.
|
||||
describe('catalog views', () => {
|
||||
async function load() {
|
||||
vi.resetModules();
|
||||
stub(GROUPED_BY_LOCATION);
|
||||
return import('./params');
|
||||
}
|
||||
|
||||
it('defaults to manufacturer sections, alphabetical', async () => {
|
||||
const { parseLibraryState } = await load();
|
||||
expect(parseLibraryState(new URLSearchParams('view=filaments'))).toMatchObject({
|
||||
view: 'filaments',
|
||||
group: 'vendor',
|
||||
sortKey: 'name',
|
||||
sortAsc: true
|
||||
});
|
||||
expect(parseLibraryState(new URLSearchParams('view=manufacturers'))).toMatchObject({
|
||||
view: 'manufacturers',
|
||||
group: 'none',
|
||||
sortKey: 'name',
|
||||
sortAsc: true
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to spools for an unknown view', async () => {
|
||||
const { parseLibraryState } = await load();
|
||||
expect(parseLibraryState(new URLSearchParams('view=colours')).view).toBe('spools');
|
||||
});
|
||||
|
||||
it('only takes the groupings and sorts the view offers', async () => {
|
||||
const { parseLibraryState } = await load();
|
||||
expect(
|
||||
parseLibraryState(new URLSearchParams('view=filaments&group=none&sort=last_used&dir=desc'))
|
||||
).toMatchObject({ group: 'none', sortKey: 'last_used', sortAsc: false });
|
||||
expect(parseLibraryState(new URLSearchParams('view=filaments&sort=filament.extra.shelf'))).toMatchObject({
|
||||
sortKey: 'filament.extra.shelf'
|
||||
});
|
||||
// A spool-only grouping or sort is not an option here, a filament's custom
|
||||
// field is not one in the manufacturer list, and that list has nothing to
|
||||
// group by at all.
|
||||
expect(parseLibraryState(new URLSearchParams('view=filaments&group=location&sort=price'))).toMatchObject({
|
||||
group: 'vendor',
|
||||
sortKey: 'name'
|
||||
});
|
||||
expect(
|
||||
parseLibraryState(new URLSearchParams('view=manufacturers&group=vendor&sort=filament.extra.shelf'))
|
||||
).toMatchObject({ group: 'none', sortKey: 'name' });
|
||||
});
|
||||
|
||||
it('keeps filters on the spools themselves', async () => {
|
||||
const { parseLibraryState } = await load();
|
||||
const state = parseLibraryState(
|
||||
new URLSearchParams('view=filaments&f=material%3APLA&f=location%3AShelf&empty=1')
|
||||
);
|
||||
expect(state.filters).toEqual([
|
||||
{ prop: 'material', value: 'PLA' },
|
||||
{ prop: 'location', value: 'Shelf' }
|
||||
]);
|
||||
// Filaments with no spools are always listed here, so there's no toggle to carry.
|
||||
expect(state.showEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it('is left alone by the remembered spool view', async () => {
|
||||
expect(await hrefFor(GROUPED_BY_LOCATION, '?view=filaments')).toBeNull();
|
||||
expect(await hrefFor(GROUPED_BY_LOCATION, '?view=manufacturers&sel=vendor:3')).toBeNull();
|
||||
});
|
||||
|
||||
it('links to a fresh catalog, omitting its own defaults', async () => {
|
||||
const { catalogHref } = await load();
|
||||
expect(catalogHref('filaments')).toBe('/?view=filaments');
|
||||
expect(catalogHref('filaments', [{ prop: 'vendor', value: 'Prusa:ment' }])).toBe(
|
||||
'/?view=filaments&f=vendor%3APrusa%253Ament'
|
||||
);
|
||||
expect(catalogHref('manufacturers')).toBe('/?view=manufacturers');
|
||||
});
|
||||
|
||||
it('keeps the view when a row is selected', async () => {
|
||||
const { parseLibraryState, selectHrefFromState } = await load();
|
||||
const state = parseLibraryState(new URLSearchParams('view=filaments&group=none'));
|
||||
expect(selectHrefFromState(state, 'filament', '7')).toBe('?view=filaments&group=none&sel=filament%3A7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lot number visibility', () => {
|
||||
it('parses off by default, opt-in on spools, and off in catalogs', async () => {
|
||||
const { parseLibraryState, selectHrefFromState } = await import('./params');
|
||||
expect(parseLibraryState(new URLSearchParams()).showLotNumbers).toBe(false);
|
||||
const state = parseLibraryState(new URLSearchParams('lots=1'));
|
||||
expect(state.showLotNumbers).toBe(true);
|
||||
expect(selectHrefFromState(state, 'spool', '7')).toBe('?lots=1&sel=spool%3A7');
|
||||
for (const view of ['filaments', 'manufacturers']) {
|
||||
expect(parseLibraryState(new URLSearchParams(`view=${view}&lots=1`)).showLotNumbers).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the preference and lets an explicit URL win', async () => {
|
||||
const stored = '{"group":"filament","sort":"last_used","asc":false,"lots":true}';
|
||||
expect(await hrefFor(stored, '')).toBe('/?lots=1');
|
||||
expect(await hrefFor(stored, '?lots=0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('page size', () => {
|
||||
const DEFAULT_VIEW = '{"group":"filament","sort":"last_used","asc":false';
|
||||
|
||||
it('restores a stored page size on a bare URL', async () => {
|
||||
expect(await hrefFor(`${DEFAULT_VIEW},"size":100}`, '')).toBe('/?size=100');
|
||||
});
|
||||
|
||||
it('lets an explicit URL page size win', async () => {
|
||||
expect(await hrefFor(`${DEFAULT_VIEW},"size":100}`, '?size=50')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the default for an old stored view or an invalid size', async () => {
|
||||
expect(await hrefFor(`${DEFAULT_VIEW}}`, '')).toBeNull();
|
||||
expect(await hrefFor(`${DEFAULT_VIEW},"size":"100"}`, '')).toBeNull();
|
||||
expect(await hrefFor(`${DEFAULT_VIEW},"size":-5}`, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { EntityKind, Selection } from '$lib/types';
|
||||
import { isGroupOrderable, defaultSortAsc } from '$lib/utils/library';
|
||||
import { isGroupOrderable, defaultSortAsc, catalogSortAsc, catalogSortField } from '$lib/utils/library';
|
||||
import { isDateFilterProp, parseDateFilter } from './dateFilter';
|
||||
import { rememberedView, rememberView } from './viewPrefs';
|
||||
|
||||
@@ -22,14 +22,27 @@ import { rememberedView, rememberView } from './viewPrefs';
|
||||
|
||||
export type GroupMode = 'filament' | 'vendor' | 'material' | 'location' | 'none';
|
||||
|
||||
/**
|
||||
* Which list the Library shows. Spools is the Library proper; the other two are
|
||||
* the power-user catalog sub-views, reached from the Library tab's menu, the
|
||||
* search panel and the manufacturer inspector. The view is never remembered:
|
||||
* opening the Library always lands on spools.
|
||||
*/
|
||||
export type LibraryView = 'spools' | 'filaments' | 'manufacturers';
|
||||
export type CatalogView = Exclude<LibraryView, 'spools'>;
|
||||
|
||||
export interface FilterChip {
|
||||
prop: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LibraryState {
|
||||
view: LibraryView;
|
||||
selection: Selection | null;
|
||||
/** In the filament view only `vendor` (manufacturer sections) or `none`; the
|
||||
* manufacturer view is always `none`. */
|
||||
group: GroupMode;
|
||||
/** In a catalog view, a key of utils/library's catalogSortDefs. */
|
||||
sortKey: string;
|
||||
sortAsc: boolean;
|
||||
filters: FilterChip[];
|
||||
@@ -37,18 +50,21 @@ export interface LibraryState {
|
||||
showArchived: boolean;
|
||||
/** Also list filaments you hold no spools of, when grouping by filament. */
|
||||
showEmpty: boolean;
|
||||
/** Append lot numbers to spool row secondary text. */
|
||||
showLotNumbers: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const GROUP_MODES: GroupMode[] = ['filament', 'vendor', 'material', 'location', 'none'];
|
||||
const ENTITY_KINDS: EntityKind[] = ['spool', 'filament', 'vendor'];
|
||||
const VIEWS: LibraryView[] = ['spools', 'filaments', 'manufacturers'];
|
||||
|
||||
/** The params that spell out how the list is laid out, as opposed to what it
|
||||
* holds. A URL naming none of them is the one that defers to the remembered
|
||||
* view; naming any of them describes a view of its own, which is taken whole so
|
||||
* a stored grouping never gets spliced onto a link's sort. */
|
||||
const VIEW_PARAMS = ['group', 'sort', 'dir', 'empty'];
|
||||
const VIEW_PARAMS = ['view', 'group', 'sort', 'dir', 'empty', 'lots', 'size'];
|
||||
|
||||
const DEFAULTS = {
|
||||
group: 'filament' as GroupMode,
|
||||
@@ -56,10 +72,38 @@ const DEFAULTS = {
|
||||
sortAsc: false,
|
||||
showArchived: false,
|
||||
showEmpty: false,
|
||||
showLotNumbers: false,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
type Layout = Pick<LibraryState, 'group' | 'sortKey' | 'sortAsc'>;
|
||||
|
||||
/** Each view's default layout: what an untouched URL for it means, and so what
|
||||
* serialisation omits. The catalogs read alphabetically, with sections. */
|
||||
const LAYOUT_DEFAULTS: Record<LibraryView, Layout> = {
|
||||
spools: { group: DEFAULTS.group, sortKey: DEFAULTS.sortKey, sortAsc: DEFAULTS.sortAsc },
|
||||
filaments: { group: 'vendor', sortKey: 'name', sortAsc: true },
|
||||
manufacturers: { group: 'none', sortKey: 'name', sortAsc: true }
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter props that describe a SPOOL rather than the filament it is of.
|
||||
*
|
||||
* Everything not listed here — filament, material, vendor, direction, and the
|
||||
* `filament.extra.` / `filament.vendor.extra.` fields — is a fact about the
|
||||
* filament, which a filament with no spools still has. The API refuses the
|
||||
* spool-scoped ones together with include_empty, so the lists that ask for empty
|
||||
* filaments stop asking while one is active (see query.ts).
|
||||
*/
|
||||
export function isSpoolScopedFilter(prop: string): boolean {
|
||||
if (prop === 'location' || prop === 'lot') return true;
|
||||
if (isDateFilterProp(prop)) return true;
|
||||
// `extra.<key>` is the spool's own custom field; the filament's and the
|
||||
// vendor's are prefixed, so a bare `extra.` is the spool-scoped one.
|
||||
return prop.startsWith('extra.');
|
||||
}
|
||||
|
||||
function parseFilters(raw: string[]): FilterChip[] {
|
||||
return (
|
||||
raw
|
||||
@@ -92,6 +136,9 @@ function parsePositiveInt(value: string | null, fallback: number): number {
|
||||
* would be served stale after that preference changed (see rememberedViewHref).
|
||||
*/
|
||||
export function parseLibraryState(params: URLSearchParams): LibraryState {
|
||||
const rawView = params.get('view') as LibraryView | null;
|
||||
const view = rawView && VIEWS.includes(rawView) ? rawView : 'spools';
|
||||
const layout = LAYOUT_DEFAULTS[view];
|
||||
const group = params.get('group') as GroupMode | null;
|
||||
|
||||
const sel = params.get('sel');
|
||||
@@ -99,26 +146,51 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
|
||||
const kind = si > 0 ? (sel!.slice(0, si) as EntityKind) : null;
|
||||
const selection = kind && ENTITY_KINDS.includes(kind) ? { kind, id: sel!.slice(si + 1) } : null;
|
||||
|
||||
const dir = params.get('dir');
|
||||
const sortAsc = dir === null ? layout.sortAsc : dir === 'asc';
|
||||
const common = {
|
||||
view,
|
||||
selection,
|
||||
sortAsc,
|
||||
filters: parseFilters(params.getAll('f')),
|
||||
showArchived: params.get('arch') === '1',
|
||||
showEmpty: params.get('empty') === '1',
|
||||
showLotNumbers: params.get('lots') === '1',
|
||||
page: parsePositiveInt(params.get('page'), DEFAULTS.page),
|
||||
pageSize: parsePositiveInt(params.get('size'), DEFAULTS.pageSize)
|
||||
};
|
||||
|
||||
if (view !== 'spools') {
|
||||
// A catalog lists the filaments (or manufacturers) with no spools as well,
|
||||
// so there is no "No spools" toggle to carry.
|
||||
const sort = params.get('sort');
|
||||
return {
|
||||
...common,
|
||||
group: view === 'filaments' && group === 'none' ? 'none' : layout.group,
|
||||
sortKey: sort && catalogSortField(view, sort) ? sort : layout.sortKey,
|
||||
showEmpty: false,
|
||||
showLotNumbers: false
|
||||
};
|
||||
}
|
||||
|
||||
const rawGroup = group && GROUP_MODES.includes(group) ? group : DEFAULTS.group;
|
||||
const sortKey = params.get('sort') ?? DEFAULTS.sortKey;
|
||||
const sortAsc = params.get('dir') === 'asc';
|
||||
// Enforce the grouped-view invariant: a group can only be ordered by a
|
||||
// group-orderable sort. A hand-crafted or stale URL pairing a grouping with a
|
||||
// per-spool sort renders flat, honouring the more specific sort intent. (Our
|
||||
// own mutators never emit such a pairing — see setSortKey/setGroup.)
|
||||
return {
|
||||
selection,
|
||||
...common,
|
||||
group: isGroupOrderable(sortKey, rawGroup) ? rawGroup : 'none',
|
||||
sortKey,
|
||||
sortAsc,
|
||||
filters: parseFilters(params.getAll('f')),
|
||||
showArchived: params.get('arch') === '1',
|
||||
showEmpty: params.get('empty') === '1',
|
||||
page: parsePositiveInt(params.get('page'), DEFAULTS.page),
|
||||
pageSize: parsePositiveInt(params.get('size'), DEFAULTS.pageSize)
|
||||
sortKey
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `state` is one of the catalog sub-views rather than the spool list. */
|
||||
export function isCatalogView(state: LibraryState): boolean {
|
||||
return state.view !== 'spools';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `kind`/`id` is the current selection, tested against a URLSearchParams
|
||||
* (pass a reactive `page.url.searchParams` so list rows re-highlight on
|
||||
@@ -131,9 +203,11 @@ export function isSelected(params: URLSearchParams, kind: EntityKind, id: string
|
||||
/** Encode Library state back to a canonical query string (no leading `?`). */
|
||||
function serializeState(s: LibraryState): string {
|
||||
const p = new URLSearchParams();
|
||||
if (s.group !== DEFAULTS.group) p.set('group', s.group);
|
||||
if (s.sortKey !== DEFAULTS.sortKey) p.set('sort', s.sortKey);
|
||||
if (s.sortAsc !== DEFAULTS.sortAsc) p.set('dir', s.sortAsc ? 'asc' : 'desc');
|
||||
const layout = LAYOUT_DEFAULTS[s.view];
|
||||
if (s.view !== 'spools') p.set('view', s.view);
|
||||
if (s.group !== layout.group) p.set('group', s.group);
|
||||
if (s.sortKey !== layout.sortKey) p.set('sort', s.sortKey);
|
||||
if (s.sortAsc !== layout.sortAsc) p.set('dir', s.sortAsc ? 'asc' : 'desc');
|
||||
// Encode prop and value separately so a `:` inside either (locations, lot
|
||||
// numbers) survives the round-trip.
|
||||
for (const f of s.filters) {
|
||||
@@ -141,6 +215,7 @@ function serializeState(s: LibraryState): string {
|
||||
}
|
||||
if (s.showArchived !== DEFAULTS.showArchived) p.set('arch', s.showArchived ? '1' : '0');
|
||||
if (s.showEmpty !== DEFAULTS.showEmpty) p.set('empty', s.showEmpty ? '1' : '0');
|
||||
if (s.showLotNumbers !== DEFAULTS.showLotNumbers) p.set('lots', s.showLotNumbers ? '1' : '0');
|
||||
if (s.page !== DEFAULTS.page) p.set('page', String(s.page));
|
||||
if (s.pageSize !== DEFAULTS.pageSize) p.set('size', String(s.pageSize));
|
||||
if (s.selection) p.set('sel', `${s.selection.kind}:${s.selection.id}`);
|
||||
@@ -154,7 +229,7 @@ function currentState(): LibraryState {
|
||||
|
||||
/**
|
||||
* Where entering the Library on `url` should actually land: the same view with
|
||||
* the remembered grouping and sort spelled out in the query string, or null when
|
||||
* the remembered grouping, sort and page size spelled out in the query string, or null when
|
||||
* the URL already describes a view (or there's nothing worth restoring). The
|
||||
* Library page navigates there on entry, replacing the history entry — see
|
||||
* routes/+page.svelte.
|
||||
@@ -182,7 +257,9 @@ export function rememberedViewHref(url: URL): string | null {
|
||||
group === DEFAULTS.group &&
|
||||
stored.sortKey === DEFAULTS.sortKey &&
|
||||
stored.sortAsc === DEFAULTS.sortAsc &&
|
||||
stored.showEmpty === DEFAULTS.showEmpty
|
||||
stored.showEmpty === DEFAULTS.showEmpty &&
|
||||
stored.showLotNumbers === DEFAULTS.showLotNumbers &&
|
||||
stored.pageSize === DEFAULTS.pageSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -195,7 +272,9 @@ export function rememberedViewHref(url: URL): string | null {
|
||||
group,
|
||||
sortKey: stored.sortKey,
|
||||
sortAsc: stored.sortAsc,
|
||||
showEmpty: stored.showEmpty
|
||||
showEmpty: stored.showEmpty,
|
||||
showLotNumbers: stored.showLotNumbers,
|
||||
pageSize: stored.pageSize
|
||||
});
|
||||
return `${url.pathname}?${qs}`;
|
||||
}
|
||||
@@ -212,13 +291,18 @@ function navigate(next: LibraryState, replace = false): void {
|
||||
// setSortKey so it always matches the view actually shown, including
|
||||
// setGroup's fallback to the default sort. Back/forward doesn't come through
|
||||
// here: stepping through history replays old views without redefining what
|
||||
// "the Library" means next time it's opened fresh.
|
||||
rememberView({
|
||||
group: next.group,
|
||||
sortKey: next.sortKey,
|
||||
sortAsc: next.sortAsc,
|
||||
showEmpty: next.showEmpty
|
||||
});
|
||||
// "the Library" means next time it's opened fresh. Only the spool view is
|
||||
// remembered; a catalog's layout means nothing to the spool list.
|
||||
if (next.view === 'spools') {
|
||||
rememberView({
|
||||
group: next.group,
|
||||
sortKey: next.sortKey,
|
||||
sortAsc: next.sortAsc,
|
||||
showEmpty: next.showEmpty,
|
||||
showLotNumbers: next.showLotNumbers,
|
||||
pageSize: next.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
const qs = serializeState(next);
|
||||
// Both targets are base-path-independent: a bare `?query` resolves against the
|
||||
@@ -235,6 +319,11 @@ function navigate(next: LibraryState, replace = false): void {
|
||||
|
||||
export function setGroup(group: GroupMode): void {
|
||||
const s = currentState();
|
||||
// Every catalog sort orders sections and rows alike, so nothing to reconcile.
|
||||
if (s.view !== 'spools') {
|
||||
navigate({ ...s, group, page: DEFAULTS.page });
|
||||
return;
|
||||
}
|
||||
// A group can only be ordered three ways; if the active sort isn't one of
|
||||
// them, fall back to the default group ordering so the new view is coherent
|
||||
// rather than silently sorting only within groups.
|
||||
@@ -247,6 +336,11 @@ export function setGroup(group: GroupMode): void {
|
||||
/** Pick a sort key; re-selecting the active key flips its direction. */
|
||||
export function setSortKey(key: string): void {
|
||||
const s = currentState();
|
||||
if (s.view !== 'spools') {
|
||||
const sortAsc = s.sortKey === key ? !s.sortAsc : catalogSortAsc(s.view, key);
|
||||
navigate({ ...s, sortKey: key, sortAsc, page: DEFAULTS.page });
|
||||
return;
|
||||
}
|
||||
const sortAsc = s.sortKey === key ? !s.sortAsc : defaultSortAsc(key);
|
||||
// A per-spool ranking can't order groups, so switch to the flat list where
|
||||
// the ranking is actually visible instead of reordering only within groups.
|
||||
@@ -290,6 +384,10 @@ export function setShowEmpty(showEmpty: boolean): void {
|
||||
navigate({ ...currentState(), showEmpty, page: DEFAULTS.page });
|
||||
}
|
||||
|
||||
export function setShowLotNumbers(showLotNumbers: boolean): void {
|
||||
navigate({ ...currentState(), showLotNumbers, page: DEFAULTS.page });
|
||||
}
|
||||
|
||||
export function setShowArchived(showArchived: boolean): void {
|
||||
navigate({ ...currentState(), showArchived, page: DEFAULTS.page });
|
||||
}
|
||||
@@ -351,6 +449,17 @@ export function libraryHref(kind: EntityKind, id: string): string {
|
||||
return `${resolve('/')}?sel=${kind}:${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute (base-aware) href to a catalog view in its default layout, optionally
|
||||
* pre-filtered (e.g. one manufacturer's filaments, from its inspector). Like
|
||||
* {@link libraryHref} it carries nothing over from the current view: a catalog
|
||||
* is entered fresh.
|
||||
*/
|
||||
export function catalogHref(view: CatalogView, filters: FilterChip[] = []): string {
|
||||
const fresh = parseLibraryState(new URLSearchParams());
|
||||
return `${resolve('/')}?${serializeState({ ...fresh, ...LAYOUT_DEFAULTS[view], view, filters })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Href for a search result. The search box lives in the layout, so a result can
|
||||
* be picked from any route: on the Library page we merge into the current view
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseStoredView } from './viewPrefs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseStoredView, rememberView } from './viewPrefs';
|
||||
|
||||
// The stored view is the only piece of Library state that doesn't come from the
|
||||
// URL (#1036), so it's also the only piece that can arrive malformed — from an
|
||||
@@ -13,7 +13,9 @@ describe('parseStoredView', () => {
|
||||
group: 'location',
|
||||
sortKey: 'name',
|
||||
sortAsc: true,
|
||||
showEmpty: false
|
||||
showEmpty: false,
|
||||
showLotNumbers: false,
|
||||
pageSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +24,9 @@ describe('parseStoredView', () => {
|
||||
group: 'filament',
|
||||
sortKey: 'name',
|
||||
sortAsc: true,
|
||||
showEmpty: true
|
||||
showEmpty: true,
|
||||
showLotNumbers: false,
|
||||
pageSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +58,48 @@ describe('parseStoredView', () => {
|
||||
group: 'colour',
|
||||
sortKey: 'name',
|
||||
sortAsc: false,
|
||||
showEmpty: false
|
||||
showEmpty: false,
|
||||
showLotNumbers: false,
|
||||
pageSize: 20
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('round-trips the lot number preference through storage', () => {
|
||||
let stored: string | null = null;
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => stored,
|
||||
setItem: (_key: string, value: string) => {
|
||||
stored = value;
|
||||
}
|
||||
});
|
||||
const view = {
|
||||
group: 'filament',
|
||||
sortKey: 'name',
|
||||
sortAsc: true,
|
||||
showEmpty: false,
|
||||
showLotNumbers: true,
|
||||
pageSize: 20
|
||||
};
|
||||
rememberView(view);
|
||||
expect(parseStoredView(stored)).toEqual(view);
|
||||
rememberView({ ...view, showLotNumbers: false });
|
||||
expect(parseStoredView(stored)?.showLotNumbers).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults lot numbers off for an old stored view with empty enabled', () => {
|
||||
expect(parseStoredView('{"group":"filament","sort":"name","asc":true,"empty":true}')?.showLotNumbers).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips the page size and defaults it for old or invalid entries', () => {
|
||||
const old = '{"group":"filament","sort":"name","asc":true';
|
||||
expect(parseStoredView(`${old},"size":100}`)?.pageSize).toBe(100);
|
||||
expect(parseStoredView(`${old}}`)?.pageSize).toBe(20);
|
||||
expect(parseStoredView(`${old},"size":"100"}`)?.pageSize).toBe(20);
|
||||
expect(parseStoredView(`${old},"size":0}`)?.pageSize).toBe(20);
|
||||
expect(parseStoredView(`${old},"size":2.5}`)?.pageSize).toBe(20);
|
||||
});
|
||||
@@ -16,12 +16,15 @@
|
||||
// cache, one URL per distinct view — depends on the address bar being the whole
|
||||
// truth about what's on screen.
|
||||
//
|
||||
// Only grouping and sort are remembered, plus the one toggle that ADDS rows
|
||||
// (showEmpty). Filters, search and archived-visibility hide spools, and a hidden
|
||||
// filter silently restored days later reads as missing data rather than as a
|
||||
// preference. Showing filaments you own no spools of cannot produce that
|
||||
// confusion -- restoring it can only put more on screen, never less -- so it is
|
||||
// remembered while the hiding toggles still are not.
|
||||
// Only grouping and sort are remembered, the page size (a layout choice that
|
||||
// hides nothing, it just sets how many rows load at once), plus toggles that ADD
|
||||
// information (showEmpty adds rows, showLotNumbers adds text). Filters, search and archived-
|
||||
// visibility hide spools, and a hidden filter silently restored days later reads
|
||||
// as missing data rather than as a preference. Showing empty filaments or lot
|
||||
// numbers cannot produce that confusion -- restoring them can only put more on
|
||||
// screen, never less -- so they are remembered while the hiding toggles are not.
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
const KEY = 'spoolman-v2-library-view';
|
||||
|
||||
@@ -35,6 +38,8 @@ export interface StoredView {
|
||||
sortKey: string;
|
||||
sortAsc: boolean;
|
||||
showEmpty: boolean;
|
||||
showLotNumbers: boolean;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** Rebuild a stored view from its JSON, or null if there isn't a usable one. */
|
||||
@@ -43,15 +48,23 @@ export function parseStoredView(raw: string | null): StoredView | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const { group, sort, asc, empty } = parsed as Record<string, unknown>;
|
||||
const { group, sort, asc, empty, lots, size } = parsed as Record<string, unknown>;
|
||||
if (typeof group !== 'string' || typeof sort !== 'string' || typeof asc !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
// `empty` arrived after this key shipped, so an entry written by an older
|
||||
// build simply has no such field. Treating that as "off" keeps those
|
||||
// `empty`, `lots` and `size` arrived after this key shipped, so an entry written
|
||||
// by an older build simply has no such fields. Treating that as "off" keeps those
|
||||
// entries valid; rejecting them would throw away a grouping the user
|
||||
// picked long ago the first time they loaded a new version.
|
||||
return { group, sortKey: sort, sortAsc: asc, showEmpty: empty === true };
|
||||
return {
|
||||
group,
|
||||
sortKey: sort,
|
||||
sortAsc: asc,
|
||||
showEmpty: empty === true,
|
||||
showLotNumbers: lots === true,
|
||||
// An absent or unusable size is the default, not a reason to drop the entry.
|
||||
pageSize: typeof size === 'number' && Number.isInteger(size) && size > 0 ? size : DEFAULT_PAGE_SIZE
|
||||
};
|
||||
} catch {
|
||||
// Corrupt entry: the shipped view is no worse than what a first-time
|
||||
// visitor gets.
|
||||
@@ -80,7 +93,9 @@ export function rememberView(view: StoredView): void {
|
||||
prev.group === view.group &&
|
||||
prev.sortKey === view.sortKey &&
|
||||
prev.sortAsc === view.sortAsc &&
|
||||
prev.showEmpty === view.showEmpty
|
||||
prev.showEmpty === view.showEmpty &&
|
||||
prev.showLotNumbers === view.showLotNumbers &&
|
||||
prev.pageSize === view.pageSize
|
||||
) {
|
||||
// Selecting a spool or turning a page navigates too; only the changes that
|
||||
// actually move the view are worth a synchronous localStorage write.
|
||||
@@ -109,7 +124,9 @@ function write(view: StoredView): void {
|
||||
group: view.group,
|
||||
sort: view.sortKey,
|
||||
asc: view.sortAsc,
|
||||
empty: view.showEmpty
|
||||
empty: view.showEmpty,
|
||||
lots: view.showLotNumbers,
|
||||
size: view.pageSize
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -80,8 +80,8 @@ class ScannerState {
|
||||
* rather than an unconfigured state: most people have exactly one reader, and
|
||||
* pairing only starts to matter once there are two. */
|
||||
pairedReaderId = $state<string | null>(null);
|
||||
/** Whether a scan matching a spool navigates this browser to it. Off by
|
||||
* default — a page that navigates itself unasked is hostile. */
|
||||
/** Whether a scan matching a spool or filament navigates this browser to it.
|
||||
* Off by default — a page that navigates itself unasked is hostile. */
|
||||
autoNavigate = $state(false);
|
||||
|
||||
/**
|
||||
@@ -91,6 +91,10 @@ class ScannerState {
|
||||
* point nobody knows the name and the id is the honest thing to show.
|
||||
*/
|
||||
#names = $state<Record<string, string>>({});
|
||||
// What each reader last reported, newest reader first. Only ever filled from
|
||||
// the server's registry, which is in-memory there: this is "what is on the
|
||||
// reader", not a scan history, and it is empty after a server restart.
|
||||
#lastSeen = $state<{ readerId: string; uid: string }[]>([]);
|
||||
#loadingNames: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
@@ -131,10 +135,32 @@ class ScannerState {
|
||||
}
|
||||
|
||||
/** Take note of the names in the server's reader registry. */
|
||||
learnReaders(readers: { readerId: string; name?: string }[]) {
|
||||
learnReaders(readers: { readerId: string; name?: string; lastUid?: string }[]) {
|
||||
for (const reader of readers) {
|
||||
if (reader.name) this.#names[reader.readerId] = reader.name;
|
||||
}
|
||||
// Order is the server's: most recently seen first. It decides which reader
|
||||
// answers when this browser is listening to all of them.
|
||||
this.#lastSeen = readers
|
||||
.filter((reader) => reader.lastUid)
|
||||
.map((reader) => ({ readerId: reader.readerId, uid: reader.lastUid as string }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag already sitting on the reader this browser listens to, if there is
|
||||
* one. A scale holds a spool on its pad to weigh it, so asking for another tap
|
||||
* means lifting it and putting it back; offering what was just seen is the
|
||||
* same identity without the dance.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
get listeningLastSeen(): { uid: string; label: string } | null {
|
||||
const paired = this.pairedReaderId;
|
||||
const hit =
|
||||
paired === null ? this.#lastSeen[0] : this.#lastSeen.find((reader) => reader.readerId === paired);
|
||||
return hit ? { uid: hit.uid, label: this.readerLabel(hit.readerId) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -157,6 +157,46 @@ describe('scanner reader names', () => {
|
||||
scanner.receive({ uid: '04A2B3C4', readerId: 'desk' });
|
||||
expect(scanner.readerLabel('desk')).toBe('Desk reader');
|
||||
});
|
||||
});
|
||||
|
||||
// What is already lying on the reader, so the Add-tag dialog can offer it rather
|
||||
// than ask for another tap. On a scale that tap means lifting the spool off the
|
||||
// pad and putting it back, which reads as the dialog not working.
|
||||
describe('scanner last seen uid', () => {
|
||||
beforeEach(() => scanner.unpair());
|
||||
|
||||
it('answers with what the paired reader last read', () => {
|
||||
scanner.learnReaders([
|
||||
{ readerId: 'printer', name: 'Voron', lastUid: '0455667788' },
|
||||
{ readerId: 'desk', name: 'Desk reader', lastUid: '04A2B3C4' }
|
||||
]);
|
||||
scanner.pair('desk');
|
||||
expect(scanner.listeningLastSeen).toEqual({ uid: '04A2B3C4', label: 'Desk reader' });
|
||||
});
|
||||
|
||||
it('takes the most recent reader while listening to all of them', () => {
|
||||
// The server lists them most recently seen first, and that order is the
|
||||
// answer: it is the reader the person most likely just used.
|
||||
scanner.learnReaders([
|
||||
{ readerId: 'desk', name: 'Desk reader', lastUid: '04A2B3C4' },
|
||||
{ readerId: 'printer', name: 'Voron', lastUid: '0455667788' }
|
||||
]);
|
||||
expect(scanner.listeningLastSeen).toEqual({ uid: '04A2B3C4', label: 'Desk reader' });
|
||||
});
|
||||
|
||||
it('offers nothing when the paired reader has not scanned', () => {
|
||||
scanner.learnReaders([
|
||||
{ readerId: 'desk', name: 'Desk reader' },
|
||||
{ readerId: 'printer', name: 'Voron', lastUid: '0455667788' }
|
||||
]);
|
||||
scanner.pair('desk');
|
||||
expect(scanner.listeningLastSeen).toBeNull();
|
||||
});
|
||||
|
||||
it('offers nothing after a server restart, when no reader has scanned yet', () => {
|
||||
scanner.learnReaders([{ readerId: 'desk', name: 'Desk reader' }]);
|
||||
expect(scanner.listeningLastSeen).toBeNull();
|
||||
});
|
||||
|
||||
// Its own id: the store is a session-long singleton and learned names stay
|
||||
// learned, which is the point of them, so a shared one would be answered by
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSettings, setSetting, parseSetting } from '$lib/api/settings';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
|
||||
// Server-backed user settings (currency, round_prices, base_url) plus one
|
||||
// client-only preference (low-stock threshold, persisted to localStorage — the
|
||||
@@ -15,8 +16,9 @@ class Settings {
|
||||
|
||||
async load() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem(LOW_THRESHOLD_KEY);
|
||||
if (stored) this.lowThreshold = Number(stored) || this.lowThreshold;
|
||||
// 0 g is a real threshold, so `||` can't stand in for "nothing stored".
|
||||
const stored = parseDecimal(localStorage.getItem(LOW_THRESHOLD_KEY));
|
||||
if (stored !== null && stored >= 0) this.lowThreshold = stored;
|
||||
}
|
||||
try {
|
||||
const s = await getSettings();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/settings', () => ({
|
||||
getSettings: async () => ({}),
|
||||
setSetting: async () => {},
|
||||
parseSetting: <T>(_: unknown, fallback: T) => fallback
|
||||
}));
|
||||
|
||||
const { settings } = await import('./settings.svelte');
|
||||
|
||||
function storeThreshold(value: string | null) {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => value,
|
||||
setItem: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
describe('low-stock threshold', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
settings.lowThreshold = 150;
|
||||
});
|
||||
|
||||
it('keeps a stored 0 across a reload', async () => {
|
||||
storeThreshold('0');
|
||||
await settings.load();
|
||||
expect(settings.lowThreshold).toBe(0);
|
||||
});
|
||||
|
||||
it('reads a stored threshold', async () => {
|
||||
storeThreshold('42.5');
|
||||
await settings.load();
|
||||
expect(settings.lowThreshold).toBe(42.5);
|
||||
});
|
||||
|
||||
it('falls back to the default for nothing stored or garbage', async () => {
|
||||
for (const bad of [null, '', 'NaN', 'abc', '-5', 'Infinity']) {
|
||||
storeThreshold(bad);
|
||||
await settings.load();
|
||||
expect(settings.lowThreshold, String(bad)).toBe(150);
|
||||
}
|
||||
});
|
||||
});
|
||||
+12
-10
@@ -15,14 +15,14 @@ export type ExtraPatch = Record<string, string | null>;
|
||||
export type MultiColorDirection = 'coaxial' | 'longitudinal';
|
||||
|
||||
/**
|
||||
* A physical NFC/RFID tag linked to a spool. The UID is the tag's hardware
|
||||
* serial, normalized by the server — always render the one it returns rather
|
||||
* than the one you sent, since any separator spelling maps to the same tag.
|
||||
* A physical NFC/RFID tag linked to a spool or a filament. The UID is the tag's
|
||||
* hardware serial, normalized by the server — always render the one it returns
|
||||
* rather than the one you sent, since any separator spelling maps to the same tag.
|
||||
*
|
||||
* There is deliberately no row id on the wire: `uid` is globally unique and is
|
||||
* what you link, unlink and look up by.
|
||||
* There is deliberately no row id on the wire: `uid` is globally unique across
|
||||
* spools and filaments, and is what you link, unlink and look up by.
|
||||
*/
|
||||
export interface SpoolTag {
|
||||
export interface Tag {
|
||||
uid: string;
|
||||
/** Tag hardware type, e.g. "ntag". Informational, and often absent. */
|
||||
format?: string;
|
||||
@@ -68,6 +68,8 @@ export interface Filament {
|
||||
externalId?: string;
|
||||
/** Human label for registration date, e.g. "Jan 14". */
|
||||
registeredLabel: string;
|
||||
/** Linked NFC/RFID tags. Always present; empty when the filament has none. */
|
||||
tags: Tag[];
|
||||
extra: Extra;
|
||||
}
|
||||
|
||||
@@ -108,15 +110,15 @@ export interface Spool {
|
||||
archived: boolean;
|
||||
comment: string;
|
||||
/** Linked NFC/RFID tags. Always present; empty when the spool has none. */
|
||||
tags: SpoolTag[];
|
||||
tags: Tag[];
|
||||
extra: Extra;
|
||||
}
|
||||
|
||||
// Write shapes: a partial entity, except that `extra` may carry nulls to clear values.
|
||||
export type VendorPatch = Partial<Omit<Vendor, 'extra'>> & { extra?: ExtraPatch };
|
||||
export type FilamentPatch = Partial<Omit<Filament, 'extra'>> & { extra?: ExtraPatch };
|
||||
// Tags are not part of a spool PATCH — they are linked and unlinked through
|
||||
// their own endpoints (see api/tags.ts), so they stay out of the write shape.
|
||||
// Tags are not part of a PATCH — they are linked and unlinked through their own
|
||||
// endpoints (see api/tags.ts), so they stay out of the write shapes.
|
||||
export type FilamentPatch = Partial<Omit<Filament, 'extra' | 'tags'>> & { extra?: ExtraPatch };
|
||||
export type SpoolPatch = Partial<Omit<Spool, 'extra' | 'tags'>> & { extra?: ExtraPatch };
|
||||
|
||||
export type EntityKind = 'spool' | 'filament' | 'vendor';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { startingCamera, videoCameras } from './cameraChoice';
|
||||
|
||||
// A phone can expose half a dozen cameras, and which one the browser picks for
|
||||
// 'environment' is up to the browser: on some it's the telephoto, which only
|
||||
// reads a label from across the room (#693). These decide which camera the
|
||||
// scanner opens with and what the camera picker offers.
|
||||
|
||||
// The order Chrome enumerates them on an Oppo X9 Ultra.
|
||||
const cameras = [
|
||||
{ id: 'c1', label: 'camera 1, facing front' },
|
||||
{ id: 'c5', label: 'camera 5, facing back' },
|
||||
{ id: 'c4', label: 'camera 4, facing back' },
|
||||
{ id: 'c3', label: 'camera 3, facing back' },
|
||||
{ id: 'c2', label: 'camera 2, facing back' },
|
||||
{ id: 'c0', label: 'camera 0, facing back' }
|
||||
];
|
||||
const ids = cameras.map((c) => c.id);
|
||||
|
||||
describe('startingCamera', () => {
|
||||
it('opens the camera the user picked last time', () => {
|
||||
expect(startingCamera('c0', ids)).toBe('c0');
|
||||
});
|
||||
|
||||
it('asks for the rear camera when nothing was picked yet', () => {
|
||||
expect(startingCamera(null, ids)).toBe('environment');
|
||||
});
|
||||
|
||||
it('asks for the rear camera when the picked one is gone', () => {
|
||||
// qr-scanner retries a missing deviceId with no constraint at all, which
|
||||
// can land on the front camera. The rear one is the better fallback.
|
||||
expect(startingCamera('unplugged-webcam', ids)).toBe('environment');
|
||||
});
|
||||
|
||||
it('asks for the rear camera before the browser reveals device ids', () => {
|
||||
// Without camera permission enumerateDevices lists every camera with an
|
||||
// empty id, and an empty saved id must not match one of those.
|
||||
expect(startingCamera('', ['', ''])).toBe('environment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('videoCameras', () => {
|
||||
const device = (kind: MediaDeviceKind, deviceId: string, label = '') => ({ kind, deviceId, label });
|
||||
|
||||
it('lists the video inputs in the order the browser gives them', () => {
|
||||
expect(
|
||||
videoCameras([
|
||||
device('audioinput', 'mic', 'Microphone'),
|
||||
device('videoinput', 'c1', 'camera 1, facing front'),
|
||||
device('videoinput', 'c5', 'camera 5, facing back'),
|
||||
device('audiooutput', 'spk', 'Speaker')
|
||||
])
|
||||
).toEqual([
|
||||
{ id: 'c1', label: 'camera 1, facing front' },
|
||||
{ id: 'c5', label: 'camera 5, facing back' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an empty label empty so the picker can number it', () => {
|
||||
// qr-scanner's listCameras() fills these in with an untranslated
|
||||
// "Default Camera" / "Camera N", which the picker couldn't tell apart
|
||||
// from a real device name.
|
||||
expect(videoCameras([device('videoinput', 'c1')])).toEqual([{ id: 'c1', label: '' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// Which camera the QR scanner opens with, remembered per browser.
|
||||
//
|
||||
// Asking for the 'environment' camera lets the browser choose among every rear
|
||||
// camera, and on a phone with several lenses that can be the telephoto: the
|
||||
// scanner then only reads a label from across the room (#693). A desktop has
|
||||
// the same problem with a virtual webcam listed first. The scanner offers a
|
||||
// picker of every camera, and the one the user chooses is what it opens with
|
||||
// next time.
|
||||
|
||||
const KEY = 'spoolman-v2-scanner-camera';
|
||||
|
||||
export interface Camera {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to hand qr-scanner as `preferredCamera`: the remembered camera if the
|
||||
* browser still lists it, otherwise the rear camera. Checked up front because a
|
||||
* deviceId qr-scanner can't open makes it fall back to any camera at all, which
|
||||
* can be the front one.
|
||||
*/
|
||||
export function startingCamera(saved: string | null, deviceIds: readonly string[]): string {
|
||||
return saved && deviceIds.includes(saved) ? saved : 'environment';
|
||||
}
|
||||
|
||||
/**
|
||||
* The cameras among the browser's media devices, in its order. An empty label
|
||||
* stays empty for the caller to number: qr-scanner's listCameras() would fill in
|
||||
* an untranslated "Default Camera" / "Camera N" that can't be told apart from a
|
||||
* real device name.
|
||||
*/
|
||||
export function videoCameras(
|
||||
devices: readonly Pick<MediaDeviceInfo, 'kind' | 'deviceId' | 'label'>[]
|
||||
): Camera[] {
|
||||
return devices.filter((d) => d.kind === 'videoinput').map((d) => ({ id: d.deviceId, label: d.label }));
|
||||
}
|
||||
|
||||
/** The camera the user last switched to in this browser, if any. */
|
||||
export function rememberedCamera(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
return localStorage.getItem(KEY);
|
||||
} catch {
|
||||
// Storage disabled (private mode, blocked third-party context).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberCamera(id: string): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(KEY, id);
|
||||
} catch {
|
||||
/* nothing to do — remembering the camera is a convenience, not a requirement */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { groupUnusedByFilament, usageLabel } from './library';
|
||||
import { groupUnusedByFilament, rowIdentity, usageLabel } from './library';
|
||||
import type { SpoolVM } from './library';
|
||||
import { mapSpool } from '$lib/api/map';
|
||||
|
||||
@@ -89,3 +89,45 @@ describe('groupUnusedByFilament', () => {
|
||||
expect(groupUnusedByFilament([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowIdentity', () => {
|
||||
const vm = (lot: string, unused = false): SpoolVM =>
|
||||
({
|
||||
spool: { lot, unused },
|
||||
filament: { name: 'Galaxy Black', material: 'PLA', diameter: 1.75 },
|
||||
vendor: { name: 'Prusament' },
|
||||
rightLabel: unused ? 'unused' : 'used 3 days ago'
|
||||
}) as SpoolVM;
|
||||
|
||||
const contexts = ['flat', 'filament', 'vendor', 'material', 'location'] as const;
|
||||
const subs = ['PLA · 1.75 mm', 'used 3 days ago', 'PLA · 1.75 mm', '1.75 mm', 'PLA · 1.75 mm'];
|
||||
|
||||
it.each(contexts)('defaults to master text in %s rows', (context) => {
|
||||
expect(rowIdentity(vm('LOT'), context)).toEqual({
|
||||
title: context === 'filament' ? '' : context === 'vendor' ? 'Galaxy Black' : 'Prusament Galaxy Black',
|
||||
sub: subs[contexts.indexOf(context)]
|
||||
});
|
||||
});
|
||||
|
||||
it.each(contexts)('appends the trimmed lot when enabled in %s rows', (context) => {
|
||||
const identity = rowIdentity(vm(''), context);
|
||||
expect(rowIdentity(vm(' LOT-2026-0915-A7 '), context, true)).toEqual({
|
||||
...identity,
|
||||
sub: `${identity.sub} · Lot Nr LOT-2026-0915-A7`
|
||||
});
|
||||
});
|
||||
|
||||
it.each(contexts)('adds nothing for blank lots in %s rows', (context) => {
|
||||
for (const lot of ['', ' ']) {
|
||||
expect(rowIdentity(vm(lot), context, true)).toEqual(rowIdentity(vm(lot), context));
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the lot for individual unused spools too', () => {
|
||||
expect(rowIdentity(vm('24A-1187', true), 'filament', true).sub).toBe('unused · Lot Nr 24A-1187');
|
||||
});
|
||||
|
||||
it('omits the separator when the existing secondary text is empty', () => {
|
||||
expect(rowIdentity({ ...vm('LOT'), rightLabel: '' }, 'filament', true).sub).toBe('Lot Nr LOT');
|
||||
});
|
||||
});
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user