mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-19 00:29:46 -05:00
Compare 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 | ||
|
|
6c5d03d4ec |
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.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 49 KiB |
+39
-13
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 800 355" width="800" height="355">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 800 570" width="800" height="570">
|
||||
<!-- Generated by https://github.com/antfu-collective/sponsorkit -->
|
||||
<style>
|
||||
text {
|
||||
@@ -15,27 +15,53 @@ text {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
<text x="400" y="40" text-anchor="middle" class="sponsorkit-tier-title">Sponsors</text><a href="https://github.com/mrgirosky" class="sponsorkit-link" target="_blank" id="mrgirosky">
|
||||
<text x="400" y="153" text-anchor="middle" class="sponsorkit-name" fill="currentColor">mrgirosky</text>
|
||||
<text x="400" y="40" text-anchor="middle" class="sponsorkit-tier-title">Gold Sponsors</text><a href="https://joshix.com" class="sponsorkit-link" target="_blank" id="joshix">
|
||||
<text x="400" y="203" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Josh Wood</text>
|
||||
|
||||
<clipPath id="c0">
|
||||
<rect x="365" y="65" width="70" height="70" rx="35" ry="35" />
|
||||
<rect x="340" y="65" width="120" height="120" rx="60" ry="60" />
|
||||
</clipPath>
|
||||
<image x="365" y="65" width="70" height="70" href="data:image/webp;base64,UklGRvwBAABXRUJQVlA4IPABAAAwDwCdASpQAFAAPmUqkUWkIqGbix0kQAZEsgBnGtdsjzP/Ecq4L39vxb4m/Uz5YXQU8wH7D+sh/nPUr/svT/6h3+YeoB0ygT24/QMuF+zXFvXSjFOioIMm6v60zSJQaxKhX6whSf6VtmBC5SeSGdBguhbrunJoifis9a3V2ak/cYcAAP78bzZ55Ogwoa8uUDxZ449/WJWP06V1jI5rA6VJhBRcgNklWlgccRt8ueZCH6aXt37H7vzCiGyIVoexPxYMaRppan8kXgzEhe2nZubuS6UBzsTwBM6CyU4UM6Q5J/eKXkmvWeltJs5q9OF5sdD/Huwnz+urGJEC3Toq43krvLRw5nxtkTFKonew1+PanXbcDsYJHsDxnkhRa9GgoSvMMVMoVs52Dy8gm8nii7e3ACNXQFqMQSw5B6z857D8OvYeIjh5mIAMvagLZ0+9xKBIyACPxamiN1vVKH3DNLDivXpQIfqpTvSsieLku9Jm2crqNH3H56ZdQRnR369GzSuC/ZHiaTXZ/Bmbdb5Vrxmn/HtBdZGx+LgiNfWNf3167VyoS32aZgJWPfSWFR+FZDwo/oTLFzq0uxND24bfbb+cWlx6Pu854mZCjdOZ27HHVwg8py/8WqJjTnqg6uQN7w4w/h2/kA5vozXEYoAAAAAA" clip-path="url(#c0)"/>
|
||||
</a><text x="400" y="210" text-anchor="middle" class="sponsorkit-tier-title">Supporters</text><a href="https://github.com/marko-p" class="sponsorkit-link" target="_blank" id="marko-p">
|
||||
<text x="360" y="303" text-anchor="middle" class="sponsorkit-name" fill="currentColor">marko-p</text>
|
||||
<image x="340" y="65" width="120" height="120" href="data:image/webp;base64,UklGRsgGAABXRUJQVlA4ILwGAACwJACdASp4AHgAPm0skkYkIqGhL5ENkIANiUfPo+yShwQpAbER6gG8GTCe9OgMo/Ux8Mb6gcyqPI8xGY/5PrE+/PpB5ypAjRkDFzrivwF62s/DK34rzvVhAPgCBtQjb6uCuPF1IciZ/eOsAm2YcRY0JTDqwAeM+a8G/bPnitwjyrcNv4JeKAdm+2kIGNTUplFYHTh5H/6cvX/J2boEqumFpsZ4H+BTtbcCJ1umGOvIdIZ7mJJ4wKAIqBezjekx2kJcEuJv1TQjTzvFltO1PQyGeQS85A0kZIQo/eL9EMzL7yHbRNB95Za7xMB23tgYLYRGioR+cObcTdJh1itU3qSDjOx5GX2qxaVkPqMDgaM/dJltCbRr48Uj9txgtIhRKvgNxLd0fZ2iU1+9n5MIMAD+/PQBSjyc0vbGv1rJpHHondsozJ5is5nynZ+bxtrKmbuULzqkfkHTd2EZMZR4pKqYe4vgUljlur1epNBEkArOAPbtmGjmkwqXRkZtDettTONSDKtn7bi5F52h+cnz5FvLoE7c+pK+ZkBlpZy9Y7Wzx9bvKE/Org0ItinAq7f9TmihQ1eizXWPtgBKFd+Qu7BF+wqFv/68qGwtXD/9iMyD9G0NFBxF/YHjCzsUt0rvHlkPkG3u0s43UFSi+tFMWXfJVDMX9bqV1Y7dvCwhQ7NY+SZCNaoq487tNhvEhIlUb1mqdXv3rxhJ8QLM0iZ5muNzgAZH8rnxR/JlNXsfZhe4ZvsIgsa8/f3eUhXSZ/acnJ1dh8SayPmOJysvTMy4SmXCvIWvFZrAdbXjfUKSV2SDj8CFjFa3S/U+VcTXqTm5RTpQy2R6citHAtZ/FAo0R2l+ubQvHAZbG1naXrdCmvx9IQxQE9DrQrFccqLpAroykRLrIPEyTcvzXPoy6DcrqpzWpVsskaKDksL8HIeM6LvYhAMaz7kmdYfHfQhxnTzQEV3bii5mXAh7RLbsvuml5HPVj3hHYPeWcyqXuwdbKs1ei9Is2LWps/OYoY1/S8HkS7yWsH3lJGIp3YRtJrl0CBj80B5Kc7FCt1kLT7l02XMn2sp4G/Tmc2PTOSToCkOEzWm60rUaX7gU/NBUAHcFJzcveQBzWbtm/QvAzmyYRv27bSozA8jcjTB11XwBz7aRW6bOdhZevq1oNodI75fAQ9SjxsqijU6icL/C17hatpRfNDRK5339AsbtAKnhUpcmbBrYXmYrjfmcXLsTjT9m4yYPLzmAr38FhZbZYTXEwcQ611OLliezqUGH63rwrrD/Y1D7DP5TeGiQ6fSqUs5MbV+bW1zQlCU4ZZ6GFhrOnXhlH6oN2N9aO/Eu35SHfnCxZk2cch4YgaPmOGn8R7/6u2il6/EgXYMDeK7HcSEmss7n1CREawmfINYUCUxLB4s3z4pQQkVOTAy0PM3uylOfQt6LDpQ7ZjnhwGGNbVqPRwsClzPpxRnIAPXaDHJW0tFtNHZSNOmo3m9VzNkVN0wUOHb+/i1tW3RUHhuE+6p8D8C4px/D5MTU4l9INiryqSPO8KR9Pauk1PT7p4Iuf1GbZ9pYXVqIQcpIkYNKRyyJObLYCEVJYyUQ9s1sHx3kWGY8BVNVsZ45IfByrst+0iJywhlRMZLvY719IU2ssAyx9bQyJ+FOZOH11nib/g5i86gKhw1inytSYr7KSrOZ3vEZK0d131xF8D4Tqq5fmEuoaR7R9e/DvkPRxiSPwp6caZYHNjW0vw28jfBIeuc2Hu86STZP0dSWeaIOiTXK74vJEmLdMAUbYCX3jw2eTZJxYSuEI9YivnsnsJSPF9gk8RFk5dx+BVE3a+ypXgbjVO8Bg1tuNmJDEAH22PMgOxKSCbOhQ/ox19PW73FJ4ybJojgF3dPjPKzue/4Hf429x0Jsn2/tlNCrJf0T7XyE1cIgKvt5DbNFfh5Ss0cZe+I/9HUA7JwZCQ5LF2GqEB21XI1qJZor9y46SIjj2HxWJnJPdqHCEe6Tb7D+NLFsjfl+ZJGZu/HX6vjuORb0RIkmbq0aEVzHGjIawLzSZRKs+q7uRCXS2erdaj3ZPTqTV4YfqwFYAW3uYbwD+3Ktu9mIXLpFMsw8IiQNZopAHBbVQo+/Qk+1LBLeHztLhFDya0zWaexlFWshmq4c3J6RepzwNd/yweyeESecwL9eXf8fanxvJ2hOMKKU7Rh1g1ZBdGFJYXrZzN9PgAoBk/w77D0swqmrjebS9hmnON+C9owqx15EgS8f/BGWnupWZs+frjmVzRfc881ei87vK+ccntgAq2JzNWMLH1DB2R+Y8pkBzwAAAA==" clip-path="url(#c0)"/>
|
||||
</a><text x="400" y="255" text-anchor="middle" class="sponsorkit-tier-title">Supporters</text><a href="https://github.com/swtaylor" class="sponsorkit-link" target="_blank" id="swtaylor">
|
||||
<text x="360" y="348" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Scott</text>
|
||||
|
||||
<clipPath id="c1">
|
||||
<rect x="335" y="235" width="50" height="50" rx="25" ry="25" />
|
||||
<rect x="335" y="280" width="50" height="50" rx="25" ry="25" />
|
||||
</clipPath>
|
||||
<image x="335" y="235" width="50" height="50" href="data:image/webp;base64,UklGRtIBAABXRUJQVlA4IMYBAABwDQCdASpQAFAAPl0ojkWjoqEdbDQAOAXEsgBkDELGpKsFXfX1S/UBtm/Eg9ZPo//SA9Un0AKtQA42uXnr7NGq14fnKqAKWifgtAJKaGODm1DKqW1JC7w48iNqbR3EsZATHUiQiiAqaWRyc8N5cjpRFSLMAAD++LxD4g2y/4OWQs5nyxE4mqVuPu7/I+5SujmdcC45tAmqYHZ2gdczvCgVNQpllSNIw2yeTkDYN32j0a6AQ9X1emvkndKJ6EYr11vguW/ETlM8zLOlnjGXYE0SWqgylFWx0LOB8AmIYPCcaMPhEiSuv3vwJH/MGwNCm8ozigt1S4p6QFOml1r/Du6VbH/lNxoQp+iitZsJ0PuEO5C8oYLEZzrr2+4aAOdeIg9VBqV5JhRgBrlJLwAoL/3UmeHU/r7RxRYDwA65FtvkN9Qsv4TyDbvHhz59AHOFEVJvOzcxx+v6v6nzqZWJOmEOG3mkGSTfrNY3xRKqJvPvj7Wo7jCOEcqccpNX5TJ/yzeF+4h48fYT2mRgM+w+bkqM6Rb/mwnZannvnJqc7WPUNP2kvHJn/TqFl7UAOrJbPlznsMtCORpct0p/JeCcHjK+sCmNGAAA" clip-path="url(#c1)"/>
|
||||
<image x="335" y="280" width="50" height="50" href="data:image/webp;base64,UklGRpoEAABXRUJQVlA4II4EAABQFgCdASpQAFAAPmkqkUWkIqGXrAdgQAaEsYBhWgnHTNnEshj28rNZ+GmOXmp93KFPcld/L/07TSyJCfZvbGHb5moRzw0e45eO2MkuhDHxKdpqpve1uYdG0BsLtaR1qQkiaMCv1f1r0sWQvsyKv+yTj4NBtLITGsATKV0IENN3Bl8f6aAHqFPbX/IIwHqJ5dhsp8hpZiO3hdsJnuJjXBSNoo/ohDiJMtm1nZDTgJRCWMgANxShzJ+lEFPAAP79QB9OMqjqK/Wb/XKJUhuVyjCLtjHwBkqY5p46HUIIHw/VhGYrttpwJLsPtJ3UJc+EdV/S8DwYN/yByU6U0rMLIGZSGqa2ErEr7V4GGC9IxFbTvXtQDpVVaddvvt0O1HhsxCFwCfsVJwpY1+j6FX0BkGY97/sXD6e4FiqF4u1+mgpFo5AvXaoOAnyAXGYvs9mrbX7kY2vf2rzqVsTsXUDRBIdM9zf8QYGvyqij+qJIWtiJDJtziH6+PIJPNyYaF3dIun0bWAOTdHDJDIxzmdua4QIZ3+SGIdZ6zj9igFRf/D/OkJzQCGsrZMNHiAul/RsCjH5bv2NQCrlcm8Tek/Bol07kSj6qBCsK/tnGmvsT5mrYoWbQsc4tflV2jYurFzbXEjfmMJTron7S5k+b88/hOxLGsnpY7GN+cajmuqSNIAdZ5Es22QFpRJOcqh4DbtElMbp4JmiIP45o+v5aoyEyIsS6tJ/LWKnofIE+2BdiITSXHMzWWzLZ4yBVsG5jcvHbzsqFp9QFsEMe6GYubnNwvK+3uPptk8jnZN/0geWJiLFMxprrPEhv3N3VEKZ1n4yf3EoOYfywMYJ8syuqMKLLH7PB7tDql0Qxsl77fR4fURVBPGgoHbDUjrhRmEeoMSuuWD6FfZ2sx4D8aLlWKdTcRjMO7i2yNb+apfklcWh9CkNjigw1K6OlcaslrUAguez3uDHP3/e0RlxlD9oXf2i7bPFmeW32p6/krBex+5LE4YroYFb6azBBWEixY90qpd5zwSzarPhNoxV4gRRgKTbMmmIQBTtNa7l3sPIIf0znFnxX+RXwIzLmfRPY66VGkoxV5zEcJwTYngDDe8zfqp4uBhCEIq76aOUn/Kf6ydnnBwkv9EvRuTQ5wEh7jIfn74wudig9529et3sKwVIC/3DyAKgOvwkmC2wiuE/MdSRyVEgJfYBkOWSn8IJEl+ZBUelXSxrKGBeAn3CJUN7vn0hBYCKhrVCuYqCQxB4RtM9ewors7V6olzJUbu6E8WazDVYTU69/pqNi1eOfeYqbv3/il9BdKqx1InGxSUo06kUmrmTyn6cmrDkyIUYrAOttFM6Sy9SKCxPlXgDG3YV1NWmkW8vZQli8jp71xHO/80aXNIOPq4kWo3h4dKuH3JF6yDQVpAN3PJb6rD5ZE8m6JN4hbYWXPjH5YnM0xk8X7xJCgZyaSgFgvc5CeJSohiXsOdwanYvLqgpgjznJYJw+YLcVlbPsLGeg0I1HVpaxWt8Sds1iBtQ+I5ZF2VWTAbdhn9vK+oC8c498LHPQiREbCHMgAA==" clip-path="url(#c1)"/>
|
||||
</a>
|
||||
<a href="https://github.com/swtaylor" class="sponsorkit-link" target="_blank" id="swtaylor">
|
||||
<text x="440" y="303" text-anchor="middle" class="sponsorkit-name" fill="currentColor">Scott</text>
|
||||
<a href="https://github.com/tlangkabel" class="sponsorkit-link" target="_blank" id="tlangkabel">
|
||||
<text x="440" y="348" text-anchor="middle" class="sponsorkit-name" fill="currentColor">tlangkabel</text>
|
||||
|
||||
<clipPath id="c2">
|
||||
<rect x="415" y="235" width="50" height="50" rx="25" ry="25" />
|
||||
<rect x="415" y="280" width="50" height="50" rx="25" ry="25" />
|
||||
</clipPath>
|
||||
<image x="415" y="235" width="50" height="50" href="data:image/webp;base64,UklGRpoEAABXRUJQVlA4II4EAABQFgCdASpQAFAAPmkqkUWkIqGXrAdgQAaEsYBhWgnHTNnEshj28rNZ+GmOXmp93KFPcld/L/07TSyJCfZvbGHb5moRzw0e45eO2MkuhDHxKdpqpve1uYdG0BsLtaR1qQkiaMCv1f1r0sWQvsyKv+yTj4NBtLITGsATKV0IENN3Bl8f6aAHqFPbX/IIwHqJ5dhsp8hpZiO3hdsJnuJjXBSNoo/ohDiJMtm1nZDTgJRCWMgANxShzJ+lEFPAAP79QB9OMqjqK/Wb/XKJUhuVyjCLtjHwBkqY5p46HUIIHw/VhGYrttpwJLsPtJ3UJc+EdV/S8DwYN/yByU6U0rMLIGZSGqa2ErEr7V4GGC9IxFbTvXtQDpVVaddvvt0O1HhsxCFwCfsVJwpY1+j6FX0BkGY97/sXD6e4FiqF4u1+mgpFo5AvXaoOAnyAXGYvs9mrbX7kY2vf2rzqVsTsXUDRBIdM9zf8QYGvyqij+qJIWtiJDJtziH6+PIJPNyYaF3dIun0bWAOTdHDJDIxzmdua4QIZ3+SGIdZ6zj9igFRf/D/OkJzQCGsrZMNHiAul/RsCjH5bv2NQCrlcm8Tek/Bol07kSj6qBCsK/tnGmvsT5mrYoWbQsc4tflV2jYurFzbXEjfmMJTron7S5k+b88/hOxLGsnpY7GN+cajmuqSNIAdZ5Es22QFpRJOcqh4DbtElMbp4JmiIP45o+v5aoyEyIsS6tJ/LWKnofIE+2BdiITSXHMzWWzLZ4yBVsG5jcvHbzsqFp9QFsEMe6GYubnNwvK+3uPptk8jnZN/0geWJiLFMxprrPEhv3N3VEKZ1n4yf3EoOYfywMYJ8syuqMKLLH7PB7tDql0Qxsl77fR4fURVBPGgoHbDUjrhRmEeoMSuuWD6FfZ2sx4D8aLlWKdTcRjMO7i2yNb+apfklcWh9CkNjigw1K6OlcaslrUAguez3uDHP3/e0RlxlD9oXf2i7bPFmeW32p6/krBex+5LE4YroYFb6azBBWEixY90qpd5zwSzarPhNoxV4gRRgKTbMmmIQBTtNa7l3sPIIf0znFnxX+RXwIzLmfRPY66VGkoxV5zEcJwTYngDDe8zfqp4uBhCEIq76aOUn/Kf6ydnnBwkv9EvRuTQ5wEh7jIfn74wudig9529et3sKwVIC/3DyAKgOvwkmC2wiuE/MdSRyVEgJfYBkOWSn8IJEl+ZBUelXSxrKGBeAn3CJUN7vn0hBYCKhrVCuYqCQxB4RtM9ewors7V6olzJUbu6E8WazDVYTU69/pqNi1eOfeYqbv3/il9BdKqx1InGxSUo06kUmrmTyn6cmrDkyIUYrAOttFM6Sy9SKCxPlXgDG3YV1NWmkW8vZQli8jp71xHO/80aXNIOPq4kWo3h4dKuH3JF6yDQVpAN3PJb6rD5ZE8m6JN4hbYWXPjH5YnM0xk8X7xJCgZyaSgFgvc5CeJSohiXsOdwanYvLqgpgjznJYJw+YLcVlbPsLGeg0I1HVpaxWt8Sds1iBtQ+I5ZF2VWTAbdhn9vK+oC8c498LHPQiREbCHMgAA==" clip-path="url(#c2)"/>
|
||||
<image x="415" y="280" width="50" height="50" href="data:image/webp;base64,UklGRlADAABXRUJQVlA4IEQDAACQEQCdASpQAFAAPm0wlEikIqGhJnM6aIANiWVfalewGPbCULeZf9lVKyeAFRcq/sb9iNrYjtrl20Tl9Brpxl6pzt/hrSsHFFGzOJtddJg/shyIiZ1LzVI7175shK6DAWErqJbPidMLUWg3ubKTpxfjBC+/K7Eh9dbQf4YG/eS9ejJPeMpsfu0N2Ta9ObSG201Sy7FkAAD+/lcRS9KSg1a5BZKoJe3h3qj9X3MvB+vnUNlwpci/lOuX9adPeFnDrws/r9sfemPaCgBt/l/Us8nItNrXxn5gmJbR+d1kXCM15yJi87WEtT4HCgIDAlIcW7CNJpCpFzXlmUiZuTpXY9h0L3P1/9DlXWJ6UxR6Eka9lGj1h3gbBdw5XJNayBK6l5knJ75c3VxeMfA41IqIUfRBLqAJxTUAFirrc8GjSidiV2PrpxHD/vIDyqxi0CwSZHZneIXXKdQgElMsiWY7lv2ZYJRbz852fWqChfw8V7O8CmRHJeflh1n6bsJZJW15B0GdmNkd9rEcEEN9c02fOga7TRSa5J9qqm5mQK6ttlG3o20FKndVI0RGhhZ0QoJZiWB2E6EVN8GReTD++Q4tmgx+anuuTQYLlqIbPj0fiNMWruZYNaWBR7lQfGtCltKKoXpGrdsEsb78iI+dXE+PByx8SYCDE7YMosWquKmh38fWmtCWRExHFt6J+fOK7878sckHHUxk7dOZiMG6VJPl1M6s7RVupY4pG4KbEsXrIVmKJ2PMgZqR81XYynE68U6nfMgqjmpQKS+5rFiSdo9GtJr0g9EWmfCGX21fjpknlYAQ4LQMZxVpxCGUkeedmYL/GqnCnxtjZJ4m7M8/y8rkFZZ1HEy8m7h9/qg9sTYZEd+SuGny1fVkAXSsJO6xrQFyB4W5X+QY+dKANG78hoe1ZJqb7LqT4nlWM/49WvYLYLjxLvUEcDmySZFru6bT8XZangg+KyZQR73z0+f7IiRj/PuASJHhqcqirbnHPTDWfKrQu6dWrKHR5kcRlz5Dpnb0gBEPKIQrv5DXBVJRILmF23xJK0rUzUkwQ/L6pkq0Ty8GGXnfzba9DbTT4HHDUwuMOzyNOyuL3/r/ySX1m8qwdpWmnnoAAA==" clip-path="url(#c2)"/>
|
||||
</a><text x="400" y="400" text-anchor="middle" class="sponsorkit-tier-title">Backers</text><a href="https://polymorphic.dk" class="sponsorkit-link" target="_blank" id="polymorphicdk">
|
||||
|
||||
<clipPath id="c3">
|
||||
<rect x="372.5" y="425" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="372.5" y="425" width="25" height="25" href="data:image/webp;base64,UklGRjYCAABXRUJQVlA4ICoCAADwCwCdASoyADIAPmUqkEWkIquZ/Z4AuAZEsYBfefuIopNZ6c4YXlAnC2/DzVHzOazQULy+IzbYtxgfWvdvZMf3fvpN64RGUtlBqaalSwhyxpsxwa2VBwP9czxhfRUkgHDC3n7wM35VAAD++cjP0J212mopM4/Rh0hOscBf9Z3H09zvnh+baFS5+i/jyZ8i0WlzZRzLr9W56mlBTdHVe7poj6YN27piHQHv4HFq8LNPFIaEo2UCdds7haNciUEC46JgnRrnv7x+SK2UmmeRCGWLE0tMvr9Lj7Msgwnee0YpKdP2gtm7UAQ6hAMR+/hIP28TYk/3Jc3oOFFIcOOAwbvtVofjEaORNOQj5vpSLYgA1YmU3MvsWKJiHK3wAxZSAEODjaEXSgWuFQDo/u4k0dv0NcRqX5vjk4COQg+LoZEOWWOnfY3J0rqmkONX0GtvUXQ1vhgSv0fst67tyvesub67hOcUmsPZCS2W/NVUshMelGBQ6+shQOlHXxNysIkuO9ciE/I5vxamqTWlG92zlO5w2IZIbq2x10b5KfSCjkFr/Lspe+4EXhtwhfaZOKBx8eESeBLmBmMMDoK+v1nSYACGfyvQ7ii2bxvOkchviukeoj4B7Q3i8cHNE2d+he9657d5nwMMJ1cubwm7umN5MGKk5B731r3aX3uCbazwf6lY5MQLnw7xU4XxGtQNkQEHQqsG5ajWgOeLm5upXvEKFTLc8trHi1L8A4pAAA==" clip-path="url(#c3)"/>
|
||||
</a>
|
||||
<a href="https://github.com/Kreism" class="sponsorkit-link" target="_blank" id="Kreism">
|
||||
|
||||
<clipPath id="c4">
|
||||
<rect x="402.5" y="425" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="402.5" y="425" width="25" height="25" href="data:image/webp;base64,UklGRlAEAABXRUJQVlA4IEQEAAAQEwCdASoyADIAPm0wkkakIyGhKhzK6IANiUATpygqc9AuwDC76jadzy/+jFdrvoARipqP9l4STP6Jkfek45SHQI9T+wT+s2+cmrUBCJ9Mic9iBdMAlBmtbr3k3fO+bnxwdUPe9YN0drh6w7q4kO+jqNk0uEzUpkgTMYiFhrnVc9qKcB0G35IUcNmQFww80vxGnlkoIEV/s7YNLN7cVA0xQAD++ScfQpSJONSUqCMK+Ol5ZCh0b26aEtYQ7wo7d/iK4GFJ/Oln8/RBtU+uqnwhlmNv/XvVpC/PS5bd8Mbw8mdK7EK/bTtH2GXbGoqm+fSThwv9egzABKWJmjoyNIybs62ApVKMR1syEfaHs9rrVJa4Ku+3KznIaFb3nP3rKOJTl0JxjplCTw0qEoyER2/l8s9Fx7Wj27E7je+BerqTiLU2WKIiK6oWo6qxLj7DM4pmLTmy2HRcdZW4HbJKEvw2qtft+aVqnIpmkpHQ7gIUZB8YqXVLOuf479mRmJSRWUM8XDM+/ZHsiRjQLB8/63zHaV36axymJybL7DPubsyezxXHl3gW1oAHTPcn4dG6tghnVGt61bBjLL4xKpu+jhr9KGulx5hg9zz03dwj6wQGMzLoAS0KS1ootiTv7tSxN8h8BQKdViSfbkmzM/WQZgxko9b7T0mJ3RPfTsolRpzwAOjDjSkxnIA6VC2hoKAHSGYvW/ug0PznrxyLQlqs0eNgZM46ou98sNg2+VDv3Lc+ke9E7g3i9yt7ws/XeGzfyvfyZZlBCDKPStuncmbbhTf808x5SIglbQFAZGxHtRnUaqkoa8E4PlA6mlfzqRey1HGPP/oBPQe25l86Y71vr3eeZn2GcLFzKwjIaDNA9OMteOhej30/TXc+yBzpdTsWtQPyLo3IqXfdT5c8eyWgB4lmNJaOIgcTBCsZW/E/js1Iww91n/KKNlN9puBaomIKqdBj9FbL1nVpR97y+x9x7Vuw2LRI8xu8jNj73/Guv+NyuTRu6t7+QlUXob0HmqrdNuLf8HmZoRE6bv4f6MTQWWfGBSeT6vkpw9I2Qpz3NyC7sC4HMdPjh5JvW4J93BcKqA1N6Nw+bVRvl2ew47w+Kc2vN3IAj5IngbnUvDKEqQZgN+zQ/7BqM5d4YIk6vkyUxeXcAZ0pwBuwstcAJh0sx2PbQrT71ohYigAPz7xq1HAZ/mYsBogBIa5gO2HGgEFavUP9k+jD/GJZvLziNZzsbcX7YdjCdKTH0xwuEqiuufKzOaECClx4DsZuvipljXph50HXFc5XAfZgS+sQOZbpmb6Kf3mMjtfCCDW7efAAxeoPhCCHRcVOV3vJgGILkvW4p2k4VUrVyfDLJ3k9+AriK2tAZroSkIBkalglP1yJ4tYR22YS5slOQ3/CLnU0an8lFGOhWHQVw1CN0JCygQkClgJR4q5+ghh23OXWA/3HEAKNDvo3oAA=" clip-path="url(#c4)"/>
|
||||
</a><text x="400" y="485" text-anchor="middle" class="sponsorkit-tier-title">Past Sponsors</text><a href="https://github.com/marko-p" class="sponsorkit-link" target="_blank" id="marko-p">
|
||||
|
||||
<clipPath id="c5">
|
||||
<rect x="372.5" y="510" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="372.5" y="510" width="25" height="25" href="data:image/webp;base64,UklGRl4BAABXRUJQVlA4IFIBAAAwCQCdASoyADIAPm00lEakIyIhLhkoAIANiUAYoZG41zSo4e3a0MDqVd5hvyUF1IEn9JgXObpn2Mt9prYfftYf4GRNRZ7hZ/fWD/6kU0akoeFYAP7mh/5A2Gfo2763tH9O1yC1D3TebcFfF/bO2z76334lot7jYxzsHcClMluiJtad5Z2A9ZtsHn0CgO1qVt/DcFFWwoCom7DWqiX/JeWTPmV7+u5ecLmedHJQ6jheSaRadpREwA9UiSGzpAggcCbnuZdKF5py6aWBIPoYdwJPwQTuDBoyC3glR90OjgWIADT7RZMYVBOeJZ0jA7g8++YxIx+GNqekY+gLy77edX+cDhTWBlwoSyzAKTVFGooGOFJwHGufQopr/cr1nMvbodaPTkzq9FZtdY+eVmTADFYRaCF0QDCX+qURMnt+yeGaWDzHEhEsTMeIJbGyRhUdMgAAAA==" clip-path="url(#c5)"/>
|
||||
</a>
|
||||
<a href="https://github.com/mrgirosky" class="sponsorkit-link" target="_blank" id="mrgirosky">
|
||||
|
||||
<clipPath id="c6">
|
||||
<rect x="402.5" y="510" width="25" height="25" rx="12.5" ry="12.5" />
|
||||
</clipPath>
|
||||
<image x="402.5" y="510" width="25" height="25" href="data:image/webp;base64,UklGRqoBAABXRUJQVlA4IJ4BAADwCwCdASoyADIAPm0wlUYkIyIhMBYJAIANiWYAy5x8BkkfdwjET0C9gJwHmAfqB0gPMB0EPOg6lXn2PaFvyUJKOZTATBzOiskTHqIVkrjKyrKQ96FyqvM1Gv1jQBXFMKi+U5hNm1EI8AD+8QHHdvGR1JUV2sahGaCbtFiJaByUNdMrLaM575u+kR44ggAKv3SXhizdVAvYYtYu0xpoP1QuZzHV05ERx1UDN3JJOZQA2eCKoCPnSiD3aW7Cp+XxYc07Q+Xfur3IkZX89Dy0NN7ogFFINEj83SaUWhjo7BEkmq5OUe8vjhUSout2T6n2+fwYwcD6/3bekvfYqCCWM1HLHyjGroEjvI40NzvNVty9uCAfSiHRL48OpEWqqoi85QWoAQLMK4WoSLcWsXpVCQCI88AiLHFkRtMAV4H+PUfk2ZsEzGSka1EFOtnn3npbU1/aitYgLuS4rcly4D0v9dMawDumQfY5uMacYPo6dkjOTJAdPQDXloB9LbiSBWrbcdnYyZZ/WOaAySoDhCCl3eQFOdBCSqnKG0gHZXAAAAA=" clip-path="url(#c6)"/>
|
||||
</a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 11 KiB |
Generated
+119
-119
@@ -31,9 +31,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -50,13 +50,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -73,13 +73,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
@@ -87,7 +87,7 @@
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -97,9 +97,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -114,9 +114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -131,9 +131,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -148,9 +148,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -165,9 +165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -182,9 +182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -199,9 +199,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -216,9 +216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -233,9 +233,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -250,9 +250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -267,9 +267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -286,13 +286,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -309,13 +309,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -332,13 +332,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -355,13 +355,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -378,13 +378,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -401,13 +401,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -424,13 +424,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -447,18 +447,18 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -468,9 +468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -478,7 +478,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -488,9 +488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -508,9 +508,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -528,9 +528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -713,9 +713,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -730,31 +730,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.5",
|
||||
"@img/sharp-darwin-x64": "0.35.5",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||
"@img/sharp-linux-arm": "0.35.5",
|
||||
"@img/sharp-linux-arm64": "0.35.5",
|
||||
"@img/sharp-linux-ppc64": "0.35.5",
|
||||
"@img/sharp-linux-riscv64": "0.35.5",
|
||||
"@img/sharp-linux-s390x": "0.35.5",
|
||||
"@img/sharp-linux-x64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||
"@img/sharp-win32-arm64": "0.35.5",
|
||||
"@img/sharp-win32-ia32": "0.35.5",
|
||||
"@img/sharp-win32-x64": "0.35.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+1112
-759
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -22,7 +22,7 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"react": "^19.2.3",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 上贊助專案"
|
||||
}
|
||||
@@ -163,11 +163,12 @@
|
||||
"filament.fields.weight": "Gewicht",
|
||||
"filament.fieldsHelp.articleNumber": "z.B. EAN, UPC o.Ä.",
|
||||
"filament.fieldsHelp.multiColorDirection": "Filamente können auf zwei Arten mehrere Farben haben: entweder durch Koextrusion, wie bei zweifarbigen Filamenten mit konsistenten Mehrfarben, oder durch Farbveränderungen in Längsrichtung, wie bei Gradient-Filamenten, welche die Farben entlang der Spule verschieben.",
|
||||
"filament.fieldsHelp.spoolWeight": "Das Gewicht der leeren Spule. Wird verwendet, um das gemessene Gewicht der Spule zu berechnen.",
|
||||
"filament.fieldsHelp.spoolWeight": "Das Gewicht einer leeren Spule. Wird verwendet, um das gemessene Gewicht einer Spule zu bestimmen. Nicht sicher? Pappspulen wiegen normalerweise 130–150 g und Kunststoffspulen 180–250 g – für eine genaue Zahl einfach die Spule auf die Waage legen, sobald sie leer ist.",
|
||||
"filament.fieldsHelp.weight": "Das Gewicht einer vollen Spule (Nettogewicht). Sollte nicht das Gewicht der Spule, sondern nur dass des Filaments auf der Spule enthalten. Entspricht normalerweise dem Gewicht, das auf der Spule aufgedruckt ist.",
|
||||
"filament.filament": "Filamente",
|
||||
"footer.documentation": "Dokumentation",
|
||||
"footer.reportIssue": "Ein Problem melden",
|
||||
"footer.updateAvailable": "v{{version}} verfügbar",
|
||||
"help.help": "Hilfe",
|
||||
"inspector.addSpoolsOfThis": "Diese Spulen hinzufügen",
|
||||
"inspector.adjustFailed": "Anpassung des Gewichts fehlgeschlagen.",
|
||||
@@ -341,6 +342,18 @@
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Konnte die Spoolman API nicht erreichen. Läuft das Backend?",
|
||||
"library.catalog.allFilaments": "Alle Filamente",
|
||||
"library.catalog.allManufacturers": "Alle Hersteller",
|
||||
"library.catalog.backToSpools": "Zurück zu Spulen",
|
||||
"library.catalog.filamentCount_one": "{{count}} Filament",
|
||||
"library.catalog.filamentCount_other": "{{count}} Filamente",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} Hersteller",
|
||||
"library.catalog.manufacturerCount_other": "{{count}} Hersteller",
|
||||
"library.catalog.noManufacturer": "kein Hersteller",
|
||||
"library.catalog.notSet": "Nicht gesetzt",
|
||||
"library.catalog.switchList": "Liste wechseln",
|
||||
"library.catalog.unitFilaments": "Filamente",
|
||||
"library.catalog.unitManufacturers": "Hersteller",
|
||||
"library.collapseGroup": "Verstecke diese Spulen",
|
||||
"library.dateFilter.ago": "vor {{span}}",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
@@ -377,7 +390,7 @@
|
||||
"library.sort.nozzle": "Düsen Temperatur",
|
||||
"library.sortBy": "Sortieren",
|
||||
"library.unassigned": "nicht zugewiesen",
|
||||
"library.unitGroups": "Guppen",
|
||||
"library.unitGroups": "Gruppen",
|
||||
"library.unitSpools": "Spulen",
|
||||
"library.unused": "ungenutzt",
|
||||
"library.unusedGroup": "{{count}} unbenutzt · {{weight}} g jede",
|
||||
@@ -386,6 +399,8 @@
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "verbraucht",
|
||||
"library.usedAgo": "vor {{time}} verwendet",
|
||||
"library.viewMenu.browse": "Durchsuchen",
|
||||
"library.views": "Bibliotheksansichten",
|
||||
"loading": "Läd",
|
||||
"locations.locations": "Orte",
|
||||
"locations.newLocation": "Neuer Ort",
|
||||
@@ -425,6 +440,7 @@
|
||||
"printing.generic.verticalSpacing": "Vertikaler Abstand",
|
||||
"printing.qrcode.button": "Beschriftung drucken",
|
||||
"printing.qrcode.customUrl.caveat": "Eine benutzerdefinierte URL wird nicht zurück in Spoolman gescannt – benutze sie, um einen gedruckten QR-Code auf eine externe App oder einen anderen Host zu verweisen.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Beschriftungsfelder funktionieren zum Beispiel auch",
|
||||
"printing.qrcode.customUrl.hint": "Setze die Spulen Id irgendwo mit dem Platzhalter ein",
|
||||
"printing.qrcode.customUrl.label": "Benutzerdefinierte URL-Vorlage",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR Code Link",
|
||||
@@ -435,6 +451,8 @@
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "Die Standardeinstellung verwendet einen proprietären Link, der nur funktioniert, wenn er mit der Scanfunktion von Spoolman gescannt wird. URL verwendet entweder die in den Einstellungen angegebene Basis-URL oder wenn diese nicht festgelegt ist die aktuelle Seiten-URL.",
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} Spule ausgewählt",
|
||||
"printing.spoolSelect.selectedTotal_other": "{{count}} Spulen ausgewählt",
|
||||
"scanner.camera": "Kamera",
|
||||
"scanner.cameraNumbered": "Kamera {{number}}",
|
||||
"scanner.description": "Spoolman QR-Code scannen, um Details der Spule anzuzeigen.",
|
||||
"scanner.error.inUse": "Die Kamera wird bereits von einer anderen Anwendung genutzt.",
|
||||
"scanner.error.insecureContext": "Die Seite wird nicht über HTTPS bereitgestellt.",
|
||||
@@ -444,7 +462,9 @@
|
||||
"scanner.error.unknown": "Ein unbekannter Fehler ist aufgetreten. ({{error}})",
|
||||
"scanner.starting": "Starte Kamera…",
|
||||
"scanner.title": "QR-Code Scanner",
|
||||
"search.browseAll": "Durchsuche alle",
|
||||
"search.colorHint": "Farbnamen müssen in englisch eingegeben werden.",
|
||||
"search.hint": "Suche Spulen, Filamente und Hersteller nach Name, Farbe, Material oder nach jedem anderen Feld.",
|
||||
"search.moreSpools": "+{{count}} mehr",
|
||||
"search.noResults": "Keine Treffer gefunden",
|
||||
"search.searching": "Suchen…",
|
||||
@@ -515,12 +535,12 @@
|
||||
"settings.library.lowThreshold.desc": "Spulen mit diesem Gewicht oder weniger werden rot markiert (nur in diesem Browser)",
|
||||
"settings.library.lowThreshold.label": "Niedriger Lagerbestand Schwelle",
|
||||
"settings.library.tab": "Bibliotheksverhalten",
|
||||
"settings.scanner.autoNavigate.desc": "Navigiere automatisch in diesem Webbrowser zu einer Spule, die von einem Tag Lesegerät gelesen wird.",
|
||||
"settings.scanner.autoNavigate.desc": "Navigiere automatisch in diesem Webbrowser zu einer Spule oder Filament, die von einem Tag Lesegerät gelesen wird.",
|
||||
"settings.scanner.autoNavigate.label": "Auto-Navigation",
|
||||
"settings.scanner.docs": "Wie man einen Tag Leser einrichtet",
|
||||
"settings.scanner.lastSeen": "vor {{when}}",
|
||||
"settings.scanner.pair": "Zum Koppeln tippen",
|
||||
"settings.scanner.reader.anyDesc": "Scans von jedem Leser, öffnet Spulen in diesem Webbrowser. Wenn du mehr Kontrolle willst, kannst du dich mit einem bestimmten koppeln. Das ist auch der Leser, der verwendet wird, um neue Tags zu Spulen hinzuzufügen.",
|
||||
"settings.scanner.reader.anyDesc": "Scans von jedem Leser, öffnet Spulen in diesem Webbrowser. Wenn du mehr Kontrolle willst, kannst du dich mit einem bestimmten koppeln. Das ist auch der Leser, der verwendet wird, um neue Tags zu Spulen oder Filamenten hinzuzufügen.",
|
||||
"settings.scanner.reader.label": "Tag Leser",
|
||||
"settings.scanner.reader.pairedDesc": "Nur Scans von diesem Leser öffnen Spulen in diesem Webbrowser.",
|
||||
"settings.scanner.recent": "Kürzliche Leser",
|
||||
@@ -561,16 +581,21 @@
|
||||
"tags.added": "Tag verlinkt.",
|
||||
"tags.addedOn": "Hinzugefügt {{date}}",
|
||||
"tags.modal.alreadyHere": "Dieser Tag kennzeichnet diese Spule bereits.",
|
||||
"tags.modal.alreadyHereFilament": "Dieses Tag kennzeichnet dieses Filament bereits.",
|
||||
"tags.modal.badUid": "Eine Tag UID ist hexadezimal, wie 04A2B3C4.",
|
||||
"tags.modal.checking": "Prüfen…",
|
||||
"tags.modal.failed": "Konnte den Tag nicht verknüpfen.",
|
||||
"tags.modal.filamentNamed": "Filament {{name}}",
|
||||
"tags.modal.free": "Dieser Tag ist noch mit nichts verbunden.",
|
||||
"tags.modal.intro": "Wenn du diesen Tag auf einem Reader antippst, findest du dann Spule #{{id}}.",
|
||||
"tags.modal.introFilament": "Wenn man diesen Tag auf einem Leser antippt, wird dieses Filament dann gefunden.",
|
||||
"tags.modal.lastSeenOn": "{{reader}} zuletzt gelesen {{uid}}.",
|
||||
"tags.modal.link": "Verbinde Tag",
|
||||
"tags.modal.linking": "Verbinden…",
|
||||
"tags.modal.move": "Verschiebe es hierher von #{{id}}",
|
||||
"tags.modal.moveFailed": "Konnte den Tag nicht verschieben. Er ist nun möglicherweise mit keiner Spule verbunden – versuche ihn erneut zu verlinken.",
|
||||
"tags.modal.moveFailedFilament": "Der Tag konnte nicht verschoben werden. Er ist jetzt möglicherweise mit nichts verknüpft – versuche, ihn erneut zu verknüpfen.",
|
||||
"tags.modal.moveHere": "Bewege es hierhin",
|
||||
"tags.modal.moving": "Verschieben…",
|
||||
"tags.modal.phoneReading": "Halte den Tag an dein Handy...",
|
||||
"tags.modal.phoneScan": "Lese den Tag mit deinem Handy",
|
||||
@@ -585,16 +610,20 @@
|
||||
"tags.modal.waitingAny": "Warte auf ein Tippen auf einem beliebigen Leser.",
|
||||
"tags.modal.waitingOn": "Warte auf ein Tippen auf {{reader}}.",
|
||||
"tags.moved": "Tag wurde von Spule #{{id}} hierher verschoben.",
|
||||
"tags.movedFromFilament": "Tag wurde von Filament {{name}} hierher verschoben.",
|
||||
"tags.nfc.insecureContext": "Das Lesen von Tags erfordert eine sichere Verbindung. Öffne Spoolman über HTTPS, um das NFC deines Handy zu nutzen.",
|
||||
"tags.nfc.notAllowed": "Diese Seite darf NFC nicht verwenden. Gewähre ihr die Erlaubnis in den Seiten Einstellungen deines Browsers und versuche es erneut.",
|
||||
"tags.nfc.notReadable": "Der NFC-Leser ist nicht verfügbar — eine andere App könnte ihn gerade benutzen.",
|
||||
"tags.nfc.notSupported": "Dieses Handy hat kein NFC oder NFC ist in den Systemeinstellungen ausgeschaltet.",
|
||||
"tags.nfc.unknown": "Konnte keinen Tag lesen.",
|
||||
"tags.none": "Keine Tag's verbunden.",
|
||||
"tags.scan.openedFilament": "Tag gescannt: Filament {{name}} geöffnet.",
|
||||
"tags.scan.openedSpool": "Tag gescannt: Spule #{{id}}, {{name}} geöffnet.",
|
||||
"tags.scan.unknown": "Unbekannter Tag {{uid}}. Öffne die zugehörige Spule und benutze „Tag hinzufügen“, um ihn zu verknüpfen.",
|
||||
"tags.section": "Tag's",
|
||||
"tags.unlink": "Verknüpfung aufheben",
|
||||
"tags.unlinkBody": "Tag {{uid}} wird stoppen, diese Spule zu identifizieren. Nichts wird direkt auf dem Tag selbst geschrieben, daher kannst du ihn jederzeit wieder — mit dieser Spule oder einer Anderen — verknüpfen.",
|
||||
"tags.unlinkBodyFilament": "Tag {{uid}} wird aufhören, dieses Filament zu identifizieren. Es wird nichts auf den Tag selbst geschrieben, sodass du ihn jederzeit wieder verknüpfen kannst — hier oder irgendwo anders.",
|
||||
"tags.unlinkFailed": "Konnte die Verbindung des Tag's nicht entfernen.",
|
||||
"tags.unlinkTitle": "Verbindung für diesen Tag aufheben?",
|
||||
"tags.unlinked": "Verbindung des Tag's aufgehoben.",
|
||||
@@ -616,5 +645,6 @@
|
||||
"vendor.fields.name": "Name",
|
||||
"vendor.fields.registered": "Registriert",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "Das Gewicht einer leeren Spule von diesem Hersteller.",
|
||||
"vendor.showAsList": "Zeige als Liste",
|
||||
"yes": "Ja"
|
||||
}
|
||||
@@ -62,6 +62,7 @@
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Save",
|
||||
"buttons.showArchived": "Show Archived",
|
||||
"buttons.showLotNumbers": "Show lot numbers",
|
||||
"buttons.showNoSpools": "Show filaments with no spools",
|
||||
"buttons.unArchive": "Unarchive",
|
||||
"changeFilament.adoptWeight": "Set this spool’s full weight to {{full}}",
|
||||
@@ -167,6 +168,7 @@
|
||||
"filament.filament": "Filaments",
|
||||
"footer.documentation": "Documentation",
|
||||
"footer.reportIssue": "Report an issue",
|
||||
"footer.updateAvailable": "v{{version}} available",
|
||||
"help.help": "Help",
|
||||
"inspector.addSpoolsOfThis": "Add spools of this",
|
||||
"inspector.adjustFailed": "Failed to adjust weight.",
|
||||
@@ -349,6 +351,18 @@
|
||||
"library.dateFilter.since": "Since {{date}}",
|
||||
"library.dateFilter.to": "To (optional)",
|
||||
"library.dateFilter.until": "Until {{date}}",
|
||||
"library.catalog.allFilaments": "All filaments",
|
||||
"library.catalog.allManufacturers": "All manufacturers",
|
||||
"library.catalog.backToSpools": "Back to spools",
|
||||
"library.catalog.filamentCount_one": "{{count}} filament",
|
||||
"library.catalog.filamentCount_other": "{{count}} filaments",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} manufacturer",
|
||||
"library.catalog.manufacturerCount_other": "{{count}} manufacturers",
|
||||
"library.catalog.noManufacturer": "No manufacturer",
|
||||
"library.catalog.notSet": "Not set",
|
||||
"library.catalog.switchList": "Switch list",
|
||||
"library.catalog.unitFilaments": "filaments",
|
||||
"library.catalog.unitManufacturers": "manufacturers",
|
||||
"library.emptyFiltered": "No {{unit}} match the current filters.",
|
||||
"library.emptyTitle": "No spools yet",
|
||||
"library.emptyBody": "Add your first spool to start tracking your filament.",
|
||||
@@ -383,6 +397,8 @@
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "used",
|
||||
"library.usedAgo": "used {{time}} ago",
|
||||
"library.viewMenu.browse": "Browse",
|
||||
"library.views": "Library views",
|
||||
"loading": "Loading",
|
||||
"nav.labels": "Labels",
|
||||
"nav.library": "Library",
|
||||
@@ -417,6 +433,7 @@
|
||||
"printing.generic.verticalSpacing": "Vertical Spacing",
|
||||
"printing.qrcode.button": "Print Labels",
|
||||
"printing.qrcode.customUrl.caveat": "A custom URL won't scan back into Spoolman — use it to point a printed QR at an external app or a different host.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Label fields work too, for example",
|
||||
"printing.qrcode.customUrl.hint": "Insert the spool id anywhere with the placeholder",
|
||||
"printing.qrcode.customUrl.label": "Custom URL template",
|
||||
"printing.qrcode.useHTTPUrl.label": "QR code link",
|
||||
@@ -427,6 +444,8 @@
|
||||
"printing.qrcode.useHTTPUrl.tooltip": "Default encodes a Spoolman-only code that only Spoolman's own scanner can read. URL encodes a normal web link that any scanner can open, built from the base URL in settings — or this site's address if that's unset.",
|
||||
"printing.spoolSelect.selectedTotal_one": "{{count}} spool selected",
|
||||
"printing.spoolSelect.selectedTotal_other": "{{count}} spools selected",
|
||||
"scanner.camera": "Camera",
|
||||
"scanner.cameraNumbered": "Camera {{number}}",
|
||||
"scanner.description": "Scan a Spoolman QR code to view details about the spool.",
|
||||
"scanner.error.insecureContext": "The page is not served over HTTPS.",
|
||||
"scanner.error.notAllowed": "You have not allowed access to the camera.",
|
||||
@@ -437,6 +456,8 @@
|
||||
"scanner.starting": "Starting camera...",
|
||||
"scanner.title": "QR Code Scanner",
|
||||
"search.colorHint": "Color names must be entered in English.",
|
||||
"search.browseAll": "Browse all",
|
||||
"search.hint": "Search spools, filaments and manufacturers by name, color, material or any field.",
|
||||
"search.moreSpools": "+{{count}} more",
|
||||
"search.noResults": "No matches found",
|
||||
"search.searching": "Searching...",
|
||||
@@ -512,7 +533,7 @@
|
||||
"settings.scanner.docs": "How to set up a tag reader",
|
||||
"settings.scanner.lastSeen": "{{when}} ago",
|
||||
"settings.scanner.pair": "Pair by tapping",
|
||||
"settings.scanner.reader.anyDesc": "Scans from any reader open spools in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
|
||||
"settings.scanner.reader.anyDesc": "Scans from any reader open spools or filaments in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
|
||||
"settings.scanner.reader.label": "Tag Reader",
|
||||
"settings.scanner.reader.pairedDesc": "Only scans from this reader open spools in this web browser.",
|
||||
"settings.scanner.recent": "Recent readers",
|
||||
@@ -591,7 +612,7 @@
|
||||
"tags.none": "No tags linked.",
|
||||
"tags.scan.openedFilament": "Tag scanned: opened filament {{name}}.",
|
||||
"tags.scan.openedSpool": "Tag scanned: opened spool #{{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool it belongs to and use Add tag to link it.",
|
||||
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool or filament it belongs to and use Add tag to link it.",
|
||||
"tags.section": "Tags",
|
||||
"tags.unlink": "Unlink",
|
||||
"tags.unlinkBody": "Tag {{uid}} will stop identifying this spool. Nothing is written to the tag itself, so you can link it again — to this spool or another one — whenever you like.",
|
||||
@@ -616,6 +637,7 @@
|
||||
"vendor.fields.id": "ID",
|
||||
"vendor.fields.name": "Name",
|
||||
"vendor.fields.registered": "Registered",
|
||||
"vendor.showAsList": "Show as a list",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "The weight of an empty spool from this manufacturer.",
|
||||
"yes": "Yes"
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"add.addAndNew": "",
|
||||
"add.addN_many": "Añadir {{count}} bobinas",
|
||||
"add.addN_one": "Añadir {{count}} bobina",
|
||||
"add.addN_other": "Añadir {{count}} bobinas",
|
||||
"add.adding": "",
|
||||
"add.advanced": "",
|
||||
"add.advancedNote": "",
|
||||
"add.advanced": "Especificaciones Avanzadas",
|
||||
"add.advancedNote": "densidad, diámetro, temperaturas (autorrellenado desde material)",
|
||||
"add.change": "",
|
||||
"add.count": "",
|
||||
"add.createNew": "",
|
||||
|
||||
+227
-155
@@ -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,7 @@
|
||||
"buttons.filter": "Filtruj",
|
||||
"buttons.save": "Zapisz",
|
||||
"buttons.showArchived": "Pokaż zarchiwizowane",
|
||||
"buttons.showLotNumbers": "Pokaż numery partii",
|
||||
"buttons.showNoSpools": "Pokaż filamenty bez szpul",
|
||||
"buttons.unArchive": "Przywróć",
|
||||
"changeFilament.action": "Zmień",
|
||||
@@ -119,7 +120,7 @@
|
||||
"dashboard.errorEmptyName": "Nazwa nie może być pusta",
|
||||
"dashboard.errorExists": "Grupa o tej nazwie już istnieje",
|
||||
"dashboard.errorRename": "Nie udało się zmienić nazwy grupy",
|
||||
"dashboard.lastUsed": "Ostatnio użyta {{time}} temu",
|
||||
"dashboard.lastUsed": "Ostatnio użyto {{time}} temu",
|
||||
"dashboard.newGroup": "Nowa grupa",
|
||||
"dashboard.newGroupName": "Nowa grupa {{n}}",
|
||||
"dashboard.reorderGroup": "Przeciągnij, aby zmienić kolejność grupy",
|
||||
@@ -171,6 +172,7 @@
|
||||
"filament.filament": "Filamenty",
|
||||
"footer.documentation": "Dokumentacja",
|
||||
"footer.reportIssue": "Zgłoś problem",
|
||||
"footer.updateAvailable": "Nowa wersja: v{{version}}",
|
||||
"help.help": "Pomoc",
|
||||
"inspector.addSpoolsOfThis": "Dodaj szpule tego filamentu",
|
||||
"inspector.adjustFailed": "Nie udało się skorygować wagi.",
|
||||
@@ -349,6 +351,20 @@
|
||||
"labels.xMm": "X",
|
||||
"labels.yMm": "Y",
|
||||
"library.apiError": "Nie udało się połączyć z API Spoolmana. Czy backend działa?",
|
||||
"library.catalog.allFilaments": "Wszystkie filamenty",
|
||||
"library.catalog.allManufacturers": "Wszyscy producenci",
|
||||
"library.catalog.backToSpools": "Cofnij do szpul",
|
||||
"library.catalog.filamentCount_few": "{{count}} filamenty",
|
||||
"library.catalog.filamentCount_many": "{{count}} filamentów",
|
||||
"library.catalog.filamentCount_one": "{{count}} filament",
|
||||
"library.catalog.manufacturerCount_few": "{{count}} producenci",
|
||||
"library.catalog.manufacturerCount_many": "{{count}} producentów",
|
||||
"library.catalog.manufacturerCount_one": "{{count}} producent",
|
||||
"library.catalog.noManufacturer": "Brak producenta",
|
||||
"library.catalog.notSet": "Nie ustawiono",
|
||||
"library.catalog.switchList": "Przełącz listę",
|
||||
"library.catalog.unitFilaments": "filamenty",
|
||||
"library.catalog.unitManufacturers": "producenci",
|
||||
"library.collapseGroup": "Ukryj te szpule",
|
||||
"library.dateFilter.ago": "{{span}} temu",
|
||||
"library.dateFilter.between": "{{from}} – {{to}}",
|
||||
@@ -370,7 +386,7 @@
|
||||
"library.groupSpools_few": "{{count}} szpule",
|
||||
"library.groupSpools_many": "{{count}} szpul",
|
||||
"library.groupSpools_one": "{{count}} szpula",
|
||||
"library.nUnused": "×{{count}} nieużywane",
|
||||
"library.nUnused": "×{{count}} nierozpoczętych",
|
||||
"library.noLocation": "brak lokalizacji",
|
||||
"library.noSpools": "Brak szpul",
|
||||
"library.noValues": "Brak wartości",
|
||||
@@ -388,13 +404,15 @@
|
||||
"library.unassigned": "nieprzypisane",
|
||||
"library.unitGroups": "grupy",
|
||||
"library.unitSpools": "szpule",
|
||||
"library.unused": "nieużywane",
|
||||
"library.unused": "nieużyta",
|
||||
"library.unusedGroup": "{{count}} nieużywanych · po {{weight}} g",
|
||||
"library.unusedGroupMixed": "{{count}} nieużywanych",
|
||||
"library.unusedSub": "po {{weight}} g · {{location}}",
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.used": "używane",
|
||||
"library.usedAgo": "użyta {{time}} temu",
|
||||
"library.used": "użyta",
|
||||
"library.usedAgo": "użyto {{time}} temu",
|
||||
"library.viewMenu.browse": "Przeglądaj",
|
||||
"library.views": "Widoki biblioteki",
|
||||
"loading": "Wczytywanie",
|
||||
"locations.locations": "Lokalizacje",
|
||||
"locations.newLocation": "Nowa lokalizacja",
|
||||
@@ -434,6 +452,7 @@
|
||||
"printing.generic.verticalSpacing": "Odstęp pionowy",
|
||||
"printing.qrcode.button": "Drukuj etykiety",
|
||||
"printing.qrcode.customUrl.caveat": "Własny URL nie wróci skanem do Spoolmana — użyj go, żeby kod QR otwierał zewnętrzną aplikację albo inny host.",
|
||||
"printing.qrcode.customUrl.fieldsHint": "Pola etykiet również działają, na przykład",
|
||||
"printing.qrcode.customUrl.hint": "Wstaw identyfikator szpuli w dowolnym miejscu za pomocą placeholdera",
|
||||
"printing.qrcode.customUrl.label": "Własny szablon URL",
|
||||
"printing.qrcode.useHTTPUrl.label": "Link w kodzie QR",
|
||||
@@ -445,6 +464,8 @@
|
||||
"printing.spoolSelect.selectedTotal_few": "Wybrano {{count}} szpule",
|
||||
"printing.spoolSelect.selectedTotal_many": "Wybrano {{count}} szpul",
|
||||
"printing.spoolSelect.selectedTotal_one": "Wybrano {{count}} szpulę",
|
||||
"scanner.camera": "Kamera",
|
||||
"scanner.cameraNumbered": "Kamera {{number}}",
|
||||
"scanner.description": "Zeskanuj kod QR Spoolmana, aby zobaczyć szczegóły szpuli.",
|
||||
"scanner.error.inUse": "Kamera jest już używana przez inną aplikację.",
|
||||
"scanner.error.insecureContext": "Strona nie jest serwowana przez HTTPS.",
|
||||
@@ -454,7 +475,9 @@
|
||||
"scanner.error.unknown": "Wystąpił nieznany błąd. ({{error}})",
|
||||
"scanner.starting": "Uruchamianie kamery...",
|
||||
"scanner.title": "Skaner kodów QR",
|
||||
"search.browseAll": "Przeglądaj wszystko",
|
||||
"search.colorHint": "Nazwy kolorów trzeba wpisywać po angielsku.",
|
||||
"search.hint": "Wyszukuj szpule, filamenty i producentów według nazwy, koloru, materiału lub dowolnego pola.",
|
||||
"search.moreSpools": "+{{count}} więcej",
|
||||
"search.noResults": "Brak wyników",
|
||||
"search.searching": "Szukanie...",
|
||||
@@ -530,7 +553,7 @@
|
||||
"settings.scanner.docs": "Jak skonfigurować czytnik tagów",
|
||||
"settings.scanner.lastSeen": "{{when}} temu",
|
||||
"settings.scanner.pair": "Sparuj przez przyłożenie tagu",
|
||||
"settings.scanner.reader.anyDesc": "Skanowanie z dowolnego czytnika otwiera szpule w tej przeglądarce. Możesz sparować konkretny czytnik, jeśli chcesz mieć większą kontrolę. Jest to również czytnik używany do dodawania nowych tagów do szpul i filamentów.",
|
||||
"settings.scanner.reader.anyDesc": "Skanowanie z dowolnego czytnika otwiera szpule lub filamenty w tej przeglądarce. Możesz sparować konkretny czytnik, aby uzyskać większą kontrolę. Ten czytnik będzie również używany do dodawania nowych tagów do szpul i filamentów.",
|
||||
"settings.scanner.reader.label": "Czytnik tagów",
|
||||
"settings.scanner.reader.pairedDesc": "Tylko odczyty z tego czytnika otwierają szpule w tej przeglądarce.",
|
||||
"settings.scanner.recent": "Ostatnio używane czytniki",
|
||||
@@ -609,7 +632,7 @@
|
||||
"tags.none": "Brak połączonych tagów.",
|
||||
"tags.scan.openedFilament": "Zeskanowano tag: otwarto filament {{name}}.",
|
||||
"tags.scan.openedSpool": "Zeskanowano tag: otwarto szpulę nr {{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Nieznany tag {{uid}}. Otwórz szpulę, do której należy, i użyj opcji „Dodaj tag”, aby go połączyć.",
|
||||
"tags.scan.unknown": "Nieznany tag {{uid}}. Otwórz szpulę lub filament, do którego należy, i użyj opcji „Dodaj tag”, aby go przypisać.",
|
||||
"tags.section": "Tagi",
|
||||
"tags.unlink": "Odłącz",
|
||||
"tags.unlinkBody": "Tag {{uid}} przestanie identyfikować tę szpulę. Na samym tagu nie są zapisywane żadne dane, więc możesz go ponownie połączyć z tą lub inną szpulą, kiedy tylko zechcesz.",
|
||||
@@ -635,5 +658,6 @@
|
||||
"vendor.fields.name": "Nazwa",
|
||||
"vendor.fields.registered": "Zarejestrowano",
|
||||
"vendor.fieldsHelp.emptySpoolWeight": "Waga pustej szpuli tego producenta.",
|
||||
"vendor.showAsList": "Pokaż jako listę",
|
||||
"yes": "Tak"
|
||||
}
|
||||
+465
-316
@@ -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",
|
||||
|
||||
@@ -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": "是"
|
||||
}
|
||||
Generated
+442
-414
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,8 @@
|
||||
"ta",
|
||||
"th",
|
||||
"lt",
|
||||
"tr"
|
||||
"tr",
|
||||
"sl",
|
||||
"ko"
|
||||
]
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -172,7 +172,18 @@ export function mapGroup(g: Json): GroupSummary {
|
||||
totalRemaining: g.total_remaining_weight ?? 0,
|
||||
hasStock: (g.spool_count ?? 0) > 0,
|
||||
lastUsedLabel: formatDurationShort(g.last_used),
|
||||
lastUsedSort: 0
|
||||
lastUsedSort: 0,
|
||||
filamentCount: g.filament_count ?? undefined,
|
||||
previewSpools: (g.spools as Json[] | undefined)?.map((s) => ({
|
||||
id: String(s.id),
|
||||
remaining: s.remaining_weight ?? null,
|
||||
initial: s.initial_weight ?? null
|
||||
})),
|
||||
previewFilaments: (g.filaments as Json[] | undefined)?.map((f) => ({
|
||||
id: String(f.id),
|
||||
colors: colorsFromApi(f),
|
||||
direction: f.multi_color_direction ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[]> {
|
||||
@@ -254,7 +273,7 @@ class HttpSpoolSource {
|
||||
*/
|
||||
async importExternalFilament(ext: ExternalFilament): Promise<Filament> {
|
||||
// Already imported?
|
||||
const existing = await getJson<Json[]>('/filament', { external_id: ext.id });
|
||||
const existing = await getJson<Json[]>('/filament', { external_id: quote(ext.id) });
|
||||
if (existing.length) {
|
||||
if (existing[0].vendor) inventory.upsertVendor(mapVendor(existing[0].vendor));
|
||||
const f = mapFilament(existing[0]);
|
||||
@@ -262,19 +281,14 @@ class HttpSpoolSource {
|
||||
return f;
|
||||
}
|
||||
|
||||
// Get-or-create the vendor (matched by external_id = manufacturer name).
|
||||
let vendorId: number;
|
||||
const vendors = await getJson<Json[]>('/vendor', { external_id: ext.manufacturer });
|
||||
// Match the vendor by external_id first, then by name.
|
||||
let vendorId: number | undefined;
|
||||
const vendors = await getJson<Json[]>('/vendor', { external_id: quote(ext.manufacturer) });
|
||||
if (vendors.length) {
|
||||
inventory.upsertVendor(mapVendor(vendors[0]));
|
||||
vendorId = vendors[0].id;
|
||||
} else {
|
||||
const created = await postJson<Json>('/vendor', {
|
||||
name: ext.manufacturer,
|
||||
external_id: ext.manufacturer
|
||||
});
|
||||
inventory.upsertVendor(mapVendor(created));
|
||||
vendorId = created.id;
|
||||
vendorId = await this.getOrCreateVendor(ext.manufacturer, undefined, ext.manufacturer);
|
||||
}
|
||||
|
||||
const body: Json = {
|
||||
@@ -305,10 +319,10 @@ class HttpSpoolSource {
|
||||
/**
|
||||
* Find a vendor by (case-insensitive) name or create it; returns its id.
|
||||
*
|
||||
* `extra` only applies to a vendor this call creates — matching an existing
|
||||
* `extra` and `externalId` only apply to a vendor this call creates — matching an existing
|
||||
* name links that record, it never edits it.
|
||||
*/
|
||||
async getOrCreateVendor(name: string, extra?: Extra): Promise<number | undefined> {
|
||||
async getOrCreateVendor(name: string, extra?: Extra, externalId?: string): Promise<number | undefined> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const all = await getJson<Json[]>('/vendor');
|
||||
@@ -319,6 +333,7 @@ class HttpSpoolSource {
|
||||
}
|
||||
const created = await postJson<Json>('/vendor', {
|
||||
name: trimmed,
|
||||
external_id: externalId,
|
||||
extra: extra && Object.keys(extra).length ? extra : undefined
|
||||
});
|
||||
inventory.upsertVendor(mapVendor(created));
|
||||
@@ -335,10 +350,10 @@ class HttpSpoolSource {
|
||||
density: draft.density,
|
||||
diameter: draft.diameter,
|
||||
weight: draft.weight || undefined,
|
||||
spool_weight: draft.spoolWeight || undefined,
|
||||
settings_extruder_temp: draft.nozzleTemp || undefined,
|
||||
settings_bed_temp: draft.bedTemp || undefined,
|
||||
price: draft.price || undefined,
|
||||
spool_weight: draft.spoolWeight,
|
||||
settings_extruder_temp: draft.nozzleTemp,
|
||||
settings_bed_temp: draft.bedTemp,
|
||||
price: draft.price,
|
||||
article_number: draft.articleNumber || undefined,
|
||||
comment: draft.comment || undefined,
|
||||
extra: draft.extra && Object.keys(draft.extra).length ? draft.extra : undefined,
|
||||
@@ -532,6 +547,16 @@ class HttpSpoolSource {
|
||||
async locations(): Promise<string[]> {
|
||||
return getJson<string[]>('/location');
|
||||
}
|
||||
/**
|
||||
* Locations to offer when placing a spool. GET /location only knows the ones that
|
||||
* hold a spool right now, so the empty ones kept as dashboard cards (an idle
|
||||
* toolhead, a new shelf) are added from the saved layout, as the old client did
|
||||
* with its `locations` setting (#1261).
|
||||
*/
|
||||
async locationChoices(): Promise<string[]> {
|
||||
const [inUse, layout] = await Promise.all([this.locations(), loadLayout().catch(() => null)]);
|
||||
return [...new Set([...inUse, ...(layout?.groups.location ?? [])])].filter(Boolean).sort();
|
||||
}
|
||||
/**
|
||||
* Replace one value of one spool field wherever it occurs, in a single request. Works for
|
||||
* any field the spool owns, so the dashboard can rename a whole group without paging in its
|
||||
|
||||
@@ -45,6 +45,27 @@ export interface GroupSummary {
|
||||
lastUsedLabel: string;
|
||||
/** Numeric recency for group ordering (higher = more recent). */
|
||||
lastUsedSort: number;
|
||||
/** Vendor groups only: how many filaments the group spans — every matching
|
||||
* filament of the manufacturer when includeEmpty, else those with spools. */
|
||||
filamentCount?: number;
|
||||
/** Filament groups, with GroupQuery.preview: the first few spools (by id). */
|
||||
previewSpools?: SpoolPreview[];
|
||||
/** Vendor groups, with GroupQuery.preview: the first few filaments (by name). */
|
||||
previewFilaments?: FilamentPreview[];
|
||||
}
|
||||
|
||||
/** Just enough of a spool to draw its fill pip. Grams; null when unknown. */
|
||||
export interface SpoolPreview {
|
||||
id: string;
|
||||
remaining: number | null;
|
||||
initial: number | null;
|
||||
}
|
||||
|
||||
/** Just enough of a filament to draw its swatch. */
|
||||
export interface FilamentPreview {
|
||||
id: string;
|
||||
colors: string[];
|
||||
direction?: MultiColorDirection;
|
||||
}
|
||||
|
||||
export interface GroupScope {
|
||||
@@ -74,9 +95,11 @@ export interface GroupQuery {
|
||||
sort: SortField[];
|
||||
/** Include archived spools in the group aggregates (default: excluded). */
|
||||
allowArchived?: boolean;
|
||||
/** List matching filaments that hold no matching spools as groups of zero
|
||||
* (filament grouping only; see buildGroupQuery). */
|
||||
/** List matching filaments (filament grouping) or manufacturers (vendor
|
||||
* grouping) that hold no matching spools, as groups of zero. */
|
||||
includeEmpty?: boolean;
|
||||
/** Attach previewSpools / previewFilaments to filament / vendor groups. */
|
||||
preview?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
lowThreshold: number;
|
||||
@@ -88,6 +111,10 @@ export interface Page<T> {
|
||||
items: T[];
|
||||
/** Total matching rows across all pages (from X-Total-Count). */
|
||||
total: number;
|
||||
/** Group pages only: spools and remaining grams summed over ALL matching
|
||||
* groups, not just this page (X-Total-Spools / X-Total-Remaining-Weight). */
|
||||
totalSpools?: number;
|
||||
totalRemaining?: number;
|
||||
}
|
||||
|
||||
// --- cross-entity search (GET /search) -----------------------------------
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { ExternalSearch } from '$lib/api/externalSearch.svelte';
|
||||
import { roundGrams, weightAuto } from '$lib/utils/format';
|
||||
import { numErr } from '$lib/utils/validate';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import {
|
||||
FILAMENT_ADVANCED_KEYS,
|
||||
FILAMENT_FIELD_ORDER,
|
||||
@@ -140,7 +141,7 @@
|
||||
fields.ensure('filament');
|
||||
fields.ensure('vendor');
|
||||
spoolSource
|
||||
.locations()
|
||||
.locationChoices()
|
||||
.then((l) => (locations = l))
|
||||
.catch(() => {});
|
||||
if (presetFilamentId) {
|
||||
@@ -277,9 +278,9 @@
|
||||
chosen = c;
|
||||
netWeight = String(cWeight(c) || 1000);
|
||||
const sw = cSpoolWeight(c);
|
||||
spoolWeight = sw ? String(sw) : '';
|
||||
spoolWeight = sw != null ? String(sw) : '';
|
||||
const p = cPrice(c);
|
||||
price = p ? String(p) : '';
|
||||
price = p != null ? String(p) : '';
|
||||
resetSpoolForm();
|
||||
step = 2;
|
||||
}
|
||||
@@ -382,24 +383,24 @@
|
||||
}
|
||||
|
||||
const n = countN;
|
||||
const net = Number(netWeight) || 0;
|
||||
const spool = Number(spoolWeight) || 0;
|
||||
const net = parseDecimal(netWeight) ?? 0;
|
||||
const spool = parseDecimal(spoolWeight) ?? 0;
|
||||
const body: Record<string, unknown> = {
|
||||
filament_id: filamentId,
|
||||
initial_weight: Number(netWeight) || undefined,
|
||||
spool_weight: Number(spoolWeight) || undefined,
|
||||
price: parseFloat(price) || undefined,
|
||||
initial_weight: parseDecimal(netWeight) ?? undefined,
|
||||
spool_weight: parseDecimal(spoolWeight) ?? undefined,
|
||||
price: parseDecimal(price) ?? undefined,
|
||||
location: location.trim() || undefined,
|
||||
lot_nr: lot.trim() || undefined,
|
||||
comment: comment.trim() || undefined
|
||||
};
|
||||
if (fillMode === 'used') body.used_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'measured')
|
||||
if (fillMode === 'used') body.used_weight = parseDecimal(fillWeight) ?? undefined;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = parseDecimal(fillWeight) ?? undefined;
|
||||
else if (fillMode === 'measured' && parseDecimal(fillWeight) !== null)
|
||||
// Rounded, because this subtraction is where float dust gets born: weighing a full
|
||||
// 1000 g spool on a 128.11 g core gives 1000 + 128.11 − 1128.11 = 2.3e-13, and a
|
||||
// spool created with that much used is not an unused spool any more (#986).
|
||||
body.used_weight = roundGrams(Math.max(0, net + spool - (Number(fillWeight) || 0)));
|
||||
body.used_weight = roundGrams(Math.max(0, net + spool - (parseDecimal(fillWeight) ?? 0)));
|
||||
if (firstUsed) body.first_used = firstUsed;
|
||||
if (lastUsed) body.last_used = lastUsed;
|
||||
if (Object.keys(extraValues).length) body.extra = extraValues;
|
||||
@@ -440,9 +441,9 @@
|
||||
// Cross-check the fill amount against the net/empty-spool weights above.
|
||||
// Only applies the upper bound when those weights are themselves valid so
|
||||
// we don't cascade an unrelated error into this field.
|
||||
const netN = Number(netWeight);
|
||||
const netValid = netWeight.trim() !== '' && Number.isFinite(netN) && netN > 0;
|
||||
const spoolN = Number.isFinite(Number(spoolWeight)) ? Number(spoolWeight) : 0;
|
||||
const netN = parseDecimal(netWeight);
|
||||
const netValid = netN !== null && netN > 0;
|
||||
const spoolN = parseDecimal(spoolWeight) ?? 0;
|
||||
if (fillMode === 'measured') {
|
||||
// Weight on the scale = filament left + empty spool, so it can be at most
|
||||
// net+spool (full) and at least the empty-spool weight (nothing left).
|
||||
@@ -725,8 +726,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="preset"
|
||||
class:on={Number(netWeight) === preset}
|
||||
aria-pressed={Number(netWeight) === preset}
|
||||
class:on={parseDecimal(netWeight) === preset}
|
||||
aria-pressed={parseDecimal(netWeight) === preset}
|
||||
onclick={() => (netWeight = String(preset))}>{weightAuto(preset)}</button
|
||||
>
|
||||
{/each}
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
Spoolman
|
||||
{#if info}
|
||||
v{info.version}{#if info.git_commit}<span class="commit"> ({info.git_commit})</span>{/if}
|
||||
{#if info.update_available && info.latest_version}
|
||||
<a
|
||||
class="update"
|
||||
href="https://github.com/Donkie/Spoolman/releases/tag/v{info.latest_version}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{m['footer.updateAvailable']({ version: info.latest_version })}</a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
·
|
||||
<a href="https://github.com/Donkie/Spoolman">{m['footer.documentation']()}</a>
|
||||
@@ -56,6 +64,11 @@
|
||||
.commit {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.update {
|
||||
margin-left: 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sponsor {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -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>
|
||||
@@ -108,6 +108,9 @@
|
||||
<p class="hint">
|
||||
{m['printing.qrcode.customUrl.hint']()} <code>{'{id}'}</code>
|
||||
</p>
|
||||
<p class="hint">
|
||||
{m['printing.qrcode.customUrl.fieldsHint']()} <code>{'{filament.id}'}</code>
|
||||
</p>
|
||||
<p class="hint">{m['printing.qrcode.customUrl.caveat']()}</p>
|
||||
{:else}
|
||||
<p class="hint">{m['printing.qrcode.useHTTPUrl.tooltip']()}</p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import Pagination from '$components/Pagination.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
import LabelCanvas from './LabelCanvas.svelte';
|
||||
import { labelKind, type LabelDesign } from '$lib/labels/types';
|
||||
import { labelKind, type LabelKind, type LabelDesign } from '$lib/labels/types';
|
||||
import type { LabelBinding } from '$lib/labels/template';
|
||||
import { PAPER_NAMES, layoutMargin, paperSize, sheetGrid } from '$lib/labels/paper';
|
||||
import { printLabels, exportLabels, ZIP_THRESHOLD } from '$lib/labels/print';
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
let selected = $state<Set<number>>(new Set(untrack(() => preselected)));
|
||||
let search = $state('');
|
||||
// Default to newest-first so spools you just added sit at the top of the list —
|
||||
// Default to newest-first so items you just added sit at the top of the list,
|
||||
// the common case when printing labels for a fresh batch.
|
||||
let sort = $state<'newest' | 'id'>('newest');
|
||||
let loading = $state(true);
|
||||
@@ -41,8 +41,8 @@
|
||||
let printing = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
// The full inventory can be thousands of spools, so loading every one up front
|
||||
// was an extremely heavy query. Instead the list shows one page at a time and
|
||||
// The full inventory can be thousands of spools or filaments, so loading it all
|
||||
// up front was an extremely heavy query. Instead the list shows one page at a time and
|
||||
// search pulls in anything else on demand. Pages rather than infinite scroll so
|
||||
// "All" stays honest: it adds the page you are looking at.
|
||||
const PAGE = 100;
|
||||
@@ -58,6 +58,8 @@
|
||||
// search result (null when not searching). All index into the reactive inventory
|
||||
// cache, so the list stays live as spools are edited or removed.
|
||||
let pageIds = $state<number[]>([]);
|
||||
let pageFIds = $state<string[]>([]);
|
||||
let resultFIds = $state<string[] | null>(null);
|
||||
let recentIds = $state<number[]>([]);
|
||||
let resultIds = $state<number[] | null>(null);
|
||||
let pageNo = $state(1);
|
||||
@@ -85,29 +87,38 @@
|
||||
const dir = $derived(sort === 'newest' ? 'desc' : 'asc');
|
||||
|
||||
$effect(() => {
|
||||
if (kind !== 'spool') return;
|
||||
void kind;
|
||||
untrack(() => (pageNo = 1));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const ctrl = new AbortController();
|
||||
void loadPage(dir, (pageNo - 1) * pageSize, pageSize, ctrl.signal);
|
||||
void loadPage(kind, dir, (pageNo - 1) * pageSize, pageSize, ctrl.signal);
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
async function loadPage(dir: 'asc' | 'desc', offset: number, limit: number, signal: AbortSignal) {
|
||||
async function loadPage(
|
||||
kind: LabelKind,
|
||||
dir: 'asc' | 'desc',
|
||||
offset: number,
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
loading = true;
|
||||
try {
|
||||
const page = await spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'id', dir }],
|
||||
limit,
|
||||
offset,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
const query = { sort: [{ field: 'id', dir }], limit, offset, signal };
|
||||
const page =
|
||||
kind === 'filament'
|
||||
? await spoolSource.listFilamentPage(query)
|
||||
: await spoolSource.listSpools({ ...query, filters: {}, lowThreshold: settings.lowThreshold });
|
||||
if (signal.aborted) return;
|
||||
pageIds = page.items.map((s) => s.id);
|
||||
if (kind === 'filament') pageFIds = (page.items as Filament[]).map((f) => f.id);
|
||||
else pageIds = (page.items as Spool[]).map((s) => s.id);
|
||||
total = page.total;
|
||||
listEl?.scrollTo(0, 0);
|
||||
} catch (e) {
|
||||
// Abandoning the tab mid-load is exactly when cancelling matters most.
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load spools for printing', e);
|
||||
console.error(`Failed to load ${kind}s for printing`, e);
|
||||
} finally {
|
||||
if (!signal.aborted) loading = false;
|
||||
}
|
||||
@@ -148,28 +159,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Search hits the backend rather than filtering the preloaded slice, so spools
|
||||
// Search hits the backend rather than filtering the preloaded slice, so entities
|
||||
// outside the latest batch stay findable. Debounced, and superseded queries are
|
||||
// aborted on cleanup.
|
||||
$effect(() => {
|
||||
if (kind !== 'spool') return;
|
||||
const q = search.trim();
|
||||
const searchKind = kind;
|
||||
const q = (searchKind === 'filament' ? fSearch : search).trim();
|
||||
if (!q) {
|
||||
resultIds = null;
|
||||
resultFIds = null;
|
||||
searching = false;
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => void runSearch(q, ctrl.signal), 250);
|
||||
const timer = setTimeout(() => void runSearch(searchKind, q, ctrl.signal), 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
ctrl.abort();
|
||||
};
|
||||
});
|
||||
async function runSearch(q: string, signal: AbortSignal) {
|
||||
async function runSearch(kind: LabelKind, q: string, signal: AbortSignal) {
|
||||
try {
|
||||
const { spools, filaments } = await searchAll(q, COLOR_THRESHOLD, signal);
|
||||
if (signal.aborted) return;
|
||||
if (kind === 'filament') {
|
||||
resultFIds = filaments.map((m) => m.entity.id);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const ids = new Set<number>(spools.map((m) => m.entity.id));
|
||||
// The /search spool pass only matches spool-native fields, so also pull
|
||||
@@ -187,10 +204,10 @@
|
||||
});
|
||||
for (const s of page.items) ids.add(s.id);
|
||||
}
|
||||
resultIds = [...ids];
|
||||
if (!signal.aborted) resultIds = [...ids];
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Spool search failed', e);
|
||||
console.error(`${kind} search failed`, e);
|
||||
} finally {
|
||||
if (!signal.aborted) searching = false;
|
||||
}
|
||||
@@ -254,27 +271,8 @@
|
||||
}
|
||||
|
||||
// --- Filament selection (filament labels) ------------------------------------
|
||||
// Filament catalogs are small enough to load in one page and filter client-side,
|
||||
// so the filament picker is simpler than the spool one: no incremental paging,
|
||||
// no backend search, no "recent" window.
|
||||
let selectedF = $state<Set<string>>(new Set());
|
||||
let fSearch = $state('');
|
||||
let fLoading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (kind !== 'filament') return;
|
||||
const ctrl = new AbortController();
|
||||
fLoading = true;
|
||||
spoolSource
|
||||
.listFilaments(1000, ctrl.signal)
|
||||
.catch((e) => {
|
||||
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filaments for printing', e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) fLoading = false;
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
|
||||
function filamentLabel(f: Filament): string {
|
||||
const v = inventory.vendorById(f.vendorId);
|
||||
@@ -282,16 +280,12 @@
|
||||
return `#${f.id} · ${name}${f.material ? ' · ' + f.material : ''}`;
|
||||
}
|
||||
|
||||
const visibleFilaments = $derived.by(() => {
|
||||
const q = fSearch.trim().toLowerCase();
|
||||
return inventory.filaments
|
||||
.filter((f) => {
|
||||
if (!q) return true;
|
||||
const v = inventory.vendorById(f.vendorId);
|
||||
return `${f.name} ${f.material} ${v?.name ?? ''}`.toLowerCase().includes(q);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
const visibleFilaments = $derived(
|
||||
(resultFIds ?? pageFIds)
|
||||
.map((id) => inventory.filamentById(id))
|
||||
.filter((f): f is Filament => !!f)
|
||||
.sort((a, b) => (sort === 'newest' ? Number(b.id) - Number(a.id) : Number(a.id) - Number(b.id)))
|
||||
);
|
||||
|
||||
function toggleF(id: string) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local; `selectedF` updates via reassignment below
|
||||
@@ -301,7 +295,7 @@
|
||||
selectedF = next;
|
||||
}
|
||||
function selectAllF() {
|
||||
selectedF = new Set(visibleFilaments.map((f) => f.id));
|
||||
selectedF = new Set([...selectedF, ...visibleFilaments.map((f) => f.id)]);
|
||||
}
|
||||
function clearAllF() {
|
||||
selectedF = new Set();
|
||||
@@ -381,21 +375,31 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet sortControls()}
|
||||
<div class="seg sort" title={kind === 'spool' ? m['labels.sortHint']() : undefined}>
|
||||
<button class:active={sort === 'newest'} onclick={() => setSort('newest')}
|
||||
>{m['labels.sortNewest']()}</button
|
||||
>
|
||||
<button class:active={sort === 'id'} onclick={() => setSort('id')}>{m['labels.sortId']()}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="print-panel">
|
||||
<div class="col spools">
|
||||
{#if kind === 'filament'}
|
||||
<div class="col-head">
|
||||
<span>{m['filament.filament']()}</span>
|
||||
<div class="mini-actions">
|
||||
<button onclick={selectAllF}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={selectAllF} disabled={loading || searching}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={clearAllF}>{m['labels.selectNoneShort']()}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input class="search" placeholder={m['labels.searchFilaments']()} bind:value={fSearch} />
|
||||
{@render sortControls()}
|
||||
</div>
|
||||
<div class="spool-list">
|
||||
{#if fLoading && visibleFilaments.length === 0}
|
||||
<div class="spool-list" bind:this={listEl}>
|
||||
{#if loading || (searching && visibleFilaments.length === 0)}
|
||||
<div class="muted">{m.loading()}…</div>
|
||||
{:else if visibleFilaments.length === 0}
|
||||
<div class="muted">{m['labels.noFilaments']()}</div>
|
||||
@@ -414,7 +418,7 @@
|
||||
<div class="col-head">
|
||||
<span>{m['spool.spool']()}</span>
|
||||
<div class="mini-actions">
|
||||
<button onclick={selectAll}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={selectAll} disabled={loading || searching}>{m['labels.selectAllShort']()}</button>
|
||||
<button onclick={selectRecent} disabled={recentCount === 0} title={m['labels.selectRecentHint']()}
|
||||
>{m['labels.selectRecentShort']({ count: recentCount })}</button
|
||||
>
|
||||
@@ -423,12 +427,7 @@
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input class="search" placeholder={m['labels.searchSpools']()} bind:value={search} />
|
||||
<div class="seg sort" title={m['labels.sortHint']()}>
|
||||
<button class:active={sort === 'newest'} onclick={() => setSort('newest')}
|
||||
>{m['labels.sortNewest']()}</button
|
||||
>
|
||||
<button class:active={sort === 'id'} onclick={() => setSort('id')}>{m['labels.sortId']()}</button>
|
||||
</div>
|
||||
{@render sortControls()}
|
||||
</div>
|
||||
<div class="spool-list" bind:this={listEl}>
|
||||
{#if loading || (searching && visibleSpools.length === 0)}
|
||||
@@ -445,21 +444,21 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !resultIds && total > pageSize}
|
||||
<Pagination
|
||||
page={pageNo}
|
||||
{pageSize}
|
||||
{total}
|
||||
unit={m['library.unitSpools']()}
|
||||
onpage={(p) => (pageNo = p)}
|
||||
onpagesize={(s) => {
|
||||
pageSize = s;
|
||||
pageNo = 1;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="count">{m['printing.spoolSelect.selectedTotal']({ count: selected.size })}</div>
|
||||
{/if}
|
||||
{#if !(kind === 'filament' ? resultFIds : resultIds) && total > pageSize}
|
||||
<Pagination
|
||||
page={pageNo}
|
||||
{pageSize}
|
||||
{total}
|
||||
unit={kind === 'filament' ? m['library.catalog.unitFilaments']() : m['library.unitSpools']()}
|
||||
onpage={(p) => (pageNo = p)}
|
||||
onpagesize={(s) => {
|
||||
pageSize = s;
|
||||
pageNo = 1;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="col layout">
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from resolve() or params.catalogHref(), which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import { resolve } from '$app/paths';
|
||||
import { tick } from 'svelte';
|
||||
import { catalogHref, type CatalogView } from '$lib/library/params';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
view: CatalogView;
|
||||
/** Rows in the whole list (not the page); absent until the first load. */
|
||||
count?: number;
|
||||
}
|
||||
let { view, count }: Props = $props();
|
||||
|
||||
const lists = [
|
||||
{ view: 'filaments', label: m['library.catalog.allFilaments'] },
|
||||
{ view: 'manufacturers', label: m['library.catalog.allManufacturers'] }
|
||||
] as const;
|
||||
let title = $derived(lists.find((l) => l.view === view)!.label());
|
||||
|
||||
// The menu follows the Library tab's (NavTabs): arrow keys walk the items,
|
||||
// Escape hands focus back to the button, a click anywhere else closes it.
|
||||
let open = $state(false);
|
||||
let wrap = $state<HTMLElement>();
|
||||
let button = $state<HTMLButtonElement>();
|
||||
let menuEl = $state<HTMLElement>();
|
||||
|
||||
function items(): HTMLElement[] {
|
||||
return menuEl ? [...menuEl.querySelectorAll<HTMLElement>('[role^="menuitem"]')] : [];
|
||||
}
|
||||
async function openMenu(focusFirst: boolean) {
|
||||
open = true;
|
||||
if (!focusFirst) return;
|
||||
await tick();
|
||||
items()[0]?.focus();
|
||||
}
|
||||
function closeMenu(refocus = false) {
|
||||
open = false;
|
||||
if (refocus) button?.focus();
|
||||
}
|
||||
|
||||
// detail === 0 means the click came from Enter/Space, not a pointer.
|
||||
function onButtonClick(e: MouseEvent) {
|
||||
if (open) closeMenu();
|
||||
else openMenu(e.detail === 0);
|
||||
}
|
||||
function onButtonKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
openMenu(true);
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
// Opened by a click, focus is still here rather than in the menu.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
function onMenuKeydown(e: KeyboardEvent) {
|
||||
const list = items();
|
||||
const i = list.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | undefined;
|
||||
if (e.key === 'ArrowDown') next = (i + 1) % list.length;
|
||||
else if (e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = list.length - 1;
|
||||
if (next !== undefined) {
|
||||
e.preventDefault();
|
||||
list[next].focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Don't let it also close a surrounding dialog or panel.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu(true);
|
||||
} else if (e.key === 'Tab') {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Capture phase, like Combobox, so a click that something else swallows still closes the menu.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (wrap && !wrap.contains(e.target as Node)) closeMenu();
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
return () => window.removeEventListener('pointerdown', onDown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Where you are and the way back: the catalogs are side trips from the spool
|
||||
list, so the spools stay one click away and the title says which list this is. -->
|
||||
<div class="head">
|
||||
<a class="back" href={resolve('/')} title={m['library.catalog.backToSpools']()}
|
||||
><ChevronLeft size={16} />{m['search.section.spools']()}</a
|
||||
>
|
||||
<span class="slash" aria-hidden="true">/</span>
|
||||
<div class="title-wrap" bind:this={wrap}>
|
||||
<button
|
||||
type="button"
|
||||
class="title"
|
||||
class:open
|
||||
bind:this={button}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={m['library.catalog.switchList']()}
|
||||
onclick={onButtonClick}
|
||||
onkeydown={onButtonKeydown}
|
||||
>
|
||||
<span class="label">{title}</span>
|
||||
{#if count !== undefined}<span class="count mono">{count}</span>{/if}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{#if open}
|
||||
<!-- eslint-disable-next-line svelte/no-static-element-interactions -- arrow keys bubble up from the focused item -->
|
||||
<div
|
||||
class="menu"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={m['library.catalog.switchList']()}
|
||||
bind:this={menuEl}
|
||||
onkeydown={onMenuKeydown}
|
||||
>
|
||||
{#each lists as l (l.view)}
|
||||
{@const checked = l.view === view}
|
||||
<a
|
||||
class="mi"
|
||||
class:sel={checked}
|
||||
href={catalogHref(l.view)}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
onclick={() => closeMenu()}
|
||||
>
|
||||
<span class="mi-check" aria-hidden="true"
|
||||
>{#if checked}<Check size={15} />{/if}</span
|
||||
>
|
||||
<span class="mi-label">{l.label()}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<div class="menu-sep" role="separator"></div>
|
||||
<a class="mi" href={resolve('/')} role="menuitem" onclick={() => closeMenu()}>
|
||||
<span class="mi-check" aria-hidden="true"></span>
|
||||
<span class="mi-label">{m['library.catalog.backToSpools']()}</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 8px 14px 0;
|
||||
flex: none;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: none;
|
||||
height: 32px;
|
||||
padding: 0 8px 0 4px;
|
||||
margin-left: -6px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.slash {
|
||||
flex: none;
|
||||
padding: 0 2px;
|
||||
color: var(--text-faint);
|
||||
font-size: 14px;
|
||||
}
|
||||
.title-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title:hover,
|
||||
.title.open {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
/* A long translation gives way before the count and the chevron do. */
|
||||
.label {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.count {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.back:focus-visible,
|
||||
.title:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 220px;
|
||||
padding: 4px 0;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mi:hover,
|
||||
.mi:focus-visible {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.mi:focus-visible {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
.mi.sel {
|
||||
color: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mi-check {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
display: flex;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Phone: the same 44px tap targets as the rest of the mobile chrome. */
|
||||
@media (max-width: 860px) {
|
||||
.head {
|
||||
padding-top: 6px;
|
||||
}
|
||||
.back,
|
||||
.title {
|
||||
height: 44px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.mi {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Section links come from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import CatalogHeader from './CatalogHeader.svelte';
|
||||
import CatalogRow from './CatalogRow.svelte';
|
||||
import ListToolbar from './ListToolbar.svelte';
|
||||
import Pagination from '../Pagination.svelte';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import type { GroupSummary } from '$lib/api/types';
|
||||
import type { CatalogView, LibraryState } from '$lib/library/params';
|
||||
import * as params from '$lib/library/params';
|
||||
import { catalogSections } from '$lib/library/catalog';
|
||||
import { buildCatalogQuery, buildVendorTotalsQuery } from '$lib/api/query';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { live } from '$lib/api/live';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The filament and manufacturer catalogs: sub-views of the Library that page
|
||||
// the group endpoint the way FilamentList does, one row per filament or
|
||||
// manufacturer, spools or not.
|
||||
let { libraryState }: { libraryState: LibraryState } = $props();
|
||||
|
||||
let view = $derived(libraryState.view as CatalogView);
|
||||
|
||||
// What is on screen, swapped whole when a load lands. `sectioned` belongs to the
|
||||
// rows it was loaded with, not to the current URL: while a regrouped page is on
|
||||
// its way, the old rows keep the layout they arrived in.
|
||||
let groups = $state<GroupSummary[]>([]);
|
||||
let sectioned = $state(false);
|
||||
/** Section totals by manufacturer id, for the manufacturers on this page. */
|
||||
let vendorTotals = $state<Record<string, GroupSummary>>({});
|
||||
let total = $state<number>();
|
||||
let totalSpools = $state<number>();
|
||||
let totalRemaining = $state<number>();
|
||||
let loading = $state(false);
|
||||
let errored = $state(false);
|
||||
/** Bumped by live events; forces a refetch of the page. */
|
||||
let revision = $state(0);
|
||||
|
||||
// One request chain at a time, aborted by the effect's cleanup when superseded,
|
||||
// exactly like FilamentList. The section totals need the page's manufacturer
|
||||
// ids, so they follow the page rather than racing it, and both land together.
|
||||
let reqId = 0;
|
||||
$effect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const s = libraryState;
|
||||
const q = buildCatalogQuery(s, ctrl.signal);
|
||||
const withSections = s.view === 'filaments' && s.group === 'vendor';
|
||||
void revision; // refetch on live events
|
||||
const mine = ++reqId;
|
||||
loading = true;
|
||||
errored = false;
|
||||
(async () => {
|
||||
const page = await spoolSource.listGroups(q);
|
||||
const totals: Record<string, GroupSummary> = {};
|
||||
// The filaments with no manufacturer have no vendor group to total them.
|
||||
const ids = withSections
|
||||
? [...new Set(page.items.map((g) => g.vendorId).filter((id): id is string => !!id))]
|
||||
: [];
|
||||
// The totals only dress the section headers, so losing them costs the
|
||||
// headers their meta line, not the page its rows.
|
||||
if (ids.length) {
|
||||
try {
|
||||
const vendors = await spoolSource.listGroups(buildVendorTotalsQuery(s, ids, ctrl.signal));
|
||||
for (const v of vendors.items) totals[v.key] = v;
|
||||
} catch (err) {
|
||||
if (isAbortError(err, ctrl.signal)) throw err;
|
||||
console.error('Failed to load the manufacturer totals', err);
|
||||
}
|
||||
}
|
||||
if (mine !== reqId) return;
|
||||
groups = page.items;
|
||||
sectioned = withSections;
|
||||
vendorTotals = totals;
|
||||
total = page.total;
|
||||
totalSpools = page.totalSpools;
|
||||
totalRemaining = page.totalRemaining;
|
||||
loading = false;
|
||||
})().catch((err) => {
|
||||
if (mine !== reqId || isAbortError(err, ctrl.signal)) return;
|
||||
console.error('Failed to load the catalog', err);
|
||||
errored = true;
|
||||
loading = false;
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
|
||||
// Live updates, coalesced with the same leading-edge throttle as FilamentList
|
||||
// (see the reasoning there): the first event refetches at once, a burst folds
|
||||
// into one catch-up refetch when the window ends.
|
||||
const COALESCE_MS = 300;
|
||||
let cooldown: ReturnType<typeof setTimeout> | undefined;
|
||||
let coalesced = false;
|
||||
function bumpRevision() {
|
||||
if (cooldown) {
|
||||
coalesced = true;
|
||||
return;
|
||||
}
|
||||
revision++;
|
||||
cooldown = setTimeout(() => {
|
||||
cooldown = undefined;
|
||||
if (coalesced) {
|
||||
coalesced = false;
|
||||
bumpRevision();
|
||||
}
|
||||
}, COALESCE_MS);
|
||||
}
|
||||
$effect(() => {
|
||||
const offs = (['spool', 'filament', 'vendor'] as const).map((resource) =>
|
||||
live.subscribe(resource, {}, bumpRevision)
|
||||
);
|
||||
return () => {
|
||||
offs.forEach((off) => off());
|
||||
if (cooldown) clearTimeout(cooldown);
|
||||
cooldown = undefined;
|
||||
coalesced = false;
|
||||
};
|
||||
});
|
||||
|
||||
let sections = $derived(sectioned ? catalogSections(groups) : []);
|
||||
|
||||
let unit = $derived(
|
||||
view === 'filaments' ? m['library.catalog.unitFilaments']() : m['library.catalog.unitManufacturers']()
|
||||
);
|
||||
function countLabel(count: number): string {
|
||||
return view === 'filaments'
|
||||
? m['library.catalog.filamentCount']({ count })
|
||||
: m['library.catalog.manufacturerCount']({ count });
|
||||
}
|
||||
|
||||
// "11 filaments · 23 spools · 15.1 kg": the whole list, not the page. The two
|
||||
// totals come from response headers an older server doesn't send.
|
||||
let summary = $derived(
|
||||
total
|
||||
? [
|
||||
countLabel(total),
|
||||
totalSpools != null ? m['library.groupSpools']({ count: totalSpools }) : '',
|
||||
totalRemaining != null ? weightAuto(totalRemaining) : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="list">
|
||||
<CatalogHeader {view} count={total} />
|
||||
<ListToolbar {libraryState} />
|
||||
<div class="rows scroll-y" class:loading>
|
||||
{#if sectioned}
|
||||
{#each sections as sec (sec.vendorId + ':' + sec.rows[0].key)}
|
||||
{@const totals = vendorTotals[sec.vendorId]}
|
||||
<!-- One box per section, so its sticky header lets go where the section ends. -->
|
||||
<div>
|
||||
<div class="section">
|
||||
{#if sec.vendorId}
|
||||
<a
|
||||
class="vendor"
|
||||
href={params.selectHrefFromState(libraryState, 'vendor', sec.vendorId)}
|
||||
title={m['library.openManufacturer']({ name: sec.vendorName ?? '' })}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
>
|
||||
<Factory size={12} />
|
||||
<span class="vname">{sec.vendorName}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="vendor-none">{m['library.catalog.noManufacturer']()}</span>
|
||||
{/if}
|
||||
{#if totals}
|
||||
<span class="meta"
|
||||
>{countLabel(totals.filamentCount ?? 0)} · {weightAuto(totals.totalRemaining)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#each sec.rows as group (group.key)}
|
||||
<CatalogRow {group} {libraryState} />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each groups as group (group.field + ':' + group.key)}
|
||||
<CatalogRow {group} {libraryState} showVendor={group.field === 'filament'} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if errored}
|
||||
<div class="empty">{m['library.apiError']()}</div>
|
||||
{:else if total === 0 && !loading}
|
||||
<div class="empty">
|
||||
{libraryState.filters.length ? m['library.emptyFiltered']({ unit }) : m['pagination.empty']({ unit })}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Pagination
|
||||
page={libraryState.page}
|
||||
pageSize={libraryState.pageSize}
|
||||
total={total ?? 0}
|
||||
{unit}
|
||||
{summary}
|
||||
onpage={(p) => params.setPage(p)}
|
||||
onpagesize={(s) => params.setPageSize(s)}
|
||||
hrefFor={(p) => params.pageHrefFromState(libraryState, p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.rows {
|
||||
flex: 1;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.rows.loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* The manufacturer section bar: sunken like a group header, and sticky so a
|
||||
long run of filaments still says whose they are. */
|
||||
.section {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 7px 14px;
|
||||
background: var(--surface-sunken);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* The padding makes a ~28px tap target (over the 24px WCAG 2.2 floor) while the
|
||||
matching negative margin keeps the bar as tight as it looks. */
|
||||
.vendor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
margin: -7px;
|
||||
border-radius: var(--radius-sm);
|
||||
/* --accent-soft, not --accent-link: it holds AA on the sunken surface in both
|
||||
themes (see GroupHeader). */
|
||||
color: var(--accent-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.vendor:hover {
|
||||
background: var(--surface-raised);
|
||||
color: var(--accent-link-hover);
|
||||
}
|
||||
.vendor:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.vname,
|
||||
.meta {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.vendor-none {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.meta {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
}
|
||||
.empty {
|
||||
padding: 40px 14px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
The href comes from a src/lib/library/params.ts helper, which already resolves
|
||||
against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
import type { GroupSummary } from '$lib/api/types';
|
||||
import type { LibraryState } from '$lib/library/params';
|
||||
import * as params from '$lib/library/params';
|
||||
import { spoolPips } from '$lib/library/catalog';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// One row of a catalog sub-view: a filament or a manufacturer. Both share the
|
||||
// anatomy — picture, a name line, one details line, a fixed stock column — so
|
||||
// nothing a user types or a translation spells out can widen the list: every
|
||||
// text truncates in place.
|
||||
interface Props {
|
||||
/** A filament or vendor group from the catalog query. */
|
||||
group: GroupSummary;
|
||||
libraryState: LibraryState;
|
||||
/** Flat filament list: no section header names the manufacturer, so the row does. */
|
||||
showVendor?: boolean;
|
||||
}
|
||||
let { group, libraryState, showVendor = false }: Props = $props();
|
||||
|
||||
let kind = $derived<'filament' | 'vendor'>(group.field === 'vendor' ? 'vendor' : 'filament');
|
||||
let href = $derived(params.selectHrefFromState(libraryState, kind, group.key));
|
||||
let selected = $derived(params.isSelected(page.url.searchParams, kind, group.key));
|
||||
|
||||
// The group carries the aggregates; the entity itself (upserted into the cache
|
||||
// by the same request) carries the fields the details line shows.
|
||||
let filament = $derived(kind === 'filament' ? inventory.filamentById(group.key) : undefined);
|
||||
let vendor = $derived(kind === 'vendor' ? inventory.vendorById(group.key) : undefined);
|
||||
|
||||
let pips = $derived(spoolPips(group.previewSpools ?? [], settings.lowThreshold));
|
||||
|
||||
// Up to four of a manufacturer's filaments, overlapping in a 24px square.
|
||||
const STACK_OFFSETS = [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[0, 10],
|
||||
[10, 10]
|
||||
];
|
||||
</script>
|
||||
|
||||
<a class="row" class:selected {href} data-sveltekit-keepfocus data-sveltekit-noscroll>
|
||||
<span class="id mono">#{group.key}</span>
|
||||
{#if kind === 'filament'}
|
||||
<Swatch colors={group.colors} direction={group.direction} size={24} radius={6} />
|
||||
{:else}
|
||||
<span class="stack" aria-hidden="true">
|
||||
{#each (group.previewFilaments ?? []).slice(0, 4) as f, i (f.id)}
|
||||
<span class="dot" style:left="{STACK_OFFSETS[i][0]}px" style:top="{STACK_OFFSETS[i][1]}px"
|
||||
><Swatch colors={f.colors} direction={f.direction} size={14} radius={4} /></span
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="body">
|
||||
<span class="line">
|
||||
<!-- The name gives way first; the badge and the id stay whole. -->
|
||||
<span class="name" use:truncTitle>{group.title}</span>
|
||||
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
|
||||
</span>
|
||||
<!-- Labels dim, values bright, one line ending in an ellipsis. -->
|
||||
<span class="details" use:truncTitle>
|
||||
{#if kind === 'filament'}
|
||||
{#if showVendor}
|
||||
<span class="it"
|
||||
><span class="v" class:unset={!group.vendorName}
|
||||
>{group.vendorName ?? m['library.catalog.noManufacturer']()}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{#if filament}
|
||||
<span class="it"><span class="v">{filament.diameter} mm</span></span>
|
||||
<span class="it"
|
||||
><span class="k">{m['library.sort.nozzle']()}</span>
|
||||
<span class="v" class:unset={!filament.nozzleTemp}
|
||||
>{filament.nozzleTemp ? `${filament.nozzleTemp} °C` : m['library.catalog.notSet']()}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="it"
|
||||
><span class="v">{m['library.catalog.filamentCount']({ count: group.filamentCount ?? 0 })}</span
|
||||
></span
|
||||
>
|
||||
<!-- An empty spool weight of 0 g is no weight at all: the API's null maps to 0. -->
|
||||
<span class="it"
|
||||
><span class="k">{m['vendor.fields.emptySpoolWeight']()}</span>
|
||||
<span class="v" class:unset={!vendor?.emptyWeight}
|
||||
>{vendor?.emptyWeight ? weightAuto(vendor.emptyWeight) : m['library.catalog.notSet']()}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="stock">
|
||||
{#if kind === 'filament' && group.spoolCount === 0}
|
||||
<span class="no-spools">{m['library.noSpools']()}</span>
|
||||
{:else}
|
||||
<span class="kg">{weightAuto(group.totalRemaining)}</span>
|
||||
{#if kind === 'filament'}
|
||||
<!-- One pip per spool, filled by what's left; past five the count carries
|
||||
the rest. A spool of unknown size gets a plain grey pip. -->
|
||||
<span class="pips" title={m['library.groupSpools']({ count: group.spoolCount })}>
|
||||
{#each pips as p, i (i)}
|
||||
<span class="pip" class:low={p.low} class:unknown={p.fill === null} aria-hidden="true"
|
||||
><span class="fill" style:width="{(p.fill ?? 1) * 100}%"></span></span
|
||||
>
|
||||
{/each}
|
||||
<span class="count">{group.spoolCount}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="count">{m['library.groupSpools']({ count: group.spoolCount })}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px 9px 12px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.row.selected {
|
||||
background: var(--accent-wash);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.stack {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
/* A ring in the page colour cuts each swatch out of the one beneath it. */
|
||||
.dot {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1.5px var(--bg);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Same id column as the spool rows. */
|
||||
.id {
|
||||
flex: none;
|
||||
width: 36px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.details {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.it + .it::before {
|
||||
content: '·';
|
||||
margin: 0 6px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.k {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.v {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.v.unset {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
/* Fixed, so the column of weights lines up down the list whatever the rows say. */
|
||||
.stock {
|
||||
flex: none;
|
||||
width: 92px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.kg {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.pip {
|
||||
width: 11px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--track);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.pip.low .fill {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.pip.unknown .fill {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pips .count {
|
||||
margin-left: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
/* The unused-spool colours: a filament with nothing on the shelf is a
|
||||
shopping-list entry, and a catalog holds many of them, so it stays calm. */
|
||||
.no-spools {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--unused-bg);
|
||||
border: 1px solid var(--unused-border);
|
||||
color: var(--unused-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.row {
|
||||
min-height: 56px;
|
||||
}
|
||||
.name {
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.stock {
|
||||
width: 78px;
|
||||
}
|
||||
.kg {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@
|
||||
import { pct, weightAuto } from '$lib/utils/format';
|
||||
import { usageLabel } from '$lib/utils/library';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { allPages } from '$lib/api/query';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { classifyDeleteFailure, planFilamentDelete } from '$lib/library/deletion';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
@@ -80,6 +81,7 @@
|
||||
// fetch's own cache upsert (which replaces the `filament` prop object) doesn't
|
||||
// re-trigger this effect in an infinite loop.
|
||||
let filamentId = $derived(filament.id);
|
||||
const SPOOL_PAGE = 100;
|
||||
let spools = $state<Spool[]>([]);
|
||||
// Bumped by live spool events so adding/removing/editing a spool refetches
|
||||
// this filament's list (the fetch below is server-ordered, not read from the
|
||||
@@ -96,24 +98,33 @@
|
||||
const id = filamentId;
|
||||
const archived = showArchived;
|
||||
void revision; // refetch on live spool events
|
||||
let cancelled = false;
|
||||
spoolSource
|
||||
.listSpools({
|
||||
const ctrl = new AbortController();
|
||||
// Every spool, not the first page: the count and total above are read off
|
||||
// this list. That renders all of them; a filament with thousands of spools
|
||||
// would want a "show more" here and the group endpoint's totals for the header.
|
||||
allPages(SPOOL_PAGE, (offset) =>
|
||||
spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'last_used', dir: 'desc' }],
|
||||
sort: [
|
||||
{ field: 'last_used', dir: 'desc' },
|
||||
{ field: 'id', dir: 'asc' }
|
||||
],
|
||||
groupScope: { field: 'filament', key: id },
|
||||
allowArchived: archived,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold
|
||||
limit: SPOOL_PAGE,
|
||||
offset,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal: ctrl.signal
|
||||
})
|
||||
.then((page) => {
|
||||
if (!cancelled) spools = page.items;
|
||||
)
|
||||
.then((items) => {
|
||||
// A spool used mid-load moves up the sort and can turn up on two pages.
|
||||
if (!ctrl.signal.aborted) spools = [...new Map(items.map((s) => [s.id, s])).values()];
|
||||
})
|
||||
.catch((e) => console.error('Failed to load filament spools', e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
.catch((e) => {
|
||||
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filament spools', e);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
});
|
||||
|
||||
const saver = makeSaver<string, Partial<Filament>>((id, patch) =>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
{/each}
|
||||
{:else}
|
||||
{#each flatVMs as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} showSwatch indent={14} />
|
||||
<SpoolRow showLotNumbers={libraryState.showLotNumbers} {vm} showSwatch indent={14} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -182,15 +182,33 @@
|
||||
/>
|
||||
{#if !collapsed && !noSpools}
|
||||
{#each inUse as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{vm}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/each}
|
||||
{#each unusedByFilament as unused (unused[0].spool.id)}
|
||||
{#if unused.length === 1}
|
||||
<!-- A lone unused spool gains nothing from a collapsing header — it just
|
||||
adds a click and a row — so render it inline like the used spools. -->
|
||||
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
vm={unused[0]}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{:else}
|
||||
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
|
||||
<UnusedRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{unused}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if moreCount > 0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { asset } from '$app/paths';
|
||||
import SpoolInspector from './SpoolInspector.svelte';
|
||||
import FilamentInspector from './FilamentInspector.svelte';
|
||||
@@ -6,7 +7,6 @@
|
||||
import type { Selection } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { selection }: { selection: Selection | null } = $props();
|
||||
@@ -30,17 +30,24 @@
|
||||
// across runs to stop a genuine miss / 404 from re-fetching every tick.
|
||||
let attempted = '';
|
||||
let loading = $state(false);
|
||||
// The effect follows the selection by value, not by object. Arriving on the
|
||||
// Library rewrites the URL to restore the remembered view (see +page.svelte),
|
||||
// which parses the same selection into a new object; following the object
|
||||
// re-ran the effect mid-fetch and its cleanup aborted the request (#1243).
|
||||
let key = $derived(sel ? `${sel.kind}:${sel.id}` : '');
|
||||
$effect(() => {
|
||||
const s = sel;
|
||||
if (!s || found) return;
|
||||
const key = `${s.kind}:${s.id}`;
|
||||
if (attempted === key) return;
|
||||
attempted = key;
|
||||
const k = key;
|
||||
if (!k || found) return;
|
||||
if (attempted === k) return;
|
||||
attempted = k;
|
||||
loading = true;
|
||||
|
||||
const s = untrack(() => sel)!;
|
||||
const ctrl = new AbortController();
|
||||
let pending = true;
|
||||
const done = (err?: unknown) => {
|
||||
if (isAbortError(err, ctrl.signal)) return; // superseded selection; leave loading for the next run
|
||||
if (ctrl.signal.aborted) return; // superseded; the run that aborted it owns `loading`
|
||||
pending = false;
|
||||
if (err) console.warn('deep-link fetch failed', err);
|
||||
loading = false;
|
||||
};
|
||||
@@ -52,7 +59,12 @@
|
||||
: spoolSource.fetchVendor(s.id, ctrl.signal);
|
||||
p.then(() => done()).catch(done);
|
||||
|
||||
return () => ctrl.abort();
|
||||
return () => {
|
||||
// Cancelled before it answered: forget the attempt, so whichever run
|
||||
// comes next asks again instead of waiting on a request that is gone.
|
||||
if (pending) attempted = '';
|
||||
ctrl.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
withinLast,
|
||||
type DateFilterProp
|
||||
} from '$lib/library/dateFilter';
|
||||
import { sortDefs, filamentLabel, type FilterOption, type SortDef } from '$lib/utils/library';
|
||||
import {
|
||||
sortDefs,
|
||||
catalogSortDefs,
|
||||
filamentLabel,
|
||||
type FilterOption,
|
||||
type SortDef
|
||||
} from '$lib/utils/library';
|
||||
import { filterByQuery, matchesTerms, searchTerms } from '$lib/utils/match';
|
||||
import MenuSearch from '../MenuSearch.svelte';
|
||||
import Swatch from '../Swatch.svelte';
|
||||
@@ -33,6 +39,10 @@
|
||||
// Named libraryState, not `state`, to avoid shadowing the $state rune.
|
||||
let { libraryState }: { libraryState: LibraryState } = $props();
|
||||
|
||||
// The catalog sub-views (filaments, manufacturers) reuse this toolbar with the
|
||||
// few groupings, orders and filters a list of filaments can answer.
|
||||
let catalog = $derived(params.isCatalogView(libraryState));
|
||||
|
||||
type Menu = 'filter' | 'group' | 'sort' | null;
|
||||
let open = $state<Menu>(null);
|
||||
|
||||
@@ -260,7 +270,11 @@
|
||||
close();
|
||||
}
|
||||
|
||||
let sorts = $derived(sortDefs(fields.get('spool')));
|
||||
let sorts = $derived<SortDef[]>(
|
||||
libraryState.view === 'spools'
|
||||
? sortDefs(fields.get('spool'))
|
||||
: catalogSortDefs(libraryState.view, fields.get('filament'), fields.get('vendor'))
|
||||
);
|
||||
let activeSort = $derived(sorts.find((s) => s.key === libraryState.sortKey) ?? sorts[0]);
|
||||
|
||||
const groupOptions: { key: GroupMode; labelKey: () => string }[] = [
|
||||
@@ -270,8 +284,15 @@
|
||||
{ key: 'location', labelKey: m['spool.fields.location'] },
|
||||
{ key: 'none', labelKey: m['library.groupNone'] }
|
||||
];
|
||||
// The filament catalog sections by manufacturer or not at all; the manufacturer
|
||||
// catalog has nothing to group by and shows no Group control.
|
||||
const catalogGroupOptions: typeof groupOptions = [
|
||||
{ key: 'vendor', labelKey: m['filament.fields.vendor'] },
|
||||
{ key: 'none', labelKey: m['library.groupNone'] }
|
||||
];
|
||||
let groupChoices = $derived(catalog ? catalogGroupOptions : groupOptions);
|
||||
let groupLabel = $derived(
|
||||
groupOptions.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
|
||||
groupChoices.find((g) => g.key === libraryState.group)?.labelKey() ?? m['spool.fields.filament']
|
||||
);
|
||||
|
||||
function chipLabel(prop: string, value: string): string {
|
||||
@@ -280,6 +301,11 @@
|
||||
// Date filters hold a range, whose value is grammar rather than something to
|
||||
// show ("-24h.." reads as "Last 24 hours").
|
||||
if (isDateFilterProp(prop)) return `${label}: ${rangeLabel(value)}`;
|
||||
// A manufacturer picked by id (the inspector's "Show as a list"): its name,
|
||||
// so the chip reads like the by-name Manufacturer chip the menu makes.
|
||||
if (prop === 'vendorId') {
|
||||
return `${m['filament.fields.vendor']()}: ${inventory.vendorById(value)?.name ?? '#' + value}`;
|
||||
}
|
||||
// Filament filters store the numeric id; show the filament's name instead.
|
||||
if (prop === 'filament') {
|
||||
const fil = inventory.filamentById(value);
|
||||
@@ -340,6 +366,9 @@
|
||||
let noSpoolsMatches = $derived(
|
||||
libraryState.group === 'filament' && matchesTerms(m['buttons.showNoSpools'](), searchTerms(menuQuery))
|
||||
);
|
||||
let lotNumbersMatches = $derived(
|
||||
libraryState.view === 'spools' && matchesTerms(m['buttons.showLotNumbers'](), searchTerms(menuQuery))
|
||||
);
|
||||
// An option's secondary text is on screen, so it has to be searchable: typing
|
||||
// "petg" against a list that visibly says PETG must not come back empty.
|
||||
let visibleOptions = $derived(
|
||||
@@ -375,7 +404,12 @@
|
||||
<div
|
||||
class="toolbar"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation();
|
||||
// A menu opened by a click leaves focus on its button, out here rather than
|
||||
// in the menu, so this is where Escape has to close it.
|
||||
if (e.key === 'Escape' && open) close();
|
||||
}}
|
||||
role="toolbar"
|
||||
tabindex="-1"
|
||||
>
|
||||
@@ -412,10 +446,12 @@
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="link-btn" onclick={() => toggle('group')}
|
||||
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
|
||||
<ChevronDown size={13} /></button
|
||||
>
|
||||
{#if libraryState.view !== 'manufacturers'}
|
||||
<button class="link-btn" onclick={() => toggle('group')}
|
||||
><span class="ctrl-label">{m['library.groupBy']()}: </span>{groupLabel}
|
||||
<ChevronDown size={13} /></button
|
||||
>
|
||||
{/if}
|
||||
<!-- One chip, two controls (#1091): the field opens the menu, the arrow only
|
||||
flips the direction. Reversing an order used to mean reopening the menu
|
||||
and hunting down the field that was already selected. A divider and a
|
||||
@@ -491,7 +527,28 @@
|
||||
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !visibleCategories.length}
|
||||
{#if lotNumbersMatches}
|
||||
{#if visibleCategories.length && !archivedMatches && !noSpoolsMatches}<div
|
||||
class="menu-sep"
|
||||
></div>{/if}
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={libraryState.showLotNumbers}
|
||||
onclick={() => {
|
||||
params.setShowLotNumbers(!libraryState.showLotNumbers);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span class="mi-check"
|
||||
>{#if libraryState.showLotNumbers}<SquareCheck size={15} />{:else}<Square
|
||||
size={15}
|
||||
/>{/if}</span
|
||||
>
|
||||
<span class="mi-label">{m['buttons.showLotNumbers']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !lotNumbersMatches && !visibleCategories.length}
|
||||
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -590,7 +647,7 @@
|
||||
|
||||
{#if open === 'group'}
|
||||
<div class="menu group-menu">
|
||||
{#each groupOptions as g (g.key)}
|
||||
{#each groupChoices as g (g.key)}
|
||||
<button
|
||||
class="menu-item"
|
||||
class:sel={libraryState.group === g.key}
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import MaterialBadge from '../MaterialBadge.svelte';
|
||||
import Factory from '@lucide/svelte/icons/factory';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import { goto } from '$app/navigation';
|
||||
import { searchAll } from '$lib/api/search';
|
||||
import type { SearchResults } from '$lib/api/types';
|
||||
import { openSearchResult, searchResultHref } from '$lib/library/params';
|
||||
import { catalogHref, openSearchResult, searchResultHref } from '$lib/library/params';
|
||||
import { truncTitle } from '$lib/actions/truncated';
|
||||
import { page } from '$app/state';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import type { EntityKind } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -31,8 +34,9 @@
|
||||
|
||||
function loadThreshold(): number {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_THRESHOLD;
|
||||
const n = Number(localStorage.getItem(THRESHOLD_KEY));
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRESHOLD;
|
||||
// 0 is a real choice on the slider (exact colour only), so keep it.
|
||||
const n = parseDecimal(localStorage.getItem(THRESHOLD_KEY));
|
||||
return n !== null && n >= 0 ? n : DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
let query = $state('');
|
||||
@@ -100,14 +104,30 @@
|
||||
});
|
||||
|
||||
interface FlatItem {
|
||||
kind: EntityKind;
|
||||
/** 'browse' rows are plain links to a catalog list, not entities. */
|
||||
kind: EntityKind | 'browse';
|
||||
id: string;
|
||||
href?: string;
|
||||
/** Unique per row. A spool can be both its own result and a pill under a
|
||||
* filament, so kind+id alone would highlight two rows at once. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
let flat = $derived<FlatItem[]>(
|
||||
const browseFilaments = {
|
||||
kind: 'browse',
|
||||
id: 'filaments',
|
||||
href: catalogHref('filaments'),
|
||||
key: 'browse:filaments'
|
||||
} as const;
|
||||
const browseManufacturers = {
|
||||
kind: 'browse',
|
||||
id: 'manufacturers',
|
||||
href: catalogHref('manufacturers'),
|
||||
key: 'browse:manufacturers'
|
||||
} as const;
|
||||
let emptyQuery = $derived(query.trim().length === 0);
|
||||
|
||||
let resultItems = $derived<FlatItem[]>(
|
||||
results
|
||||
? [
|
||||
...results.spools.map((m) => ({
|
||||
@@ -134,8 +154,18 @@
|
||||
: []
|
||||
);
|
||||
|
||||
let hasResults = $derived(flat.length > 0);
|
||||
let showPanel = $derived(open && query.trim().length > 0);
|
||||
let hasResults = $derived(resultItems.length > 0);
|
||||
// Browse links sit last so they never displace the first result under Enter,
|
||||
// and are reachable by the same arrow keys.
|
||||
let flat = $derived<FlatItem[]>(
|
||||
emptyQuery
|
||||
? [browseFilaments, browseManufacturers]
|
||||
: hasResults
|
||||
? [...resultItems, browseFilaments, browseManufacturers]
|
||||
: []
|
||||
);
|
||||
// Focused-but-empty shows the hint and the browse links instead of nothing.
|
||||
let showPanel = $derived(open);
|
||||
|
||||
// Each result is a real `<a href>` (see searchResultHref) so it opens in a new
|
||||
// tab / copies its address like any link. Close and clear the panel afterwards.
|
||||
@@ -158,6 +188,13 @@
|
||||
dismiss();
|
||||
}
|
||||
|
||||
// Arrowing past the panel's fold must bring the row along; the browse links
|
||||
// sit last, so without this they'd be picked while out of sight.
|
||||
$effect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
wrapper?.querySelector('.panel .active')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
open = false;
|
||||
@@ -174,7 +211,10 @@
|
||||
if (activeIndex >= 0 && activeIndex < flat.length) {
|
||||
e.preventDefault();
|
||||
const item = flat[activeIndex];
|
||||
chooseByKey(item.kind, item.id);
|
||||
if (item.kind === 'browse') {
|
||||
goto(item.href!);
|
||||
dismiss();
|
||||
} else chooseByKey(item.kind, item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +288,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && !results}
|
||||
{#if emptyQuery}
|
||||
<p class="empty-hint">{m['search.hint']()}</p>
|
||||
<div class="section-label">{m['library.viewMenu.browse']()}</div>
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf(browseFilaments.key)}
|
||||
href={browseFilaments.href}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><List size={14} /></span>
|
||||
<span class="text"><span class="title">{m['library.catalog.allFilaments']()}</span></span>
|
||||
</a>
|
||||
<a
|
||||
class="result"
|
||||
class:active={activeIndex === indexOf(browseManufacturers.key)}
|
||||
href={browseManufacturers.href}
|
||||
data-sveltekit-noscroll
|
||||
onclick={onResultClick}
|
||||
>
|
||||
<span class="vendor-icon" aria-hidden="true"><Factory size={14} /></span>
|
||||
<span class="text"><span class="title">{m['library.catalog.allManufacturers']()}</span></span>
|
||||
</a>
|
||||
{:else if loading && !results}
|
||||
<div class="msg">{m['search.searching']()}</div>
|
||||
{:else if errored}
|
||||
<div class="msg">{m['library.apiError']()}</div>
|
||||
@@ -377,6 +440,21 @@
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
{m['search.browseAll']()}
|
||||
<a
|
||||
href={browseFilaments.href}
|
||||
class:active={activeIndex === indexOf(browseFilaments.key)}
|
||||
onclick={onResultClick}>{m['library.catalog.unitFilaments']()}</a
|
||||
>
|
||||
·
|
||||
<a
|
||||
href={browseManufacturers.href}
|
||||
class:active={activeIndex === indexOf(browseManufacturers.key)}
|
||||
onclick={onResultClick}>{m['library.catalog.unitManufacturers']()}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -442,6 +520,31 @@
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot {
|
||||
margin-top: 4px;
|
||||
padding: 8px 12px 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
padding: 2px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.foot a:hover,
|
||||
.foot a.active {
|
||||
text-decoration: underline;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.msg {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
let serverLocations = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
spoolSource
|
||||
.locations()
|
||||
.locationChoices()
|
||||
.then((l) => (serverLocations = l))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
indent?: number;
|
||||
/** Listing context — drops whatever the enclosing group already implies. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
let identity = $derived(rowIdentity(vm, context));
|
||||
let identity = $derived(rowIdentity(vm, context, showLotNumbers));
|
||||
let selected = $derived(params.isSelected(page.url.searchParams, 'spool', String(vm.spool.id)));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
indent?: number;
|
||||
/** Listing context, forwarded to the expanded per-instance rows. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
// Unused spools (never drawn from — used_weight 0) of one filament are
|
||||
// collapsed into a single summary row. Clicking can't know *which* instance
|
||||
@@ -109,7 +110,7 @@
|
||||
{#each unused as vm (vm.spool.id)}
|
||||
<!-- Divider-mode instances align with the sibling used rows (same indent)
|
||||
so the divider reads as the header for the section beneath it. -->
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} />
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} {showLotNumbers} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Swatch from '../Swatch.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import ConfirmDialog from '../ConfirmDialog.svelte';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import EditableField from '../EditableField.svelte';
|
||||
import NumberInput from '../NumberInput.svelte';
|
||||
@@ -156,6 +157,13 @@
|
||||
onclose={() => (confirmOpen = false)}
|
||||
/>
|
||||
|
||||
{#snippet showAsList()}
|
||||
<!-- By id, not name, so a namesake manufacturer stays out; it shows as a normal dismissable chip. -->
|
||||
<a class="golist" href={params.catalogHref('filaments', [{ prop: 'vendorId', value: vendor.id }])}
|
||||
>{m['vendor.showAsList']()} <ChevronRight size={13} /></a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.fields.vendor']()}</SectionLabel>
|
||||
@@ -186,7 +194,8 @@
|
||||
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} manage />
|
||||
</div>
|
||||
<div class="col">
|
||||
<SectionLabel>{m['filament.filament']()}</SectionLabel>
|
||||
<SectionLabel right={filaments.length ? showAsList : undefined}>{m['filament.filament']()}</SectionLabel
|
||||
>
|
||||
<div class="fils">
|
||||
{#each filaments as f (f.id)}
|
||||
<a
|
||||
@@ -249,6 +258,17 @@
|
||||
gap: 0 32px;
|
||||
padding: 4px 20px 24px;
|
||||
}
|
||||
.golist {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.golist:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fils {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,16 @@ export interface RowIdentity {
|
||||
* only causes truncation — so we drop it and surface the part that actually
|
||||
* distinguishes this spool instead.
|
||||
*/
|
||||
export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
export function rowIdentity(vm: SpoolVM, ctx: RowContext, showLotNumbers = false): RowIdentity {
|
||||
const identity = contextualIdentity(vm, ctx);
|
||||
const lot = showLotNumbers ? vm.spool.lot.trim() : '';
|
||||
if (lot) {
|
||||
identity.sub = [identity.sub, `${m['spool.fields.lotNr']()} ${lot}`].filter(Boolean).join(' · ');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function contextualIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
const { vendor, filament } = vm;
|
||||
const dims = `${filament.material} · ${filament.diameter} mm`;
|
||||
switch (ctx) {
|
||||
@@ -139,9 +148,12 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
|
||||
/**
|
||||
* Distinguish spools of one and the same filament. They're identical except for
|
||||
* how they've been used — and #id, fill, weight and location already sit in their
|
||||
* own columns — so there's nothing to make a bold title out of. The usage status
|
||||
* is all the row claims; the inspector has the rest.
|
||||
* how they've been used and which lot they came from — #id, fill, weight and
|
||||
* location already sit in their own columns — so there's nothing to make a bold
|
||||
* title out of. The usage status leads, since every row can say it; the lot
|
||||
* number joins only when the user turns it on and there is one (#1173). A long
|
||||
* lot is what the ellipsis cuts, with the full text on hover. The inspector has
|
||||
* the rest.
|
||||
*
|
||||
* The comment deliberately stays out of it. It reads like a label on the handful
|
||||
* of spools whose owner treats it as one, but it's a free-form textarea: people
|
||||
@@ -246,6 +258,122 @@ export const FIXED_SORTS: SortDef[] = [
|
||||
|
||||
const FIXED_SORT_KEYS = new Set(FIXED_SORTS.map((s) => s.key));
|
||||
|
||||
/**
|
||||
* A sort of the filament or manufacturer list. Those page the group endpoint, so
|
||||
* each orders whole groups: by an aggregate over their spools (`group.*`), or by
|
||||
* a field of the filament or manufacturer, which the endpoint takes in the spool
|
||||
* endpoint's dotted form. `key` is what the URL carries.
|
||||
*/
|
||||
export interface CatalogSortDef extends SortDef {
|
||||
/** The group endpoint's sort field. */
|
||||
field: string;
|
||||
/** Reads A→Z when first picked, rather than biggest/newest first. */
|
||||
asc?: boolean;
|
||||
}
|
||||
|
||||
function fieldSort(
|
||||
key: string,
|
||||
labelKey: () => string,
|
||||
section: SortDef['section'],
|
||||
extra: Partial<CatalogSortDef> = {}
|
||||
): CatalogSortDef {
|
||||
return { key, field: key, labelKey, section, ...extra };
|
||||
}
|
||||
|
||||
const STOCK_SORTS: CatalogSortDef[] = [
|
||||
{
|
||||
key: 'remaining_weight',
|
||||
field: 'group.total_remaining',
|
||||
labelKey: m['spool.fields.remainingWeight'],
|
||||
section: 'spool',
|
||||
unit: 'g'
|
||||
},
|
||||
{ key: 'last_used', field: 'group.last_used', labelKey: m['spool.fields.lastUsed'], section: 'spool' },
|
||||
{ key: 'spool_count', field: 'group.spool_count', labelKey: m['search.section.spools'], section: 'spool' }
|
||||
];
|
||||
|
||||
const FILAMENT_CATALOG_SORTS: CatalogSortDef[] = [
|
||||
...STOCK_SORTS,
|
||||
{ key: 'name', field: 'group.title', labelKey: m['filament.fields.name'], section: 'filament', asc: true },
|
||||
fieldSort('filament.id', m['filament.fields.id'], 'filament'),
|
||||
fieldSort('filament.material', m['filament.fields.material'], 'filament', { asc: true }),
|
||||
fieldSort('filament.color_hex', m['filament.fields.colorHex'], 'filament', { asc: true }),
|
||||
fieldSort('filament.diameter', m['filament.fields.diameter'], 'filament', { unit: 'mm' }),
|
||||
fieldSort('filament.density', m['filament.fields.density'], 'filament'),
|
||||
fieldSort('filament.settings_extruder_temp', m['filament.fields.settingsExtruderTemp'], 'filament', {
|
||||
unit: '°C'
|
||||
}),
|
||||
fieldSort('filament.settings_bed_temp', m['filament.fields.settingsBedTemp'], 'filament', { unit: '°C' }),
|
||||
fieldSort('filament.weight', m['filament.fields.weight'], 'filament', { unit: 'g' }),
|
||||
fieldSort('filament.spool_weight', m['filament.fields.spoolWeight'], 'filament', { unit: 'g' }),
|
||||
fieldSort('filament.price', m['filament.fields.price'], 'filament'),
|
||||
fieldSort('filament.article_number', m['filament.fields.articleNumber'], 'filament', { asc: true }),
|
||||
fieldSort('filament.external_id', m['filament.fields.externalId'], 'filament', { asc: true }),
|
||||
fieldSort('filament.registered', m['filament.fields.registered'], 'filament'),
|
||||
fieldSort('filament.vendor.name', m['filament.fields.vendor'], 'vendor', { asc: true })
|
||||
];
|
||||
|
||||
const VENDOR_CATALOG_SORTS: CatalogSortDef[] = [
|
||||
...STOCK_SORTS,
|
||||
{
|
||||
key: 'filament_count',
|
||||
field: 'group.filament_count',
|
||||
labelKey: m['filament.filament'],
|
||||
section: 'filament'
|
||||
},
|
||||
{ key: 'name', field: 'group.title', labelKey: m['vendor.fields.name'], section: 'vendor', asc: true },
|
||||
fieldSort('filament.vendor.id', m['vendor.fields.id'], 'vendor'),
|
||||
fieldSort('filament.vendor.empty_spool_weight', m['vendor.fields.emptySpoolWeight'], 'vendor', {
|
||||
unit: 'g'
|
||||
}),
|
||||
fieldSort('filament.vendor.external_id', m['vendor.fields.externalId'], 'vendor', { asc: true }),
|
||||
fieldSort('filament.vendor.registered', m['vendor.fields.registered'], 'vendor')
|
||||
];
|
||||
|
||||
const FILAMENT_EXTRA = 'filament.extra.';
|
||||
const VENDOR_EXTRA = 'filament.vendor.extra.';
|
||||
|
||||
/**
|
||||
* Every sort a catalog view offers: the stock aggregates, the entity's own
|
||||
* fields and its custom fields. The filament list can also order by the
|
||||
* manufacturer's fields, since every filament has (at most) one.
|
||||
*/
|
||||
export function catalogSortDefs(
|
||||
view: 'filaments' | 'manufacturers',
|
||||
filamentFields: FieldDef[] = [],
|
||||
vendorFields: FieldDef[] = []
|
||||
): CatalogSortDef[] {
|
||||
const extra = (prefix: string, defs: FieldDef[], section: SortDef['section']) =>
|
||||
defs.map((f) => fieldSort(prefix + f.key, () => f.name, section, { unit: f.unit ?? undefined }));
|
||||
return view === 'filaments'
|
||||
? [
|
||||
...FILAMENT_CATALOG_SORTS,
|
||||
...extra(VENDOR_EXTRA, vendorFields, 'vendor'),
|
||||
...extra(FILAMENT_EXTRA, filamentFields, 'extra')
|
||||
]
|
||||
: [...VENDOR_CATALOG_SORTS, ...extra(VENDOR_EXTRA, vendorFields, 'extra')];
|
||||
}
|
||||
|
||||
/**
|
||||
* The group endpoint's sort field for a catalog sort key, or undefined when the
|
||||
* key isn't one the view offers (a stale or hand-edited URL). Custom-field keys
|
||||
* are taken on their prefix: which fields exist is only known once the field
|
||||
* registry has loaded, and the endpoint skips a key it doesn't know.
|
||||
*/
|
||||
export function catalogSortField(view: 'filaments' | 'manufacturers', key: string): string | undefined {
|
||||
const fixed = view === 'filaments' ? FILAMENT_CATALOG_SORTS : VENDOR_CATALOG_SORTS;
|
||||
const def = fixed.find((s) => s.key === key);
|
||||
if (def) return def.field;
|
||||
if (key.startsWith(VENDOR_EXTRA) || (view === 'filaments' && key.startsWith(FILAMENT_EXTRA))) return key;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Default direction for a freshly picked catalog sort. */
|
||||
export function catalogSortAsc(view: 'filaments' | 'manufacturers', key: string): boolean {
|
||||
const fixed = view === 'filaments' ? FILAMENT_CATALOG_SORTS : VENDOR_CATALOG_SORTS;
|
||||
return fixed.find((s) => s.key === key)?.asc ?? false;
|
||||
}
|
||||
|
||||
// The sort key that reduces to each grouping axis's own title. Grouping by X and
|
||||
// sorting by X's name are the same operation on the group list, so this key maps
|
||||
// to the backend's `group.title` (alphabetical) ordering for that axis.
|
||||
|
||||
@@ -75,48 +75,66 @@ function reasonFor(err: unknown): NfcErrorReason {
|
||||
/**
|
||||
* Wait for one tag to be tapped and resolve with its UID.
|
||||
*
|
||||
* The UID arrives in whatever spelling the platform uses (Chrome's is
|
||||
* colon-separated lowercase) and is passed on untouched — normalization belongs
|
||||
* to the server, which does it for every reader rather than for this one.
|
||||
*
|
||||
* Scanning continues until a tag is read or `signal` aborts, so callers must
|
||||
* always pass one and abort it when their dialog closes; otherwise the radio
|
||||
* keeps running behind a screen nobody is looking at.
|
||||
*
|
||||
* Rejects with an `NfcError` carrying a reason, or with the abort's own reason
|
||||
* when cancelled — check `signal.aborted` before reporting anything.
|
||||
* Rejects like `watchTagUids`.
|
||||
*/
|
||||
export function readTagUid(signal: AbortSignal): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => watchTagUids(signal, resolve, reject));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `onUid` with the UID of every tag tapped until `signal` aborts.
|
||||
*
|
||||
* The UID arrives in whatever spelling the platform uses (Chrome's is
|
||||
* colon-separated lowercase) and is passed on untouched — normalization belongs
|
||||
* to the server, which does it for every reader rather than for this one.
|
||||
*
|
||||
* `onError` gets an `NfcError` carrying a reason, or the abort's own reason when
|
||||
* cancelled — check `signal.aborted` before reporting anything. Without a user
|
||||
* gesture, starting only succeeds if NFC permission was already granted; it
|
||||
* fails with `notAllowed` instead of prompting.
|
||||
*/
|
||||
export function watchTagUids(
|
||||
signal: AbortSignal,
|
||||
onUid: (uid: string) => void,
|
||||
onError: (err: unknown) => void
|
||||
): void {
|
||||
if (typeof window === 'undefined' || !('NDEFReader' in window)) {
|
||||
return Promise.reject(new NfcError('unsupported'));
|
||||
onError(new NfcError('unsupported'));
|
||||
return;
|
||||
}
|
||||
if (!window.isSecureContext) {
|
||||
onError(new NfcError('insecureContext'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
onError(signal.reason);
|
||||
return;
|
||||
}
|
||||
if (!window.isSecureContext) return Promise.reject(new NfcError('insecureContext'));
|
||||
|
||||
const Ctor = (window as unknown as { NDEFReader: NDEFReaderCtor }).NDEFReader;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
let reader: NDEFReaderLike;
|
||||
try {
|
||||
reader = new Ctor();
|
||||
} catch (err) {
|
||||
reject(new NfcError(reasonFor(err)));
|
||||
return;
|
||||
}
|
||||
let reader: NDEFReaderLike;
|
||||
try {
|
||||
reader = new Ctor();
|
||||
} catch (err) {
|
||||
onError(new NfcError(reasonFor(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
reader.addEventListener('reading', (ev) => resolve(ev.serialNumber));
|
||||
// A tag that came and went before it could be read. Not fatal and not worth
|
||||
// reporting: the user's next tap is the retry, and the scan is still live.
|
||||
reader.addEventListener('readingerror', () => {});
|
||||
signal.addEventListener('abort', () => onError(signal.reason), { once: true });
|
||||
reader.addEventListener('reading', (ev) => onUid(ev.serialNumber));
|
||||
// A tag that came and went before it could be read. Not fatal and not worth
|
||||
// reporting: the user's next tap is the retry, and the scan is still live.
|
||||
reader.addEventListener('readingerror', () => {});
|
||||
|
||||
// scan() is what raises the permission prompt, so its rejection is the one
|
||||
// that carries a reason worth showing.
|
||||
reader.scan({ signal }).catch((err) => {
|
||||
if (signal.aborted) return; // our own cancellation, already rejected above
|
||||
reject(new NfcError(reasonFor(err)));
|
||||
});
|
||||
// scan() is what raises the permission prompt, so its rejection is the one
|
||||
// that carries a reason worth showing.
|
||||
reader.scan({ signal }).catch((err) => {
|
||||
if (signal.aborted) return; // our own cancellation, already reported above
|
||||
onError(new NfcError(reasonFor(err)));
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,16 @@ import { filterDecimalInsertion, isPartialDecimal, normalizeDecimal, parseDecima
|
||||
// number may reach the API.
|
||||
|
||||
describe('parseDecimal', () => {
|
||||
it.each([
|
||||
['', undefined],
|
||||
[' ', undefined],
|
||||
['0', 0],
|
||||
['0,5', 0.5],
|
||||
['garbage', undefined]
|
||||
])('converts optional API input %j to %s', (raw, expected) => {
|
||||
expect(parseDecimal(raw) ?? undefined).toBe(expected);
|
||||
});
|
||||
|
||||
it('reads a decimal point', () => {
|
||||
expect(parseDecimal('1.75')).toBe(1.75);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
import { serverInfo } from '$lib/stores/serverInfo.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { startLiveSync } from '$lib/api/liveSync';
|
||||
import { scanRelay } from '$lib/api/scanRelay';
|
||||
import { scanRelay, type TagScan } from '$lib/api/scanRelay';
|
||||
import { findTagHolder } from '$lib/api/tags';
|
||||
import { nfcSupported, watchTagUids } from '$lib/utils/nfc';
|
||||
import { scanner, isBrowsableRoute } from '$lib/stores/scanner.svelte';
|
||||
import { toasts } from '$lib/stores/toasts.svelte';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
@@ -62,47 +64,94 @@
|
||||
if (!scanner.autoNavigate) return;
|
||||
return scanRelay.subscribe(scanner.pool, (scan) => {
|
||||
scanner.receive(scan);
|
||||
// Read inside the handler, never in the effect body: depending on the route
|
||||
// here would tear the socket down and rebuild it on every navigation.
|
||||
// A page you are configuring reacts to nothing — not even the toast, which
|
||||
// during pairing would explain how to link the tag you just tapped to pair.
|
||||
if (!isBrowsableRoute(page.route.id)) return;
|
||||
// A tag identifies a spool or a filament, and either opens in the inspector.
|
||||
const hit = scan.spool
|
||||
? { kind: 'spool' as const, id: String(scan.spool.id) }
|
||||
: scan.filament
|
||||
? { kind: 'filament' as const, id: scan.filament.id }
|
||||
: null;
|
||||
if (!hit) {
|
||||
// An unknown tag has nowhere to navigate to, and silently ignoring it
|
||||
// would look like the tap failed. Say what was read and where to link
|
||||
// it — repeats coalesce, and the relay already debounces a reader that
|
||||
// re-reads a tag left sitting on it. Not an error: tapping a tag no
|
||||
// spool claims yet is how enrolling one starts.
|
||||
toasts.info(m['tags.scan.unknown']({ uid: scan.uid }));
|
||||
return;
|
||||
}
|
||||
if (!scanner.mayNavigate(document.activeElement, ui.addModalOpen || ui.scannerOpen)) return;
|
||||
// The same navigation a picked search result gets, and for the same reason:
|
||||
// on the Library it merges the selection into the view you are already in,
|
||||
// so a tap reveals the spool without throwing away the grouping, sort and
|
||||
// filters you had set up; from anywhere else it opens the Library on just
|
||||
// that spool. The inspector resolves a selection by id on its own, so the
|
||||
// spool still opens when the active filters exclude it from the list behind
|
||||
// it -- a scan answers "where is this spool", never "is it in this view".
|
||||
openSearchResult(hit.kind, hit.id);
|
||||
// Say what the tap did. The reader is often in another room from the screen,
|
||||
// and a page that changes by itself with no word why reads as a glitch. The
|
||||
// relay already cached the spool's filament and vendor, so naming is local.
|
||||
const filament = scan.filament ?? inventory.filamentById(scan.spool?.filamentId ?? '');
|
||||
const name = filament ? filamentLabel(filament, inventory.vendorById(filament.vendorId)) : '';
|
||||
toasts.info(
|
||||
scan.spool
|
||||
? m['tags.scan.openedSpool']({ id: scan.spool.id, name })
|
||||
: m['tags.scan.openedFilament']({ name })
|
||||
);
|
||||
onScan(scan);
|
||||
});
|
||||
});
|
||||
|
||||
// The same, for a tag tapped against this device's own NFC (Chrome on Android
|
||||
// over HTTPS). Pairing is about which remote reader to follow, so it has no say
|
||||
// here: the phone in your hand is always this browser's reader.
|
||||
//
|
||||
// Without a user gesture scan() only starts if NFC permission is already
|
||||
// granted, which is the usual case on reload. Otherwise it starts the moment
|
||||
// permission is granted, e.g. by reading a tag in the link-tag dialog.
|
||||
$effect(() => {
|
||||
if (!scanner.autoNavigate || !nfcSupported()) return;
|
||||
const ac = new AbortController();
|
||||
const start = () =>
|
||||
watchTagUids(
|
||||
ac.signal,
|
||||
async (uid) => {
|
||||
// A tag dialog is open and reading this very tap for itself.
|
||||
if (scanner.suppressed) return;
|
||||
const holder = await findTagHolder(uid, ac.signal).catch(() => undefined);
|
||||
if (ac.signal.aborted) return;
|
||||
onScan({
|
||||
uid,
|
||||
readerId: 'this-device',
|
||||
spool: holder?.kind === 'spool' ? holder.spool : undefined,
|
||||
filament: holder?.kind === 'filament' ? holder.filament : undefined
|
||||
});
|
||||
},
|
||||
() => {} // no permission yet, or NFC switched off: the relay still works
|
||||
);
|
||||
start();
|
||||
navigator.permissions
|
||||
?.query({ name: 'nfc' as PermissionName })
|
||||
.then((status) => {
|
||||
status.addEventListener(
|
||||
'change',
|
||||
() => {
|
||||
if (status.state === 'granted') start();
|
||||
},
|
||||
{ signal: ac.signal }
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => ac.abort();
|
||||
});
|
||||
|
||||
function onScan(scan: TagScan) {
|
||||
// Read inside the handler, never in the effect body: depending on the route
|
||||
// here would tear the socket down and rebuild it on every navigation.
|
||||
// A page you are configuring reacts to nothing — not even the toast, which
|
||||
// during pairing would explain how to link the tag you just tapped to pair.
|
||||
if (!isBrowsableRoute(page.route.id)) return;
|
||||
// A tag identifies a spool or a filament, and either opens in the inspector.
|
||||
const hit = scan.spool
|
||||
? { kind: 'spool' as const, id: String(scan.spool.id) }
|
||||
: scan.filament
|
||||
? { kind: 'filament' as const, id: scan.filament.id }
|
||||
: null;
|
||||
if (!hit) {
|
||||
// An unknown tag has nowhere to navigate to, and silently ignoring it
|
||||
// would look like the tap failed. Say what was read and where to link
|
||||
// it — repeats coalesce, and the relay already debounces a reader that
|
||||
// re-reads a tag left sitting on it. Not an error: tapping a tag no
|
||||
// spool claims yet is how enrolling one starts.
|
||||
toasts.info(m['tags.scan.unknown']({ uid: scan.uid }));
|
||||
return;
|
||||
}
|
||||
if (!scanner.mayNavigate(document.activeElement, ui.addModalOpen || ui.scannerOpen)) return;
|
||||
// The same navigation a picked search result gets, and for the same reason:
|
||||
// on the Library it merges the selection into the view you are already in,
|
||||
// so a tap reveals the spool without throwing away the grouping, sort and
|
||||
// filters you had set up; from anywhere else it opens the Library on just
|
||||
// that spool. The inspector resolves a selection by id on its own, so the
|
||||
// spool still opens when the active filters exclude it from the list behind
|
||||
// it -- a scan answers "where is this spool", never "is it in this view".
|
||||
openSearchResult(hit.kind, hit.id);
|
||||
// Say what the tap did. The reader is often in another room from the screen,
|
||||
// and a page that changes by itself with no word why reads as a glitch. The
|
||||
// relay already cached the spool's filament and vendor, so naming is local.
|
||||
const filament = scan.filament ?? inventory.filamentById(scan.spool?.filamentId ?? '');
|
||||
const name = filament ? filamentLabel(filament, inventory.vendorById(filament.vendorId)) : '';
|
||||
toasts.info(
|
||||
scan.spool
|
||||
? m['tags.scan.openedSpool']({ id: scan.spool.id, name })
|
||||
: m['tags.scan.openedFilament']({ name })
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import FilamentList from '$components/library/FilamentList.svelte';
|
||||
import CatalogList from '$components/library/CatalogList.svelte';
|
||||
import Inspector from '$components/library/Inspector.svelte';
|
||||
import DetailPane from '$components/DetailPane.svelte';
|
||||
import Splitter from '$components/Splitter.svelte';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { clearSelection, rememberedViewHref } from '$lib/library/params';
|
||||
import { clearSelection, isCatalogView, rememberedViewHref } from '$lib/library/params';
|
||||
import {
|
||||
listWidth,
|
||||
clampListWidth,
|
||||
@@ -64,7 +65,13 @@
|
||||
<div class="library" style="--list-w: {width}px" bind:clientWidth={available}>
|
||||
<!-- Left list: front and centre; on mobile it fills the screen. -->
|
||||
<div class="list-pane">
|
||||
<FilamentList libraryState={data.state} />
|
||||
<!-- The filament and manufacturer catalogs are sub-views of the same page:
|
||||
the URL's `view` picks the list, the inspector beside it stays the same. -->
|
||||
{#if isCatalogView(data.state)}
|
||||
<CatalogList libraryState={data.state} />
|
||||
{:else}
|
||||
<FilamentList libraryState={data.state} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- The divider between the two panes is also the handle that moves it. -->
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { fields } from '$lib/stores/fields.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { allPages } from '$lib/api/query';
|
||||
import { live, type LiveEvent } from '$lib/api/live';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { mapSpool } from '$lib/api/map';
|
||||
@@ -36,6 +37,8 @@
|
||||
const FLIP = 160;
|
||||
/** Spools fetched per request when filling (or extending) a card. */
|
||||
const PAGE = 30;
|
||||
/** Groups fetched per request when listing the cards. */
|
||||
const GROUP_PAGE = 1000;
|
||||
/** Distance from the bottom of a card's list at which the next page is fetched. */
|
||||
const SCROLL_MARGIN = 120;
|
||||
/** The unassigned card's key: no value for the grouped-by field. */
|
||||
@@ -170,20 +173,24 @@
|
||||
// controller for a later view.
|
||||
const signal = pageAbort.signal;
|
||||
try {
|
||||
const page = await spoolSource.listGroups({
|
||||
field: fieldKey,
|
||||
filters: {},
|
||||
sort: [{ field: 'group.title', dir: 'asc' }],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
// Every group gets a card, so page through all of them rather than stopping at
|
||||
// the first page; a group past it would never show up at all.
|
||||
const groups = await allPages(GROUP_PAGE, (offset) =>
|
||||
spoolSource.listGroups({
|
||||
field: fieldKey,
|
||||
filters: {},
|
||||
sort: [{ field: 'group.title', dir: 'asc' }],
|
||||
limit: GROUP_PAGE,
|
||||
offset,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
})
|
||||
);
|
||||
// A NULL value and an empty-string one are distinct rows to the database but the
|
||||
// same "unassigned" card here, so counts are summed by key.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const totals = new Map<string, number>();
|
||||
for (const g of page.items) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
|
||||
for (const g of groups) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
|
||||
|
||||
for (const [key, total] of totals) {
|
||||
const b = bucket(key);
|
||||
@@ -1012,8 +1019,13 @@
|
||||
* scrolling it to the bottom fetches the next page. */
|
||||
.card-body {
|
||||
/* Single source of truth for the height cap: the reserved min-height (set inline from
|
||||
* the spool count) is clamped to this too, so it never exceeds max-height. */
|
||||
--card-body-cap: min(52vh, 420px);
|
||||
* the spool count) is clamped to this too, so it never exceeds max-height.
|
||||
*
|
||||
* A single row of cards reaches down to the footer (#1242). The 1000px ceiling is
|
||||
* not cosmetic: the next page only loads when the body is scrolled, so the body
|
||||
* must stay shorter than one page of chips (PAGE × CHIP_H, about 1500px) or a tall
|
||||
* screen would show the first page with no scrollbar and never fetch the rest. */
|
||||
--card-body-cap: clamp(min(52vh, 420px), 100dvh - 240px, 1000px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -1127,6 +1139,11 @@
|
||||
.ctrl-label {
|
||||
display: none;
|
||||
}
|
||||
/* Cards stack on a touch screen, and a body nearly as tall as the screen would
|
||||
* swallow the swipes meant for scrolling the page past it. */
|
||||
.card-body {
|
||||
--card-body-cap: min(52vh, 420px);
|
||||
}
|
||||
}
|
||||
/* Phone: keep the controls together on the first row (title · group-by · new group)
|
||||
and give the hint the full width of the row below them. */
|
||||
|
||||
@@ -24,7 +24,6 @@ dependencies = [
|
||||
"httpx~=0.28",
|
||||
"hishel~=0.1",
|
||||
"aiomysql[rsa]~=0.3",
|
||||
"influxdb-client>=1.50.0",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
+32
-36
@@ -2,51 +2,47 @@
|
||||
# uv export --no-dev -o requirements.txt --no-annotate --no-hashes
|
||||
aiomysql==0.3.2
|
||||
aiosqlite==0.22.1
|
||||
alembic==1.18.5
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.14.1
|
||||
alembic==1.20.0
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
anysqlite==0.0.5
|
||||
async-timeout==5.0.1 ; python_full_version < '3.11'
|
||||
asyncpg==0.31.0
|
||||
certifi==2026.6.17
|
||||
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
||||
click==8.4.2
|
||||
colorama==0.4.6 ; sys_platform == 'win32'
|
||||
cryptography==50.0.0
|
||||
asyncpg==0.32.0
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1 ; platform_python_implementation != 'PyPy'
|
||||
click==8.5.0
|
||||
cryptography==50.0.2
|
||||
exceptiongroup==1.3.1 ; python_full_version < '3.11'
|
||||
fastapi==0.139.0
|
||||
greenlet==3.5.3
|
||||
fastapi==0.143.0
|
||||
greenlet==3.5.6
|
||||
h11==0.16.0
|
||||
hishel==0.1.5
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0 ; platform_machine != 'armv7l'
|
||||
httptools==0.9.0 ; platform_machine != 'armv7l'
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
influxdb-client==1.50.0
|
||||
mako==1.3.12
|
||||
markupsafe==3.0.3
|
||||
msgpack==1.2.1
|
||||
platformdirs==4.10.0
|
||||
prometheus-client==0.25.0
|
||||
psycopg2-binary==2.9.12
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||
pydantic==2.13.4
|
||||
pydantic-core==2.46.4
|
||||
pymysql==1.2.0
|
||||
python-dateutil==2.9.0.post0
|
||||
reactivex==5.1.0
|
||||
idna==3.20
|
||||
mako==1.4.3
|
||||
markupsafe==3.0.4
|
||||
msgpack==1.2.3
|
||||
opentelemetry-api==1.45.1
|
||||
platformdirs==4.12.4 ; python_full_version < '3.11'
|
||||
platformdirs==4.13.0 ; python_full_version >= '3.11'
|
||||
prometheus-client==0.26.0
|
||||
psycopg2-binary==2.9.13
|
||||
pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||
pydantic==2.14.0
|
||||
pydantic-core==2.50.0
|
||||
pymysql==1.2.3
|
||||
scheduler==0.8.11
|
||||
setuptools==83.0.0
|
||||
six==1.17.0
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.54
|
||||
sqlalchemy-cockroachdb==2.0.4
|
||||
starlette==1.3.1
|
||||
tomli==2.4.1 ; python_full_version < '3.11'
|
||||
typeguard==4.5.2
|
||||
starlette==1.7.0
|
||||
tomli==2.5.0 ; python_full_version < '3.11'
|
||||
typeguard==4.6.0
|
||||
typing-extensions==4.16.0
|
||||
typing-inspection==0.4.2
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.50.1
|
||||
uvloop==0.22.1 ; platform_machine != 'armv7l' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
||||
typing-inspection==0.4.4
|
||||
uvicorn==0.54.0
|
||||
uvloop==0.23.0 ; platform_machine != 'armv7l' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
||||
websockets==15.0.1
|
||||
@@ -481,6 +481,39 @@ class Spool(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SpoolGroupPreviewSpool(BaseModel):
|
||||
"""One spool in a filament group's preview: just enough to draw a fill-level pip."""
|
||||
|
||||
id: int = Field(description="Spool ID.", examples=[3])
|
||||
remaining_weight: float | None = Field(
|
||||
None,
|
||||
description="Remaining filament weight in grams, as on the spool. Null when the initial weight is unknown.",
|
||||
examples=[750.0],
|
||||
)
|
||||
initial_weight: float | None = Field(
|
||||
None,
|
||||
description="Initial filament weight in grams, falling back to the filament's weight. Null when unknown.",
|
||||
examples=[1000.0],
|
||||
)
|
||||
|
||||
|
||||
class SpoolGroupPreviewFilament(BaseModel):
|
||||
"""One filament in a vendor group's preview: just enough to draw a color swatch."""
|
||||
|
||||
id: int = Field(description="Filament ID.", examples=[7])
|
||||
color_hex: str | None = Field(None, description="Hexadecimal color code of the filament.", examples=["FF0000"])
|
||||
multi_color_hexes: str | None = Field(
|
||||
None,
|
||||
description="Comma-separated color codes of a multi-color filament.",
|
||||
examples=["FF0000,00FF00"],
|
||||
)
|
||||
multi_color_direction: MultiColorDirection | None = Field(
|
||||
None,
|
||||
description="Type of multi-color filament. Only set if multi_color_hexes is set.",
|
||||
examples=["coaxial"],
|
||||
)
|
||||
|
||||
|
||||
class SpoolGroup(BaseModel):
|
||||
"""A group of spools with server-computed aggregates.
|
||||
|
||||
@@ -524,6 +557,30 @@ class SpoolGroup(BaseModel):
|
||||
None,
|
||||
description="The vendor, embedded for group_by=vendor.",
|
||||
)
|
||||
filament_count: int | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Only for group_by=vendor: the number of distinct filaments in the group. With "
|
||||
"include_empty=true that is every matching filament of the manufacturer, including "
|
||||
"those with no spools; otherwise it is the number of distinct filaments among the "
|
||||
"matching spools."
|
||||
),
|
||||
examples=[4],
|
||||
)
|
||||
spools: list[SpoolGroupPreviewSpool] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Only with preview=true and group_by=filament: up to 5 of the group's matching spools, by "
|
||||
"ascending ID. They are exactly the spools counted in spool_count."
|
||||
),
|
||||
)
|
||||
filaments: list[SpoolGroupPreviewFilament] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Only with preview=true and group_by=vendor: up to 4 of the manufacturer's filaments that "
|
||||
"match the filament filters, by name then ID, whether or not they have matching spools."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SearchResultSpool(BaseModel):
|
||||
@@ -732,6 +789,19 @@ class Info(BaseModel):
|
||||
)
|
||||
git_commit: str | None = Field(None, examples=["a1b2c3d"])
|
||||
build_date: SpoolmanDateTime | None = Field(None, examples=["2021-01-01T00:00:00Z"])
|
||||
latest_version: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Latest Spoolman release on GitHub. Null until the server has checked, "
|
||||
"or if the check is disabled via SPOOLMAN_UPDATE_CHECK."
|
||||
),
|
||||
examples=["0.28.0"],
|
||||
)
|
||||
update_available: bool = Field(
|
||||
default=False,
|
||||
description="True if latest_version is newer than the running version.",
|
||||
examples=[False],
|
||||
)
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
|
||||
@@ -131,7 +131,7 @@ class RenameLocationBody(BaseModel):
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/location/{location}",
|
||||
"/location/{location:path}",
|
||||
name="Rename location",
|
||||
description="Rename a spool location. All spools in this location will be moved to the new location.",
|
||||
response_model_exclude_none=True,
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from spoolman import env
|
||||
from spoolman import env, update_check
|
||||
from spoolman.database.database import backup_global_db
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
from spoolman.externaldb import get_external_db_name
|
||||
@@ -59,6 +59,8 @@ async def info() -> models.Info:
|
||||
external_db_name=get_external_db_name(),
|
||||
git_commit=env.get_commit_hash(),
|
||||
build_date=env.get_build_date(),
|
||||
latest_version=update_check.latest_version,
|
||||
update_available=update_check.is_update_available(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+38
-12
@@ -451,7 +451,8 @@ async def notify_any(
|
||||
"in-use count, total remaining weight and most recent usage. Pagination is over groups, so "
|
||||
"a group is never split and its aggregates are always complete. Uses the same filters as "
|
||||
"the spool search endpoint. The total number of matching groups is returned in the "
|
||||
"x-total-count header."
|
||||
"x-total-count header, and the spool count and remaining weight (grams) summed over all "
|
||||
"matching groups in the x-total-spools and x-total-remaining-weight headers."
|
||||
),
|
||||
response_model_exclude_none=True,
|
||||
responses={
|
||||
@@ -548,12 +549,24 @@ async def find_groups(
|
||||
Query(
|
||||
title="Include Empty",
|
||||
description=(
|
||||
"Also return matching filaments that hold no matching spools, as groups of zero, "
|
||||
"instead of omitting them. Only valid together with group_by=filament: every "
|
||||
"other axis is keyed by a value read off the spools themselves and so has no "
|
||||
"empty groups to list. Filters on the spools themselves (location, lot_nr, the "
|
||||
"date filters, spool extra fields) are rejected in combination with it, since a "
|
||||
"filament with no spools has no value for them."
|
||||
"Also return matching filaments (group_by=filament) or manufacturers "
|
||||
"(group_by=vendor) that hold no matching spools, as groups of zero, instead of "
|
||||
"omitting them. In vendor mode the group of filaments with no manufacturer is not "
|
||||
"listed, since this mode lists manufacturers. Every other axis is keyed by a value "
|
||||
"read off the spools themselves and so has no empty groups to list. Filters on the "
|
||||
"spools themselves (location, lot_nr, the date filters, spool extra fields) are "
|
||||
"rejected in combination with it, since a filament with no spools has no value for them."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
preview: Annotated[
|
||||
bool,
|
||||
Query(
|
||||
title="Preview",
|
||||
description=(
|
||||
"Attach a few members to each group: up to 5 spools (id, remaining_weight, "
|
||||
"initial_weight) for group_by=filament, up to 4 filaments (id and colors) for "
|
||||
"group_by=vendor. Ignored for other axes."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
@@ -566,8 +579,13 @@ async def find_groups(
|
||||
title="Sort",
|
||||
description=(
|
||||
'Sort the groups by the given field. Comma-separated "field:direction" items. '
|
||||
"Available fields: group.title, group.total_remaining, group.last_used, "
|
||||
"group.spool_count, group.in_use_count."
|
||||
"Available fields: group.title, group.vendor_name (manufacturer name, groups with "
|
||||
"no manufacturer last), group.total_remaining, group.last_used, "
|
||||
"group.spool_count, group.in_use_count, group.filament_count, any filament column "
|
||||
"as filament.<column> (e.g. filament.material) or manufacturer column as "
|
||||
"filament.vendor.<column> (e.g. filament.vendor.name), and extra fields as "
|
||||
"filament.extra.<key> or filament.vendor.extra.<key>. Empty values sort last; unknown "
|
||||
"fields are ignored."
|
||||
),
|
||||
examples=["group.last_used:desc"],
|
||||
),
|
||||
@@ -592,7 +610,7 @@ async def find_groups(
|
||||
spool_extra, filament_extra, vendor_extra = _parse_extra_field_filters(request.query_params)
|
||||
|
||||
try:
|
||||
groups, total_count = await spool.find_groups(
|
||||
page = await spool.find_groups(
|
||||
db=db,
|
||||
group_by=group_by,
|
||||
filament_name=filament_name,
|
||||
@@ -614,6 +632,7 @@ async def find_groups(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include_empty=include_empty,
|
||||
preview=preview,
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
|
||||
@@ -628,12 +647,19 @@ async def find_groups(
|
||||
last_used=group.last_used,
|
||||
filament=Filament.from_db(group.filament) if group.filament is not None else None,
|
||||
vendor=Vendor.from_db(group.vendor) if group.vendor is not None else None,
|
||||
filament_count=group.filament_count,
|
||||
spools=group.spools,
|
||||
filaments=group.filaments,
|
||||
)
|
||||
for group in groups
|
||||
for group in page.groups
|
||||
]
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(content, exclude_none=True),
|
||||
headers={"x-total-count": str(total_count)},
|
||||
headers={
|
||||
"x-total-count": str(page.total_count),
|
||||
"x-total-spools": str(page.total_spools),
|
||||
"x-total-remaining-weight": str(page.total_remaining_weight),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""SQLAlchemy database setup."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import filecmp
|
||||
import logging
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import closing
|
||||
@@ -30,6 +32,10 @@ MIN_SECONDS_BETWEEN_ROTATIONS = 5 * 60
|
||||
# Name of the newest backup. Older ones get a .1 ... .N suffix.
|
||||
BACKUP_NAME = "spoolman.db"
|
||||
|
||||
# sqlite3's backup() retries forever while the source is locked. Give up after this long, so a lock
|
||||
# that never clears fails the backup loudly instead of hanging it (#1191).
|
||||
BACKUP_LOCK_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
def _unicode_lower(value: str | None) -> str | None:
|
||||
"""Lowercase SQLite text using Python's Unicode support."""
|
||||
@@ -97,6 +103,7 @@ class Database:
|
||||
# Monotonic timestamp of the last rotation, for the rate limit. Process-local, which is
|
||||
# fine: Spoolman is single-instance, and a restart erring towards one extra backup is safe.
|
||||
self._last_rotation: float | None = None
|
||||
self._backup_lock = threading.Lock()
|
||||
|
||||
def is_file_based_sqlite(self) -> bool:
|
||||
"""Return True if the database is file based."""
|
||||
@@ -134,8 +141,21 @@ class Database:
|
||||
|
||||
logger.info("Backing up SQLite database to %s", target_path)
|
||||
|
||||
def progress(_: int, remaining: int, total: int) -> None:
|
||||
logger.info("Copied %d of %d pages.", total - remaining, total)
|
||||
busy_since: float | None = None
|
||||
|
||||
def progress(status: int, remaining: int, total: int) -> None:
|
||||
nonlocal busy_since
|
||||
if status not in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED):
|
||||
busy_since = None
|
||||
logger.debug("Copied %d of %d pages.", total - remaining, total)
|
||||
return
|
||||
busy_since = busy_since or time.monotonic()
|
||||
if time.monotonic() - busy_since > BACKUP_LOCK_TIMEOUT_SECONDS:
|
||||
raise sqlite3.OperationalError(
|
||||
f"Database {self.connection_url.database} stayed locked for over "
|
||||
f"{BACKUP_LOCK_TIMEOUT_SECONDS} seconds, giving up on the backup. "
|
||||
"Is another program holding it open?",
|
||||
)
|
||||
|
||||
if self.connection_url.database == target_path:
|
||||
raise ValueError("Cannot backup database to itself.")
|
||||
@@ -146,9 +166,12 @@ class Database:
|
||||
# transaction manager that commits on exit, it does not close the connection. Leaving the
|
||||
# handles open leaks them on every backup, and on Windows it makes the caller's unlink/move
|
||||
# of the file we just wrote fail with "used by another process" (POSIX unlink hides this).
|
||||
# nolock=1: the target is a scratch file nothing else opens, so it needs no file locks. Backup
|
||||
# folders on network shares often refuse SQLite's locks (CIFS without nobrl, NFS without
|
||||
# lockd), which failed every backup with a "locked" database that was not locked at all (#1246).
|
||||
with (
|
||||
closing(sqlite3.connect(self.connection_url.database)) as src,
|
||||
closing(sqlite3.connect(target_path)) as dst,
|
||||
closing(sqlite3.connect(f"{Path(target_path).absolute().as_uri()}?nolock=1", uri=True)) as dst,
|
||||
):
|
||||
src.backup(dst, pages=1, progress=progress)
|
||||
|
||||
@@ -191,7 +214,11 @@ class Database:
|
||||
logger.info("Skipping backup as the database is not SQLite.")
|
||||
return BackupResult(None, created=False)
|
||||
|
||||
backup_folder = Path(backup_folder)
|
||||
# Backups run in worker threads, so a manual one can overlap the scheduled one.
|
||||
with self._backup_lock:
|
||||
return self._backup_and_rotate(Path(backup_folder), num_backups)
|
||||
|
||||
def _backup_and_rotate(self, backup_folder: Path, num_backups: int) -> BackupResult:
|
||||
backup_folder.mkdir(parents=True, exist_ok=True)
|
||||
newest = backup_folder.joinpath(BACKUP_NAME)
|
||||
|
||||
@@ -250,7 +277,8 @@ async def backup_global_db(num_backups: int = 5) -> BackupResult:
|
||||
"""
|
||||
if __db is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=num_backups)
|
||||
# Off the event loop: the copy is blocking, and can wait on a lock held by our own connection.
|
||||
return await asyncio.to_thread(__db.backup_and_rotate, env.get_backups_dir(), num_backups=num_backups)
|
||||
|
||||
|
||||
async def _backup_task() -> BackupResult:
|
||||
@@ -258,7 +286,7 @@ async def _backup_task() -> BackupResult:
|
||||
logger.info("Performing scheduled database backup.")
|
||||
if __db is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=5)
|
||||
return await asyncio.to_thread(__db.backup_and_rotate, env.get_backups_dir(), num_backups=5)
|
||||
|
||||
|
||||
async def _metrics() -> None:
|
||||
|
||||
@@ -516,6 +516,19 @@ async def find_extra_field_values(
|
||||
return sorted(values)
|
||||
|
||||
|
||||
def extra_field_sort_expr(value: ColumnElement, field_type: ExtraFieldType) -> ColumnElement:
|
||||
"""Turn a stored extra-field value into the expression to order by, typed so numbers sort numerically."""
|
||||
if field_type == ExtraFieldType.integer:
|
||||
return sqlalchemy.cast(value, sqlalchemy.Integer)
|
||||
if field_type == ExtraFieldType.float:
|
||||
return sqlalchemy.cast(value, sqlalchemy.Float)
|
||||
if field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range):
|
||||
cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float
|
||||
# Use dialect-specific JSON first-element extraction, then cast to numeric.
|
||||
return sqlalchemy.cast(_JsonArrayFirstElement(value), cast_type)
|
||||
return value
|
||||
|
||||
|
||||
def add_order_by_extra_field(
|
||||
stmt: Select,
|
||||
base_obj: type[models.Base],
|
||||
@@ -540,16 +553,7 @@ def add_order_by_extra_field(
|
||||
.correlate(base_obj)
|
||||
)
|
||||
|
||||
if field_type == ExtraFieldType.integer:
|
||||
sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Integer)
|
||||
elif field_type == ExtraFieldType.float:
|
||||
sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Float)
|
||||
elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range):
|
||||
cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float
|
||||
# Use dialect-specific JSON first-element extraction, then cast to numeric.
|
||||
sort_expr = sqlalchemy.cast(_JsonArrayFirstElement(value_subq), cast_type)
|
||||
else:
|
||||
sort_expr = value_subq
|
||||
sort_expr = extra_field_sort_expr(value_subq, field_type)
|
||||
|
||||
# A spool that has no value for the field yields NULL here, and "not filled in" belongs at
|
||||
# the bottom of the list in both directions -- see order_by_clauses.
|
||||
|
||||
+406
-69
@@ -5,6 +5,7 @@ import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import ColumnElement, case, func
|
||||
@@ -13,13 +14,22 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
|
||||
from spoolman.api.v1.models import EventType, Spool, SpoolEvent
|
||||
from spoolman.api.v1.models import (
|
||||
EventType,
|
||||
Spool,
|
||||
SpoolEvent,
|
||||
SpoolGroupPreviewFilament,
|
||||
SpoolGroupPreviewSpool,
|
||||
_sanitize_color_hex,
|
||||
_sanitize_multi_color_hexes,
|
||||
)
|
||||
from spoolman.database import filament, models
|
||||
from spoolman.database.extra_field_query import (
|
||||
ExtraFieldJoin,
|
||||
apply_extra_field_filters_and_sort,
|
||||
apply_spool_related_extra_filters,
|
||||
extra_field_join,
|
||||
extra_field_sort_expr,
|
||||
extra_field_value_text,
|
||||
)
|
||||
from spoolman.database.utils import (
|
||||
@@ -258,6 +268,13 @@ ENTITY_GROUP_BY_TITLES = {
|
||||
"vendor": models.Vendor.name,
|
||||
}
|
||||
|
||||
# The group key and title of the two axes include_empty can list, which select FROM the entity
|
||||
# itself (see _entities_with_their_spools_stmt) rather than reading it off the spools.
|
||||
EMPTY_GROUP_COLUMNS = {
|
||||
"filament": (models.Filament.id, models.Filament.name),
|
||||
"vendor": (models.Vendor.id, models.Vendor.name),
|
||||
}
|
||||
|
||||
# Prefix marking a reference to one of the spool's extra fields rather than a built-in column.
|
||||
EXTRA_FIELD_PREFIX = "extra."
|
||||
|
||||
@@ -288,6 +305,7 @@ def _apply_filament_filters(
|
||||
stmt: sqlalchemy.Select,
|
||||
*,
|
||||
filament_id_column: ColumnElement,
|
||||
vendor_id_column: ColumnElement = models.Filament.vendor_id,
|
||||
filament_name: str | None = None,
|
||||
filament_id: int | Sequence[int] | None = None,
|
||||
filament_material: str | None = None,
|
||||
@@ -301,11 +319,14 @@ def _apply_filament_filters(
|
||||
its own, with no spool involved -- which is exactly what the query behind include_empty needs
|
||||
(see find_groups). `filament_id_column` names whichever column holds the filament's id in the
|
||||
query at hand: Spool.filament_id when selecting spools, Filament.id when selecting filaments.
|
||||
`vendor_id_column` likewise: the vendor-listing query selects FROM Vendor, where a vendor with
|
||||
no filaments has a NULL Filament.vendor_id, so a `vendor.id` filter must test Vendor.id there
|
||||
or it would silently drop the very manufacturers that mode exists to list.
|
||||
|
||||
Assumes Filament and Vendor are already joined.
|
||||
"""
|
||||
stmt = add_where_clause_int(stmt, filament_id_column, filament_id)
|
||||
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
|
||||
stmt = add_where_clause_int_opt(stmt, vendor_id_column, vendor_id)
|
||||
stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material)
|
||||
@@ -439,6 +460,37 @@ class SpoolGroupResult:
|
||||
last_used: datetime | None
|
||||
filament: models.Filament | None
|
||||
vendor: models.Vendor | None
|
||||
# Only for group_by=vendor: the number of distinct filaments behind the group.
|
||||
filament_count: int | None = None
|
||||
# Only with `preview` (see find_groups): a few of the group's members, for the list to draw.
|
||||
spools: list[SpoolGroupPreviewSpool] | None = None
|
||||
filaments: list[SpoolGroupPreviewFilament] | None = None
|
||||
|
||||
|
||||
def _axis_columns(group_by: str) -> tuple[ColumnElement, ...]:
|
||||
"""Extra columns only some axes select, so every other axis' SQL stays exactly as it was."""
|
||||
if group_by == "vendor":
|
||||
return (func.count(sqlalchemy.distinct(models.Filament.id)).label("filament_count"),)
|
||||
return ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpoolGroupsPage:
|
||||
"""One page of groups plus the totals over ALL matching groups (before pagination)."""
|
||||
|
||||
groups: list[SpoolGroupResult]
|
||||
total_count: int
|
||||
# Sums of the per-group aggregates across every matching group, not just this page, so a list
|
||||
# can show "N spools, X g" for the whole result without fetching it.
|
||||
total_spools: int
|
||||
total_remaining_weight: float
|
||||
|
||||
|
||||
# How many members a group's `preview` carries. The list draws a row of pips / swatches per group,
|
||||
# and a few are enough to recognise the group by; the full set is one spool/filament search away.
|
||||
# Capped here (in Python, after one query) rather than in SQL, which would need a window function.
|
||||
PREVIEW_SPOOL_LIMIT = 5
|
||||
PREVIEW_FILAMENT_LIMIT = 4
|
||||
|
||||
|
||||
def _group_aggregates() -> tuple[ColumnElement, ColumnElement, ColumnElement, ColumnElement]:
|
||||
@@ -486,15 +538,15 @@ SPOOL_SCOPED_FILTERS = ("location", "lot_nr", "first_used", "last_used", "regist
|
||||
|
||||
|
||||
def _reject_spool_scoped_filters(group_by: str, filters: dict[str, object]) -> None:
|
||||
"""Refuse an include_empty query that also filters on the spools themselves.
|
||||
"""Refuse an include_empty query on an unsupported axis or with filters on the spools themselves.
|
||||
|
||||
"Which of these filaments are on Shelf A" has no answer for a filament that is nowhere: it
|
||||
would either drop every empty group (making the flag a no-op) or return the whole catalogue
|
||||
as empty. Both are worse than saying so.
|
||||
as empty. Both are worse than saying so. The same holds for a manufacturer.
|
||||
"""
|
||||
if group_by != "filament":
|
||||
if group_by not in ("filament", "vendor"):
|
||||
raise ValueError(
|
||||
f"include_empty is only supported when grouping by filament, not by '{group_by}'.",
|
||||
f"include_empty is only supported when grouping by filament or vendor, not by '{group_by}'.",
|
||||
)
|
||||
named = [name for name in SPOOL_SCOPED_FILTERS if filters.get(name) is not None]
|
||||
if filters.get("extra_field_filters"):
|
||||
@@ -506,9 +558,10 @@ def _reject_spool_scoped_filters(group_by: str, filters: dict[str, object]) -> N
|
||||
)
|
||||
|
||||
|
||||
def _filaments_with_their_spools_stmt(
|
||||
def _entities_with_their_spools_stmt(
|
||||
*,
|
||||
aggregates: tuple[ColumnElement, ...],
|
||||
group_by: str,
|
||||
columns: tuple[ColumnElement, ...],
|
||||
allow_archived: bool,
|
||||
filament_name: str | None,
|
||||
filament_id: int | Sequence[int] | None,
|
||||
@@ -517,12 +570,12 @@ def _filaments_with_their_spools_stmt(
|
||||
vendor_name: str | None,
|
||||
vendor_id: int | Sequence[int] | None,
|
||||
) -> sqlalchemy.Select:
|
||||
"""Select every matching filament with its spools outer-joined on, aggregates and all.
|
||||
"""Select every matching filament (or vendor) with its spools outer-joined on, aggregates and all.
|
||||
|
||||
The inverse of the usual grouping query: it reads its rows from the filaments rather than
|
||||
from the spools, so a filament with no spools still produces one (see find_groups'
|
||||
include_empty). Only the FROM differs -- the caller groups, orders and pages it exactly like
|
||||
the spool-driven query.
|
||||
The inverse of the usual grouping query: it reads its rows from the filaments (or the
|
||||
vendors) rather than from the spools, so an entity with no spools still produces one (see
|
||||
find_groups' include_empty). Only the FROM differs -- the caller groups, orders and pages it
|
||||
exactly like the spool-driven query.
|
||||
"""
|
||||
# The archived rule is the one spool-level condition that reaches this query, and it has to
|
||||
# ride in the JOIN rather than the WHERE: hiding a filament's archived spools must leave the
|
||||
@@ -530,15 +583,30 @@ def _filaments_with_their_spools_stmt(
|
||||
join_cond = [models.Spool.filament_id == models.Filament.id]
|
||||
if not allow_archived:
|
||||
join_cond.append(_not_archived())
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Filament.id.label("group_key"), *aggregates)
|
||||
.select_from(models.Filament)
|
||||
.outerjoin(models.Spool, sqlalchemy.and_(*join_cond))
|
||||
.join(models.Filament.vendor, isouter=True)
|
||||
)
|
||||
if group_by == "vendor":
|
||||
# Vendor first, filaments and spools hanging off it by outer joins: a manufacturer with no
|
||||
# filaments is a row of NULLs for both, which the aggregates score as zero. No FULL OUTER
|
||||
# JOIN needed, and none available on MySQL. Filament filters then land in the WHERE and so
|
||||
# drop a vendor with no matching filament -- "the manufacturers of these filaments".
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Vendor.id.label("group_key"), *columns)
|
||||
.select_from(models.Vendor)
|
||||
.outerjoin(models.Filament, models.Filament.vendor_id == models.Vendor.id)
|
||||
.outerjoin(models.Spool, sqlalchemy.and_(*join_cond))
|
||||
)
|
||||
filament_id_column, vendor_id_column = models.Filament.id, models.Vendor.id
|
||||
else:
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Filament.id.label("group_key"), *columns)
|
||||
.select_from(models.Filament)
|
||||
.outerjoin(models.Spool, sqlalchemy.and_(*join_cond))
|
||||
.join(models.Filament.vendor, isouter=True)
|
||||
)
|
||||
filament_id_column, vendor_id_column = models.Filament.id, models.Filament.vendor_id
|
||||
return _apply_filament_filters(
|
||||
stmt,
|
||||
filament_id_column=models.Filament.id,
|
||||
filament_id_column=filament_id_column,
|
||||
vendor_id_column=vendor_id_column,
|
||||
filament_name=filament_name,
|
||||
filament_id=filament_id,
|
||||
filament_material=filament_material,
|
||||
@@ -548,6 +616,80 @@ def _filaments_with_their_spools_stmt(
|
||||
)
|
||||
|
||||
|
||||
def _order_groups(
|
||||
stmt: sqlalchemy.Select,
|
||||
order_exprs: dict[str, list[ColumnElement]],
|
||||
sort_by: dict[str, SortOrder] | None,
|
||||
*,
|
||||
default_expr: ColumnElement,
|
||||
group_col: ColumnElement,
|
||||
) -> sqlalchemy.Select:
|
||||
"""Order the groups by the requested fields, defaulting to the title, with a total-order tiebreak."""
|
||||
applied_sort = False
|
||||
if sort_by:
|
||||
for fieldstr, order in sort_by.items():
|
||||
exprs = order_exprs.get(fieldstr)
|
||||
if exprs is None:
|
||||
continue
|
||||
stmt = stmt.order_by(*order_by_clauses(exprs, order))
|
||||
applied_sort = True
|
||||
if not applied_sort:
|
||||
stmt = stmt.order_by(*order_by_clauses([default_expr], SortOrder.ASC))
|
||||
# Break ties on the grouped column itself, so paging partitions the groups instead of
|
||||
# dropping or repeating one. Every ordering above can tie -- two filaments share a name, two
|
||||
# groups hold the same number of spools, and every empty group ties on all four aggregates --
|
||||
# and without a total order the database may answer "the first 20" and "the next 20" from two
|
||||
# different arrangements of the same rows.
|
||||
return stmt.order_by(group_col.asc())
|
||||
|
||||
|
||||
_EXTRA_SORT_PREFIXES = (
|
||||
("filament.vendor.extra.", EntityType.vendor),
|
||||
("filament.extra.", EntityType.filament),
|
||||
)
|
||||
|
||||
|
||||
async def _entity_sort_exprs(
|
||||
db: AsyncSession,
|
||||
stmt: sqlalchemy.Select,
|
||||
sort_by: dict[str, SortOrder] | None,
|
||||
) -> tuple[sqlalchemy.Select, dict[str, list[ColumnElement]]]:
|
||||
"""Resolve `filament.*` / `filament.vendor.*` sort keys to aggregate expressions.
|
||||
|
||||
Columns are wrapped in MIN() so they stay valid aggregates on every axis (the value is constant
|
||||
within a filament group, and a vendor column within a vendor group). Extra fields are
|
||||
outer-joined (one row per entity and key, so no aggregate changes) and ordered typed like the
|
||||
entity endpoints do. Anything that doesn't resolve -- a key off the filament, a column or extra
|
||||
field that doesn't exist -- is skipped, the way find_groups has always ignored a sort key it
|
||||
doesn't know: the endpoint is released, and rejecting what it used to accept would break callers.
|
||||
"""
|
||||
exprs: dict[str, list[ColumnElement]] = {}
|
||||
definitions: dict[EntityType, dict[str, ExtraField]] = {}
|
||||
for key in sort_by or {}:
|
||||
if key.startswith("group."):
|
||||
continue
|
||||
extra = next(((p, t) for p, t in _EXTRA_SORT_PREFIXES if key.startswith(p)), None)
|
||||
if extra is None:
|
||||
if not key.startswith("filament."):
|
||||
continue
|
||||
try:
|
||||
exprs[key] = [func.min(parse_nested_field(models.Spool, key))]
|
||||
except ValueError:
|
||||
continue
|
||||
continue
|
||||
prefix, entity_type = extra
|
||||
extra_key = key.removeprefix(prefix)
|
||||
if entity_type not in definitions:
|
||||
definitions[entity_type] = {f.key: f for f in await get_extra_fields(db, entity_type)}
|
||||
field = definitions[entity_type].get(extra_key)
|
||||
if field is None:
|
||||
continue
|
||||
join = extra_field_join(entity_type, extra_key)
|
||||
stmt = join.apply(stmt, models.Vendor.id if entity_type == EntityType.vendor else models.Filament.id)
|
||||
exprs[key] = [func.min(extra_field_sort_expr(join.value, field.field_type))]
|
||||
return stmt, exprs
|
||||
|
||||
|
||||
async def find_groups(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -571,7 +713,8 @@ async def find_groups(
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
include_empty: bool = False,
|
||||
) -> tuple[list[SpoolGroupResult], int]:
|
||||
preview: bool = False,
|
||||
) -> SpoolGroupsPage:
|
||||
"""Group matching spools by one axis and return per-group aggregates.
|
||||
|
||||
Aggregation, group ordering and pagination happen in the database. Pagination is over
|
||||
@@ -583,13 +726,38 @@ async def find_groups(
|
||||
The flag turns the query around to select FROM the filaments and outer-join the spools onto
|
||||
them; a filament with none still yields its row, and the aggregates above are written to come
|
||||
out as zero for it. Everything past this point -- ordering, paging, hydration -- is the same
|
||||
code either way. Only `group_by="filament"` supports it, and only without spool-level filters
|
||||
(see _reject_spool_scoped_filters).
|
||||
code either way. Only `group_by="filament"` and `"vendor"` support it, and only without
|
||||
spool-level filters (see _reject_spool_scoped_filters). For vendor it lists the manufacturers,
|
||||
so the group of filaments with NO manufacturer (key null) does not appear in that mode.
|
||||
|
||||
Returns a tuple of the requested page of groups and the total number of matching groups.
|
||||
`group_by="vendor"` groups also carry `filament_count`, the distinct filaments behind the
|
||||
group: with include_empty every matching filament of the manufacturer, otherwise only those
|
||||
that have a matching spool.
|
||||
|
||||
`preview` attaches a few members to each group on the filament and vendor axes (see
|
||||
_attach_previews); other axes ignore it.
|
||||
|
||||
Returns the requested page of groups plus the totals over all matching groups.
|
||||
"""
|
||||
aggregates = _group_aggregates()
|
||||
spool_count, in_use_count, total_remaining, last_used_agg = aggregates
|
||||
columns = (*aggregates, *_axis_columns(group_by))
|
||||
# The filters the spool-driven query applies, kept in one place because the preview query has
|
||||
# to apply exactly the same ones.
|
||||
spool_filters: dict[str, Any] = {
|
||||
"filament_name": filament_name,
|
||||
"filament_id": filament_id,
|
||||
"filament_material": filament_material,
|
||||
"filament_multi_color_direction": filament_multi_color_direction,
|
||||
"vendor_name": vendor_name,
|
||||
"vendor_id": vendor_id,
|
||||
"location": location,
|
||||
"lot_nr": lot_nr,
|
||||
"allow_archived": allow_archived,
|
||||
"first_used": first_used,
|
||||
"last_used": last_used,
|
||||
"registered": registered,
|
||||
}
|
||||
|
||||
if include_empty:
|
||||
_reject_spool_scoped_filters(
|
||||
@@ -603,9 +771,10 @@ async def find_groups(
|
||||
"extra_field_filters": extra_field_filters,
|
||||
},
|
||||
)
|
||||
group_col, title_col = models.Filament.id, models.Filament.name
|
||||
stmt = _filaments_with_their_spools_stmt(
|
||||
aggregates=aggregates,
|
||||
group_col, title_col = EMPTY_GROUP_COLUMNS[group_by]
|
||||
stmt = _entities_with_their_spools_stmt(
|
||||
group_by=group_by,
|
||||
columns=columns,
|
||||
allow_archived=allow_archived,
|
||||
filament_name=filament_name,
|
||||
filament_id=filament_id,
|
||||
@@ -617,19 +786,8 @@ async def find_groups(
|
||||
else:
|
||||
group_col, title_col, extra_join = await _resolve_group_by(db, group_by)
|
||||
stmt = _apply_spool_filters(
|
||||
sqlalchemy.select(group_col.label("group_key"), *aggregates),
|
||||
filament_name=filament_name,
|
||||
filament_id=filament_id,
|
||||
filament_material=filament_material,
|
||||
filament_multi_color_direction=filament_multi_color_direction,
|
||||
vendor_name=vendor_name,
|
||||
vendor_id=vendor_id,
|
||||
location=location,
|
||||
lot_nr=lot_nr,
|
||||
allow_archived=allow_archived,
|
||||
first_used=first_used,
|
||||
last_used=last_used,
|
||||
registered=registered,
|
||||
sqlalchemy.select(group_col.label("group_key"), *columns),
|
||||
**spool_filters,
|
||||
)
|
||||
if extra_join is not None:
|
||||
stmt = extra_join.apply(stmt, models.Spool.id)
|
||||
@@ -643,7 +801,9 @@ async def find_groups(
|
||||
)
|
||||
|
||||
# A filament's (or its vendor's) extra fields describe the filament, so they apply either way
|
||||
# -- reached through the spool in one query and off the filament directly in the other.
|
||||
# -- reached through the spool in one query and off the filament directly in the other. In the
|
||||
# vendor listing that makes them WHERE conditions on the outer-joined filament, so a vendor
|
||||
# filter on an extra field also drops a filament-less vendor (its Filament.id is NULL).
|
||||
stmt = await apply_spool_related_extra_filters(
|
||||
db=db,
|
||||
stmt=stmt,
|
||||
@@ -653,38 +813,41 @@ async def find_groups(
|
||||
)
|
||||
stmt = stmt.group_by(group_col)
|
||||
|
||||
# Total number of matching groups (before pagination).
|
||||
count_stmt = sqlalchemy.select(func.count()).select_from(stmt.order_by(None).subquery())
|
||||
total_count = (await db.execute(count_stmt)).scalar_one()
|
||||
# Total number of matching groups (before pagination), and the sums of two aggregates over
|
||||
# them, all from the one query over the grouped subquery. SUM of no rows is NULL, folded to zero
|
||||
# in Python rather than with COALESCE: CockroachDB sums an INT column to DECIMAL and rejects
|
||||
# an int or float literal as the COALESCE fallback. The sums come back as DECIMAL on
|
||||
# PostgreSQL and CockroachDB, hence the conversions below.
|
||||
grouped = stmt.order_by(None).subquery()
|
||||
count_stmt = sqlalchemy.select(
|
||||
func.count(),
|
||||
func.sum(grouped.c.spool_count),
|
||||
func.sum(grouped.c.total_remaining_weight),
|
||||
).select_from(grouped)
|
||||
total_count, total_spools, total_remaining_sum = (await db.execute(count_stmt)).one()
|
||||
|
||||
# Group ordering. Every option is an aggregate (or the grouped column), so no non-grouped
|
||||
# bare column is referenced — portable across SQLite, PostgreSQL, MySQL and CockroachDB.
|
||||
# Ordering by `group.last_used` ranks each group by its most recently used spool, which is
|
||||
# what a library sorted on "last used" is expected to show; groups holding nothing but
|
||||
# unused spools aggregate to NULL and belong at the bottom, hence order_by_clauses.
|
||||
# Each option is a list: `group.vendor_name` is the manufacturer's name and then its id, so two
|
||||
# vendors sharing a name do not interleave across pages. Aggregates, like the title, because the
|
||||
# filament axis groups by filament and a bare Vendor column would not be grouped; a filament
|
||||
# with no manufacturer is NULL on both and so goes last (order_by_clauses). It exists for paging
|
||||
# filament groups in manufacturer sections.
|
||||
order_exprs = {
|
||||
"group.spool_count": spool_count,
|
||||
"group.in_use_count": in_use_count,
|
||||
"group.total_remaining": total_remaining,
|
||||
"group.last_used": last_used_agg,
|
||||
"group.title": func.min(title_col),
|
||||
"group.spool_count": [spool_count],
|
||||
"group.in_use_count": [in_use_count],
|
||||
"group.total_remaining": [total_remaining],
|
||||
"group.last_used": [last_used_agg],
|
||||
"group.title": [func.min(title_col)],
|
||||
"group.vendor_name": [func.min(models.Vendor.name), func.min(models.Vendor.id)],
|
||||
"group.filament_count": [func.count(sqlalchemy.distinct(models.Filament.id))],
|
||||
}
|
||||
applied_sort = False
|
||||
if sort_by:
|
||||
for fieldstr, order in sort_by.items():
|
||||
expr = order_exprs.get(fieldstr)
|
||||
if expr is None:
|
||||
continue
|
||||
stmt = stmt.order_by(*order_by_clauses([expr], order))
|
||||
applied_sort = True
|
||||
if not applied_sort:
|
||||
stmt = stmt.order_by(*order_by_clauses([func.min(title_col)], SortOrder.ASC))
|
||||
# Break ties on the grouped column itself, so paging partitions the groups instead of
|
||||
# dropping or repeating one. Every ordering above can tie -- two filaments share a name, two
|
||||
# groups hold the same number of spools, and every empty group ties on all four aggregates --
|
||||
# and without a total order the database may answer "the first 20" and "the next 20" from two
|
||||
# different arrangements of the same rows.
|
||||
stmt = stmt.order_by(group_col.asc())
|
||||
stmt, entity_exprs = await _entity_sort_exprs(db, stmt, sort_by)
|
||||
order_exprs.update(entity_exprs)
|
||||
stmt = _order_groups(stmt, order_exprs, sort_by, default_expr=func.min(title_col), group_col=group_col)
|
||||
|
||||
if limit is not None:
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
@@ -707,7 +870,7 @@ async def find_groups(
|
||||
vstmt = sqlalchemy.select(models.Vendor).where(models.Vendor.id.in_(keys))
|
||||
vendor_map = {v.id: v for v in (await db.execute(vstmt)).unique().scalars().all()}
|
||||
|
||||
return [
|
||||
results = [
|
||||
SpoolGroupResult(
|
||||
key=row.group_key,
|
||||
spool_count=int(row.spool_count or 0),
|
||||
@@ -716,9 +879,181 @@ async def find_groups(
|
||||
last_used=row.last_used,
|
||||
filament=filament_map.get(row.group_key) if group_by == "filament" else None,
|
||||
vendor=vendor_map.get(row.group_key) if group_by == "vendor" else None,
|
||||
filament_count=int(row.filament_count or 0) if group_by == "vendor" else None,
|
||||
)
|
||||
for row in rows
|
||||
], total_count
|
||||
]
|
||||
if preview:
|
||||
await _attach_previews(
|
||||
db=db,
|
||||
group_by=group_by,
|
||||
results=results,
|
||||
keys=keys,
|
||||
spool_filters=spool_filters,
|
||||
extra_field_filters=extra_field_filters,
|
||||
filament_extra_field_filters=filament_extra_field_filters,
|
||||
vendor_extra_field_filters=vendor_extra_field_filters,
|
||||
)
|
||||
return SpoolGroupsPage(
|
||||
groups=results,
|
||||
total_count=total_count,
|
||||
total_spools=int(total_spools or 0),
|
||||
total_remaining_weight=float(total_remaining_sum or 0),
|
||||
)
|
||||
|
||||
|
||||
async def _attach_previews(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
group_by: str,
|
||||
results: list[SpoolGroupResult],
|
||||
keys: list[int],
|
||||
spool_filters: dict[str, Any],
|
||||
extra_field_filters: dict[str, str] | None,
|
||||
filament_extra_field_filters: dict[str, str] | None,
|
||||
vendor_extra_field_filters: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Give each group a few of its members, from ONE query for the whole page.
|
||||
|
||||
The members are sliced per group in Python: the alternative, a top-N-per-group, needs a window
|
||||
function (or a LATERAL join) that is not worth it for at most a page of groups. Only the
|
||||
filament and vendor axes have members to show; the rest are left untouched.
|
||||
"""
|
||||
if not keys:
|
||||
return
|
||||
if group_by == "filament":
|
||||
await _attach_spool_previews(
|
||||
db=db,
|
||||
results=results,
|
||||
keys=keys,
|
||||
spool_filters=spool_filters,
|
||||
extra_field_filters=extra_field_filters,
|
||||
filament_extra_field_filters=filament_extra_field_filters,
|
||||
vendor_extra_field_filters=vendor_extra_field_filters,
|
||||
)
|
||||
elif group_by == "vendor":
|
||||
await _attach_filament_previews(
|
||||
db=db,
|
||||
results=results,
|
||||
keys=keys,
|
||||
spool_filters=spool_filters,
|
||||
filament_extra_field_filters=filament_extra_field_filters,
|
||||
vendor_extra_field_filters=vendor_extra_field_filters,
|
||||
)
|
||||
|
||||
|
||||
async def _attach_spool_previews(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
results: list[SpoolGroupResult],
|
||||
keys: list[int],
|
||||
spool_filters: dict[str, Any],
|
||||
extra_field_filters: dict[str, str] | None,
|
||||
filament_extra_field_filters: dict[str, str] | None,
|
||||
vendor_extra_field_filters: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Fill filament groups' `spools`."""
|
||||
# The very same filter builder the group's aggregates went through, so a pip can never
|
||||
# disagree with `spool_count`: whatever the group counted is what it may show. Only the
|
||||
# columns the preview needs are selected, not whole ORM spools.
|
||||
stmt = _apply_spool_filters(
|
||||
sqlalchemy.select(
|
||||
models.Spool.id,
|
||||
models.Spool.filament_id,
|
||||
models.Spool.initial_weight,
|
||||
models.Spool.used_weight,
|
||||
models.Filament.weight,
|
||||
),
|
||||
**spool_filters,
|
||||
).where(models.Spool.filament_id.in_(keys))
|
||||
stmt = await apply_extra_field_filters_and_sort(
|
||||
db=db,
|
||||
stmt=stmt,
|
||||
base_obj=models.Spool,
|
||||
entity_type=EntityType.spool,
|
||||
extra_field_filters=extra_field_filters,
|
||||
sort_by=None,
|
||||
)
|
||||
stmt = await apply_spool_related_extra_filters(
|
||||
db=db,
|
||||
stmt=stmt,
|
||||
filament_filters=filament_extra_field_filters,
|
||||
vendor_filters=vendor_extra_field_filters,
|
||||
link_column=models.Spool.filament_id,
|
||||
)
|
||||
by_filament: dict[int, list[SpoolGroupPreviewSpool]] = {key: [] for key in keys}
|
||||
for row in (await db.execute(stmt.order_by(models.Spool.id.asc()))).all():
|
||||
members = by_filament[row.filament_id]
|
||||
if len(members) >= PREVIEW_SPOOL_LIMIT:
|
||||
continue
|
||||
# The same fallback and clamp as Spool.from_db, so a pip matches the spool endpoint.
|
||||
initial = row.initial_weight if row.initial_weight is not None else row.weight
|
||||
members.append(
|
||||
SpoolGroupPreviewSpool(
|
||||
id=row.id,
|
||||
initial_weight=initial,
|
||||
remaining_weight=None if initial is None else max(initial - row.used_weight, 0),
|
||||
),
|
||||
)
|
||||
for group in results:
|
||||
if group.key is not None:
|
||||
group.spools = by_filament[group.key]
|
||||
|
||||
|
||||
async def _attach_filament_previews(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
results: list[SpoolGroupResult],
|
||||
keys: list[int],
|
||||
spool_filters: dict[str, Any],
|
||||
filament_extra_field_filters: dict[str, str] | None,
|
||||
vendor_extra_field_filters: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Fill vendor groups' `filaments`."""
|
||||
# Vendor groups preview the manufacturer's filaments that match the filament-level filters.
|
||||
# In the spool-driven mode that is any such filament of the vendor, not only those with a
|
||||
# matching spool: the preview is a swatch strip for the manufacturer, and `filament_count`
|
||||
# is the number that says how many actually hold stock.
|
||||
stmt = _apply_filament_filters(
|
||||
sqlalchemy.select(
|
||||
models.Filament.id,
|
||||
models.Filament.vendor_id,
|
||||
models.Filament.color_hex,
|
||||
models.Filament.multi_color_hexes,
|
||||
models.Filament.multi_color_direction,
|
||||
).join(models.Filament.vendor, isouter=True),
|
||||
filament_id_column=models.Filament.id,
|
||||
filament_name=spool_filters["filament_name"],
|
||||
filament_id=spool_filters["filament_id"],
|
||||
filament_material=spool_filters["filament_material"],
|
||||
filament_multi_color_direction=spool_filters["filament_multi_color_direction"],
|
||||
vendor_name=spool_filters["vendor_name"],
|
||||
vendor_id=spool_filters["vendor_id"],
|
||||
).where(models.Filament.vendor_id.in_(keys))
|
||||
stmt = await apply_spool_related_extra_filters(
|
||||
db=db,
|
||||
stmt=stmt,
|
||||
filament_filters=filament_extra_field_filters,
|
||||
vendor_filters=vendor_extra_field_filters,
|
||||
link_column=models.Filament.id,
|
||||
)
|
||||
by_vendor: dict[int, list[SpoolGroupPreviewFilament]] = {key: [] for key in keys}
|
||||
for row in (
|
||||
await db.execute(stmt.order_by(*order_by_clauses([models.Filament.name], SortOrder.ASC), models.Filament.id))
|
||||
).all():
|
||||
members = by_vendor[row.vendor_id]
|
||||
if len(members) < PREVIEW_FILAMENT_LIMIT:
|
||||
members.append(
|
||||
SpoolGroupPreviewFilament(
|
||||
id=row.id,
|
||||
color_hex=_sanitize_color_hex(row.color_hex),
|
||||
multi_color_hexes=_sanitize_multi_color_hexes(row.multi_color_hexes),
|
||||
multi_color_direction=row.multi_color_direction,
|
||||
),
|
||||
)
|
||||
for group in results:
|
||||
if group.key is not None:
|
||||
group.filaments = by_vendor[group.key]
|
||||
|
||||
|
||||
async def update(
|
||||
@@ -897,7 +1232,9 @@ async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spoo
|
||||
|
||||
initial_weight = spool_info[0]
|
||||
spool_weight = spool_info[2]
|
||||
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
|
||||
# A tare of 0 is a real value (a refill wound on no spool), so only a missing one is looked
|
||||
# up further down the chain.
|
||||
if initial_weight is None or initial_weight == 0 or spool_weight is None:
|
||||
# Get filament weight and spool_weight, and the vendor's empty spool weight as the
|
||||
# level below that. The vendor is joined with an outer join on purpose: a filament
|
||||
# without a vendor still has to return its row.
|
||||
@@ -916,7 +1253,7 @@ async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spoo
|
||||
except NoResultFound as exc:
|
||||
raise ItemNotFoundError("Filament not found for spool.") from exc
|
||||
|
||||
if spool_weight is None or spool_weight == 0:
|
||||
if spool_weight is None:
|
||||
# The filament's tare, and failing that its vendor's. A filament's tare is only a
|
||||
# copy of the vendor's, taken when the filament was created, so a tare the vendor
|
||||
# was given later never reached it - the same order create() resolves it in.
|
||||
|
||||
+16
-3
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -332,6 +333,18 @@ def is_automatic_backup_enabled() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def is_update_check_enabled() -> bool:
|
||||
"""Get whether Spoolman should check GitHub for newer releases. Defaults to True."""
|
||||
update_check = os.getenv("SPOOLMAN_UPDATE_CHECK", "TRUE").upper()
|
||||
if update_check in {"FALSE", "0"}:
|
||||
return False
|
||||
if update_check in {"TRUE", "1"}:
|
||||
return True
|
||||
raise ValueError(
|
||||
f"Failed to parse SPOOLMAN_UPDATE_CHECK variable: Unknown value '{update_check}'.",
|
||||
)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Get the data directory.
|
||||
|
||||
@@ -446,9 +459,9 @@ def get_build_date() -> datetime | None:
|
||||
def can_write_to_data_dir() -> bool:
|
||||
"""Check if the data directory is writable."""
|
||||
try:
|
||||
test_file = get_data_dir().joinpath("test.txt")
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
# A uniquely named file, so the check can never clobber one of the user's own.
|
||||
with tempfile.NamedTemporaryFile(dir=get_data_dir()):
|
||||
pass
|
||||
except: # noqa: E722
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -84,14 +84,15 @@ def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None:
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("Value is not a string.")
|
||||
elif field.field_type == ExtraFieldType.integer:
|
||||
if not isinstance(data, int):
|
||||
# Python bool is a subclass of int, but JSON true/false are not integer values.
|
||||
if not isinstance(data, int) or isinstance(data, bool):
|
||||
raise ValueError("Value is not an integer.")
|
||||
elif field.field_type == ExtraFieldType.integer_range:
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("Value is not a list.")
|
||||
if len(data) != 2: # noqa: PLR2004
|
||||
raise ValueError("Value list must have exactly two values.")
|
||||
if not all(isinstance(item, int) or item is None for item in data):
|
||||
if not all((isinstance(item, int) and not isinstance(item, bool)) or item is None for item in data):
|
||||
raise ValueError("Value list must contain only integers or null.")
|
||||
elif field.field_type == ExtraFieldType.float:
|
||||
if not isinstance(data, (float, int)) or isinstance(data, bool):
|
||||
|
||||
+3
-2
@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
||||
from prometheus_client import generate_latest
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import env, externaldb, security
|
||||
from spoolman import env, externaldb, security, update_check
|
||||
from spoolman.api.v1.router import app as v1_app
|
||||
from spoolman.client import SinglePageApplication, render_config_js
|
||||
from spoolman.database import database
|
||||
@@ -177,7 +177,7 @@ def add_cors_middleware() -> None:
|
||||
allow_credentials=allow_credentials,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Total-Count"],
|
||||
expose_headers=["X-Total-Count", "X-Total-Spools", "X-Total-Remaining-Weight"],
|
||||
)
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ async def startup() -> None:
|
||||
schedule = Scheduler()
|
||||
database.schedule_tasks(schedule)
|
||||
externaldb.schedule_tasks(schedule)
|
||||
update_check.schedule_tasks(schedule)
|
||||
|
||||
logger.info("Startup complete.")
|
||||
|
||||
|
||||
@@ -41,6 +41,24 @@ def make_metrics_app() -> Callable:
|
||||
|
||||
metrics_app = make_asgi_app()
|
||||
|
||||
# The label sets each gauge was last given, so the next refresh knows which ones are gone.
|
||||
_published: dict[Gauge, set[tuple]] = {}
|
||||
|
||||
|
||||
def _publish(gauge: Gauge, values: dict[tuple, float]) -> None:
|
||||
"""Make a gauge hold exactly these label sets, dropping any left from an earlier refresh.
|
||||
|
||||
Without the drop, an archived or deleted spool (or a deleted filament) kept being exported
|
||||
until restart. The new values are set before the stale ones are removed rather than
|
||||
clearing first: /metrics is served from a worker thread, so a scrape can land mid-refresh,
|
||||
and this way it never sees a series that is still live go missing.
|
||||
"""
|
||||
for labels, value in values.items():
|
||||
gauge.labels(*labels).set(value)
|
||||
for labels in _published.get(gauge, set()) - values.keys():
|
||||
gauge.remove(*labels)
|
||||
_published[gauge] = set(values)
|
||||
|
||||
|
||||
async def spool_metrics(db: AsyncSession) -> None:
|
||||
"""Get metrics by Spools from DB and write to prometheus.
|
||||
@@ -57,12 +75,19 @@ async def spool_metrics(db: AsyncSession) -> None:
|
||||
)
|
||||
rows = await db.execute(stmt)
|
||||
result = list(rows.unique().scalars().all())
|
||||
price: dict[tuple, float] = {}
|
||||
initial_weight: dict[tuple, float] = {}
|
||||
used_weight: dict[tuple, float] = {}
|
||||
for row in result:
|
||||
labels = (str(row.id), str(row.filament_id))
|
||||
if row.price is not None:
|
||||
SPOOL_PRICE.labels(str(row.id), str(row.filament_id)).set(row.price)
|
||||
price[labels] = row.price
|
||||
if row.initial_weight is not None:
|
||||
SPOOL_INITIAL_WEIGHT.labels(str(row.id), str(row.filament_id)).set(row.initial_weight)
|
||||
SPOOL_USED_WEIGHT.labels(str(row.id), str(row.filament_id)).set(row.used_weight)
|
||||
initial_weight[labels] = row.initial_weight
|
||||
used_weight[labels] = row.used_weight
|
||||
_publish(SPOOL_PRICE, price)
|
||||
_publish(SPOOL_INITIAL_WEIGHT, initial_weight)
|
||||
_publish(SPOOL_USED_WEIGHT, used_weight)
|
||||
|
||||
|
||||
async def filament_metrics(db: AsyncSession) -> None:
|
||||
@@ -79,18 +104,20 @@ async def filament_metrics(db: AsyncSession) -> None:
|
||||
)
|
||||
rows = await db.execute(stmt)
|
||||
result = list(rows.unique().scalars().all())
|
||||
info: dict[tuple, float] = {}
|
||||
density: dict[tuple, float] = {}
|
||||
diameter: dict[tuple, float] = {}
|
||||
weight: dict[tuple, float] = {}
|
||||
for row in result:
|
||||
vendor_name = "-"
|
||||
if row.vendor is not None:
|
||||
vendor_name = row.vendor.name
|
||||
FILAMENT_INFO.labels(
|
||||
str(row.id),
|
||||
vendor_name,
|
||||
row.name,
|
||||
row.material,
|
||||
row.color_hex,
|
||||
).set(1)
|
||||
FILAMENT_DENSITY.labels(str(row.id)).set(row.density)
|
||||
FILAMENT_DIAMETER.labels(str(row.id)).set(row.diameter)
|
||||
info[(str(row.id), vendor_name, row.name, row.material, row.color_hex)] = 1
|
||||
density[(str(row.id),)] = row.density
|
||||
diameter[(str(row.id),)] = row.diameter
|
||||
if row.weight is not None:
|
||||
FILAMENT_WEIGHT.labels(str(row.id)).set(row.weight)
|
||||
weight[(str(row.id),)] = row.weight
|
||||
_publish(FILAMENT_INFO, info)
|
||||
_publish(FILAMENT_DENSITY, density)
|
||||
_publish(FILAMENT_DIAMETER, diameter)
|
||||
_publish(FILAMENT_WEIGHT, weight)
|
||||
@@ -84,6 +84,8 @@ def normalize_uid(uid: str) -> str:
|
||||
raise ValueError(f'"{uid}" is not a valid tag UID. A UID must be hexadecimal.')
|
||||
if len(normalized) > UID_MAX_LENGTH:
|
||||
raise ValueError(f"A tag UID can be at most {UID_MAX_LENGTH} hex characters.")
|
||||
if len(normalized) % 2:
|
||||
raise ValueError("A tag UID must contain a whole number of bytes (an even number of hex characters).")
|
||||
return normalized
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Periodically check GitHub for a newer Spoolman release."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LATEST_RELEASE_URL = "https://api.github.com/repos/Donkie/Spoolman/releases/latest"
|
||||
CHECK_INTERVAL = datetime.timedelta(hours=12)
|
||||
|
||||
# Latest released version seen on GitHub, None until a check has succeeded.
|
||||
latest_version: str | None = None
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[int, ...] | None:
|
||||
"""Parse "v1.2.3" or "1.2.3" into (1, 2, 3). Returns None for anything non-numeric."""
|
||||
try:
|
||||
return tuple(int(part) for part in version.strip().removeprefix("v").split("."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str) -> bool:
|
||||
"""Return True if candidate is a strictly newer version than current."""
|
||||
cand, cur = _parse_version(candidate), _parse_version(current)
|
||||
return cand is not None and cur is not None and cand > cur
|
||||
|
||||
|
||||
def is_update_available() -> bool:
|
||||
"""Return True if a newer release than the running version has been seen."""
|
||||
return latest_version is not None and is_newer(latest_version, env.get_version())
|
||||
|
||||
|
||||
async def _check() -> None:
|
||||
global latest_version # noqa: PLW0603
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(LATEST_RELEASE_URL, headers={"Accept": "application/vnd.github+json"})
|
||||
response.raise_for_status()
|
||||
version = str(response.json()["tag_name"]).removeprefix("v")
|
||||
except (httpx.HTTPError, KeyError, ValueError) as exc:
|
||||
logger.info("Could not check for Spoolman updates: %s", exc)
|
||||
return
|
||||
|
||||
if version == latest_version:
|
||||
return
|
||||
latest_version = version
|
||||
if is_update_available():
|
||||
logger.warning(
|
||||
"A new version of Spoolman is available: v%s (running v%s). https://github.com/Donkie/Spoolman/releases",
|
||||
version,
|
||||
env.get_version(),
|
||||
)
|
||||
|
||||
|
||||
def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
"""Schedule the update check: once in the background at startup, then periodically."""
|
||||
if not env.is_update_check_enabled():
|
||||
logger.info("Update check disabled.")
|
||||
return
|
||||
scheduler.once(datetime.timedelta(seconds=0), _check) # type: ignore[arg-type]
|
||||
scheduler.cyclic(CHECK_INTERVAL, _check) # type: ignore[arg-type]
|
||||
+3
-1
@@ -39,7 +39,9 @@ class SubscriptionTree:
|
||||
def remove(self, path: tuple[str, ...], websocket: WebSocket) -> None:
|
||||
"""Remove a websocket from the subscription tree."""
|
||||
if len(path) == 0:
|
||||
self.subscribers.remove(websocket)
|
||||
# A failed broadcast can already have dropped this socket before its
|
||||
# handler receives the disconnect event. Cleanup must be idempotent.
|
||||
self.subscribers.discard(websocket)
|
||||
elif path[0] in self.children:
|
||||
self.children[path[0]].remove(path[1:], websocket)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ cross-origin form post.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -140,3 +141,49 @@ def test_non_sqlite_database_is_skipped(backups: Path):
|
||||
result = db.backup_and_rotate(backups)
|
||||
|
||||
assert result == database.BackupResult(None, created=False)
|
||||
|
||||
|
||||
def test_backup_gives_up_on_a_lock_that_never_clears(
|
||||
db: Database,
|
||||
db_path: Path,
|
||||
backups: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""sqlite3's backup() retries a locked source forever, which hung the server (#1191)."""
|
||||
monkeypatch.setattr(database, "BACKUP_LOCK_TIMEOUT_SECONDS", 0.5)
|
||||
with closing(sqlite3.connect(db_path, isolation_level=None)) as holder:
|
||||
holder.execute("BEGIN EXCLUSIVE")
|
||||
with pytest.raises(sqlite3.OperationalError, match="locked"):
|
||||
db.backup_and_rotate(backups)
|
||||
holder.execute("ROLLBACK")
|
||||
assert not (backups / f"{BACKUP_NAME}.pending").exists()
|
||||
|
||||
|
||||
def test_backup_needs_no_lock_on_the_target(db: Database, backups: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Backup folders on network shares often refuse file locks, which failed every backup (#1246).
|
||||
|
||||
Simulate that by holding an exclusive lock on the target from the moment it is created.
|
||||
"""
|
||||
monkeypatch.setattr(database, "BACKUP_LOCK_TIMEOUT_SECONDS", 0.5)
|
||||
pending = backups / f"{BACKUP_NAME}.pending"
|
||||
real_connect = sqlite3.connect
|
||||
holders: list[sqlite3.Connection] = []
|
||||
|
||||
def connect(*args: object, **kwargs: object) -> sqlite3.Connection:
|
||||
conn = real_connect(*args, **kwargs)
|
||||
if pending.exists() and not holders:
|
||||
holders.append(real_connect(pending, isolation_level=None))
|
||||
holders[0].execute("BEGIN EXCLUSIVE")
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(sqlite3, "connect", connect)
|
||||
try:
|
||||
result = db.backup_and_rotate(backups)
|
||||
finally:
|
||||
for holder in holders:
|
||||
holder.close()
|
||||
|
||||
assert holders, "the target was never locked, so this test proves nothing"
|
||||
assert result.created is True
|
||||
with closing(real_connect(backups / BACKUP_NAME)) as conn:
|
||||
assert conn.execute("SELECT note FROM spool").fetchall() == [("original",)]
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for environment variable parsing."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from spoolman import env
|
||||
@@ -46,3 +49,23 @@ def test_get_cors_origin_drops_empty_and_duplicate_entries(monkeypatch: pytest.M
|
||||
def test_get_cors_origin_raw_is_unparsed(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("SPOOLMAN_CORS_ORIGIN", " https://a.local, https://b.local ")
|
||||
assert env.get_cors_origin_raw() == " https://a.local, https://b.local "
|
||||
|
||||
|
||||
def test_can_write_to_data_dir_leaves_existing_files_alone(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
||||
"""The write check must not touch a file of the user's, even one named like a probe file."""
|
||||
monkeypatch.setenv("SPOOLMAN_DIR_DATA", str(tmp_path))
|
||||
(tmp_path / "test.txt").write_text("mine")
|
||||
assert env.can_write_to_data_dir() is True
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["test.txt"]
|
||||
assert (tmp_path / "test.txt").read_text() == "mine"
|
||||
|
||||
|
||||
def test_can_write_to_data_dir_read_only(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
||||
if os.name == "nt" or os.geteuid() == 0:
|
||||
pytest.skip("directory permissions are not enforced here")
|
||||
monkeypatch.setenv("SPOOLMAN_DIR_DATA", str(tmp_path))
|
||||
tmp_path.chmod(0o500)
|
||||
try:
|
||||
assert env.can_write_to_data_dir() is False
|
||||
finally:
|
||||
tmp_path.chmod(0o700)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for how measure() resolves a spool's empty spool (tare) weight."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from spoolman.database import models, spool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("spool_weight", "expected_used"),
|
||||
[
|
||||
(0, 600), # A refill on no spool: its own 0 is used, the 400 g reading is all filament.
|
||||
(None, 850), # No tare of its own: the filament's 250 g is subtracted from the reading.
|
||||
],
|
||||
)
|
||||
async def test_measure_tare(spool_weight: float | None, expected_used: float) -> None:
|
||||
"""An explicit 0 tare sticks; only a missing one falls back to the filament's."""
|
||||
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(models.Base.metadata.create_all)
|
||||
|
||||
async with AsyncSession(engine, expire_on_commit=False) as db:
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None) # stored naive, as UTC
|
||||
filament = models.Filament(registered=now, density=1.24, diameter=1.75, weight=1000, spool_weight=250)
|
||||
item = models.Spool(
|
||||
filament=filament,
|
||||
registered=now,
|
||||
initial_weight=1000,
|
||||
spool_weight=spool_weight,
|
||||
used_weight=0,
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
|
||||
# A fresh session, as each API request gets.
|
||||
async with AsyncSession(engine, expire_on_commit=False) as db:
|
||||
result = await spool.measure(db, item.id, 400)
|
||||
assert result.used_weight == pytest.approx(expected_used)
|
||||
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the Prometheus metrics refresh dropping spools and filaments that are gone."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from spoolman.database import models
|
||||
from spoolman.prometheus.metrics import filament_metrics, registry, spool_metrics
|
||||
|
||||
|
||||
async def refresh(engine: AsyncEngine) -> None:
|
||||
"""Run one metrics refresh in a fresh session, the way the scheduler does."""
|
||||
async with AsyncSession(engine) as db:
|
||||
await filament_metrics(db)
|
||||
await spool_metrics(db)
|
||||
|
||||
|
||||
def used(spool_id: int, filament_id: int) -> float | None:
|
||||
"""Return a spool's exported used weight, or None if it is not exported."""
|
||||
labels = {"spool_id": str(spool_id), "filament_id": str(filament_id)}
|
||||
return registry.get_sample_value("spoolman_spool_weight_used", labels)
|
||||
|
||||
|
||||
def density(filament_id: int) -> float | None:
|
||||
"""Return a filament's exported density, or None if it is not exported."""
|
||||
return registry.get_sample_value("spoolman_filament_density", {"filament_id": str(filament_id)})
|
||||
|
||||
|
||||
def info(filament_id: int, name: str) -> float | None:
|
||||
"""Return a vendorless filament's exported info sample, or None if it is not exported."""
|
||||
labels = {"filament_id": str(filament_id), "vendor": "-", "name": name, "material": "None", "color": "None"}
|
||||
return registry.get_sample_value("spoolman_filament_info", labels)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_drops_archived_and_deleted() -> None:
|
||||
"""Archived and deleted spools, and deleted filaments, stop being exported on the next refresh."""
|
||||
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(models.Base.metadata.create_all)
|
||||
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None) # stored naive, as UTC
|
||||
async with AsyncSession(engine, expire_on_commit=False) as db:
|
||||
kept = models.Filament(registered=now, name="Kept", density=1.24, diameter=1.75)
|
||||
gone = models.Filament(registered=now, name="Gone", density=1.27, diameter=1.75)
|
||||
spools = [models.Spool(filament=kept, registered=now, used_weight=w) for w in (10, 20, 30)]
|
||||
db.add_all([kept, gone, *spools])
|
||||
await db.commit()
|
||||
|
||||
await refresh(engine)
|
||||
assert [used(s.id, kept.id) for s in spools] == [10, 20, 30]
|
||||
assert density(gone.id) == 1.27
|
||||
assert info(gone.id, "Gone") == 1
|
||||
|
||||
async with AsyncSession(engine) as db:
|
||||
(await db.get(models.Spool, spools[1].id)).archived = True
|
||||
await db.delete(await db.get(models.Spool, spools[2].id))
|
||||
await db.delete(await db.get(models.Filament, gone.id))
|
||||
await db.commit()
|
||||
|
||||
await refresh(engine)
|
||||
assert [used(s.id, kept.id) for s in spools] == [10, None, None]
|
||||
assert density(kept.id) == 1.24
|
||||
assert density(gone.id) is None
|
||||
assert info(kept.id, "Kept") == 1
|
||||
assert info(gone.id, "Gone") is None
|
||||
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Tests for the release version comparison used by the update check."""
|
||||
|
||||
from spoolman.update_check import is_newer
|
||||
|
||||
|
||||
def test_is_newer() -> None:
|
||||
assert is_newer("0.28.0", "0.27.0")
|
||||
assert is_newer("v0.27.1", "0.27.0")
|
||||
assert is_newer("1.0.0", "0.99.9")
|
||||
assert is_newer("0.10.0", "0.9.0") # numeric, not string, compare
|
||||
assert not is_newer("0.27.0", "0.27.0")
|
||||
assert not is_newer("0.26.5", "0.27.0")
|
||||
assert not is_newer("0.28.0", "unknown")
|
||||
assert not is_newer("0.28.0-beta", "0.27.0")
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
screenshots/
|
||||
Generated
+1136
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^7.6.0",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/node": "^22.20.1"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,15 @@ export default defineConfig({
|
||||
// The tests share one Spoolman instance, so keep them serial to avoid
|
||||
// cross-test interference in the shared database.
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||
// Argos uploads only from the CI screenshots step; any other run would upload
|
||||
// an empty build and show every screen as removed.
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
["list"],
|
||||
["html", { open: "never" }],
|
||||
["@argos-ci/playwright/reporter", { uploadToArgos: !!process.env.ARGOS_UPLOAD }],
|
||||
]
|
||||
: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
// Pin the browser locale so date/number formatting is deterministic. The
|
||||
@@ -30,9 +38,21 @@ export default defineConfig({
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
// The screenshots project seeds a fixed dataset and needs an empty database,
|
||||
// so CI runs it first on its own; see tests/screenshots.spec.ts.
|
||||
projects: [
|
||||
{
|
||||
name: "screenshots",
|
||||
testMatch: "screenshots.spec.ts",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
// Argos-recommended flags for stable font rendering across runs.
|
||||
launchOptions: { args: ["--disable-lcd-text", "--font-render-hinting=none"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: "screenshots.spec.ts",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -169,3 +169,51 @@ test("a filament's extra fields can be filled in while creating it", async ({ pa
|
||||
await expect(page.getByText(fieldKey, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a typed zero tare overrides the filament's spool weight", async ({ page, request }) => {
|
||||
const API_BASE =
|
||||
process.env.SPOOLMAN_API_URL ??
|
||||
`${process.env.SPOOLMAN_BASE_URL ?? "http://localhost:8001"}/api/v1`;
|
||||
const filamentName = unique("ZeroTare");
|
||||
const created = await request.post(`${API_BASE}/filament`, {
|
||||
data: {
|
||||
name: filamentName,
|
||||
material: "PLA",
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
weight: 1000,
|
||||
spool_weight: 200,
|
||||
price: 20,
|
||||
},
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const filament = await created.json();
|
||||
let spoolId: number | undefined;
|
||||
|
||||
try {
|
||||
await openApp(page);
|
||||
const dialog = await openAddSpoolModal(page);
|
||||
await dialog.locator("input.search-big").fill(filamentName);
|
||||
await dialog.locator(".res-item").filter({ hasText: filamentName }).first().click();
|
||||
await numberField(dialog, "Spool Weight").fill("0");
|
||||
await numberField(dialog, "Price").fill("0");
|
||||
const saved = page.waitForResponse(
|
||||
(r) => r.request().method() === "POST" && new URL(r.url()).pathname.endsWith("/spool"),
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Add 1 spool", exact: true }).click();
|
||||
const response = await saved;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
spoolId = (await response.json()).id;
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
const stored = await request.get(`${API_BASE}/spool/${spoolId}`);
|
||||
expect(stored.ok()).toBeTruthy();
|
||||
expect(await stored.json()).toMatchObject({
|
||||
spool_weight: 0,
|
||||
price: 0,
|
||||
});
|
||||
} finally {
|
||||
if (spoolId !== undefined) await request.delete(`${API_BASE}/spool/${spoolId}`);
|
||||
await request.delete(`${API_BASE}/filament/${filament.id}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { unique } from "./helpers";
|
||||
|
||||
/**
|
||||
* A dashboard card shows its spools a page at a time and fetches the next page
|
||||
* when its list is scrolled to the bottom. The card's height cap has to stay
|
||||
* below one page of spools for that to work: on a tall screen a card that fits
|
||||
* its whole first page has no scrollbar, so the rest would never load (#1242).
|
||||
*/
|
||||
|
||||
const API_BASE =
|
||||
process.env.SPOOLMAN_API_URL ?? `${process.env.SPOOLMAN_BASE_URL ?? "http://localhost:8001"}/api/v1`;
|
||||
|
||||
test.use({ viewport: { width: 1920, height: 2160 } });
|
||||
|
||||
test("a big group's card loads every spool on a tall screen", async ({ page, request }) => {
|
||||
const location = unique("Rack");
|
||||
const res = await request.post(`${API_BASE}/filament`, {
|
||||
data: { name: unique("Filament"), density: 1.24, diameter: 1.75 },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const filamentId = (await res.json()).id;
|
||||
// More than one page of spools (30).
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const spool = await request.post(`${API_BASE}/spool`, { data: { filament_id: filamentId, location } });
|
||||
expect(spool.ok()).toBeTruthy();
|
||||
ids.push((await spool.json()).id);
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto("/dashboard?by=location");
|
||||
const card = page.locator(".card").filter({ has: page.getByText(location, { exact: true }) });
|
||||
const body = card.locator(".card-body");
|
||||
await expect(body.locator(".chip")).toHaveCount(30);
|
||||
|
||||
await body.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
await expect(body.locator(".chip")).toHaveCount(40);
|
||||
} finally {
|
||||
// Leave the shared database as the other specs expect it.
|
||||
for (const id of ids) await request.delete(`${API_BASE}/spool/${id}`);
|
||||
await request.delete(`${API_BASE}/filament/${filamentId}`);
|
||||
}
|
||||
});
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user