Merge branch 'Donkie:master' into master
This commit is contained in:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -4,3 +4,4 @@
|
||||
|
||||
# Never modify line endings of our bash scripts
|
||||
*.sh -crlf
|
||||
.env.example text eol=lf
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -98,6 +98,7 @@ jobs:
|
||||
- name: Upload full Spoolman artifact
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
with:
|
||||
include-hidden-files: true
|
||||
name: spoolman
|
||||
path: .
|
||||
#
|
||||
|
||||
165
README.md
165
README.md
@@ -10,10 +10,14 @@ _Keep track of your inventory of 3D-printer filament spools._
|
||||
|
||||
Spoolman is a self-hosted web service designed to help you efficiently manage your 3D printer filament spools and monitor their usage. It acts as a centralized database that seamlessly integrates with popular 3D printing software like [OctoPrint](https://octoprint.org/) and [Klipper](https://www.klipper3d.org/)/[Moonraker](https://moonraker.readthedocs.io/en/latest/). When connected, it automatically updates spool weights as printing progresses, giving you real-time insights into filament usage.
|
||||
|
||||
[](https://github.com/Donkie/Spoolman/wiki)
|
||||
[](https://github.com/Donkie/Spoolman/releases)
|
||||
|
||||
### Features
|
||||
* **Filament Management**: Keep comprehensive records of filament types, manufacturers, and individual spools.
|
||||
* **API Integration**: The [REST API](https://donkie.github.io/Spoolman/) allows easy integration with other software, facilitating automated workflows and data exchange.
|
||||
* **Real-Time Updates**: Stay informed with live spool updates through Websockets, providing immediate feedback during printing operations.
|
||||
* **Central Filament Database**: A community-supported database of manufacturers and filaments simplify adding new spools to your inventory. Contribute by heading to [SpoolmanDB](https://github.com/Donkie/SpoolmanDB).
|
||||
* **Web-Based Client**: Spoolman includes a built-in web client that lets you manage data effortlessly:
|
||||
* View, create, edit, and delete filament data.
|
||||
* Add custom fields to tailor information to your specific needs.
|
||||
@@ -21,9 +25,9 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo
|
||||
* Contribute to its translation into 18 languages via [Weblate](https://hosted.weblate.org/projects/spoolman/).
|
||||
* **Database Support**: SQLite, PostgreSQL, MySQL, and CockroachDB.
|
||||
* **Multi-Printer Management**: Handles spool updates from several printers simultaneously.
|
||||
* **Advanced Monitoring**: Integrate with [Prometheus](https://prometheus.io/) for detailed historical analysis of filament usage, helping you track and optimize your printing processes.
|
||||
* **Advanced Monitoring**: Integrate with [Prometheus](https://prometheus.io/) for detailed historical analysis of filament usage, helping you track and optimize your printing processes. See the [Wiki](https://github.com/Donkie/Spoolman/wiki/Filament-Usage-History) for instructions on how to set it up.
|
||||
|
||||
**Integrations:**
|
||||
**Spoolman integrates with:**
|
||||
* [Moonraker](https://moonraker.readthedocs.io/en/latest/configuration/#spoolman) and most front-ends (Fluidd, KlipperScreen, Mainsail, ...)
|
||||
* [OctoPrint](https://github.com/mdziekon/octoprint-spoolman)
|
||||
* [OctoEverywhere](https://octoeverywhere.com/spoolman?source=github_spoolman)
|
||||
@@ -33,159 +37,4 @@ Spoolman is a self-hosted web service designed to help you efficiently manage yo
|
||||

|
||||
|
||||
## Installation
|
||||
Spoolman can be installed in two ways, either using **Docker** or **standalone** on your machine.
|
||||
* The Docker method can be used on any OS which supports Docker.
|
||||
* The standalone method requires a Debian (e.g. Ubuntu, Raspberry Pi OS) or Arch-based Linux distribution.
|
||||
* If you know what you're doing you can also install it standalone for any OS such as Windows, but there are no provided scripts for automated installation, follow the "Install from Source" instructions below.
|
||||
|
||||
Docker is the recommended installation method since it is more reliable and portable.
|
||||
|
||||
Spoolman comes with a built-in SQLite database that is used by default and will suit most users needs. The data is stored in a single .db file in the server's user directory. You can optionally instead use a PostgreSQL, MySQL or CockroachDB database.
|
||||
|
||||
### Docker Install
|
||||
#### First install
|
||||
Docker is a platform for developing, shipping, and running applications in "containers". Containers are lightweight, portable, and self-contained environments that can run on any machine with Docker installed. The Spoolman docker image contains everything it needs to run, so you don't need to worry about for example what Python version you have installed on your local machine.
|
||||
|
||||
To install Docker on your machine, follow the instructions for your operating system on the [Docker website](https://docs.docker.com/engine/install/). Docker also includes the docker-compose tool which lets you configure the container deployment in a simple yaml file, without having to remember all the command line options. Note: older versions of docker-compose require you to have a dash (`-`) in the following commands, like `docker-compose` instead of `docker compose`.
|
||||
|
||||
Here is a sample docker-compose config to get you started. Create a folder called `spoolman` in your home directory, CD in to it, copy-paste the below sample into a file called `docker-compose.yml` in the new directory and run `docker compose up -d` to start it. If you want to use the SQLite database as in this sample, you must first create a folder called `data` in the same directory as the `docker-compose.yml`, then you should run `chown 1000:1000 data` on it in order to give it the correct permissions for the user inside the docker container.
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
spoolman:
|
||||
image: ghcr.io/donkie/spoolman:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Mount the host machine's ./data directory into the container's /home/app/.local/share/spoolman directory
|
||||
- type: bind
|
||||
source: ./data # This is where the data will be stored locally. Could also be set to for example `source: /home/pi/printer_data/spoolman`.
|
||||
target: /home/app/.local/share/spoolman # Do NOT modify this line
|
||||
ports:
|
||||
# Map the host machine's port 7912 to the container's port 8000
|
||||
- "7912:8000"
|
||||
environment:
|
||||
- TZ=Europe/Stockholm # Optional, defaults to UTC
|
||||
```
|
||||
Once you have it up and running, you can access the web UI by browsing to `http://server.ip:7912`. *Make sure that the data folder you created now contains a `spoolman.db` file*. If you cannot find this file in your machine, then **your data will be lost** every time you update Spoolman.
|
||||
|
||||
Type `docker compose logs` in the terminal to see the server logs.
|
||||
|
||||
If you want to modify things like database connection, add environment variables to the `environment:` section of the `docker-compose.yml` like shown in the sample above and then restart the service: `docker compose restart`. See the `.env.example` file for a list of all environment variables you can use.
|
||||
|
||||
#### Updating
|
||||
If a new version of Spoolman has been released, you can update to it by first browsing to the directory where you have the `docker-compose.yml` file and then running `docker compose pull && docker compose up -d`.
|
||||
|
||||
### Standalone Install
|
||||
#### First install
|
||||
This installation method assumes you are using a Debian-based Linux distribution such as Ubuntu or Raspberry Pi OS. If you are using Arch, or any other distribution, please modify as appropriate.
|
||||
|
||||
Make sure your current directory is in your logged in user's home directory. Copy-paste the entire below command and run it on your machine to install the latest release of Spoolman.
|
||||
```bash
|
||||
sudo apt-get update && \
|
||||
sudo apt-get install -y curl jq && \
|
||||
mkdir -p ./Spoolman && \
|
||||
source_url=$(curl -s https://api.github.com/repos/Donkie/Spoolman/releases/latest | jq -r '.assets[] | select(.name == "spoolman.zip").browser_download_url') && \
|
||||
curl -sSL $source_url -o temp.zip && unzip temp.zip -d ./Spoolman && rm temp.zip && \
|
||||
cd ./Spoolman && \
|
||||
bash ./scripts/install.sh
|
||||
```
|
||||
|
||||
If you want to modify things like database connection, edit the `.env` file in the `Spoolman` folder and restart the service.
|
||||
|
||||
#### Updating
|
||||
Updating Spoolman is quite simple. If you use the default database type, SQLite, it is stored outside of the installation folder (in `~/.local/share/spoolman`), so you will not lose any data by moving to a new installation folder.
|
||||
|
||||
Copy-paste the entire below commands and run it on your machine to update Spoolman to the latest version. The command assumes your existing Spoolman folder is named `Spoolman` and is located in your current directory.
|
||||
```bash
|
||||
# Stop and disable the old Spoolman service
|
||||
sudo systemctl stop Spoolman
|
||||
sudo systemctl disable Spoolman
|
||||
systemctl --user stop Spoolman
|
||||
systemctl --user disable Spoolman
|
||||
|
||||
# Download and install the new version
|
||||
mv Spoolman Spoolman_old && \
|
||||
mkdir -p ./Spoolman && \
|
||||
source_url=$(curl -s https://api.github.com/repos/Donkie/Spoolman/releases/latest | jq -r '.assets[] | select(.name == "spoolman.zip").browser_download_url') && \
|
||||
curl -sSL $source_url -o temp.zip && unzip temp.zip -d ./Spoolman && rm temp.zip && \
|
||||
cp Spoolman_old/.env Spoolman/.env && \
|
||||
cd ./Spoolman && \
|
||||
bash ./scripts/install.sh && \
|
||||
rm -rf ../Spoolman_old
|
||||
```
|
||||
|
||||
## Frequently Asked Questions (FAQs)
|
||||
### QR Code Does not work on HTTP / The page is not served over HTTPS
|
||||
This is a limitation of the browsers. Browsers require a secure connection to the server to enable HTTPS. This is not a limitation of Spoolman. For more information read this [blog](https://blog.mozilla.org/webrtc/camera-microphone-require-https-in-firefox-68/) from Mozilla.
|
||||
|
||||
[OctoEverywhere.com](https://octoeverywhere.com/spoolman?source=github_spoolman_qr) is An easy way to get secure HTTPS access to Spoolman. Remote access with OctoEverywhere is always secure and easier than setting up a reverse proxy + SSL self-signed certificate.
|
||||
|
||||
Another option is to put Spoolman behind a reverse proxy like Caddy or Nginx to enable HTTPS. See for example [this guide](https://caddyserver.com/docs/quick-starts/reverse-proxy) for Caddy.
|
||||
|
||||
### Can Spoolman be translated into my language?
|
||||
Yes, head over to [Weblate](https://hosted.weblate.org/projects/spoolman/) to start the translation!
|
||||
|
||||
## Install from source
|
||||
**Advanced users only.**
|
||||
|
||||
If you want to run the absolute latest version of Spoolman, you can either use the `edge` tagged Docker image, or follow
|
||||
these steps to install from source. Keep in mind that this is straight from the master branch which may contain unfinished and untested features. You may also not be able to go back to a previous version since the database schema may change.
|
||||
|
||||
1. Make sure you have at least NodeJS 20 or higher installed, and Python 3.9 or higher installed.
|
||||
1. Clone this repo or download the zip source.
|
||||
2. Inside the `client/` folder:
|
||||
1. Create a .env file with `VITE_APIURL=/api/v1` in it
|
||||
2. Run `npm ci`
|
||||
3. Run `npm run build`
|
||||
4. Install PDM using `pip install --user pdm`
|
||||
5. Build the requirements.txt file: `pdm export -o requirements.txt --without-hashes > requirements.txt`
|
||||
3. Give scripts permissions: `chmod +x ./scripts/*.sh`
|
||||
6. Run the installer script like the normal install: `./scripts/install.sh`
|
||||
|
||||
## Development
|
||||
### Server/Backend (Python)
|
||||
The Python backend is built using Python 3.9 standards. It's built on FastAPI for the REST API, and SQLAlchemy to handle the databases.
|
||||
|
||||
To setup yourself for Python development, do the following:
|
||||
1. Clone this repo
|
||||
2. CD into the repo
|
||||
3. Install PDM: `pip install --user pdm`
|
||||
4. Install Spoolman dependencies: `pdm sync`
|
||||
|
||||
And you should be all setup. Read the Style and Integration Testing sections below as well.
|
||||
|
||||
#### Style
|
||||
[Black](https://black.readthedocs.io/en/stable/) and [Ruff](https://docs.astral.sh/ruff/) is used to ensure a consistent style and good code quality. You can install extensions in your editor to make them run automatically.
|
||||
|
||||
[Pre-commit](https://pre-commit.com/) is used to ensure the style is maintained for each commit. You can setup pre-commit by simply running the following in the Spoolman root directory:
|
||||
```
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
#### Integration Testing
|
||||
The entire backend is integration tested using an isolated docker container, with all 4 database types that we support (Postgres, MySQL, SQLite and CockroachDB). These integration tests live in `tests_integration/`. They are designed to "use" the REST API in the same way that a client would, and ensures that everything remains consistent between updates. The databases are created as part of the integration testing, so no external database is needed to run them.
|
||||
|
||||
If you have docker installed, you can run the integration tests using `pdm run itest` for all databases, or e.g. `pdm run itest postgres` for a single database.
|
||||
|
||||
|
||||
### Client (Node/React/Typescript)
|
||||
The client is a React-based web client, built using the [refine.dev](https://refine.dev) framework, with Ant Design as the components.
|
||||
|
||||
To test out changes to the web client, the best way is to run it in development mode.
|
||||
|
||||
Prerequisites:
|
||||
* NodeJS 20 or above installed, along with NPM. Running `node --version` should print a correct version.
|
||||
* A running Spoolman server, with the following two environment variables added in the `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- FORWARDED_ALLOW_IPS=*
|
||||
- SPOOLMAN_DEBUG_MODE=TRUE
|
||||
```
|
||||
|
||||
Instructions:
|
||||
1. Open a terminal and CD to the `client` subdirectory
|
||||
2. Run `npm install`. If it doesn't succeed, you probably have an incorrect node version. Spoolman is only tested on NodeJS 20.
|
||||
3. Run `echo "VITE_APIURL=http://192.168.0.123:7901/api/v1" > .env`, where the ip:port is the address of the running Spoolman server. This should create a `.env` file in the `client` directory. If you don't already have one running on your network, you can start one up using the `docker-compose.yml` showed above.
|
||||
4. Run `npm run dev`. The terminal will print a "Local: xxxx" URL, open that in your browser and the web client should show up. Your existing spools etc in your Spoolman database should be loaded in.
|
||||
5. Any edits in .ts/.tsx files will be automatically reloaded in your browser. If you make any change to .json files you will need to F5 in your browser.
|
||||
Please see the [Installation page on the Wiki](https://github.com/Donkie/Spoolman/wiki/Installation) for details how to install Spoolman.
|
||||
|
||||
2629
client/package-lock.json
generated
2629
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,59 @@
|
||||
{
|
||||
"name": "spoolman-ui",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"engines": {
|
||||
"node": "20.x"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"@loadable/component": "^5.16.4",
|
||||
"@refinedev/antd": "^5.42.0",
|
||||
"@refinedev/core": "^4.53.0",
|
||||
"@refinedev/antd": "^5.44.0",
|
||||
"@refinedev/core": "^4.56.0",
|
||||
"@refinedev/kbar": "^1.3.12",
|
||||
"@refinedev/react-router-v6": "^4.5.11",
|
||||
"@refinedev/react-router-v6": "^4.6.0",
|
||||
"@refinedev/simple-rest": "^5.0.8",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query-devtools": "^4.36.1",
|
||||
"@types/loadable__component": "^5.13.9",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@yudiel/react-qr-scanner": "^1.2.10",
|
||||
"antd": "^5.20.0",
|
||||
"antd": "^5.22.1",
|
||||
"axios": "^1.7.7",
|
||||
"flag-icons": "^7.2.3",
|
||||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^23.12.2",
|
||||
"i18next": "^23.16.6",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.5.2",
|
||||
"i18next-http-backend": "^2.6.2",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-to-print": "^2.15.1",
|
||||
"uuid": "^10.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
"uuid": "^11.0.3",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@refinedev/cli": "^2.16.36",
|
||||
"@types/node": "^20.14.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@refinedev/cli": "^2.16.39",
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5",
|
||||
"vite-plugin-mkcert": "^1.17.5"
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-mkcert": "^1.17.6"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "refine dev",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"preview": "Náhled:"
|
||||
},
|
||||
"template": "Šablona štítku",
|
||||
"templateHelp": "Pomocí {} vložíte hodnoty objektu cívky jako text. Například {id} bude nahrazeno ID cívky nebo {filament.material} bude nahrazeno materiálem cívky. Text uzavřete dvojitou hvězdičkou **, aby byl tučný. Kliknutím na tlačítko zobrazíte seznam všech dostupných značek.",
|
||||
"templateHelp": "Pomocí {} vložíte hodnoty objektu cívky jako text. Například, {id} bude nahrazeno ID cívky nebo {filament.material} bude nahrazeno materiálem cívky. Pokud hodnota chybí, bude nahrazena znakem „?“. K odstranění této hodnoty lze použít druhou sadu {}. Kromě toho bude odstraněn jakýkoli text mezi sadami {}, pokud hodnota chybí. Například {Lot Nr: {lot_nr}} zobrazí popisek pouze v případě, že cívka má číslo šarže. Text uzavřete dvojitou hvězdičkou **, aby byl tučný. Kliknutím na tlačítko zobrazíte seznam všech dostupných značek.",
|
||||
"showQRCode": "Tisk QR kódu",
|
||||
"showQRCodeMode": {
|
||||
"no": "Ne",
|
||||
@@ -143,8 +143,8 @@
|
||||
"noSpoolsSelected": "Nevybrali jste žádné cívky.",
|
||||
"selectAll": "Vybrat/odebrat vše",
|
||||
"selectedTotal_one": "{{count}} vybraná cívka",
|
||||
"selectedTotal_few": "{{count}} vybraných cívek",
|
||||
"selectedTotal_other": "{{count}} vybrané cívky"
|
||||
"selectedTotal_few": "{{count}} vybrané cívky",
|
||||
"selectedTotal_other": "{{count}} vybraných cívek"
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
@@ -220,6 +220,9 @@
|
||||
"measurement_type_label": "Typ měření",
|
||||
"adjust_filament_help": "Zde můžete přímo přidávat nebo odebírat filament z cívky. Kladná hodnota filament odebere, záporná hodnota ho přidá.",
|
||||
"adjust_filament_value": "Spotřebované množství"
|
||||
},
|
||||
"formats": {
|
||||
"last_used": "Naposledy použitý {{date}}"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -331,6 +334,9 @@
|
||||
},
|
||||
"help": {
|
||||
"list": "Nápověda | Spoolman"
|
||||
},
|
||||
"locations": {
|
||||
"list": "Umístění | Spoolman"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -390,5 +396,11 @@
|
||||
}
|
||||
},
|
||||
"settings": "Nastavení"
|
||||
},
|
||||
"locations": {
|
||||
"locations": "Umístění",
|
||||
"no_locations_help": "Na této stránce můžete uspořádat své cívky podle umístění, přidejte několik cívek a začněte!",
|
||||
"new_location": "Nové umístění",
|
||||
"no_location": "Žádné umístění"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,9 @@
|
||||
},
|
||||
"messages": {
|
||||
"archive": "Are you sure you want to archive this spool?"
|
||||
},
|
||||
"formats": {
|
||||
"last_used": "Last used {{date}}"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -356,6 +359,9 @@
|
||||
"help": {
|
||||
"list": "Help | Spoolman"
|
||||
},
|
||||
"locations": {
|
||||
"list": "Locations | Spoolman"
|
||||
},
|
||||
"filament": {
|
||||
"list": "Filaments | Spoolman",
|
||||
"show": "#{{id}} Show Filament | Spoolman",
|
||||
@@ -377,5 +383,11 @@
|
||||
"create": "Create Manufacturer | Spoolman",
|
||||
"clone": "#{{id}} Clone Manufacturer | Spoolman"
|
||||
}
|
||||
},
|
||||
"locations": {
|
||||
"locations": "Locations",
|
||||
"new_location": "New Location",
|
||||
"no_location": "No Location",
|
||||
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"fields_help": {
|
||||
"used_weight": "Cuanto filamento ha sido usado de la bobina. Una bobina nueva debería tener 0g usados.",
|
||||
"location": "Donde se encuentra la bobina si tiene varias ubicaciones donde almacenar sus bobinas.",
|
||||
"location": "Dónde se encuentra el carrete si tiene varias ubicaciones donde almacena sus carretes.",
|
||||
"lot_nr": "Número de lote del fabricante. Puede ser usado si el modelo a imprimir necesita color homogéneo y varias bobinas son necesarias.",
|
||||
"weight_to_use": "Elegir el peso a usar. El peso medido solo está disponible si existe el valor de peso del carrete seleccionado.",
|
||||
"measured_weight": "Cuanto pesa el filamento y la bobina.",
|
||||
@@ -118,6 +118,9 @@
|
||||
"measurement_type_label": "Tipo de medición",
|
||||
"adjust_filament_value": "Cantidad consumida",
|
||||
"adjust_filament_help": "Aquí puedes añadir o restar directamente filamento de la bobina. Un valor positivo consumirá filamento, un valor negativo lo añadirá."
|
||||
},
|
||||
"formats": {
|
||||
"last_used": "Utilizado por última vez {{date}}"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -229,6 +232,9 @@
|
||||
},
|
||||
"help": {
|
||||
"list": "Ayuda | Spoolman"
|
||||
},
|
||||
"locations": {
|
||||
"list": "Ubicaciones | Spoolman"
|
||||
}
|
||||
},
|
||||
"printing": {
|
||||
@@ -250,7 +256,7 @@
|
||||
"showFilamentComment": "Comentario de Filamento",
|
||||
"button": "Imprimir las etiquetas",
|
||||
"template": "Plantilla para la etiqueta",
|
||||
"templateHelp": "Utiliza {} para insertar valores del objeto spool como texto. Por ejemplo {id} será reemplazado por el id del spool, o {filament.material} será reemplazado por el material del spool. Encierra el texto con un doble asterisco ** para ponerlo en negrita. Haz clic en el botón para ver una lista de todas las etiquetas disponibles.",
|
||||
"templateHelp": "Utiliza {} para insertar valores del objeto spool como texto. Por ejemplo, {id} se sustituirá por el id del carrete, o {filament.material} se sustituirá por el material del carrete. si falta un valor, se sustituirá por \"?\". Se puede utilizar un segundo conjunto de {} para eliminarlo. Además, cualquier texto entre los {} se eliminará si falta el valor. Por ejemplo, {Lot Nr: {lot_nr}} solo mostrará la etiqueta si la bobina tiene un número de lote. Encierra el texto con doble asterisco ** para ponerlo en negrita. Haz clic en el botón para ver una lista de todas las etiquetas disponibles.",
|
||||
"showQRCode": "Imprimir un código qr",
|
||||
"showQRCodeMode": {
|
||||
"no": "No",
|
||||
@@ -390,5 +396,11 @@
|
||||
"description": "<p> Aquí puede agregar más campos personalizados a los asuntos.</p><p> Una vez que se agrega un campo, no puede cambiar su clave o tipo, y no puede eliminar opciones ni cambiar el estado de múltiples opciones para campos seleccionados. Si elimina un campo, se eliminarán los datos asociados a todos los sujetos.</p><p> La forma en que otros programas leen/escriben los datos es clave, por lo que si su campo personalizado se va a integrar con un programa de terceros, asegúrese de que esté configurado correctamente. El valor predeterminado solo se utilizará para elementos nuevos.</p><p> Los campos de salida no se pueden ordenar ni filtrar en las vistas de tabla.</p>"
|
||||
},
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"locations": {
|
||||
"locations": "Ubicaciones",
|
||||
"new_location": "Nueva ubicación",
|
||||
"no_location": "Sin ubicación",
|
||||
"no_locations_help": "Esta página te permite organizar tus carretes en ubicaciones, ¡añade algunos carretes para empezar!"
|
||||
}
|
||||
}
|
||||
|
||||
381
client/public/locales/fa/common.json
Normal file
381
client/public/locales/fa/common.json
Normal file
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"actions": {
|
||||
"show": "نمایش",
|
||||
"clone": "تکثیر",
|
||||
"create": "اضافه کردن",
|
||||
"list": "لیست",
|
||||
"edit": "ویرایش"
|
||||
},
|
||||
"buttons": {
|
||||
"save": "ذخیره",
|
||||
"saveAndAdd": "ذخیره و اضافه کردن",
|
||||
"cancel": "انصراف",
|
||||
"refresh": "تازه سازی",
|
||||
"show": "نمایش",
|
||||
"showArchived": "نمایش آرشیو",
|
||||
"notAccessTitle": "شما مجوز های لازم برای انجام این کار را ندارید",
|
||||
"hideColumns": "مخفی کردن ستون",
|
||||
"create": "اضافه کردن",
|
||||
"logout": "خروج",
|
||||
"edit": "ویرایش",
|
||||
"delete": "حذف",
|
||||
"confirm": "آیا از انجام این عمل اطمینان دارید؟",
|
||||
"filter": "فیلتر",
|
||||
"continue": "ادامه",
|
||||
"clear": "پاک کردن",
|
||||
"undo": "بازگشت به حالت قبلی",
|
||||
"clone": "تکثیر",
|
||||
"import": "وارد کردن",
|
||||
"archive": "آرشیو کردن",
|
||||
"unArchive": "درآوردن از آرشیو",
|
||||
"hideArchived": "آرشیو مخفی",
|
||||
"clearFilters": "حذف فیلتر ها"
|
||||
},
|
||||
"notifications": {
|
||||
"success": "ثبت موفق",
|
||||
"error": "خطای {{statusCode}} رخ داد",
|
||||
"createSuccess": "{{resource}} با موفقیت ایجاد شد",
|
||||
"deleteSuccess": "{{resource}} حذف شد",
|
||||
"editError": "در زمان ویرایش {{resource}} خطای {{statusCode}} رخ داد",
|
||||
"validationError": "خطای تایید اطلاعات {{error}}",
|
||||
"undoable": "برای برگشت به حالت قبل {{seconds}} ثانیه فرصت دارید...",
|
||||
"createError": "در زمان ساخت {{resource}} مشکل {{statusCode}} رخ داد",
|
||||
"deleteError": "حذف {{resource}} با خطای {{statusCode}} مواجه شد",
|
||||
"editSuccess": "{{resource}} با موفقیت ویرایش شد",
|
||||
"importProgress": "در حال وارد کردن {{processed}} از مجموع {{total}}",
|
||||
"saveSuccessful": "با موفقیت ثبت شد!"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "از خارج شدن خود مطمئن هستید؟ موارد ذخیره نشده نادیده گرفته خواهند شد.",
|
||||
"kofi": "حمایت از سازنده این برنامه",
|
||||
"loading": "در حال بارگذاری",
|
||||
"version": "ورژن",
|
||||
"unknown": "نامشخص",
|
||||
"yes": "بله",
|
||||
"no": "خیر",
|
||||
"tags": {
|
||||
"clone": "تکثیر"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "داشبورد"
|
||||
},
|
||||
"printing": {
|
||||
"generic": {
|
||||
"title": "در حال پرینت",
|
||||
"description": "تنظیمات زیر را جوری تنظیم کنید که چیدمان پرینت لیبل مد نظرتان بدست بیاید. توجه کنید پرینتر و سیستم عامل شما ممکن است تنظیمات خودشان را لحاظ کنند، لذا ممکن است کمی آزمون و خطا نیاز باشد تا نتیجه مطلوب بدست بیاید. قبل اینکه لیبل نهایی را پرینت کنید ابتدا روی یک تکه کاغذ معمولی پرینت بگیرید و تست کنید.",
|
||||
"helpMargin": "حاشیهها باید با کاغذ برچسب و چاپگر شما هماهنگ شوند، تغییر دادن این تنظیمات بر اندازه کل شبکه تأثیر میگذارد.",
|
||||
"print": "پرینت",
|
||||
"columns": "ستون",
|
||||
"rows": "سطر",
|
||||
"paperSize": "اندازه کاغذ",
|
||||
"customSize": "کاستوم",
|
||||
"dimensions": "ابعاد",
|
||||
"showBorder": "نمایش کناره ها",
|
||||
"previewScale": "پیش نمایش اندازه",
|
||||
"skipItems": "نادیده گرفتن یک آیتم",
|
||||
"itemCopies": "تعداد کپی هر آیتم",
|
||||
"contentSettings": "تنظیمات محتوی",
|
||||
"layoutSettings": "تنظیمات چیدمان",
|
||||
"horizontalSpacing": "فاصله افقی",
|
||||
"verticalSpacing": "فاصله عمودی",
|
||||
"marginLeft": "فاصله از چپ",
|
||||
"marginRight": "فاصله از راست",
|
||||
"printerMarginLeft": "منطقه ایمن از چپ",
|
||||
"printerMarginRight": "منطقه ایمن از راست",
|
||||
"printerMarginTop": "منطقه ایمن از بالا",
|
||||
"printerMarginBottom": "منطقه ایمن از پایین",
|
||||
"borders": {
|
||||
"none": "هیچ کدام",
|
||||
"border": "حاشیه",
|
||||
"grid": "شبکه"
|
||||
},
|
||||
"settings": "تنظیمات پیشفرض",
|
||||
"defaultSettings": "پیشفرض",
|
||||
"addSettings": "اضافه کردن پیشفرض جدید",
|
||||
"newSetting": "جدید",
|
||||
"duplicateSettings": "تکثیر کردن",
|
||||
"deleteSettings": "حذف پیشفرض فعلی",
|
||||
"deleteSettingsConfirm": "از حذف این پیشفرض مطمئن هستید؟",
|
||||
"settingsName": "اسم پیشفرض",
|
||||
"saveSetting": "ذخیره کردن پیش فرض",
|
||||
"saveAsImage": "ذخیره به عنوان عکس",
|
||||
"helpPrinterMargin": "ناحیه امن باید بر اساس نزدیکترین فاصلهای که چاپگر شما میتواند تا لبه کاغذ چاپ کند تنظیم شود. تغییر این تنظیمات بر کل شبکه تأثیری نخواهد داشت.",
|
||||
"marginTop": "فاصله از بالا",
|
||||
"marginBottom": "فاصله از پایین"
|
||||
},
|
||||
"qrcode": {
|
||||
"button": "پرینت لیبل",
|
||||
"title": "در حال پرینت لیبل",
|
||||
"template": "قالب لیبل",
|
||||
"textSize": "اندازه متن لیبل",
|
||||
"useHTTPUrl": {
|
||||
"label": "لینک بارکد QR",
|
||||
"options": {
|
||||
"default": "پیشفرض",
|
||||
"url": "لینک"
|
||||
},
|
||||
"preview": "پیش نمایش:",
|
||||
"tooltip": "از لینک اختصاصی استفاده خواهد شد که فقط در صورتی کار میکند که از ویژگی اسکن Spoolman اسکن شود (پیشفرض). آدرس URL یا از آدرس پایهای که در تنظیمات مشخص شده استفاده میکند، یا در صورت عدم تنظیم، از آدرس صفحه فعلی."
|
||||
},
|
||||
"showContent": "پرینت کردن لیبل",
|
||||
"showQRCode": "پرینت بارکد QR",
|
||||
"showQRCodeMode": {
|
||||
"no": "خیر",
|
||||
"simple": "ساده",
|
||||
"withIcon": "با آیکون"
|
||||
},
|
||||
"templateHelp": "از {} برای وارد کردن مقادیر مربوط به شیء spool بهعنوان متن استفاده کنید. بهعنوان مثال، {id} با شناسه spool جایگزین میشود، یا {filament.material} با جنس فیلامنت spool جایگزین خواهد شد. اگر مقداری وجود نداشته باشد، با \"?\" جایگزین میشود. میتوانید از یک مجموعه دوم {} استفاده کنید تا این مورد حذف شود. همچنین، هر متنی که بین این دو مجموعه {} قرار گرفته باشد، اگر مقدار وجود نداشته باشد، حذف خواهد شد. بهعنوان مثال، {Lot Nr: {lot_nr}} فقط زمانی نمایش داده میشود که spool دارای شماره lot باشد. برای برجستهکردن متن از دو علامت ** استفاده کنید. برای مشاهده لیست کامل برچسبها روی دکمه کلیک کنید."
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "انتخاب رول فیلامنت",
|
||||
"description": "انتخاب رول فیلامنتی که لیبل پرینت میکنید.",
|
||||
"showArchived": "نمایش آرشیو",
|
||||
"noSpoolsSelected": "هیچ رول فیلامنتی را انتخاب نکردید.",
|
||||
"selectAll": "انتخاب/عدم انتخاب همه",
|
||||
"selectedTotal_one": "{{count}} رول فیلامنت انتخاب شد",
|
||||
"selectedTotal_other": "تعدا د {{count}} رول فیلامنت انتخاب شد"
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
"title": "اسکنر بارکد QR",
|
||||
"description": "یک بارکد QR از Spoolman را اسکن کنید تا جزئیات مربوط به spool را مشاهده کنید.",
|
||||
"error": {
|
||||
"notAllowed": "شما اجازه دسترسی به دوربین را ندادهاید.",
|
||||
"insecureContext": "این صفحه از طریق HTTPS ارائه نمیشود.",
|
||||
"streamApiNotSupported": "مرورگر از API رسانهای (MediaStream API) پشتیبانی نمیکند.",
|
||||
"notReadable": "دوربین قابل خواندن نیست.",
|
||||
"notFound": "دوربینی پیدا نشد.",
|
||||
"unknown": "خطای نامشخص {{error}} رخ داد"
|
||||
}
|
||||
},
|
||||
"spool": {
|
||||
"spool": "رول های فیلامنت",
|
||||
"fields": {
|
||||
"id": "شماره",
|
||||
"filament_name": "فیلامنت",
|
||||
"filament": "فیلامنت",
|
||||
"filament_internal": "داخلی",
|
||||
"filament_external": "خارجی",
|
||||
"price": "قیمت",
|
||||
"material": "متریال",
|
||||
"weight_to_use": "وزن",
|
||||
"used_weight": "مقدار استفاده شده",
|
||||
"remaining_weight": "مقدار باقی مانده",
|
||||
"measured_weight": "مقدار اندازه گیری شده",
|
||||
"used_length": "طول مصرف شده",
|
||||
"remaining_length": "طول باقی مانده",
|
||||
"location": "محل نگهداری",
|
||||
"lot_nr": "شماره تولید",
|
||||
"initial_weight": "وزن خالص رول کامل",
|
||||
"spool_weight": "وزن قرقره خالی",
|
||||
"first_used": "زمان اولین استفاده",
|
||||
"last_used": "زمان آخرین استفاده",
|
||||
"registered": "ثبت شده",
|
||||
"comment": "توضیحات",
|
||||
"archived": "آرشیو شده"
|
||||
},
|
||||
"fields_help": {
|
||||
"price": "قیمت یک قرقره کامل. در صورت عدم تنظیم، قیمت فیلامنت در نظر گرفته میشود.",
|
||||
"used_weight": "چقدر فیلامنت از قرقره استفاده شده است. یک قرقره جدید باید 0 گرم استفاده شده داشته باشد.",
|
||||
"remaining_weight": "چقدر فیلامنت در قرقره باقی مانده است. برای یک قرقره جدید، این باید با وزن قرقره مطابقت داشته باشد.",
|
||||
"measured_weight": "وزن فیلامنت و قرقره چقدر است.",
|
||||
"location": "مکانی که قرقره در آن قرار دارد اگر چندین مکان برای ذخیره قرقرهها دارید.",
|
||||
"lot_nr": "شماره بچ تولیدکننده. میتواند برای اطمینان از اینکه چاپ رنگ ثابتی دارد در صورتی که از چندین قرقره استفاده شود، استفاده شود.",
|
||||
"weight_to_use": "وزن مورد نظر برای ورود را انتخاب کنید. وزن اندازهگیریشده فقط در صورتی در دسترس است که وزن قرقره برای فیلامنت انتخاب شده تنظیم شده باشد.",
|
||||
"initial_weight": "وزن اولیه فیلامنت روی قرقره (وزن خالص). اگر تنظیم نشده باشد، از وزن شیء فیلامنت استفاده خواهد شد.",
|
||||
"external_filament": "شما یک فیلامنت از پایگاه داده خارجی انتخاب کردهاید. یک شیء فیلامنت (و احتمالاً یک شیء تولیدکننده) بهطور خودکار زمانی که این قرقره را ایجاد میکنید، ایجاد خواهد شد. این ممکن است منجر به ایجاد اشیاء فیلامنت تکراری شود اگر قبلاً یک شیء فیلامنت برای این فیلامنت ایجاد کرده باشید.",
|
||||
"spool_weight": "وزن قرقره زمانی که خالی است. برای استفاده از مقدار فیلامنت یا تولیدکننده، این قسمت را خالی بگذارید."
|
||||
},
|
||||
"titles": {
|
||||
"create": "ساخت رول فیلامنت",
|
||||
"clone": "تکثیر رول فیلامنت",
|
||||
"edit": "ویرایش رول فیلامنت",
|
||||
"list": "رول های فیلامنت",
|
||||
"show": "نمایش رول فیلامنت",
|
||||
"show_title": "[رول شماره{{id}}] {{name}}",
|
||||
"archive": "آرشیو کردن رول فلامنت",
|
||||
"adjust": "تنظیم کردن مقدار باقی مانده"
|
||||
},
|
||||
"form": {
|
||||
"measurement_type": {
|
||||
"length": "طول",
|
||||
"weight": "وزن"
|
||||
},
|
||||
"measurement_type_label": "اصلاح برحسب",
|
||||
"adjust_filament_help": "اینجا میتوانید بهطور مستقیم فیلامنت را از قرقره کم یا اضافه کنید. یک مقدار مثبت فیلامنت را مصرف میکند و یک مقدار منفی آن را اضافه میکند.",
|
||||
"adjust_filament_value": "مقدار مصرف شده",
|
||||
"new_location_prompt": "محل نگهداری جدید",
|
||||
"spool_updated": "این قرقره توسط شخص یا چیزی دیگر از زمانی که این صفحه را باز کردهاید، بهروز شده است. ذخیرهسازی تغییرات شما، آن تغییرات را نادیده میگیرد!"
|
||||
},
|
||||
"messages": {
|
||||
"archive": "آیا مطمئن هستید که میخواهید این قرقره را بایگانی کنید؟"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
"filament": "فیلامنت ها",
|
||||
"fields": {
|
||||
"id": "شماره",
|
||||
"vendor_name": "تولید کننده",
|
||||
"vendor": "تولید کننده",
|
||||
"name": "عنوان",
|
||||
"material": "متریال",
|
||||
"price": "قیمت",
|
||||
"density": "چگالی (Density)",
|
||||
"weight": "وزن",
|
||||
"diameter": "ضخامت",
|
||||
"article_number": "شماره انحصاری تولید",
|
||||
"spool_weight": "وزن قرقره فیلامنت",
|
||||
"registered": "ثبت شده",
|
||||
"comment": "توضیحات",
|
||||
"settings_extruder_temp": "دمای اکسترودر",
|
||||
"settings_bed_temp": "دمای هیت بد",
|
||||
"single_color": "تک رنگ",
|
||||
"multi_color": "تغییر رنگ",
|
||||
"coaxial": "کوپلیمری",
|
||||
"longitudinal": "طولی",
|
||||
"external_id": "آیدی خارجی",
|
||||
"spools": "نمایش رول فیلامنت ها",
|
||||
"color_hex": "رنگ"
|
||||
},
|
||||
"fields_help": {
|
||||
"material": "برای مثال PLA، ABS، PETG یا ...",
|
||||
"price": "قیمت یک رول کامل.",
|
||||
"spool_weight": "وزن یک قرقره خالی. برای تعیین وزن اندازهگیریشده یک قرقره استفاده میشود.",
|
||||
"article_number": "برای مثال EAN/UPC یا ...",
|
||||
"name": "نام فیلامنت، برای تمایز این نوع فیلامنت از سایرین از همان تولیدکننده. باید شامل رنگ نیز باشد.",
|
||||
"weight": "وزن فیلامنت یک قرقره کامل (وزن خالص). این باید شامل وزن خود قرقره نباشد و فقط فیلامنت را شامل شود. معمولاً روی بستهبندی نوشته شده است.",
|
||||
"multi_color_direction": "فیلامنتها میتوانند به دو روش دارای چندین رنگ باشند: یا از طریق کوپلیمری، مانند فیلامنتهای دو رنگ با رنگهای چندگانه ثابت، یا از طریق تغییر رنگ طولی، مانند فیلامنتهای گرادیانت که رنگها را در طول قرقره تغییر میدهند."
|
||||
},
|
||||
"titles": {
|
||||
"create": "ساخت فیلامنت جدید",
|
||||
"clone": "تکثیر فیلامنت",
|
||||
"edit": "ویرایش فیلامنت",
|
||||
"list": "فیلامنت ها",
|
||||
"show": "نمایش فیلامنت",
|
||||
"show_title": "[فیلامنت شماره {{id}}] {{name}}"
|
||||
},
|
||||
"form": {
|
||||
"filament_updated": "این فیلامنت توسط شخص یا چیزی دیگر از زمانی که این صفحه را باز کردهاید، بهروز شده است. ذخیرهسازی تغییرات شما، آن تغییرات را نادیده میگیرد!",
|
||||
"import_external": "درون ریزی از لیست خارجی",
|
||||
"import_external_description": "یک فیلامنت را در لیست انتخاب کنید تا جزئیات آن بهطور خودکار در فرم ایجاد فیلامنت پر شود. این عمل هر دادهای که در فرم وارد کردهاید را نادیده میگیرد.<br><br>یک شیء تولیدکننده بهطور خودکار زمانی که روی \"تأیید\" کلیک کنید، در صورت نیاز ایجاد خواهد شد."
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "اضافه کردن رول فیلامنت"
|
||||
}
|
||||
},
|
||||
"vendor": {
|
||||
"vendor": "تولید کننده ها",
|
||||
"fields": {
|
||||
"id": "شماره",
|
||||
"name": "اسم",
|
||||
"external_id": "شماره خارجی",
|
||||
"registered": "ثبت شده",
|
||||
"comment": "توضیحات",
|
||||
"empty_spool_weight": "وزن قرقره فیلامنت خالی"
|
||||
},
|
||||
"fields_help": {
|
||||
"empty_spool_weight": "وزن یک قرقره خالی فیلامنت ارائه شده از تولید کننده."
|
||||
},
|
||||
"titles": {
|
||||
"create": "تعریف تولید کننده جدید",
|
||||
"clone": "تکثیر تولید کننده",
|
||||
"edit": "ویرایش تولید کننده",
|
||||
"list": "تولید کننده ها",
|
||||
"show": "نمایش تولید کننده",
|
||||
"show_title": "[تولید کننده شماره {{id}}] {{name}}"
|
||||
},
|
||||
"form": {
|
||||
"vendor_updated": "این تولیدکننده توسط شخص یا چیزی دیگر از زمانی که این صفحه را باز کردهاید، بهروز شده است. ذخیرهسازی تغییرات شما، آن تغییرات را نادیده میگیرد!"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"home": "صفحه اصلی"
|
||||
},
|
||||
"help": {
|
||||
"help": "راهنما",
|
||||
"resources": {
|
||||
"filament": "برندهای فیلامنت. آنها دارای ویژگیهایی مانند نام، جنس، رنگ، قطر و موارد دیگر هستند.",
|
||||
"spool": "قرقرههای فیزیکی جداگانه از یک فیلامنت خاص.",
|
||||
"vendor": "شرکتهایی که فیلامنت تولید میکنند."
|
||||
},
|
||||
"description": "<title>راهنما</title><p>در اینجا چند نکته برای شروع کارتان آورده شده است.</p><p>Spoolman سه نوع مختلف داده را نگهداری میکند:</p><itemsHelp/><p>برای اضافه کردن یک قرقره جدید به پایگاه داده، ابتدا یک <filamentCreateLink>شیء فیلامنت</filamentCreateLink> ایجاد کنید. پس از انجام این کار، به ایجاد یک <spoolCreateLink>شیء قرقره</spoolCreateLink> برای آن قرقره خاص ادامه دهید. اگر بعداً قرقرههای اضافی از همان فیلامنت به دست آوردید، به سادگی اشیاء قرقره اضافی را ایجاد کرده و از همان شیء فیلامنت استفاده کنید.</p><p>بهصورت اختیاری، میتوانید یک <vendorCreateLink>شیء تولیدکننده</vendorCreateLink> برای شرکتی که فیلامنت را تولید میکند ایجاد کنید، اگر بخواهید این اطلاعات را دنبال کنید.</p><p>شما این امکان را دارید که سایر خدمات چاپ سهبعدی را به Spoolman پیوند دهید، مانند Moonraker، که میتواند بهطور خودکار استفاده از فیلامنت را نظارت کرده و اشیاء قرقره را برای شما بهروزرسانی کند. برای راهنمایی در مورد نحوه تنظیم این موارد به <readmeLink>راهنمای Spoolman</readmeLink> مراجعه کنید.</p>"
|
||||
},
|
||||
"table": {
|
||||
"actions": "اعمال قابل انجام"
|
||||
},
|
||||
"settings": {
|
||||
"settings": "تنظیمات",
|
||||
"header": "تنظیمات",
|
||||
"general": {
|
||||
"tab": "تنظیمات کلی",
|
||||
"currency": {
|
||||
"label": "ارز مورد استفاده"
|
||||
},
|
||||
"base_url": {
|
||||
"tooltip": "آدرس پایگاه داده Spoolman که باید هنگام ایجاد ویژگیهایی مانند کدهای QR استفاده شود.",
|
||||
"label": "لینک Spoolman"
|
||||
}
|
||||
},
|
||||
"extra_fields": {
|
||||
"tab": "فیلد های اضافه",
|
||||
"params": {
|
||||
"key": "شاخصه کلیدی",
|
||||
"name": "عنوان",
|
||||
"unit": "واحد",
|
||||
"field_type": "نوع",
|
||||
"order": "ترتیب",
|
||||
"default_value": "مقدار پیشفرض",
|
||||
"choices": "آپشن های قابل استفاده",
|
||||
"multi_choice": "قابلیت انتخاب چند آپشن همزمان"
|
||||
},
|
||||
"field_type": {
|
||||
"text": "متن",
|
||||
"integer": "عدد بدون اعشار",
|
||||
"integer_range": "رنج عددی بدون اعشار",
|
||||
"float": "عدد اعشاری",
|
||||
"float_range": "رنج عددی اعشاری",
|
||||
"datetime": "زمان و تاریخ",
|
||||
"boolean": "Boolean",
|
||||
"choice": "انتخاب"
|
||||
},
|
||||
"boolean_true": "بله",
|
||||
"boolean_false": "خیر",
|
||||
"choices_missing_error": "شما نمیتوانید گزینهها را حذف کنید. گزینههای زیر موجود نیستند: {{choices}}",
|
||||
"non_unique_key_error": "شاخصه کلیدی باید منحصر به فرد باشد.",
|
||||
"key_not_changed": "لطفاً شاخصه کلیدی را به چیز دیگری تغییر دهید.",
|
||||
"delete_confirm": "آیا میخواهید {{name}} را حذف کنید؟",
|
||||
"delete_confirm_description": "این کار فیلد و تمام دادههای مرتبط با آن را برای همه موجودیها حذف خواهد کرد.",
|
||||
"description": "<p>در اینجا میتوانید فیلدهای سفارشی اضافی را به موجودیهای خود اضافه کنید.</p><p>پس از افزودن یک فیلد، نمیتوانید کلید یا نوع آن را تغییر دهید و برای فیلدهای نوع انتخاب، نمیتوانید گزینهها را حذف یا وضعیت چندانتخابی را تغییر دهید. اگر یک فیلد را حذف کنید، دادههای مرتبط با تمام موجودیها حذف خواهد شد.</p><p>کلید، اطلاعاتی است که برنامههای دیگر آن را میخوانند یا مینویسند، بنابراین اگر فیلد سفارشی شما قرار است با یک برنامه شخص ثالث ادغام شود، اطمینان حاصل کنید که به درستی تنظیم شده باشد. مقدار پیشفرض فقط بر روی اقلام جدید اعمال میشود.</p><p>فیلدهای اضافی نمیتوانند در نماهای جدول مرتب یا فیلتر شوند.</p>"
|
||||
}
|
||||
},
|
||||
"documentTitle": {
|
||||
"default": "اسپول من",
|
||||
"suffix": " | اسپول من",
|
||||
"home": {
|
||||
"list": "صفحه اصلی | اسپول من"
|
||||
},
|
||||
"help": {
|
||||
"list": "راهنما | اسپول من"
|
||||
},
|
||||
"filament": {
|
||||
"list": "فیلامنت ها | اسپول من",
|
||||
"show": "نمایش فیلامنت شماره {{id}} | اسپول من",
|
||||
"edit": "ویرایش فیلامنت شماره {{id}} | اسپول من",
|
||||
"create": "ساخت فیلامنت جدید | اسپول من",
|
||||
"clone": "تکثیر فیلامنت شماره {{id}} | اسپول من"
|
||||
},
|
||||
"spool": {
|
||||
"list": "رول های فیلامنت | اسپول من",
|
||||
"show": "نمایش رول فیلامنت شماره {{id}} | اسپول من",
|
||||
"edit": "ویرایش رول فیلامنت شماره {{id}} | اسپول من",
|
||||
"create": "ساخت رول فیلامنت جدید | اسپول من",
|
||||
"clone": "تکثیر رول فیلامنت شماره {{id}} | اسپول من"
|
||||
},
|
||||
"vendor": {
|
||||
"list": "تولید کنندگان فیلامنت | اسپول من",
|
||||
"edit": "ویرایش تولید کننده فیلامنت شماره {{id}} | اسپول من",
|
||||
"show": "نمایش تولید کننده فیلامنت شماره {{id}} | اسپول من",
|
||||
"create": "تعریف کردن تولید کننده فیلامنت جدید | اسپول من",
|
||||
"clone": "تکثیر تولید کننده فیلامنت شماره {{id}} | اسپول من"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"showFilamentComment": "Commentaire sur le filament",
|
||||
"button": "Imprimer des étiquettes",
|
||||
"showQRCode": "Imprimer le QR Code",
|
||||
"templateHelp": "Utilisez {} pour insérer les valeurs de l'objet bobine sous forme de texte. Par exemple, {id} sera remplacé par l'identifiant de la bobine ou {filament.material} sera remplacé par le matériau de la bobine. Entourez le texte d'un double astérisque ** pour le mettre en gras. Cliquez sur le bouton pour afficher une liste de toutes les balises disponibles.",
|
||||
"templateHelp": "Utilisez {} pour insérer les valeurs de l'objet bobine sous forme de texte. Par exemple, {id} sera remplacé par l'identifiant de la bobine ou {filament.material} sera remplacé par le matériau de la bobine. Si une variable n'existe pas elle sera remplacée par \"?\". Un deuxième jeu d'accolade peut empêcher cet affichage. De plus si une accolade est utilisée dans une autre tout le texte présenta dans la première accolade sera retiré si la variable est manquante. Par exemple {N° lot : {lot_nr}} sera affiché uniquement si la bobine a un numéro de lot. Entourez le texte d'un double astérisque ** pour le mettre en gras. Cliquez sur le bouton pour afficher une liste de toutes les balises disponibles.",
|
||||
"template": "Modèle d'étiquette",
|
||||
"showQRCodeMode": {
|
||||
"no": "Non",
|
||||
@@ -31,15 +31,16 @@
|
||||
"default": "Défaut",
|
||||
"url": "URL"
|
||||
},
|
||||
"preview": "Prévisualisation :"
|
||||
"preview": "Prévisualisation :",
|
||||
"label": "Lien du QR code"
|
||||
}
|
||||
},
|
||||
"generic": {
|
||||
"printerMarginBottom": "Zone de sécurité en bas",
|
||||
"verticalSpacing": "Espacement vertical",
|
||||
"rows": "Rangs",
|
||||
"rows": "Lignes",
|
||||
"borders": {
|
||||
"none": "Aucun",
|
||||
"none": "Aucune",
|
||||
"grid": "Grille",
|
||||
"border": "Bordure"
|
||||
},
|
||||
@@ -51,13 +52,13 @@
|
||||
"printerMarginRight": "Zone de sécurité à droite",
|
||||
"helpPrinterMargin": "La zone de sécurité doit être définie en fonction de la proximité du bord du papier que votre imprimante peut imprimer, la modification de ces paramètres n'affectera pas l'ensemble de la grille.",
|
||||
"previewScale": "Échelle de prévisualisation",
|
||||
"helpMargin": "Les marges doivent être configurées en fonction de votre papier à étiquettes et de votre imprimante, car leur modification aura une incidence sur la taille de l'ensemble de la grille.",
|
||||
"helpMargin": "Les marges doivent être configurées en fonction de votre papier à étiquettes et de votre imprimante, leur modification aura une incidence sur la taille de l'ensemble de la grille.",
|
||||
"marginTop": "Marge supérieure",
|
||||
"skipItems": "Articles ignorés",
|
||||
"layoutSettings": "Paramètres de mise en page",
|
||||
"paperSize": "Format du papier",
|
||||
"columns": "Colonnes",
|
||||
"description": "Ajustez les paramètres ci-dessous pour obtenir la mise en page d'impression souhaitée. Gardez à l'esprit que les imprimantes et votre système d'exploitation peuvent appliquer leurs propres marges et mises à l'échelle, il se peut donc que vous deviez faire quelques essais et erreurs avant que tout soit correct. Testez-le sur une feuille de papier ordinaire avant d'imprimer les étiquettes proprement dites.",
|
||||
"description": "Ajustez les paramètres ci-dessous pour obtenir la mise en page d'impression souhaitée. Gardez à l'esprit que les imprimantes et votre système d'exploitation peuvent appliquer leurs propres marges et mises à l'échelle, il se peut donc que vous deviez faire quelques essais avant que tout soit correct. Testez-le sur une feuille de papier ordinaire avant d'imprimer les étiquettes en elles mêmes.",
|
||||
"marginBottom": "Marge inférieure",
|
||||
"horizontalSpacing": "Espacement horizontal",
|
||||
"marginLeft": "Marge gauche",
|
||||
@@ -189,7 +190,7 @@
|
||||
"filter": "Filtre",
|
||||
"cancel": "Abandonner",
|
||||
"edit": "Editer",
|
||||
"showArchived": "Voir les archives",
|
||||
"showArchived": "Afficher les archives",
|
||||
"archive": "Archiver",
|
||||
"hideColumns": "Masquer les colonnes",
|
||||
"confirm": "Êtes-vous sur ?",
|
||||
@@ -198,8 +199,8 @@
|
||||
"import": "Importer",
|
||||
"logout": "Déconnexion",
|
||||
"refresh": "Rafraichir",
|
||||
"notAccessTitle": "Vous n'avez pas l'autorisation d'accéder",
|
||||
"hideArchived": "Cacher les archives",
|
||||
"notAccessTitle": "Vous n'avez pas l'autorisation d'accès",
|
||||
"hideArchived": "Masquer les archives",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"unArchive": "Désarchiver",
|
||||
"clone": "Cloner",
|
||||
@@ -209,7 +210,7 @@
|
||||
},
|
||||
"filament": {
|
||||
"fields_help": {
|
||||
"name": "Nom du filament, pour distinguer ce type de filament parmi d'autres du même fabricant. Doit contenir la couleur par exemple.",
|
||||
"name": "Nom du filament, pour distinguer ce type de filament parmi d'autres du même fabricant. Devrait contenir la couleur par exemple.",
|
||||
"article_number": "Exemple EAN, UPC, etc.",
|
||||
"spool_weight": "Le poids d'une bobine vide. Utilisé pour déterminer le poids d'une bobine.",
|
||||
"price": "Prix d'une bobine complète.",
|
||||
@@ -284,7 +285,7 @@
|
||||
"editError": "Erreur lors de l'édition {{resource}} (status code : {{statusCode}})",
|
||||
"undoable": "Vous avez {{seconds}} secondes pour annuler.",
|
||||
"importProgress": "Importation : {{processed}}/{{total}}",
|
||||
"createError": "Une erreur s'est produite lors de la création de {{resource}}(status code : {{statusCode}})",
|
||||
"createError": "Une erreur s'est produite lors de la création de {{resource}} (status code : {{statusCode}})",
|
||||
"success": "Réussite",
|
||||
"editSuccess": "Édité avec succès {{resource}}",
|
||||
"deleteError": "Erreur lors de la suppression de {{resource}} (status code : {{statusCode}})",
|
||||
@@ -324,7 +325,7 @@
|
||||
"table": {
|
||||
"actions": "Actions"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "Êtes-vous sûr de vouloir quitter ? Vous avez des changements non sauvés.",
|
||||
"warnWhenUnsavedChanges": "Êtes-vous sûr de vouloir quitter ? Vous avez des changements non sauvegardés.",
|
||||
"loading": "Chargement",
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord"
|
||||
@@ -383,7 +384,11 @@
|
||||
"currency": {
|
||||
"label": "Devise"
|
||||
},
|
||||
"tab": "Général"
|
||||
"tab": "Général",
|
||||
"base_url": {
|
||||
"label": "Base de l'URL",
|
||||
"tooltip": "La base de l'URL à utiliser lors de la génération de QR codes."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
"home": "Home"
|
||||
},
|
||||
"settings": {
|
||||
"settings": "Impostazioni",
|
||||
"settings": "Configurazione",
|
||||
"extra_fields": {
|
||||
"params": {
|
||||
"order": "Ordine",
|
||||
@@ -379,7 +379,7 @@
|
||||
"tab": "Campi extra",
|
||||
"description": "<p>Qui è possibile aggiungere campi personalizzati.</p><p>Una volta aggiunto un campo, non è possibile modificarne la chiave o il tipo, e per i campi di tipo scelta non è possibile rimuovere le scelte o modificare lo stato di scelta multipla. Se si rimuove un campo, tutti i dati associati saranno cancellati.</p><p>La chiave è il modo in cui gli altri programmi leggono/scrivono i dati, quindi se il campo personalizzato deve integrarsi con un programma di terze parti, assicurarsi di impostarla correttamente. Il valore predefinito viene applicato solo ai nuovi elementi.</p><p>I campi extra non possono essere ordinati o filtrati nelle viste tabella.</p>"
|
||||
},
|
||||
"header": "Impostazioni",
|
||||
"header": "Configurazione",
|
||||
"general": {
|
||||
"tab": "Generale",
|
||||
"currency": {
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"showFilamentComment": "Opmerking filament",
|
||||
"button": "Labels afdrukken",
|
||||
"template": "Label Sjabloon",
|
||||
"templateHelp": "Gebruik {} om waarden van het spoelobject als tekst in te voegen. Bijvoorbeeld {id} wordt vervangen door het id van de spoel, of {filament.material} wordt vervangen door het materiaal van de spoel. Voeg een dubbel sterretje (**) toe voor en achter de tekst om het vet te maken. Klik op de knop om een lijst met alle beschikbare tags te bekijken.",
|
||||
"templateHelp": "Gebruik {} om waarden van het spool-object als tekst in te voegen. Bijvoorbeeld, {id} wordt vervangen door de spool-id, of {filament.material} wordt vervangen door het materiaal van de spool. Als een waarde ontbreekt, wordt deze vervangen door \"?\". Een tweede set {} kan worden gebruikt om dit te verwijderen. Bovendien wordt alle tekst tussen de sets {} verwijderd als de waarde ontbreekt. Bijvoorbeeld, {Lot Nr: {lot_nr}} toont alleen het label als de spool een lotnummer heeft. Sluit tekst in met dubbele asterisk ** om deze vet te maken. Klik op de knop om een lijst met alle beschikbare tags te bekijken.",
|
||||
"showQRCodeMode": {
|
||||
"no": "Nee",
|
||||
"simple": "Simpel",
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"notAccessTitle": "Nie masz zezwolenia na dostęp",
|
||||
"hideColumns": "Edytuj kolumny",
|
||||
"clearFilters": "Wyczyść filtry",
|
||||
"saveAndAdd": "Zapisz i dodaj"
|
||||
"saveAndAdd": "Zapisz i dodaj",
|
||||
"continue": "Kontynuuj"
|
||||
},
|
||||
"spool": {
|
||||
"titles": {
|
||||
@@ -79,7 +80,13 @@
|
||||
},
|
||||
"form": {
|
||||
"new_location_prompt": "Wprowadź nową lokalizację",
|
||||
"spool_updated": "Szpula została zaktualizowana od czasu otwarcia tej strony. Zapisanie nadpisze te zmiany!"
|
||||
"spool_updated": "Szpula została zaktualizowana od czasu otwarcia tej strony. Zapisanie nadpisze te zmiany!",
|
||||
"measurement_type": {
|
||||
"length": "Długość",
|
||||
"weight": "Waga"
|
||||
},
|
||||
"measurement_type_label": "Typ pomiaru",
|
||||
"adjust_filament_help": "Tutaj możesz bezpośrednio dodać lub usunąć filament z szpuli. Dodatnia wartość spowoduje odjęcie filamentu, ujemna wartość spowoduje jego dodanie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -96,7 +103,7 @@
|
||||
"importProgress": "Importowanie: {{processed}}/{{total}}",
|
||||
"error": "Błąd (kod statusu: {{statusCode}})",
|
||||
"createError": "Wystąpił błąd podczas tworzenia {{resource}} (kod statusu: {{statusCode}})",
|
||||
"saveSuccessful": "Zapisano pomyślnie!",
|
||||
"saveSuccessful": "Pomyślnie zapisano!",
|
||||
"validationError": "Błąd walidacji: {{error}}"
|
||||
},
|
||||
"loading": "Wczytywanie",
|
||||
@@ -138,14 +145,22 @@
|
||||
},
|
||||
"description": "Dostosuj poniższe ustawienia, aby uzyskać pożądany układ wydruku. Należy pamiętać, że drukarki i system operacyjny mogą stosować własne marginesy i skalowanie, więc może być konieczne wykonanie kilku prób i błędów, zanim wszystko będzie prawidłowe. Przed wydrukowaniem etykiet przetestuj je na zwykłym papierze.",
|
||||
"horizontalSpacing": "Poziomy odstęp",
|
||||
"printerMarginBottom": "Bezpieczna strefa od dołu"
|
||||
"printerMarginBottom": "Bezpieczna strefa od dołu",
|
||||
"newSetting": "Nowe",
|
||||
"defaultSettings": "Domyślne",
|
||||
"itemCopies": "Skopiuj element",
|
||||
"settings": "Ustawienia wstępne",
|
||||
"saveAsImage": "Zapisz jako obraz",
|
||||
"duplicateSettings": "Zduplikuj",
|
||||
"deleteSettingsConfirm": "Czy jesteś pewny, że chcesz usunąć to ustawienie?",
|
||||
"addSettings": "Dodaj nowe ustawienie wstępne"
|
||||
},
|
||||
"qrcode": {
|
||||
"button": "Drukuj kody QR",
|
||||
"title": "Drukowanie kodów QR",
|
||||
"bedTemp": "TS: {{temp}}",
|
||||
"extruderTemp": "TE: {{temp}}",
|
||||
"textSize": "Rozmiar tekstu",
|
||||
"textSize": "Rozmiar tekstu etykiety",
|
||||
"showSpoolmanIcon": "Pokaż ikonę Spoolman",
|
||||
"showVendor": "Producent",
|
||||
"showContent": "Etykieta",
|
||||
@@ -156,7 +171,21 @@
|
||||
"showVendorComment": "Komentarz producenta",
|
||||
"spoolWeight": "Waga szpuli: {{weight}}",
|
||||
"lotNr": "LOT: {{lot}}",
|
||||
"showSpoolComment": "Komentarz szpuli"
|
||||
"showSpoolComment": "Komentarz szpuli",
|
||||
"template": "Wzór etykiety",
|
||||
"useHTTPUrl": {
|
||||
"preview": "Podgląd:",
|
||||
"label": "Link kodu QR",
|
||||
"options": {
|
||||
"default": "Domyślne",
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"showQRCode": "Drukuj kod QR",
|
||||
"showQRCodeMode": {
|
||||
"no": "Nie",
|
||||
"withIcon": "Z ikoną"
|
||||
}
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "Wybierz szpule",
|
||||
@@ -200,7 +229,8 @@
|
||||
"settings_bed_temp": "Temperatura stołu",
|
||||
"color_hex": "Kolor",
|
||||
"article_number": "Numer artykułu",
|
||||
"spools": "Zobacz szpule"
|
||||
"spools": "Zobacz szpule",
|
||||
"external_id": "Zewnętrzne ID"
|
||||
},
|
||||
"fields_help": {
|
||||
"material": "Np. PLA, ABS, PETG itp.",
|
||||
@@ -208,7 +238,8 @@
|
||||
"spool_weight": "Waga pustej szpuli. Używana do określenia ciężaru szpuli.",
|
||||
"article_number": "Np. EAN, UPC itp.",
|
||||
"name": "Nazwa filamentu, aby odróżnić ten typ filamentu od innych pochodzących od tego samego producenta. Powinna zawierać np. kolor.",
|
||||
"weight": "Waga filamentu na pełnej szpuli (waga netto). Nie powinno to obejmować wagi samej szpuli, a jedynie filamentu. Jest to zwykle napisane na opakowaniu."
|
||||
"weight": "Waga filamentu na pełnej szpuli (waga netto). Nie powinno to obejmować wagi samej szpuli, a jedynie filamentu. Jest to zwykle napisane na opakowaniu.",
|
||||
"multi_color_direction": "Filamenty mogą mieć wiele kolorów na dwa sposoby: Kolory mogą się ciągnąć równolegle na całej długości filamentu lub kolory filamentu mogą się zmieniać na długości filamentu."
|
||||
},
|
||||
"titles": {
|
||||
"create": "Tworzenie filamentu",
|
||||
@@ -219,7 +250,8 @@
|
||||
"show_title": "[Filament #{{id}}] {{name}}"
|
||||
},
|
||||
"form": {
|
||||
"filament_updated": "Ten filament został zaktualizowany przez kogoś/coś innego od czasu otwarcia tej strony. Zapisanie nadpisze te zmiany!"
|
||||
"filament_updated": "Ten filament został zaktualizowany przez kogoś/coś innego od czasu otwarcia tej strony. Zapisanie nadpisze te zmiany!",
|
||||
"import_external": "Importuj z zewnątrz"
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "Dodaj Szpulę"
|
||||
@@ -232,7 +264,8 @@
|
||||
"name": "Nazwa",
|
||||
"registered": "Zarejestrowano",
|
||||
"comment": "Komentarz",
|
||||
"empty_spool_weight": "Waga pustej szpuli"
|
||||
"empty_spool_weight": "Waga pustej szpuli",
|
||||
"external_id": "Zewnętrzne ID"
|
||||
},
|
||||
"titles": {
|
||||
"create": "Tworzenie producenta",
|
||||
@@ -303,7 +336,11 @@
|
||||
"currency": {
|
||||
"label": "Waluta"
|
||||
},
|
||||
"tab": "Ogólne"
|
||||
"tab": "Ogólne",
|
||||
"base_url": {
|
||||
"tooltip": "Bazowy URL zostanie użyty podczas generowania QR code oraz w innych funkcjonalnościach.",
|
||||
"label": "Bazowy URL"
|
||||
}
|
||||
},
|
||||
"extra_fields": {
|
||||
"tab": "Dodatkowe Parametry",
|
||||
|
||||
1
client/public/locales/pt-BR/common.json
Normal file
1
client/public/locales/pt-BR/common.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -102,10 +102,10 @@
|
||||
"saveAsImage": "Guardar como Imagem"
|
||||
},
|
||||
"spoolSelect": {
|
||||
"selectedTotal_one": "{{count}} carretel selecionado",
|
||||
"selectedTotal_many": "{{count}} carretéis selecionados",
|
||||
"selectedTotal_other": "{{count}} carretéis selecionados",
|
||||
"noSpoolsSelected": "Você não selecionou nenhum carretel.",
|
||||
"selectedTotal_one": "{{count}} bobine selecionado",
|
||||
"selectedTotal_many": "{{count}} bobines selecionados",
|
||||
"selectedTotal_other": "{{count}} bobines selecionados",
|
||||
"noSpoolsSelected": "Não selecionou nenhuma bobine.",
|
||||
"title": "Selecionar Carretéis",
|
||||
"description": "Selecione os carretéis para imprimir as etiquetas.",
|
||||
"showArchived": "Mostrar Arquivado",
|
||||
@@ -135,6 +135,15 @@
|
||||
"no": "Não",
|
||||
"simple": "Simples",
|
||||
"withIcon": "Com Ícone"
|
||||
},
|
||||
"useHTTPUrl": {
|
||||
"label": "Endereço do Código QR",
|
||||
"options": {
|
||||
"url": "Endereço",
|
||||
"default": "Por omissão"
|
||||
},
|
||||
"preview": "Pré-visualização:",
|
||||
"tooltip": "Irá usar um endereço proprietário que funcionará apenas se for lido pela funcionalidade do Spoolman. O endereço usará o endereço base definido nas configurações ou, caso não esteja definido, o endereço da página actual."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -164,40 +173,40 @@
|
||||
"filament_external": "Externo"
|
||||
},
|
||||
"fields_help": {
|
||||
"weight_to_use": "Selecione qual valor de peso inserir. O peso medido só estará disponível se o peso do carretel estiver definido para o filamento selecionado.",
|
||||
"weight_to_use": "Selecione qual o valor de peso a inserir. O peso medido só estará disponível se o peso da bobine estiver definido para o filamento selecionado.",
|
||||
"lot_nr": "Número de lote do fabricante. Pode ser usado para garantir que uma impressão tenha cores consistentes se vários carretéis forem usados.",
|
||||
"used_weight": "Quanto filamento foi usado do carretel. Um novo carretel deve ter 0gr usado.",
|
||||
"remaining_weight": "Quanto filamento resta no carretel. Para um carretel novo, isso deve corresponder ao peso do carretel.",
|
||||
"measured_weight": "Quanto pesa o filamento e o carretel.",
|
||||
"location": "Onde o carretel está localizado se você tiver vários locais onde armazena seus carretéis.",
|
||||
"price": "Preço de um carretel completo na moeda definida no sistema. Se não for definido, o preço do filamento será presumido.",
|
||||
"initial_weight": "O peso inicial do filamento sem o peso do carretel (peso liquido). Se nada for indicado, será usado o peso da definição do filamento.",
|
||||
"spool_weight": "O peso do carretel quando vazio. Deixe em branco para usar o valor do filamento ou do fabricante.",
|
||||
"external_filament": "Escolheu um filamento de uma base de dados externa. O filamento (e possivelmente o fabricante) será criado automaticamente quando for criado o carretel. Isto poderá criar filamentos duplicados caso já tenha criado um filamento para este filamento específico."
|
||||
"used_weight": "Quanto filamento foi usado da bobine. Uma nova bobine deve ter usado 0g.",
|
||||
"remaining_weight": "Quanto filamento resta na bobine. Para uma bobine nova, deve corresponder ao peso da bobine.",
|
||||
"measured_weight": "Quanto pesa o filamento e a bobine.",
|
||||
"location": "Onde a bobine está localizado se tiver vários locais onde armazena as suas bobines.",
|
||||
"price": "Preço de uma bobine completa na moeda definida no sistema. Se não for definido, o preço do filamento será presumido.",
|
||||
"initial_weight": "O peso inicial do filamento sem o peso da bobine (peso liquido). Se nada for indicado, será usado o peso da definição do filamento.",
|
||||
"spool_weight": "O peso da bobine vazia. Deixe em branco para usar o valor do filamento ou do fabricante.",
|
||||
"external_filament": "Escolheu um filamento de uma base de dados externa. O filamento (e possivelmente o fabricante) será criado automaticamente quando for criada a bobine. Isto poderá criar filamentos duplicados caso já tenha criado um filamento para este filamento específico."
|
||||
},
|
||||
"titles": {
|
||||
"create": "Criar Carretel",
|
||||
"clone": "Clonar Carretel",
|
||||
"edit": "Editar Carretel",
|
||||
"create": "Criar Bobine",
|
||||
"clone": "Clonar Bobine",
|
||||
"edit": "Editar Bobine",
|
||||
"list": "Carretéis",
|
||||
"show": "Mostrar Carretel",
|
||||
"show_title": "[Carretel #{{id}}] {{name}}",
|
||||
"archive": "Arquivar Carretel",
|
||||
"show": "Mostrar Bobine",
|
||||
"show_title": "[Bobine #{{id}}] {{name}}",
|
||||
"archive": "Arquivar Bobine",
|
||||
"adjust": "Ajustar Filamento"
|
||||
},
|
||||
"form": {
|
||||
"new_location_prompt": "Insira um novo local",
|
||||
"spool_updated": "Este carretel foi atualizado por alguém desde que abriu esta página. Guardar substituirá essas alterações!",
|
||||
"spool_updated": "Esta bobine foi atualizada por alguém desde que abriu esta página. Guardar substituirá essas alterações!",
|
||||
"measurement_type": {
|
||||
"length": "Comprimento",
|
||||
"weight": "Peso"
|
||||
},
|
||||
"measurement_type_label": "Tipo de Medida",
|
||||
"adjust_filament_help": "Aqui pode adicionar ou subtrair filamento do carretel. Um valor positivo consome filamento, um valor negativo adiciona filamento.",
|
||||
"adjust_filament_help": "Aqui pode adicionar ou subtrair filamento da bobine. Um valor positivo consome filamento, um valor negativo adiciona filamento.",
|
||||
"adjust_filament_value": "Consumir Montante"
|
||||
},
|
||||
"messages": {
|
||||
"archive": "Tem certeza de que deseja arquivar este carretel?"
|
||||
"archive": "Tem certeza de que deseja arquivar esta bobine?"
|
||||
},
|
||||
"spool": "Carretéis"
|
||||
},
|
||||
@@ -210,7 +219,7 @@
|
||||
"price": "Preço",
|
||||
"diameter": "Diâmetro",
|
||||
"weight": "Peso",
|
||||
"spool_weight": "Peso do Carretel",
|
||||
"spool_weight": "Peso da Bobine",
|
||||
"article_number": "Número do Artigo",
|
||||
"registered": "Registrado",
|
||||
"vendor": "Fabricante",
|
||||
@@ -229,11 +238,11 @@
|
||||
},
|
||||
"fields_help": {
|
||||
"article_number": "Por exemplo EAN, UPC, etc.",
|
||||
"price": "Preço de um carretel completo.",
|
||||
"price": "Preço de uma bobine completa.",
|
||||
"name": "Nome do filamento, para distinguir este tipo de filamento entre outros do mesmo fabricante. Deve conter a cor, por exemplo.",
|
||||
"material": "Por exemplo PLA, ABS, PETG, etc.",
|
||||
"weight": "O peso do filamento de um carretel cheio (peso líquido). Isso não deve incluir o peso do carretel em si, apenas o filamento. É o que normalmente está escrito na embalagem.",
|
||||
"spool_weight": "O peso de um carretel vazio. Usado para determinar o peso medido de um carretel.",
|
||||
"weight": "O peso do filamento de uma bobine cheia (peso líquido). Isso não deve incluir o peso da bobine em si, apenas o filamento. É o que normalmente está escrito na embalagem.",
|
||||
"spool_weight": "O peso de uma bobine vazia. Usado para determinar o peso medido de uma bobine.",
|
||||
"multi_color_direction": "Os filamentos podem ter múltiplas cores de duas formas: por coextrusão, como os filamentos de duas cores com estas consistentes ao longo do filamento, or através de mudança de cor longitudinal, como os filamentos que contêm gradientes de cores que vão mudando ao longo da bobine."
|
||||
},
|
||||
"titles": {
|
||||
@@ -250,7 +259,7 @@
|
||||
"import_external_description": "Escolha um filamento da lista para preencher automaticamente os detalhes do filamento no formulário de criação. Isto irá substituir quaisquer dados que já tenha introduzido no formulário.<br><br>Será criado o fabricante automaticamente caso seja necessário."
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "Adicionar Carretel"
|
||||
"add_spool": "Adicionar Bobine"
|
||||
}
|
||||
},
|
||||
"vendor": {
|
||||
@@ -260,7 +269,7 @@
|
||||
"name": "Nome",
|
||||
"registered": "Registrado",
|
||||
"comment": "Comentário",
|
||||
"empty_spool_weight": "Peso do Carretel Vazio",
|
||||
"empty_spool_weight": "Peso da Bobine Vazia",
|
||||
"external_id": "ID Externo"
|
||||
},
|
||||
"titles": {
|
||||
@@ -275,7 +284,7 @@
|
||||
"vendor_updated": "Este fabricante foi atualizado por alguém desde que abriu esta página. Guardar substituirá essas alterações!"
|
||||
},
|
||||
"fields_help": {
|
||||
"empty_spool_weight": "O peso do carretel vazio do fabricante."
|
||||
"empty_spool_weight": "O peso da bobine vazia do fabricante."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
@@ -288,7 +297,7 @@
|
||||
"spool": "Carretéis físicos individuais de um filamento específico.",
|
||||
"vendor": "As empresas que fabricam o filamento."
|
||||
},
|
||||
"description": "<title>Ajuda</title><p>Aqui estão algumas dicas para começar.</p><p>O Spoolman contém três tipos diferentes de dados:</p><itemsHelp/><p>Para adicionar um novo carretel para o banco de dados, comece criando um <filamentCreateLink>Filamento</filamentCreateLink>. Feito isso, prossiga para criar um <spoolCreateLink>Carretel</spoolCreateLink> para esse carretel específico. Se você adquirir carretéis adicionais do mesmo filamento posteriormente, simplesmente gere Carretéis adicionais e reutilize o mesmo objeto Filamento.</p><p>Opcionalmente, você também pode criar um <vendorCreateLink>Fabricante</vendorCreateLink> para a empresa que fabrica o filamento, se desejar rastrear essas informações.</p><p>Você tem a opção de vincular outros serviços de impressora 3D ao Spoolman, como o Moonraker, que pode monitorar automaticamente o uso do filamento e atualizar os carretéis para você. Consulte o <readmeLink>README do Spoolman</readmeLink> para obter orientação sobre como configurar isso.</p>"
|
||||
"description": "<title>Ajuda</title><p>Aqui estão algumas dicas para começar.</p><p>O Spoolman contém três tipos diferentes de dados:</p><itemsHelp/><p>Para adicionar uma nova bobine para a base de dados, comece por criar um <filamentCreateLink>Filamento</filamentCreateLink>. Feito isso, prossiga para criar uma <spoolCreateLink>Bobine</spoolCreateLink> para esse filamento específico. Se posteriormente adquirir bobines adicionais do mesmo filamento, simplesmente gere Bobines adicionais e reutilize o mesmo objeto Filamento.</p><p>Opcionalmente, também pode criar um <vendorCreateLink>Fabricante</vendorCreateLink> para a empresa que fabrica o filamento, se desejar rastrear essas informações.</p><p>Tem a opção de vincular outros serviços de impressora 3D ao Spoolman, como o Moonraker, que pode monitorizar automaticamente o uso do filamento e atualizar as bobines. Consulte o <readmeLink>README do Spoolman</readmeLink> para obter orientação sobre como configurar.</p>"
|
||||
},
|
||||
"documentTitle": {
|
||||
"suffix": " | Spoolman",
|
||||
@@ -307,10 +316,10 @@
|
||||
},
|
||||
"spool": {
|
||||
"list": "Carretéis | Spoolman",
|
||||
"show": "#{{id}} Mostrar Carretel | Spoolman",
|
||||
"edit": "#{{id}} Editar Carretel | Spoolman",
|
||||
"create": "Criar Carretel | Spoolman",
|
||||
"clone": "#{{id}} Clonar Carretel | Spoolman"
|
||||
"show": "#{{id}} Mostrar Bobine | Spoolman",
|
||||
"edit": "#{{id}} Editar Bobine | Spoolman",
|
||||
"create": "Criar Bobine | Spoolman",
|
||||
"clone": "#{{id}} Clonar Bobine | Spoolman"
|
||||
},
|
||||
"vendor": {
|
||||
"show": "#{{id}} Mostrar Fabricante | Spoolman",
|
||||
@@ -324,7 +333,7 @@
|
||||
"no": "Não",
|
||||
"scanner": {
|
||||
"title": "Leitor de Código QR",
|
||||
"description": "Digitalize um código QR do Spoolman para ver detalhes sobre o carretel.",
|
||||
"description": "Digitalize um código QR do Spoolman para ver detalhes sobre a bobine.",
|
||||
"error": {
|
||||
"notAllowed": "Você não deu permissão de acesso à câmera.",
|
||||
"insecureContext": "A página não é protegida por HTTPS.",
|
||||
@@ -375,6 +384,10 @@
|
||||
"tab": "Geral",
|
||||
"currency": {
|
||||
"label": "Moeda"
|
||||
},
|
||||
"base_url": {
|
||||
"label": "Endereço Base",
|
||||
"tooltip": "O endereço base a utilizar em funcionalidades tais como os códigos QR."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
{
|
||||
"buttons": {
|
||||
"create": "creeaza",
|
||||
"create": "creaza",
|
||||
"save": "salveaza",
|
||||
"show": "arata",
|
||||
"filter": "filtreaza",
|
||||
"cancel": "anuleaza",
|
||||
"edit": "editeaza",
|
||||
"confirm": "esti sigur?",
|
||||
"confirm": "confirma",
|
||||
"delete": "sterge",
|
||||
"undo": "inapoi",
|
||||
"import": "importa",
|
||||
"logout": "logout",
|
||||
"logout": "Iesire",
|
||||
"saveAndAdd": "salveazaSiAdauga",
|
||||
"clear": "Clar",
|
||||
"clear": "sterge",
|
||||
"refresh": "actualizeaza",
|
||||
"clone": "cloneaza",
|
||||
"archive": "arhiveaza",
|
||||
"unArchive": "dezarhiveaza",
|
||||
"hideArchived": "ascunde arhivate",
|
||||
"showArchived": "arata arhivate",
|
||||
"hideArchived": "ascundeArhivate",
|
||||
"showArchived": "Arata arhivate",
|
||||
"notAccessTitle": "nu ai permisiune de acces",
|
||||
"hideColumns": "ascunde coloane",
|
||||
"clearFilters": "reseteaza filtre"
|
||||
"clearFilters": "reseteaza filtre",
|
||||
"continue": "Continua"
|
||||
},
|
||||
"actions": {
|
||||
"create": "creeaza",
|
||||
"create": "creaza",
|
||||
"clone": "cloneaza",
|
||||
"list": "listeaza",
|
||||
"list": "Listeaza",
|
||||
"show": "arata",
|
||||
"edit": "editeaza"
|
||||
},
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"notAccessTitle": "У вас нет разрешения на доступ",
|
||||
"hideColumns": "Скрыть столбцы",
|
||||
"clearFilters": "Очистить фильтр",
|
||||
"saveAndAdd": "Сохранить и добавить"
|
||||
"saveAndAdd": "Сохранить и добавить",
|
||||
"continue": "Продолжить"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "Вы уверены, что хотите выйти? У вас есть несохраненные изменения.",
|
||||
"notifications": {
|
||||
@@ -88,23 +89,26 @@
|
||||
"border": "Общая",
|
||||
"grid": "Сетка"
|
||||
},
|
||||
"addSettings": "Добавить новую настройку",
|
||||
"addSettings": "Добавить новый пресет",
|
||||
"newSetting": "Новый",
|
||||
"deleteSettings": "Удалить текущую настройку",
|
||||
"deleteSettingsConfirm": "Вы уверены, что хотите удалить этот параметр?",
|
||||
"settings": "Настройки",
|
||||
"deleteSettings": "Удалить текущий пресет",
|
||||
"deleteSettingsConfirm": "Вы уверены, что хотите удалить этот пресет?",
|
||||
"settings": "Пресеты",
|
||||
"defaultSettings": "По умолчанию",
|
||||
"settingsName": "Имя настройки",
|
||||
"itemCopies": "Копии элементов"
|
||||
"settingsName": "Имя пресета",
|
||||
"itemCopies": "Копии элементов",
|
||||
"saveAsImage": "Сохранить как изображение",
|
||||
"duplicateSettings": "Дублировать",
|
||||
"saveSetting": "Сохранить Пресеты"
|
||||
},
|
||||
"qrcode": {
|
||||
"button": "Распечатать QR-коды",
|
||||
"title": "Печать QR-кода",
|
||||
"button": "Печать этикеток",
|
||||
"title": "Печать этикетки",
|
||||
"spoolWeight": "Вес катушки: {{weight}}",
|
||||
"lotNr": "№ партии: {{lot}}",
|
||||
"bedTemp": "ТС: {{temp}}",
|
||||
"extruderTemp": "ТЕ: {{temp}}",
|
||||
"textSize": "Размер текста",
|
||||
"textSize": "Размер Текста Надписи",
|
||||
"showSpoolmanIcon": "Показать иконку Spoolman",
|
||||
"showVendor": "Производитель",
|
||||
"showContent": "Печать информации",
|
||||
@@ -114,12 +118,27 @@
|
||||
"showSpoolComment": "Комментарий к катушке",
|
||||
"showFilamentComment": "Комментарий к филаменту",
|
||||
"showVendorComment": "Комментарий к продавцу",
|
||||
"template": "Шаблон",
|
||||
"templateHelp": "Используйте {}, чтобы вставить значения объекта катушки в виде текста. Например, {id} будет заменен на идентификатор катушки, или {filament.material} будет заменен на материал катушки. Выделите текст двойной звездочкой **, чтобы он был выделен жирным шрифтом. Нажмите на кнопку, чтобы просмотреть список всех доступных тегов."
|
||||
"template": "Шаблон этикетки",
|
||||
"templateHelp": "Используйте {} для вставки значений объекта катушки в виде текста. Например, {id} будет заменен на идентификатор катушки, или {filament.material} будет заменен на материал катушки. если значение отсутствует, оно будет заменено на \"?\". Для удаления этого можно использовать второй набор {}. Кроме того, любой текст между наборами {} будет удален, если значение отсутствует. Например, {Номер партии: {lot_nr}} будет отображаться только в том случае, если на катушке указан номер партии. Для выделения текста жирным шрифтом выделите его двойной звездочкой **. Нажмите кнопку, чтобы просмотреть список всех доступных тегов.",
|
||||
"useHTTPUrl": {
|
||||
"tooltip": "Будет использоваться фирменная ссылка, которая будет работать только при сканировании с помощью функции сканирования Spoolman (по умолчанию). В качестве URL-адреса используется либо базовый URL-адрес, указанный в настройках, либо URL-адрес текущей страницы, если он не задан.",
|
||||
"label": "Ссылка на QR-код",
|
||||
"options": {
|
||||
"default": "По умолчанию",
|
||||
"url": "URL"
|
||||
},
|
||||
"preview": "Предварительный просмотр:"
|
||||
},
|
||||
"showQRCodeMode": {
|
||||
"no": "Нет",
|
||||
"simple": "Простой",
|
||||
"withIcon": "Со значком"
|
||||
},
|
||||
"showQRCode": "Печать QR-кода"
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "Выберите катушки",
|
||||
"description": "Выберите катушки для печати QR-кодов.",
|
||||
"description": "Выберите катушки для печати этикеток.",
|
||||
"showArchived": "Показать архивные",
|
||||
"noSpoolsSelected": "Вы не выбрали ни одной катушки.",
|
||||
"selectAll": "Выбрать/отменить выбор всего",
|
||||
@@ -146,7 +165,7 @@
|
||||
"id": "ID",
|
||||
"filament_name": "Филамент",
|
||||
"filament": "Филамент",
|
||||
"material": "Тип",
|
||||
"material": "Материал",
|
||||
"used_weight": "Использованный вес",
|
||||
"remaining_weight": "Оставшийся вес",
|
||||
"used_length": "Использованная длина",
|
||||
@@ -185,14 +204,22 @@
|
||||
"list": "Катушки",
|
||||
"show": "Показать Катушку",
|
||||
"archive": "Архивировать Катушку",
|
||||
"show_title": "[Катушка #{{id}}] {{name}}"
|
||||
"show_title": "[Катушка #{{id}}] {{name}}",
|
||||
"adjust": "Отрегулируйте филамент катушки"
|
||||
},
|
||||
"messages": {
|
||||
"archive": "Вы уверены, что хотите добавить в архив эту катушку?"
|
||||
},
|
||||
"form": {
|
||||
"spool_updated": "Эта катушка была обновлена кем-то/чем-то другим с тех пор, как вы открыли эту страницу. При сохранении эти изменения перезапишутся!",
|
||||
"new_location_prompt": "Введите новое местоположение"
|
||||
"new_location_prompt": "Введите новое местоположение",
|
||||
"measurement_type": {
|
||||
"weight": "Вес",
|
||||
"length": "Длина"
|
||||
},
|
||||
"adjust_filament_help": "Здесь вы можете напрямую добавлять или вычитать филамент из катушки. При положительном значении филамент будет израсходован, при отрицательном - добавлен.",
|
||||
"measurement_type_label": "Тип измерения",
|
||||
"adjust_filament_value": "Потребляемое количество"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -202,7 +229,7 @@
|
||||
"vendor_name": "Производитель",
|
||||
"vendor": "Производитель",
|
||||
"name": "Имя",
|
||||
"material": "Тип",
|
||||
"material": "Материал",
|
||||
"price": "Цена",
|
||||
"density": "Плотность",
|
||||
"diameter": "Диаметр",
|
||||
@@ -356,6 +383,10 @@
|
||||
"tab": "Общие",
|
||||
"currency": {
|
||||
"label": "Валюта"
|
||||
},
|
||||
"base_url": {
|
||||
"label": "Базовый URL-адрес",
|
||||
"tooltip": "Базовый URL-адрес, используемый при создании таких функций, как QR-коды."
|
||||
}
|
||||
},
|
||||
"settings": "Настройки"
|
||||
|
||||
@@ -210,6 +210,9 @@
|
||||
"measurement_type_label": "Mättyp",
|
||||
"adjust_filament_value": "Mängd att konsumera",
|
||||
"adjust_filament_help": "Här kan du direkt lägga till eller subtrahera filament från spolen. Ett positivt värde kommer att konsumera filament, ett negativt värde kommer att lägga till det."
|
||||
},
|
||||
"formats": {
|
||||
"last_used": "Senast använd {{date}}"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
@@ -376,5 +379,9 @@
|
||||
"description": "<title>Hjälp</title><p>Här är några tips för att komma igång.</p><p>Spoolman innehåller 3 olika typer av data:</p><itemsHelp/><p>För att lägga till en ny spole i databasen måste du först skapa ett <filamentCreateLink>Filament</filamentCreateLink>-objekt för den. När det är klart kan du sedan skapa ett <spoolCreateLink>Spol</spoolCreateLink>-objekt för den individuella spolen. Om du sedan köper ytterligare spolar av samma filament kan du bara skapa ytterligare Spol-objekt och återanvända samma Filament-objekt.</p><p>Du kan valfritt också skapa ett <vendorCreateLink>Tillverkar</vendorCreateLink>-objekt för företaget som tillverkar filamentet om du vill spåra den informationen.</p><p>Du kan koppla andra 3D-skrivartjänster till Spoolman, som Moonraker, som sedan automatiskt kan spåra filamentanvändning och uppdatera Spol-objekten åt dig. Se <readmeLink>Spoolman README</readmeLink> för hur du gör det.</p>",
|
||||
"help": "Hjälp"
|
||||
},
|
||||
"kofi": "Dricksa mig på Ko-fi"
|
||||
"kofi": "Dricksa mig på Ko-fi",
|
||||
"locations": {
|
||||
"new_location": "Ny plats",
|
||||
"no_location": "Ingen plats"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"notAccessTitle": "У Вас бракує прав для доступу",
|
||||
"clone": "Клонувати",
|
||||
"unArchive": "Розархівувати",
|
||||
"saveAndAdd": "Зберегти та додати"
|
||||
"saveAndAdd": "Зберегти та додати",
|
||||
"continue": "Продовжити"
|
||||
},
|
||||
"spool": {
|
||||
"fields": {
|
||||
@@ -42,7 +43,11 @@
|
||||
"registered": "Додано",
|
||||
"first_used": "Вперше Використано",
|
||||
"remaining_weight": "Залишок Ваги",
|
||||
"price": "Ціна"
|
||||
"price": "Ціна",
|
||||
"filament_external": "Зовнішній",
|
||||
"filament_internal": "Внутрішній",
|
||||
"initial_weight": "Початкова вага",
|
||||
"spool_weight": "Порожня вага"
|
||||
},
|
||||
"spool": "Котушки",
|
||||
"titles": {
|
||||
@@ -52,11 +57,18 @@
|
||||
"archive": "Архівувати Котушку",
|
||||
"show": "Відобразити Котушку",
|
||||
"create": "Створити Котушку",
|
||||
"clone": "Клонувати Котушку"
|
||||
"clone": "Клонувати Котушку",
|
||||
"adjust": "Відрегулюйте філамент з котушки"
|
||||
},
|
||||
"form": {
|
||||
"spool_updated": "Ця котушка була оновлена кимось/чимось після відкриття цієї сторінки. Збереження перезапише ці зміни!",
|
||||
"new_location_prompt": "Введіть нове розташування"
|
||||
"new_location_prompt": "Введіть нове розташування",
|
||||
"adjust_filament_value": "Споживана кількість",
|
||||
"measurement_type": {
|
||||
"length": "Довжина",
|
||||
"weight": "Вага"
|
||||
},
|
||||
"measurement_type_label": "Вид вимірювання"
|
||||
},
|
||||
"fields_help": {
|
||||
"used_weight": "Скільки філаменту було використано з котушки. Для нової котушки слід використовувати 0g.",
|
||||
@@ -109,14 +121,25 @@
|
||||
"printerMarginBottom": "Безпечна зона знизу",
|
||||
"printerMarginTop": "Безпечна зона зверху",
|
||||
"printerMarginRight": "Безпечна зона справа",
|
||||
"printerMarginLeft": "Безпечна зона зліва"
|
||||
"printerMarginLeft": "Безпечна зона зліва",
|
||||
"settings": "Передналаштування",
|
||||
"defaultSettings": "За замовчуванням",
|
||||
"addSettings": "Додати нове передналаштування",
|
||||
"newSetting": "Новий",
|
||||
"itemCopies": "Копії елементів",
|
||||
"deleteSettings": "Видалити поточне передналаштування",
|
||||
"duplicateSettings": "Дублікат",
|
||||
"deleteSettingsConfirm": "Ви впевнені, що бажаєте видалити це передналаштування?",
|
||||
"settingsName": "Назва передналаштування",
|
||||
"saveSetting": "Зберегти передналаштування",
|
||||
"saveAsImage": "Зберегти як зображення"
|
||||
},
|
||||
"spoolSelect": {
|
||||
"selectedTotal_one": "{{count}} обрана котушка",
|
||||
"selectedTotal_few": "{{count}} обрані котушки",
|
||||
"selectedTotal_many": "{{count}} обраних котушок",
|
||||
"showArchived": "Показати Архівні",
|
||||
"description": "Виберіть котушки для друку QR-кодів.",
|
||||
"description": "Виберіть котушки для друку етикеток",
|
||||
"title": "Виберіть котушки",
|
||||
"selectAll": "Вибрати/Відмінити Всі",
|
||||
"noSpoolsSelected": "Ви не вибрали жодної котушки."
|
||||
@@ -130,14 +153,30 @@
|
||||
"showContent": "Друк етикеток",
|
||||
"showVendorComment": "Примітка про виробника",
|
||||
"showSpoolComment": "Примітка про котушку",
|
||||
"textSize": "Розмір тексту",
|
||||
"title": "Друк QR-кодів",
|
||||
"textSize": "Розмір тексту етикетки",
|
||||
"title": "Друк етикеток",
|
||||
"showSpoolmanIcon": "Відобразити іконку Spoolman",
|
||||
"showLotNr": "Номер партії",
|
||||
"bedTemp": "ТС: {{temp}}",
|
||||
"lotNr": "Партія номер: {{lot}}",
|
||||
"showFilamentComment": "Примітка про філамент",
|
||||
"button": "Друкувати QR-коди"
|
||||
"button": "Друкувати етикетки",
|
||||
"useHTTPUrl": {
|
||||
"label": "Посилання на QR-код",
|
||||
"preview": "Попередній перегляд:",
|
||||
"options": {
|
||||
"default": "За замовчуванням",
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"template": "Шаблон етикетки",
|
||||
"showQRCode": "Надрукувати QR-код",
|
||||
"showQRCodeMode": {
|
||||
"no": "Ні",
|
||||
"simple": "Простий",
|
||||
"withIcon": "З іконкою"
|
||||
},
|
||||
"templateHelp": "Використовуйте {} для вставки значень об'єкту котушки у вигляді тексту. Наприклад, {id} буде замінено ідентифікатором котушки, або {filament.material} буде замінено значенням матеріалу котушки. Відсутне значення буде замінено на \"?\". Щоб видалити його, можна використати ще одні {}. Крім того, будь-який текст між {} буде видалено, якщо значення відсутнє. Наприклад, {Lot Nr: {lot_nr}} покаже напис тільки якщо котушка має номер партії. Загорніть текст подвійними зірочками **, щоб зробити його жирним. Натисніть кнопку, щоб переглянути список усіх доступних тегів."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -188,7 +227,11 @@
|
||||
"article_number": "Артикул",
|
||||
"comment": "Коментар",
|
||||
"registered": "Додано",
|
||||
"spools": "Показати котушки"
|
||||
"spools": "Показати котушки",
|
||||
"single_color": "Одноколірний",
|
||||
"external_id": "Зовнішній ID",
|
||||
"multi_color": "Багатокольоровий",
|
||||
"coaxial": "Коекструдтваний"
|
||||
},
|
||||
"titles": {
|
||||
"show_title": "[Філамент #{{id}}] {{name}}",
|
||||
@@ -208,7 +251,8 @@
|
||||
},
|
||||
"filament": "Філаменти",
|
||||
"form": {
|
||||
"filament_updated": "Цей філамент хтось/щось оновив після того, як ви відкрили цю сторінку. Збереження перезапише ці зміни!"
|
||||
"filament_updated": "Цей філамент хтось/щось оновив після того, як ви відкрили цю сторінку. Збереження перезапише ці зміни!",
|
||||
"import_external": "Імпортувати з зовнішнього джерела"
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "Додати Котушку"
|
||||
@@ -235,7 +279,9 @@
|
||||
"comment": "Коментар",
|
||||
"name": "Назва",
|
||||
"registered": "Додано",
|
||||
"id": "ІД"
|
||||
"id": "ІД",
|
||||
"empty_spool_weight": "Вага пустої котушки",
|
||||
"external_id": "Зовнішній ID"
|
||||
},
|
||||
"vendor": "Виробники",
|
||||
"form": {
|
||||
@@ -295,7 +341,10 @@
|
||||
"currency": {
|
||||
"label": "Валюта"
|
||||
},
|
||||
"tab": "Загальне"
|
||||
"tab": "Загальне",
|
||||
"base_url": {
|
||||
"label": "Базова URL-адреса"
|
||||
}
|
||||
},
|
||||
"extra_fields": {
|
||||
"params": {
|
||||
|
||||
380
client/public/locales/zh-Hant/common.json
Normal file
380
client/public/locales/zh-Hant/common.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"actions": {
|
||||
"clone": "複製",
|
||||
"show": "檢視",
|
||||
"list": "列表",
|
||||
"create": "建立",
|
||||
"edit": "編輯"
|
||||
},
|
||||
"buttons": {
|
||||
"create": "建立",
|
||||
"save": "儲存",
|
||||
"saveAndAdd": "儲存並新增",
|
||||
"logout": "登出",
|
||||
"delete": "刪除",
|
||||
"edit": "編輯",
|
||||
"cancel": "取消",
|
||||
"confirm": "您確定嗎?",
|
||||
"continue": "繼續",
|
||||
"filter": "過濾器",
|
||||
"clear": "清除",
|
||||
"refresh": "重新整理",
|
||||
"show": "檢視",
|
||||
"undo": "復原",
|
||||
"import": "匯入",
|
||||
"clone": "複製",
|
||||
"archive": "封存",
|
||||
"unArchive": "取消封存",
|
||||
"hideArchived": "隱藏已封存",
|
||||
"showArchived": "檢視已封存",
|
||||
"notAccessTitle": "您沒有存取的權限",
|
||||
"hideColumns": "隱藏欄",
|
||||
"clearFilters": "清除篩選"
|
||||
},
|
||||
"warnWhenUnsavedChanges": "您確定要離開嗎?您尚有未儲存的變更。",
|
||||
"notifications": {
|
||||
"success": "成功",
|
||||
"error": "錯誤 ( 狀態碼:{{statusCode}} )",
|
||||
"undoable": "您有 {{seconds}} 秒可以還原。",
|
||||
"createSuccess": "成功建立 {{resource}}",
|
||||
"createError": "建立 {{resource}} 時發生錯誤(狀態碼:{{statusCode}})",
|
||||
"deleteSuccess": "成功刪除 {{resource}}",
|
||||
"deleteError": "刪除 {{resource}} 時發生錯誤(狀態碼:{{statusCode}})",
|
||||
"editSuccess": "成功編輯{{resource}}",
|
||||
"editError": "編輯 {{resource}} 時發生錯誤(狀態碼:{{statusCode}})",
|
||||
"importProgress": "匯入中:{{processed}}/{{total}}",
|
||||
"saveSuccessful": "儲存成功!",
|
||||
"validationError": "驗證錯誤: {{error}}"
|
||||
},
|
||||
"kofi": "在Ko-fi上打賞我",
|
||||
"loading": "讀取中",
|
||||
"version": "版本",
|
||||
"unknown": "未知",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"tags": {
|
||||
"clone": "複製"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "儀表板"
|
||||
},
|
||||
"printing": {
|
||||
"generic": {
|
||||
"title": "列印中",
|
||||
"description": "調整以下設定以獲得所需的列印佈局。請注意,列表機和您的操作系統可能會套用自己的邊距和縮放比例,因此可能需要進行一些反覆測試才能達到理想效果。在實際標籤上列印之前,先在普通紙上測試。",
|
||||
"helpMargin": "邊距應配置為與您的標籤紙和列表機相符,調整這些將影響整個網格的大小。",
|
||||
"rows": "列",
|
||||
"paperSize": "紙張大小",
|
||||
"customSize": "自訂",
|
||||
"dimensions": "尺寸",
|
||||
"showBorder": "顯示外框",
|
||||
"previewScale": "預覽縮放比例",
|
||||
"skipItems": "跳過項目",
|
||||
"itemCopies": "項目複製",
|
||||
"contentSettings": "內容設定",
|
||||
"layoutSettings": "佈局設定",
|
||||
"horizontalSpacing": "水平間距",
|
||||
"marginLeft": "左邊界",
|
||||
"marginRight": "右邊界",
|
||||
"marginBottom": "下邊界",
|
||||
"printerMarginLeft": "安全區域左側",
|
||||
"printerMarginRight": "安全區域右側",
|
||||
"printerMarginTop": "安全區域上側",
|
||||
"printerMarginBottom": "安全區域下側",
|
||||
"borders": {
|
||||
"border": "邊框",
|
||||
"grid": "格線",
|
||||
"none": "無"
|
||||
},
|
||||
"defaultSettings": "預設",
|
||||
"addSettings": "新增預設定",
|
||||
"newSetting": "新增",
|
||||
"settings": "預設集",
|
||||
"settingsName": "預設集名稱",
|
||||
"duplicateSettings": "複製",
|
||||
"deleteSettings": "刪除目前預設定",
|
||||
"deleteSettingsConfirm": "確定要刪除此預設集嗎?",
|
||||
"saveSetting": "儲存預設集",
|
||||
"saveAsImage": "另存為圖片",
|
||||
"print": "列印",
|
||||
"columns": "欄",
|
||||
"verticalSpacing": "垂直間距",
|
||||
"marginTop": "上邊界",
|
||||
"helpPrinterMargin": "安全區域應設定為列表機能列印的距離紙張的邊界,更改此選項不影響整個網格。"
|
||||
},
|
||||
"qrcode": {
|
||||
"title": "標籤列印中",
|
||||
"template": "標籤範本",
|
||||
"button": "列印標籤",
|
||||
"templateHelp": "使用 {} 插入料盤物件的值作為內容。例如,{id} 會顯示料盤的 ID,或者 {filament.material} 會顯示料盤的材料。如果缺少值,將以 \"?\" 代替。可以使用第二組 {} 來移除這個顯示。此外,任何夾在 {} 之間的文字若該值缺失也會被移除。例如,{Lot Nr: {lot_nr}} 僅在料盤有批號時顯示標籤。用雙星號 ** 圍繞文字使其加粗。點選此按鈕以查看所有可用標籤列表。",
|
||||
"textSize": "標籤文字大小",
|
||||
"showContent": "列印標籤",
|
||||
"useHTTPUrl": {
|
||||
"label": "QR碼連結",
|
||||
"preview": "預覽:",
|
||||
"options": {
|
||||
"default": "預設",
|
||||
"url": "URL"
|
||||
},
|
||||
"tooltip": "將使用專屬連結,僅在使用 Spoolman 的掃描功能掃描時有效(預設)。URL 使用設定中指定的基礎 URL,若未設置則使用目前頁面的 URL。"
|
||||
},
|
||||
"showQRCodeMode": {
|
||||
"no": "否",
|
||||
"withIcon": "包含圖示",
|
||||
"simple": "簡易"
|
||||
},
|
||||
"showQRCode": "列印QR碼"
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "選擇料盤",
|
||||
"description": "選擇要列印標籤的料盤。",
|
||||
"showArchived": "檢視已封存",
|
||||
"noSpoolsSelected": "您尚未選擇任何料盤。",
|
||||
"selectAll": "選擇/取消全選",
|
||||
"selectedTotal_other": "已選擇 {{count}} 個料盤"
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
"description": "掃描Spoolman的QR碼以檢視關於料盤的詳細資料。",
|
||||
"error": {
|
||||
"notAllowed": "您未被允許存取攝影機。",
|
||||
"streamApiNotSupported": "瀏覽器不支援MediaStream API。",
|
||||
"notReadable": "攝影機不可讀取。",
|
||||
"notFound": "未找到攝影機。",
|
||||
"insecureContext": "此頁面未通過 HTTPS 提供服務。",
|
||||
"unknown": "發生未知的錯誤。({{error}})"
|
||||
},
|
||||
"title": "掃描QR碼"
|
||||
},
|
||||
"spool": {
|
||||
"spool": "料盤",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"filament": "線材",
|
||||
"filament_internal": "內部",
|
||||
"used_weight": "已用重量",
|
||||
"remaining_weight": "剩餘重量",
|
||||
"measured_weight": "實測重量",
|
||||
"used_length": "已用長度",
|
||||
"remaining_length": "剩餘長度",
|
||||
"initial_weight": "初始重量",
|
||||
"spool_weight": "空料盤重量",
|
||||
"location": "位置",
|
||||
"lot_nr": "批號",
|
||||
"first_used": "首次使用",
|
||||
"last_used": "上次使用",
|
||||
"registered": "已註冊",
|
||||
"filament_name": "線材",
|
||||
"filament_external": "外部",
|
||||
"price": "價格",
|
||||
"weight_to_use": "重量",
|
||||
"comment": "註解",
|
||||
"archived": "封存",
|
||||
"material": "材料"
|
||||
},
|
||||
"fields_help": {
|
||||
"price": "完整線盤的價格。如果未設定,則會假設為線材的價格。",
|
||||
"used_weight": "料盤所使用的線材重量。一個新料盤應該為0克。",
|
||||
"measured_weight": "線材和料盤的重量為多少。",
|
||||
"initial_weight": "料盤上線材的初始重量(淨重)。如果未設置,將使用線材物件中的重量。",
|
||||
"spool_weight": "線盤的空盤重量。留空時將使用線材或製造商提供的數值。",
|
||||
"location": "如果您的料盤存於多個位置,請填寫料盤所在位置。",
|
||||
"lot_nr": "製造商的批號。如果使用多個線盤,可用於確保列印的顏色一致。",
|
||||
"weight_to_use": "選擇要輸入的重量值。只有在選擇的線材設置了料盤重量時,才會顯示測量重量。",
|
||||
"remaining_weight": "料盤所剩餘的線材。對於新的料盤來說應等於料盤重量。",
|
||||
"external_filament": "您已從外部資料庫選擇了一種線材。在建立此線盤時,系統將自動建立一個線材(以及可能的製造商)。如果您之前已為該線材建立過資料,這可能會產生重複的線材。"
|
||||
},
|
||||
"titles": {
|
||||
"create": "建立線盤",
|
||||
"clone": "複製線盤",
|
||||
"edit": "編輯線盤",
|
||||
"list": "線盤",
|
||||
"show": "檢視線盤",
|
||||
"adjust": "調整料盤線材",
|
||||
"show_title": "[線盤 #{{id}}] {{name}}",
|
||||
"archive": "封存線盤"
|
||||
},
|
||||
"form": {
|
||||
"measurement_type_label": "測量類型",
|
||||
"adjust_filament_help": "此處您可直接增加或減少料盤上的線材,正數為減少線材數量,負數為增加線材數量。",
|
||||
"adjust_filament_value": "消耗量",
|
||||
"measurement_type": {
|
||||
"length": "長度",
|
||||
"weight": "重量"
|
||||
},
|
||||
"new_location_prompt": "輸入一個新位置",
|
||||
"spool_updated": "此線盤已被其他人/程式更新過。若儲存將覆蓋這些變更!"
|
||||
},
|
||||
"messages": {
|
||||
"archive": "您確定要封存此線盤嗎?"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
"fields": {
|
||||
"vendor": "製造商",
|
||||
"name": "名稱",
|
||||
"material": "材料",
|
||||
"price": "價格",
|
||||
"density": "密度",
|
||||
"diameter": "線徑",
|
||||
"weight": "重量",
|
||||
"spool_weight": "料盤重量",
|
||||
"article_number": "產品編號",
|
||||
"comment": "註解",
|
||||
"color_hex": "顏色",
|
||||
"multi_color": "多色",
|
||||
"coaxial": "混色",
|
||||
"settings_extruder_temp": "擠出機溫度",
|
||||
"settings_bed_temp": "床台溫度",
|
||||
"external_id": "內部ID",
|
||||
"spools": "檢視料盤",
|
||||
"longitudinal": "縱向",
|
||||
"single_color": "單色",
|
||||
"id": "ID",
|
||||
"vendor_name": "製造商",
|
||||
"registered": "已登記"
|
||||
},
|
||||
"fields_help": {
|
||||
"material": "例如:PLA、ABS、PETG等。",
|
||||
"name": "線材名稱,用來區分相同製造商的不同線材類型。例如應該包含顏色資訊。",
|
||||
"article_number": "例如EAN、UPC等。",
|
||||
"price": "全新料盤價格。",
|
||||
"spool_weight": "空線盤的重量,用於計算整捲線的實際重量。",
|
||||
"multi_color_direction": "線材的顏色可通過兩種方式呈現多樣性:一種是共擠技術,例如雙色線材,具有穩定的多色效果;另一種是沿線材長度的顏色變化,例如漸變線材,顏色會隨著線材捲的長度逐漸轉變。",
|
||||
"weight": "整捲線材的重量(淨重)。此重量不應包含線盤本身的重量,僅限於線材的重量,通常會標示在包裝上。"
|
||||
},
|
||||
"titles": {
|
||||
"create": "建立線材",
|
||||
"clone": "複製線材",
|
||||
"edit": "編輯線材",
|
||||
"list": "線材",
|
||||
"show": "檢視線材",
|
||||
"show_title": "[線材 #{{id}}] {{name}}"
|
||||
},
|
||||
"form": {
|
||||
"import_external": "從外部匯入",
|
||||
"filament_updated": "自您開啟此頁面以後,此線材已被其他人/事物更新。儲存將覆蓋這些變更!",
|
||||
"import_external_description": "在列表中選擇一種線材,即可自動填入線材建立表單中的相關詳細資訊。此操作將覆蓋您已輸入的所有資料。<br><br>如果需要,當您點擊“確定”時,將自動建立一個製造商對象。"
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "增加料盤"
|
||||
},
|
||||
"filament": "線材"
|
||||
},
|
||||
"vendor": {
|
||||
"vendor": "製造商",
|
||||
"fields": {
|
||||
"name": "名稱",
|
||||
"empty_spool_weight": "空料盤重量",
|
||||
"external_id": "外部ID",
|
||||
"comment": "註解",
|
||||
"id": "ID",
|
||||
"registered": "已登記"
|
||||
},
|
||||
"fields_help": {
|
||||
"empty_spool_weight": "此製造商空線盤的重量。"
|
||||
},
|
||||
"titles": {
|
||||
"create": "建立製造商",
|
||||
"edit": "編輯製造商",
|
||||
"show_title": "[製造商 #{{id}}] {{name}}",
|
||||
"show": "檢視製造商",
|
||||
"clone": "複製製造商",
|
||||
"list": "製造商"
|
||||
},
|
||||
"form": {
|
||||
"vendor_updated": "此製造商已被其他人/程式更新過。若儲存將覆蓋這些變更!"
|
||||
}
|
||||
},
|
||||
"documentTitle": {
|
||||
"vendor": {
|
||||
"create": "建立製造商 | Spoolman",
|
||||
"clone": "#{{id}} 複製製造商 | Spoolman",
|
||||
"list": "製造商 | Spoolman",
|
||||
"edit": "#{{id}} 編輯製造商 | Spoolman",
|
||||
"show": "#{{id}} 檢視製造商 | Spoolman"
|
||||
},
|
||||
"default": "Spoolman",
|
||||
"suffix": " | Spoolman",
|
||||
"home": {
|
||||
"list": "主頁 | Spoolman"
|
||||
},
|
||||
"help": {
|
||||
"list": "說明 | Spoolman"
|
||||
},
|
||||
"filament": {
|
||||
"list": "線材 | Spoolman",
|
||||
"edit": "#{{id}} 編輯線材 | Spoolman",
|
||||
"show": "#{{id}} 檢視線材 | Spoolman",
|
||||
"create": "建立線材 | Spoolman",
|
||||
"clone": "#{{id}} 複製線材 | Spoolman"
|
||||
},
|
||||
"spool": {
|
||||
"create": "建立線盤 | Spoolman",
|
||||
"clone": "#{{id}} 複製線盤 | Spoolman",
|
||||
"show": "#{{id}} 檢視線盤 | Spoolman",
|
||||
"edit": "#{{id}} 編輯線盤 | Spoolman",
|
||||
"list": "線盤 | Spoolman"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"home": "主頁"
|
||||
},
|
||||
"help": {
|
||||
"help": "說明",
|
||||
"resources": {
|
||||
"filament": "線材的品牌。它們具有名稱、材料、顏色、線徑等屬性。",
|
||||
"spool": "特定線材的單個實體線盤。",
|
||||
"vendor": "製造線材的公司。"
|
||||
},
|
||||
"description": "<title>幫助</title><p>這裡是一些入門提示。</p><p>Spoolman 儲存三種類型的資料:</p><itemsHelp/><p>要將新線材加入資料庫,首先需要建立一個 <filamentCreateLink>線材</filamentCreateLink> 物件。完成後,接著為該特定線材建立一個 <spoolCreateLink>線盤</spoolCreateLink> 物件。如果之後您再獲得相同線材的其他線盤,只需生成更多的線盤物件,並重複使用相同的線材物件。</p><p>如果您希望追蹤製造商資訊,也可以選擇建立一個 <vendorCreateLink>製造商</vendorCreateLink> 物件。</p><p>您還可以將其他3D列印服務與Spoolman連接,例如Moonraker,它可以自動監控線材使用情況並為您更新線盤物件。請參閱 <readmeLink>Spoolman README</readmeLink> 以獲取設置指南。</p>"
|
||||
},
|
||||
"table": {
|
||||
"actions": "操作"
|
||||
},
|
||||
"settings": {
|
||||
"settings": "設定",
|
||||
"header": "設定",
|
||||
"general": {
|
||||
"tab": "一般",
|
||||
"currency": {
|
||||
"label": "貨幣設定標籤"
|
||||
},
|
||||
"base_url": {
|
||||
"tooltip": "生成 QR 碼等功能時使用的基本 URL。",
|
||||
"label": "基本URL"
|
||||
}
|
||||
},
|
||||
"extra_fields": {
|
||||
"tab": "額外欄位",
|
||||
"params": {
|
||||
"key": "鍵值",
|
||||
"name": "名稱",
|
||||
"field_type": "類型",
|
||||
"unit": "單位",
|
||||
"order": "順序",
|
||||
"default_value": "預設值",
|
||||
"multi_choice": "多選項",
|
||||
"choices": "選項"
|
||||
},
|
||||
"field_type": {
|
||||
"text": "文字",
|
||||
"float_range": "小數範圍",
|
||||
"datetime": "日期時間",
|
||||
"boolean": "布林",
|
||||
"choice": "選項",
|
||||
"float": "小數",
|
||||
"integer": "整數",
|
||||
"integer_range": "整數範圍"
|
||||
},
|
||||
"boolean_true": "是",
|
||||
"boolean_false": "否",
|
||||
"choices_missing_error": "您無法刪除選項。以下選項缺失:{{choices}}",
|
||||
"key_not_changed": "請將鍵值更改為其他名稱。",
|
||||
"delete_confirm": "刪除欄位 {{name}}?",
|
||||
"description": "<p>在此,您可以為您的實體加入額外的自訂欄位。</p><p>一旦加入欄位,您將無法更改其鍵值或類型,對於選擇類型欄位,您也無法刪除選項或更改多選狀態。如果刪除欄位,所有實體的相關資料將被刪除。</p><p>鍵值是其他程式讀取/寫入資料的方式,因此如果您的自訂欄位需要與第三方程式整合,請確保正確設置。預設值僅適用於新品項。</p><p>額外的欄位無法在表格視圖中排序或過濾。</p>",
|
||||
"non_unique_key_error": "鍵值必須為唯一。",
|
||||
"delete_confirm_description": "這將刪除該欄位及所有與之關聯的所有實體資料。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,16 +95,20 @@
|
||||
"settingsName": "预设名称",
|
||||
"addSettings": "添加新预设",
|
||||
"deleteSettingsConfirm": "确定删除此预设吗?",
|
||||
"saveAsImage": "保存为图片"
|
||||
"saveAsImage": "保存为图片",
|
||||
"newSetting": "新增",
|
||||
"itemCopies": "复制项目",
|
||||
"duplicateSettings": "复制",
|
||||
"deleteSettings": "删除当前预设"
|
||||
},
|
||||
"qrcode": {
|
||||
"button": "打印二维码",
|
||||
"title": "打印二维码",
|
||||
"button": "打印标签",
|
||||
"title": "打印标签",
|
||||
"spoolWeight": "料盘重量:{{weight}}",
|
||||
"lotNr": "批号:{{lot}}",
|
||||
"bedTemp": "热床温度:{{temp}}",
|
||||
"extruderTemp": "挤出温度:{{temp}}",
|
||||
"textSize": "内容文本大小",
|
||||
"textSize": "标签文本大小",
|
||||
"showSpoolmanIcon": "显示Spoolman图标",
|
||||
"showVendor": "制造商",
|
||||
"showContent": "打印标签",
|
||||
@@ -118,12 +122,23 @@
|
||||
"showQRCode": "打印二维码",
|
||||
"showQRCodeMode": {
|
||||
"no": "否",
|
||||
"withIcon": "含图标"
|
||||
"withIcon": "含图标",
|
||||
"simple": "简单"
|
||||
},
|
||||
"templateHelp": "使用 {} 将料盘的值插入为文本。例如,{id} 将被替换为料盘的ID,或者 {filament.material} 将被替换为料盘的材质。如果某个值缺失,将用 \"?\" 替代。可以使用第二组 {} 来删除缺失值相关的内容。此外,任意位于两组 {} 之间的文本,如果值缺失,将被一同删除。例如,{Lot Nr: {lot_nr}} 只有在料盘存在批号时才会显示标签内容。用双星号 ** 包裹文本可以将其加粗。点击按钮查看所有可用的标签列表。",
|
||||
"useHTTPUrl": {
|
||||
"tooltip": "将使用专有链接(默认情况下,仅能通过 **Spoolman** 的扫描功能访问)。URL 将使用设置中指定的基础 URL;如果未设置基础 URL,则使用当前页面的 URL。",
|
||||
"label": "二维码链接",
|
||||
"options": {
|
||||
"default": "默认",
|
||||
"url": "URL"
|
||||
},
|
||||
"preview": "预览:"
|
||||
}
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "选择料盘",
|
||||
"description": "选择要打印二维码的料盘。",
|
||||
"description": "选择要打印标签的料盘。",
|
||||
"showArchived": "显示存档",
|
||||
"noSpoolsSelected": "您尚未选择任何料盘。",
|
||||
"selectAll": "选择/取消选择全部",
|
||||
@@ -238,7 +253,7 @@
|
||||
"weight": "一整卷耗材的重量(净重)。不应包括料盘本身的重量,仅计算耗材的重量。通常在包装上注明。",
|
||||
"spool_weight": "空料盘的重量。使用它来计算耗材的重量。",
|
||||
"article_number": "例如EAN、UPC等。",
|
||||
"multi_color_direction": "耗材有2种方式实现多色:如通过共挤出,比如多色种类一致的双色耗材,如顺着耗材渐变的耗材"
|
||||
"multi_color_direction": "耗材可以通过两种方式实现多种颜色:共挤出:例如双色耗材,具有稳定的多色组合。纵向颜色变化:例如渐变色耗材,颜色沿着料盘逐渐变化。"
|
||||
},
|
||||
"titles": {
|
||||
"create": "创建耗材",
|
||||
@@ -367,6 +382,10 @@
|
||||
"tab": "常规",
|
||||
"currency": {
|
||||
"label": "货币"
|
||||
},
|
||||
"base_url": {
|
||||
"label": "基础URL",
|
||||
"tooltip": "生成功能(例如二维码)时使用的基础 URL。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HighlightOutlined,
|
||||
HomeOutlined,
|
||||
QuestionOutlined,
|
||||
TableOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
@@ -148,6 +149,14 @@ function App() {
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locations",
|
||||
list: "/locations",
|
||||
meta: {
|
||||
canDelete: false,
|
||||
icon: <TableOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
list: "/settings",
|
||||
@@ -222,6 +231,7 @@ function App() {
|
||||
</Route>
|
||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -61,8 +61,6 @@ const dataProvider = (
|
||||
params: queryParams,
|
||||
});
|
||||
|
||||
// console.log(url, requestMethod, queryParams, data, headers)
|
||||
|
||||
return {
|
||||
data,
|
||||
total: parseInt(headers["x-total-count"]) ?? 100,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import i18n from "i18next";
|
||||
import detector from "i18next-browser-languagedetector";
|
||||
import Backend from "i18next-http-backend";
|
||||
@@ -8,6 +9,7 @@ interface Language {
|
||||
name: string;
|
||||
countryCode: string;
|
||||
fullCode: string;
|
||||
djs: () => Promise<ILocale>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,92 +18,128 @@ interface Language {
|
||||
* name: Name of the language in the list
|
||||
* countryCode: Country code of the country's flag to display for this language
|
||||
* fullCode: Full language code, used for Ant Design's locale
|
||||
* djs: Function to load the dayjs locale, see https://github.com/iamkun/dayjs/tree/dev/src/locale for list of locales
|
||||
*/
|
||||
export const languages: { [key: string]: Language } = {
|
||||
["en"]: {
|
||||
name: "English",
|
||||
countryCode: "gb",
|
||||
fullCode: "en-GB",
|
||||
djs: () => import("dayjs/locale/en"),
|
||||
},
|
||||
["sv"]: {
|
||||
name: "Svenska",
|
||||
countryCode: "se",
|
||||
fullCode: "sv-SE",
|
||||
djs: () => import("dayjs/locale/sv"),
|
||||
},
|
||||
["de"]: {
|
||||
name: "Deutsch",
|
||||
countryCode: "de",
|
||||
fullCode: "de-DE",
|
||||
djs: () => import("dayjs/locale/de"),
|
||||
},
|
||||
["es"]: {
|
||||
name: "Español",
|
||||
countryCode: "es",
|
||||
fullCode: "es-ES",
|
||||
djs: () => import("dayjs/locale/es"),
|
||||
},
|
||||
["zh"]: {
|
||||
name: "简体中文",
|
||||
countryCode: "cn",
|
||||
fullCode: "zh-CN",
|
||||
djs: () => import("dayjs/locale/zh-cn"),
|
||||
},
|
||||
["zh-Hant"]: {
|
||||
name: "繁體中文",
|
||||
countryCode: "cn",
|
||||
fullCode: "zh-TW",
|
||||
djs: () => import("dayjs/locale/zh-hk"),
|
||||
},
|
||||
["pl"]: {
|
||||
name: "Polski",
|
||||
countryCode: "pl",
|
||||
fullCode: "pl-PL",
|
||||
djs: () => import("dayjs/locale/pl"),
|
||||
},
|
||||
["ru"]: {
|
||||
name: "Русский",
|
||||
countryCode: "ru",
|
||||
fullCode: "ru-RU",
|
||||
djs: () => import("dayjs/locale/ru"),
|
||||
},
|
||||
["cs"]: {
|
||||
name: "Česky",
|
||||
countryCode: "cz",
|
||||
fullCode: "cs-CZ",
|
||||
djs: () => import("dayjs/locale/cs"),
|
||||
},
|
||||
["nb-NO"]: {
|
||||
name: "Norsk bokmål",
|
||||
countryCode: "no",
|
||||
fullCode: "nb-NO",
|
||||
djs: () => import("dayjs/locale/nb"),
|
||||
},
|
||||
["nl"]: {
|
||||
name: "Nederlands",
|
||||
countryCode: "nl",
|
||||
fullCode: "nl-NL",
|
||||
djs: () => import("dayjs/locale/nl"),
|
||||
},
|
||||
["fr"]: {
|
||||
name: "Français",
|
||||
countryCode: "fr",
|
||||
fullCode: "fr-FR",
|
||||
djs: () => import("dayjs/locale/fr"),
|
||||
},
|
||||
["hu"]: {
|
||||
name: "Magyar",
|
||||
countryCode: "hu",
|
||||
fullCode: "hu-HU",
|
||||
djs: () => import("dayjs/locale/hu"),
|
||||
},
|
||||
["it"]: {
|
||||
name: "Italiano",
|
||||
countryCode: "it",
|
||||
fullCode: "it-IT",
|
||||
djs: () => import("dayjs/locale/it"),
|
||||
},
|
||||
["uk"]: {
|
||||
name: "Українська",
|
||||
countryCode: "ua",
|
||||
fullCode: "uk-UA",
|
||||
djs: () => import("dayjs/locale/uk"),
|
||||
},
|
||||
["el"]: {
|
||||
name: "Ελληνικά",
|
||||
countryCode: "gr",
|
||||
fullCode: "el-GR",
|
||||
djs: () => import("dayjs/locale/el"),
|
||||
},
|
||||
["da"]: {
|
||||
name: "Dansk",
|
||||
countryCode: "dk",
|
||||
fullCode: "da-DK",
|
||||
djs: () => import("dayjs/locale/da"),
|
||||
},
|
||||
["pt"]: {
|
||||
name: "Português",
|
||||
countryCode: "pt",
|
||||
fullCode: "pt-PT",
|
||||
djs: () => import("dayjs/locale/pt"),
|
||||
},
|
||||
["fa"]: {
|
||||
name: "فارسی",
|
||||
countryCode: "ir",
|
||||
fullCode: "fa-IR",
|
||||
djs: () => import("dayjs/locale/fa"),
|
||||
},
|
||||
["ro"]: {
|
||||
name: "Român",
|
||||
countryCode: "ro",
|
||||
fullCode: "ro-RO",
|
||||
djs: () => import("dayjs/locale/ro"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,4 +157,8 @@ i18n
|
||||
fallbackLng: "en",
|
||||
});
|
||||
|
||||
i18n.on("languageChanged", function (lng) {
|
||||
languages[lng].djs().then((djs) => dayjs.locale(djs.name));
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { FilamentImportModal } from "../../components/filamentImportModal";
|
||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||
import { numberFormatter, numberParser } from "../../utils/parsing";
|
||||
import { numberFormatter, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { ExternalFilament } from "../../utils/queryExternalDB";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
@@ -73,7 +73,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
invalidates: ["list", "detail"],
|
||||
});
|
||||
|
||||
setColorType(filament.color_hexes ? "multi" : "single")
|
||||
setColorType(filament.color_hexes ? "multi" : "single");
|
||||
|
||||
form.setFieldsValue({
|
||||
name: filament.name,
|
||||
@@ -251,7 +251,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
addonAfter={getCurrencySymbol(undefined, currency)}
|
||||
precision={2}
|
||||
formatter={numberFormatter}
|
||||
parser={numberParser}
|
||||
parser={numberParserAllowEmpty}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
||||
@@ -3,10 +3,10 @@ import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/co
|
||||
import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||
import { numberFormatter, numberParser } from "../../utils/parsing";
|
||||
import { numberFormatter, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { IVendor } from "../vendors/model";
|
||||
@@ -52,7 +52,6 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
|
||||
// Update colorType state
|
||||
useEffect(() => {
|
||||
console.log(formProps.initialValues?.multi_color_hexes);
|
||||
if (formProps.initialValues?.multi_color_hexes) {
|
||||
setColorType("multi");
|
||||
} else {
|
||||
@@ -227,7 +226,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
addonAfter={getCurrencySymbol(undefined, currency)}
|
||||
precision={2}
|
||||
formatter={numberFormatter}
|
||||
parser={numberParser}
|
||||
parser={numberParserAllowEmpty}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -324,6 +323,17 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
>
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.external_id")}
|
||||
name={["external_id"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.comment")}
|
||||
name={["comment"]}
|
||||
|
||||
181
client/src/pages/locations/components/location.tsx
Normal file
181
client/src/pages/locations/components/location.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Button, Input, theme } from "antd";
|
||||
import type { Identifier, XYCoord } from "dnd-core";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { useTranslate, useUpdate } from "@refinedev/core";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { DragItem, ItemTypes, SpoolDragItem } from "../dnd";
|
||||
import { EMPTYLOC } from "../functions";
|
||||
import { SpoolList } from "./spoolList";
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export function Location({
|
||||
index,
|
||||
title,
|
||||
spools,
|
||||
showDelete,
|
||||
onDelete,
|
||||
moveLocation,
|
||||
onEditTitle,
|
||||
locationSpoolOrder,
|
||||
setLocationSpoolOrder,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
spools: ISpool[];
|
||||
showDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
moveLocation: (dragIndex: number, hoverIndex: number) => void;
|
||||
onEditTitle: (newTitle: string) => void;
|
||||
locationSpoolOrder: number[];
|
||||
setLocationSpoolOrder: (spoolOrder: number[]) => void;
|
||||
}) {
|
||||
const { token } = useToken();
|
||||
const t = useTranslate();
|
||||
const [editTitle, setEditTitle] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState(title);
|
||||
const { mutate: updateSpool } = useUpdate({
|
||||
resource: "spool",
|
||||
mutationMode: "optimistic",
|
||||
successNotification: false,
|
||||
});
|
||||
|
||||
const moveSpoolLocation = (spool_id: number, location: string) => {
|
||||
updateSpool({
|
||||
id: spool_id,
|
||||
values: {
|
||||
location: location,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const dropTypes = title == EMPTYLOC ? [ItemTypes.SPOOL] : [ItemTypes.CONTAINER, ItemTypes.SPOOL];
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ handlerId }, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>({
|
||||
accept: dropTypes,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!ref.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("spool" in item) {
|
||||
// Only allow dropping spools on the container if it's empty.
|
||||
if (spools.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spoolitem = item as SpoolDragItem;
|
||||
if (spoolitem.spool.location !== title) {
|
||||
moveSpoolLocation(spoolitem.spool.id, title);
|
||||
spoolitem.spool.location = title;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get horizontal middle
|
||||
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the left
|
||||
const hoverClientX = (clientOffset as XYCoord).x - hoverBoundingRect.left;
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveLocation(dragIndex, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.CONTAINER,
|
||||
canDrag: !editTitle && title != EMPTYLOC,
|
||||
item: () => {
|
||||
return { title, index };
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const displayTitle = title == EMPTYLOC ? t("locations.no_location") : title;
|
||||
|
||||
const opacity = isDragging ? 0 : 1;
|
||||
drag(drop(ref));
|
||||
|
||||
const canEditTitle = title != EMPTYLOC;
|
||||
|
||||
const titleStyle = {
|
||||
color: canEditTitle ? undefined : token.colorTextTertiary,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"loc-container " + (title != EMPTYLOC ? "grabable" : "")}
|
||||
ref={ref}
|
||||
style={{ opacity }}
|
||||
data-handler-id={handlerId}
|
||||
>
|
||||
<h3>
|
||||
{editTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
variant="borderless"
|
||||
value={newTitle}
|
||||
onBlur={() => setEditTitle(false)}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setEditTitle(false);
|
||||
return onEditTitle(newTitle);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={canEditTitle ? "editable" : ""}
|
||||
onClick={() => {
|
||||
if (!canEditTitle) return;
|
||||
setNewTitle(title);
|
||||
setEditTitle(true);
|
||||
}}
|
||||
style={titleStyle}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
{showDelete && <Button icon={<DeleteOutlined />} size="small" type="text" onClick={onDelete} />}
|
||||
</h3>
|
||||
<SpoolList spools={spools} spoolOrder={locationSpoolOrder} setSpoolOrder={setLocationSpoolOrder} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
client/src/pages/locations/components/locationContainer.tsx
Normal file
200
client/src/pages/locations/components/locationContainer.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useList, useTranslate } from "@refinedev/core";
|
||||
import { Button } from "antd";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSetSetting } from "../../../utils/querySettings";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { EMPTYLOC, useLocations, useLocationsSpoolOrders, useRenameSpoolLocation } from "../functions";
|
||||
import { Location } from "./location";
|
||||
|
||||
export function LocationContainer() {
|
||||
const t = useTranslate();
|
||||
const renameSpoolLocation = useRenameSpoolLocation();
|
||||
|
||||
const settingsLocations = useLocations();
|
||||
const setLocationsSetting = useSetSetting<string[]>("locations");
|
||||
|
||||
const locationsSpoolOrders = useLocationsSpoolOrders();
|
||||
const setLocationsSpoolOrders = useSetSetting<Record<string, number[]>>("locations_spoolorders");
|
||||
|
||||
const {
|
||||
data: spoolData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useList<ISpool>({
|
||||
resource: "spool",
|
||||
meta: {
|
||||
queryParams: {
|
||||
["allow_archived"]: false,
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
mode: "off",
|
||||
},
|
||||
});
|
||||
|
||||
// Group spools by location
|
||||
const spoolLocations = (() => {
|
||||
const spools = spoolData?.data ?? [];
|
||||
spools.sort((a, b) => a.id - b.id);
|
||||
|
||||
const grouped: Record<string, ISpool[]> = {};
|
||||
spools.forEach((spool) => {
|
||||
const loc = spool.location ?? EMPTYLOC;
|
||||
if (!grouped[loc]) {
|
||||
grouped[loc] = [];
|
||||
}
|
||||
grouped[loc].push(spool);
|
||||
});
|
||||
|
||||
// Sort spools in the locations by the spool order
|
||||
for (const loc of Object.keys(grouped)) {
|
||||
if (!locationsSpoolOrders[loc]) {
|
||||
continue;
|
||||
}
|
||||
grouped[loc].sort((a, b) => {
|
||||
let aidx = locationsSpoolOrders[loc].indexOf(a.id);
|
||||
if (aidx === -1) {
|
||||
aidx = 999999;
|
||||
}
|
||||
let bidx = locationsSpoolOrders[loc].indexOf(b.id);
|
||||
if (bidx === -1) {
|
||||
bidx = 999999;
|
||||
}
|
||||
return aidx - bidx;
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
})();
|
||||
|
||||
// Create list of locations that's sorted
|
||||
const locationsList = useMemo(() => {
|
||||
// Start with the default loc
|
||||
let allLocs = [];
|
||||
if (EMPTYLOC in spoolLocations) {
|
||||
allLocs.push(EMPTYLOC);
|
||||
}
|
||||
|
||||
// Add from the locations setting
|
||||
if (settingsLocations) allLocs.push(...settingsLocations);
|
||||
|
||||
// Add any missing locations from the spools
|
||||
for (const loc of Object.keys(spoolLocations)) {
|
||||
if (loc != EMPTYLOC && !allLocs.includes(loc)) {
|
||||
allLocs.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
return allLocs;
|
||||
}, [spoolLocations, settingsLocations]);
|
||||
|
||||
const moveLocation = (dragIndex: number, hoverIndex: number) => {
|
||||
const newLocs = [...locationsList];
|
||||
newLocs.splice(dragIndex, 1);
|
||||
newLocs.splice(hoverIndex, 0, locationsList[dragIndex]);
|
||||
setLocationsSetting.mutate(newLocs.filter((loc) => loc != EMPTYLOC));
|
||||
};
|
||||
|
||||
const onEditTitle = async (location: string, newTitle: string) => {
|
||||
if (location == "") return; // Can't edit the default location
|
||||
if (newTitle == location) return; // No change
|
||||
if (newTitle == "") return; // Can't have an empty location
|
||||
if (locationsList.includes(newTitle)) return; // Location already exists
|
||||
|
||||
// Update all spool locations in the database
|
||||
if (spoolLocations[location] && spoolLocations[location].length > 0) {
|
||||
renameSpoolLocation.mutate({ old: location, new: newTitle });
|
||||
}
|
||||
|
||||
// Update the value in the settings
|
||||
const newLocs = [...locationsList].filter((loc) => loc != EMPTYLOC);
|
||||
newLocs[locationsList.indexOf(location)] = newTitle;
|
||||
setLocationsSetting.mutate(newLocs);
|
||||
};
|
||||
|
||||
const setLocationSpoolOrder = (location: string, spoolOrder: number[]) => {
|
||||
setLocationsSpoolOrders.mutate({
|
||||
...locationsSpoolOrders,
|
||||
[location]: spoolOrder,
|
||||
});
|
||||
};
|
||||
|
||||
// Create containers
|
||||
const containers = locationsList.map((loc, idx) => {
|
||||
const spools = spoolLocations[loc] ?? [];
|
||||
const spoolOrder = locationsSpoolOrders[loc] ?? [];
|
||||
|
||||
return (
|
||||
<Location
|
||||
key={loc}
|
||||
index={idx}
|
||||
title={loc}
|
||||
spools={spools}
|
||||
showDelete={spools.length == 0}
|
||||
onDelete={() => {
|
||||
setLocationsSetting.mutate(locationsList.filter((l) => l !== loc));
|
||||
}}
|
||||
moveLocation={moveLocation}
|
||||
onEditTitle={(newTitle: string) => onEditTitle(loc, newTitle)}
|
||||
locationSpoolOrder={spoolOrder}
|
||||
setLocationSpoolOrder={(spoolOrder: number[]) => setLocationSpoolOrder(loc, spoolOrder)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Update locations settings so it always includes all spool locations
|
||||
useEffect(() => {
|
||||
// Check if they're not the same
|
||||
const curLocList = locationsList.filter((l) => l != EMPTYLOC);
|
||||
if (settingsLocations != null && JSON.stringify(curLocList) !== JSON.stringify(settingsLocations)) {
|
||||
setLocationsSetting.mutate(curLocList);
|
||||
}
|
||||
}, [locationsList, settingsLocations, setLocationsSetting]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Failed to load spools</div>;
|
||||
}
|
||||
|
||||
const addNewLocation = () => {
|
||||
const baseLocationName = t("locations.new_location");
|
||||
let newLocationName = baseLocationName;
|
||||
|
||||
const newLocs = [...locationsList];
|
||||
let i = 1;
|
||||
while (newLocs.includes(newLocationName)) {
|
||||
newLocationName = baseLocationName + " " + i;
|
||||
i++;
|
||||
}
|
||||
newLocs.push(newLocationName);
|
||||
|
||||
setLocationsSetting.mutate(newLocs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isLoading && spoolData.data.length == 0 && (
|
||||
<div className="no-locations">{t("locations.no_locations_help")}</div>
|
||||
)}
|
||||
<div className="loc-metacontainer">
|
||||
{containers}
|
||||
<div className="newLocContainer">
|
||||
<Button
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
size="large"
|
||||
style={{
|
||||
margin: "1em",
|
||||
}}
|
||||
onClick={addNewLocation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
client/src/pages/locations/components/spoolCard.tsx
Normal file
187
client/src/pages/locations/components/spoolCard.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useNavigation, useTranslate, useUpdate } from "@refinedev/core";
|
||||
import { Button, theme } from "antd";
|
||||
import type { Identifier, XYCoord } from "dnd-core";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
|
||||
import { EditOutlined, EyeOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import SpoolIcon from "../../../components/spoolIcon";
|
||||
import { formatWeight } from "../../../utils/parsing";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { ItemTypes, SpoolDragItem, useCurrentDraggedSpool } from "../dnd";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export function SpoolCard({
|
||||
index,
|
||||
spool,
|
||||
moveSpoolOrder,
|
||||
}: {
|
||||
index: number;
|
||||
spool: ISpool;
|
||||
moveSpoolOrder: (dragIndex: number, hoverIndex: number) => void;
|
||||
}) {
|
||||
const { token } = useToken();
|
||||
const t = useTranslate();
|
||||
const { showUrl } = useNavigation();
|
||||
|
||||
// Using a global state for this, because the drag handlers are reset when the spool changes location
|
||||
const { draggedSpoolId, setDraggedSpoolId } = useCurrentDraggedSpool();
|
||||
|
||||
const { mutate: updateSpool } = useUpdate({
|
||||
resource: "spool",
|
||||
mutationMode: "optimistic",
|
||||
successNotification: false,
|
||||
});
|
||||
|
||||
const moveSpoolLocation = (spool_id: number, location: string) => {
|
||||
updateSpool({
|
||||
id: spool_id,
|
||||
values: {
|
||||
location: location,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ handlerId }, drop] = useDrop<SpoolDragItem, void, { handlerId: Identifier | null }>({
|
||||
accept: ItemTypes.SPOOL,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!ref.current || item.spool.id === spool.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.spool.location !== spool.location && spool.location) {
|
||||
moveSpoolLocation(item.spool.id, spool.location);
|
||||
item.spool.location = spool.location;
|
||||
return;
|
||||
}
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get horizontal middle
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the top
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveSpoolOrder(item.spool.id, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.SPOOL,
|
||||
item: () => {
|
||||
return { spool, index };
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
end() {
|
||||
setDraggedSpoolId(-1);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setDraggedSpoolId(spool.id);
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const colorObj = spool.filament.multi_color_hexes
|
||||
? {
|
||||
colors: spool.filament.multi_color_hexes.split(","),
|
||||
vertical: spool.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: spool.filament.color_hex || "#000000";
|
||||
|
||||
let filament_name: string;
|
||||
if (spool.filament.vendor && "name" in spool.filament.vendor) {
|
||||
filament_name = `${spool.filament.vendor.name} - ${spool.filament.name}`;
|
||||
} else {
|
||||
filament_name = spool.filament.name ?? spool.filament.id.toString();
|
||||
}
|
||||
|
||||
const opacity = draggedSpoolId === spool.id ? 0 : 1;
|
||||
const style = {
|
||||
opacity,
|
||||
backgroundColor: token.colorBgContainerDisabled,
|
||||
};
|
||||
drag(drop(ref));
|
||||
|
||||
function formatSubtitle(spool: ISpool) {
|
||||
let str = "";
|
||||
if (spool.filament.material) str += spool.filament.material + " - ";
|
||||
if (spool.filament.weight) {
|
||||
const remaining_weight = spool.remaining_weight ?? spool.filament.weight;
|
||||
str += `${formatWeight(remaining_weight, 0)} / ${formatWeight(spool.filament.weight, 0)}`;
|
||||
}
|
||||
if (spool.last_used) {
|
||||
// Format like "last used X time ago"
|
||||
const dt = dayjs(spool.last_used);
|
||||
str += ` - ${t("spool.formats.last_used", { date: dt.fromNow() })}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="spool" ref={ref} style={style} data-handler-id={handlerId}>
|
||||
<SpoolIcon color={colorObj} />
|
||||
<div className="info">
|
||||
<div className="title">
|
||||
<span>
|
||||
#{spool.id} {filament_name}
|
||||
</span>
|
||||
<div>
|
||||
<Link to={`/spool/edit/${spool.id}?return=` + encodeURIComponent(window.location.pathname)}>
|
||||
<Button icon={<EditOutlined />} title={t("buttons.edit")} size="small" type="text" />
|
||||
</Link>
|
||||
<Link to={showUrl("spool", spool.id)}>
|
||||
<Button icon={<EyeOutlined />} title={t("buttons.show")} size="small" type="text" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="subtitle"
|
||||
style={{
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{formatSubtitle(spool)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
client/src/pages/locations/components/spoolList.tsx
Normal file
54
client/src/pages/locations/components/spoolList.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { theme } from "antd";
|
||||
import { ISpool } from "../../spools/model";
|
||||
import { SpoolCard } from "./spoolCard";
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export function SpoolList({
|
||||
spools,
|
||||
spoolOrder,
|
||||
setSpoolOrder,
|
||||
}: {
|
||||
spools: ISpool[];
|
||||
spoolOrder: number[];
|
||||
setSpoolOrder: (spoolOrder: number[]) => void;
|
||||
}) {
|
||||
const { token } = useToken();
|
||||
|
||||
// Make sure all spools are in the spoolOrders array
|
||||
const finalSpoolOrder = [...spoolOrder].filter((id) => spools.find((spool) => spool.id === id)); // Remove any spools that are not in the spools array
|
||||
spools.forEach((spool) => {
|
||||
if (!finalSpoolOrder.includes(spool.id)) finalSpoolOrder.push(spool.id);
|
||||
});
|
||||
|
||||
const moveSpoolOrder = (spool_id: number, hoverIndex: number) => {
|
||||
// Move spool spool_id to position hoverIndex
|
||||
let curIdx = finalSpoolOrder.indexOf(spool_id);
|
||||
if (curIdx === -1) {
|
||||
// Spool is missing from spool order array, add it to the end of the array
|
||||
finalSpoolOrder.push(spool_id);
|
||||
curIdx = finalSpoolOrder.length - 1;
|
||||
} else if (curIdx === hoverIndex) {
|
||||
// Spool is already in the right position
|
||||
return;
|
||||
}
|
||||
|
||||
const newSpoolOrder = [...finalSpoolOrder];
|
||||
newSpoolOrder.splice(curIdx, 1);
|
||||
newSpoolOrder.splice(hoverIndex, 0, finalSpoolOrder[curIdx]);
|
||||
setSpoolOrder(newSpoolOrder);
|
||||
};
|
||||
|
||||
const style = {
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="loc-spools" style={style}>
|
||||
{spools.map((spool, idx) => (
|
||||
<SpoolCard key={spool.id} index={idx} spool={spool} moveSpoolOrder={moveSpoolOrder} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
client/src/pages/locations/dnd.ts
Normal file
29
client/src/pages/locations/dnd.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
import { ISpool } from "../spools/model";
|
||||
|
||||
export const ItemTypes = {
|
||||
SPOOL: "spool",
|
||||
CONTAINER: "spool-container",
|
||||
};
|
||||
|
||||
export interface DragItem {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SpoolDragItem extends DragItem {
|
||||
spool: ISpool;
|
||||
}
|
||||
|
||||
export interface ContainerDragItem extends DragItem {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CurrentDraggedSpool {
|
||||
draggedSpoolId: number;
|
||||
setDraggedSpoolId: (spoolid: number) => void;
|
||||
}
|
||||
|
||||
export const useCurrentDraggedSpool = create<CurrentDraggedSpool>((set) => ({
|
||||
draggedSpoolId: -1,
|
||||
setDraggedSpoolId: (spoolid: number) => set({ draggedSpoolId: spoolid }),
|
||||
}));
|
||||
109
client/src/pages/locations/functions.ts
Normal file
109
client/src/pages/locations/functions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { GetListResponse } from "@refinedev/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useGetSetting } from "../../utils/querySettings";
|
||||
import { getAPIURL } from "../../utils/url";
|
||||
import { ISpool } from "../spools/model";
|
||||
|
||||
export const EMPTYLOC = "";
|
||||
|
||||
interface LocationRename {
|
||||
old: string;
|
||||
new: string;
|
||||
}
|
||||
|
||||
export function useRenameSpoolLocation() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["default", "spool"];
|
||||
const queryKeyList = ["default", "spool", "list"];
|
||||
|
||||
return useMutation<string, unknown, LocationRename, undefined>({
|
||||
mutationFn: async (value) => {
|
||||
const response = await fetch(getAPIURL() + "/location/" + value.old, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: value.new,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return await response.text();
|
||||
},
|
||||
onMutate: async (value) => {
|
||||
await queryClient.cancelQueries(queryKeyList);
|
||||
|
||||
// Optimistically update all spools with matching location to the new one
|
||||
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
|
||||
if (old) {
|
||||
return {
|
||||
data: old.data.map((spool) => {
|
||||
if (spool.location === value.old) {
|
||||
return { ...spool, location: value.new };
|
||||
}
|
||||
return spool;
|
||||
}),
|
||||
total: old.total,
|
||||
};
|
||||
}
|
||||
return old;
|
||||
});
|
||||
},
|
||||
onError: (_error, value, _context) => {
|
||||
// Mutation failed, reset spools with matching location to the old one
|
||||
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
|
||||
if (old) {
|
||||
return {
|
||||
data: old.data.map((spool) => {
|
||||
if (spool.location === value.new) {
|
||||
return { ...spool, location: value.old };
|
||||
}
|
||||
return spool;
|
||||
}),
|
||||
total: old.total,
|
||||
};
|
||||
}
|
||||
return old;
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, _value) => {
|
||||
// Mutation succeeded, refetch
|
||||
queryClient.invalidateQueries(queryKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocations(): string[] | null {
|
||||
const query = useGetSetting("locations");
|
||||
|
||||
return useMemo(() => {
|
||||
if (!query.data) return null;
|
||||
|
||||
try {
|
||||
let data = (JSON.parse(query.data.value) ?? []) as string[];
|
||||
data = data.filter((location) => location != null && location.length > 0);
|
||||
return data;
|
||||
} catch {
|
||||
console.warn("Failed to parse locations", query.data.value);
|
||||
return null;
|
||||
}
|
||||
}, [query.data]);
|
||||
}
|
||||
|
||||
export function useLocationsSpoolOrders(): Record<string, number[]> {
|
||||
const query = useGetSetting("locations_spoolorders");
|
||||
|
||||
return useMemo(() => {
|
||||
if (!query.data) return {};
|
||||
|
||||
try {
|
||||
return (JSON.parse(query.data.value) ?? {}) as Record<string, number[]>;
|
||||
} catch {
|
||||
console.warn("Failed to parse locations spool orders", query.data.value);
|
||||
return {};
|
||||
}
|
||||
}, [query.data]);
|
||||
}
|
||||
25
client/src/pages/locations/index.tsx
Normal file
25
client/src/pages/locations/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
import { LocationContainer } from "./components/locationContainer";
|
||||
import "./locations.css";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export const Locations: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("locations.locations")}</h1>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<LocationContainer />
|
||||
</DndProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Locations;
|
||||
75
client/src/pages/locations/locations.css
Normal file
75
client/src/pages/locations/locations.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.loc-metacontainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.loc-container {
|
||||
padding: 1em;
|
||||
width: 24em;
|
||||
}
|
||||
|
||||
.loc-container.grabable,
|
||||
.loc-container .spool {
|
||||
cursor: move;
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
|
||||
.loc-container h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 21px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loc-container h3 span {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.loc-container h3 span.editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.loc-container h3 input {
|
||||
font-size: 21px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.loc-container .loc-spools {
|
||||
padding: 0.2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
min-height: 3em;
|
||||
max-height: 50em;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.loc-container .spool {
|
||||
padding: 0.5em 0.5em 0.5em 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.loc-container .spool .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loc-container .spool .info .title {
|
||||
font-size: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loc-container .spool .info .subtitle {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
@@ -100,17 +100,13 @@ export function renderLabelContents(template: string, spool: ISpool): JSX.Elemen
|
||||
// Find all {tags} in the template string and loop over them
|
||||
// let matches = [...template.matchAll(/(?:{(.*?))?{(.*?)}(.*?)(?:}(.*?))?/gs)];
|
||||
let matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
|
||||
// console.log(matches){(?:[^}{]|{[^}{]*})*}
|
||||
let label_text = template;
|
||||
matches.forEach((match) => {
|
||||
// console.log(match)
|
||||
if ((match[0].match(/{/g)||[]).length == 1) {
|
||||
if ((match[0].match(/{/g) || []).length == 1) {
|
||||
let tag = match[0].replace(/[{}]/g, "");
|
||||
// console.log(tag)
|
||||
let tagValue = getTagValue(tag, spool)
|
||||
let tagValue = getTagValue(tag, spool);
|
||||
label_text = label_text.replace(match[0], tagValue);
|
||||
}
|
||||
else if ((match[0].match(/{/g)||[]).length == 2) {
|
||||
} else if ((match[0].match(/{/g) || []).length == 2) {
|
||||
let structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
|
||||
if (structure != null) {
|
||||
const tag = structure[2];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../compo
|
||||
import { useSpoolmanLocations } from "../../components/otherModels";
|
||||
import { searchMatches } from "../../utils/filtering";
|
||||
import "../../utils/overrides.css";
|
||||
import { numberFormatter, numberParser } from "../../utils/parsing";
|
||||
import { numberFormatter, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { createFilamentFromExternal } from "../filaments/functions";
|
||||
@@ -331,7 +331,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
addonAfter={getCurrencySymbol(undefined, currency)}
|
||||
precision={2}
|
||||
formatter={numberFormatter}
|
||||
parser={numberParser}
|
||||
parser={numberParserAllowEmpty}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
||||
@@ -5,13 +5,15 @@ import TextArea from "antd/es/input/TextArea";
|
||||
import { message } from "antd/lib";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { useSpoolmanLocations } from "../../components/otherModels";
|
||||
import { searchMatches } from "../../utils/filtering";
|
||||
import { numberFormatter, numberParser } from "../../utils/parsing";
|
||||
import { numberFormatter, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { createFilamentFromExternal } from "../filaments/functions";
|
||||
import { useLocations } from "../locations/functions";
|
||||
import { useGetFilamentSelectOptions } from "./functions";
|
||||
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
|
||||
|
||||
@@ -32,6 +34,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
const extraFields = useGetFields(EntityType.spool);
|
||||
const currency = useCurrency();
|
||||
const [searchParams, _] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
|
||||
liveMode: "manual",
|
||||
@@ -40,6 +44,17 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
messageApi.warning(t("spool.form.spool_updated"));
|
||||
setHasChanged(true);
|
||||
},
|
||||
|
||||
// Custom redirect logic
|
||||
redirect: false,
|
||||
onMutationSuccess: () => {
|
||||
const returnUrl = searchParams.get("return");
|
||||
if (returnUrl) {
|
||||
navigate(returnUrl, { relative: "path" });
|
||||
} else {
|
||||
navigate("/spool");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const initialWeightValue = Form.useWatch("initial_weight", form);
|
||||
@@ -118,7 +133,6 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
useEffect(() => {
|
||||
const newFilamentWeight = selectedFilament?.weight || 0;
|
||||
const newSpoolWeight = selectedFilament?.spool_weight || 0;
|
||||
console.log("selectedFilament", selectedFilament, newFilamentWeight, newSpoolWeight);
|
||||
if (newFilamentWeight > 0) {
|
||||
form.setFieldValue("initial_weight", newFilamentWeight);
|
||||
}
|
||||
@@ -135,9 +149,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
};
|
||||
|
||||
const locations = useSpoolmanLocations(true);
|
||||
const settingsLocation = useLocations();
|
||||
const [newLocation, setNewLocation] = useState("");
|
||||
|
||||
const allLocations = [...(locations.data || [])];
|
||||
const allLocations = [...(settingsLocation || [])];
|
||||
locations?.data?.forEach((loc) => {
|
||||
if (!allLocations.includes(loc)) {
|
||||
allLocations.push(loc);
|
||||
}
|
||||
});
|
||||
if (newLocation.trim() && !allLocations.includes(newLocation)) {
|
||||
allLocations.push(newLocation.trim());
|
||||
}
|
||||
@@ -301,7 +321,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
addonAfter={getCurrencySymbol(undefined, currency)}
|
||||
precision={2}
|
||||
formatter={numberFormatter}
|
||||
parser={numberParser}
|
||||
parser={numberParserAllowEmpty}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
|
||||
@@ -189,7 +189,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
|
||||
const archiveSpoolPopup = async (spool: ISpoolCollapsed) => {
|
||||
// If the spool has no remaining weight, archive it immediately since it's likely not a mistake
|
||||
if (spool.remaining_weight && spool.remaining_weight == 0) {
|
||||
if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) {
|
||||
await archiveSpool(spool, true);
|
||||
} else {
|
||||
confirm({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrinterOutlined } from "@ant-design/icons";
|
||||
import { InboxOutlined, PrinterOutlined, ToTopOutlined } from "@ant-design/icons";
|
||||
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
|
||||
import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core";
|
||||
import { Button, Typography } from "antd";
|
||||
import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core";
|
||||
import { Button, Modal, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
@@ -12,16 +12,19 @@ import { enrichText } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { useCurrency } from "../../utils/settings";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { setSpoolArchived } from "./functions";
|
||||
import { ISpool } from "./model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
const { Title } = Typography;
|
||||
const { confirm } = Modal;
|
||||
|
||||
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
const extraFields = useGetFields(EntityType.spool);
|
||||
const currency = useCurrency();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
const { queryResult } = useShow<ISpool>({
|
||||
liveMode: "auto",
|
||||
@@ -39,6 +42,37 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
return item.price;
|
||||
};
|
||||
|
||||
// Function for opening an ant design modal that asks for confirmation for archiving a spool
|
||||
const archiveSpool = async (spool: ISpool, archive: boolean) => {
|
||||
await setSpoolArchived(spool, archive);
|
||||
invalidate({
|
||||
resource: "spool",
|
||||
id: spool.id,
|
||||
invalidates: ["list", "detail"],
|
||||
});
|
||||
};
|
||||
|
||||
const archiveSpoolPopup = async (spool: ISpool | undefined) => {
|
||||
if (spool === undefined) {
|
||||
return;
|
||||
}
|
||||
// If the spool has no remaining weight, archive it immediately since it's likely not a mistake
|
||||
if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) {
|
||||
await archiveSpool(spool, true);
|
||||
} else {
|
||||
confirm({
|
||||
title: t("spool.titles.archive"),
|
||||
content: t("spool.messages.archive"),
|
||||
okText: t("buttons.archive"),
|
||||
okType: "primary",
|
||||
cancelText: t("buttons.cancel"),
|
||||
onOk() {
|
||||
return archiveSpool(spool, true);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatFilament = (item: IFilament) => {
|
||||
let vendorPrefix = "";
|
||||
if (item.vendor) {
|
||||
@@ -88,6 +122,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
>
|
||||
{t("printing.qrcode.button")}
|
||||
</Button>
|
||||
{record?.archived ? (
|
||||
<Button icon={<ToTopOutlined />} onClick={() => archiveSpool(record, false)}>
|
||||
{t("buttons.unArchive")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button danger icon={<InboxOutlined />} onClick={() => archiveSpoolPopup(record)}>
|
||||
{t("buttons.archive")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{defaultButtons}
|
||||
</>
|
||||
)}
|
||||
|
||||
11
client/src/pages/vendors/edit.tsx
vendored
11
client/src/pages/vendors/edit.tsx
vendored
@@ -113,6 +113,17 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("vendor.fields.external_id")}
|
||||
name={["external_id"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
|
||||
{extraFields.data?.map((field, index) => (
|
||||
<ExtraFieldFormItem key={index} field={field} />
|
||||
|
||||
@@ -30,7 +30,8 @@ export function formatNumberWithSpaceSeparator(input: string): string {
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export function numberFormatter(value: number | undefined): string {
|
||||
export function numberFormatter(value: number | string | undefined): string {
|
||||
console.log("numberformatter input: ", value);
|
||||
const formattedValue = value
|
||||
? Number(value).toLocaleString(undefined, {
|
||||
useGrouping: false, // Disable thousands separator and do it manually instead so it's always spaces
|
||||
@@ -45,7 +46,7 @@ export function numberFormatter(value: number | undefined): string {
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export function numberParser(value: string | undefined) {
|
||||
export function numberParser(value: string | undefined): number {
|
||||
// Convert comma to dot
|
||||
value = value?.replace(",", ".");
|
||||
|
||||
@@ -56,6 +57,28 @@ export function numberParser(value: string | undefined) {
|
||||
return parseFloat(value || "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Number parser that supports both comma and dot as decimal separator
|
||||
* Same as numberParser but allows empty values. numberParser will always return a valid number
|
||||
* this one returns an empty string if the value is empty
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export function numberParserAllowEmpty(value: string | undefined): number | string {
|
||||
// Convert comma to dot
|
||||
value = value?.replace(",", ".");
|
||||
|
||||
// Remove all non-digit characters
|
||||
value = value?.replace(/[^\d.-]/g, "");
|
||||
|
||||
if (value === "" || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Parse as float
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich text with links
|
||||
* @param text
|
||||
@@ -97,7 +120,8 @@ export function formatWeight(weightInGrams: number, precision: number = 2): stri
|
||||
const kilograms = (weightInGrams / 1000).toFixed(precision).replace(/\.?0+$/, ""); // Remove trailing zeros
|
||||
return `${kilograms} kg`;
|
||||
} else {
|
||||
return `${weightInGrams} g`;
|
||||
const grams = weightInGrams.toFixed(precision).replace(/\.?0+$/, ""); // Remove trailing zeros
|
||||
return `${grams} g`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
[project]
|
||||
name = "spoolman"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
description = "A web service that keeps track of 3D printing spools."
|
||||
authors = [
|
||||
{ name = "Donkie", email = "daniel.cf.hultgren@gmail.com" },
|
||||
]
|
||||
dependencies = [
|
||||
"uvicorn~=0.29.0",
|
||||
"httptools>=0.5.0; platform_machine != \"armv7l\"",
|
||||
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; platform_machine != \"armv7l\" and sys_platform != \"win32\" and (sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\")",
|
||||
"fastapi~=0.110.0",
|
||||
"uvicorn~=0.32.1",
|
||||
"httptools>=0.6.4; platform_machine != \"armv7l\"",
|
||||
"uvloop!=0.15.0,!=0.15.1,>=0.21.0; platform_machine != \"armv7l\" and sys_platform != \"win32\" and (sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\")",
|
||||
"fastapi~=0.115.5",
|
||||
"SQLAlchemy[aiomysql,aiosqlite,asyncio,postgresql_asyncpg]~=2.0",
|
||||
"pydantic~=2.7.1",
|
||||
"platformdirs~=4.2.2",
|
||||
"alembic~=1.13.1",
|
||||
"scheduler~=0.8.5",
|
||||
"sqlalchemy-cockroachdb~=2.0",
|
||||
"asyncpg~=0.27",
|
||||
"pydantic~=2.10.2",
|
||||
"platformdirs~=4.3.6",
|
||||
"alembic~=1.14.0",
|
||||
"scheduler~=0.8.7",
|
||||
"sqlalchemy-cockroachdb~=2.0.2",
|
||||
"asyncpg~=0.30",
|
||||
"psycopg2-binary~=2.9",
|
||||
"setuptools~=70.0.0",
|
||||
"WebSockets~=12.0",
|
||||
"prometheus-client~=0.20.0",
|
||||
"httpx~=0.27.0",
|
||||
"hishel~=0.0.26",
|
||||
"WebSockets~=14.1",
|
||||
"prometheus-client~=0.21.0",
|
||||
"httpx~=0.28.0",
|
||||
"hishel~=0.1.1",
|
||||
]
|
||||
requires-python = ">=3.9,<=3.12"
|
||||
|
||||
@@ -36,7 +36,7 @@ dev = [
|
||||
"pre-commit~=3.7.1",
|
||||
"pytest~=8.2.1",
|
||||
"pytest-asyncio~=0.23.7",
|
||||
"httpx~=0.27.0",
|
||||
"httpx~=0.28.0",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts.docs]
|
||||
|
||||
85
spoolman/api/v1/export.py
Normal file
85
spoolman/api/v1/export.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Functions for exporting data."""
|
||||
|
||||
import io
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import filament, spool, vendor
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.models import Base
|
||||
from spoolman.export import dump_as_csv, dump_as_json
|
||||
|
||||
# ruff: noqa: D103,B008
|
||||
router = APIRouter(
|
||||
prefix="/export",
|
||||
tags=["export"],
|
||||
)
|
||||
|
||||
|
||||
class ExportFormat(Enum):
|
||||
CSV = "csv"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spools",
|
||||
name="Export spools",
|
||||
description="Export the list of spools in various formats. Filament and vendor data is included.",
|
||||
)
|
||||
async def export_spools(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
fmt: ExportFormat,
|
||||
) -> Response:
|
||||
|
||||
all_spools, _ = await spool.find(db=db)
|
||||
return await _export(all_spools, fmt)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filaments",
|
||||
name="Export filaments",
|
||||
description="Export the list of filaments in various formats. Vendor data is included.",
|
||||
)
|
||||
async def export_filaments(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
fmt: ExportFormat,
|
||||
) -> Response:
|
||||
all_filaments, _ = await filament.find(db=db)
|
||||
return await _export(all_filaments, fmt)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors",
|
||||
name="Export vendors",
|
||||
description="Export the list of vendors in various formats.",
|
||||
)
|
||||
async def export_vendors(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
fmt: ExportFormat,
|
||||
) -> Response:
|
||||
all_vendors, _ = await vendor.find(db=db)
|
||||
return await _export(all_vendors, fmt)
|
||||
|
||||
|
||||
async def _export(objects: Iterable[Base], fmt: ExportFormat) -> Response:
|
||||
"""Export the objects in various formats."""
|
||||
buffer = io.StringIO()
|
||||
media_type = ""
|
||||
|
||||
if fmt == ExportFormat.CSV:
|
||||
media_type = "text/csv"
|
||||
await dump_as_csv(objects, buffer)
|
||||
elif fmt == ExportFormat.JSON:
|
||||
media_type = "application/json"
|
||||
await dump_as_json(objects, buffer)
|
||||
else:
|
||||
raise ValueError(f"Unknown export format: {fmt}")
|
||||
|
||||
return Response(content=buffer.getvalue(), media_type=media_type)
|
||||
@@ -60,7 +60,7 @@ class Vendor(BaseModel):
|
||||
)
|
||||
empty_spool_weight: Optional[float] = Field(
|
||||
None,
|
||||
gt=0,
|
||||
ge=0,
|
||||
description="The empty spool weight, in grams.",
|
||||
examples=[140],
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import filament, spool
|
||||
@@ -123,3 +124,25 @@ async def find_locations(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> list[str]:
|
||||
return await spool.find_locations(db=db)
|
||||
|
||||
|
||||
class RenameLocationBody(BaseModel):
|
||||
name: str = Field(description="The new name of the location.", min_length=1)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/location/{location}",
|
||||
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,
|
||||
response_model=RootModel[str],
|
||||
)
|
||||
async def rename_location(
|
||||
location: str,
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
body: RenameLocationBody,
|
||||
) -> str:
|
||||
logger.info("Renaming location %s to %s", location, body.name)
|
||||
await spool.rename_location(db=db, current_name=location, new_name=body.name)
|
||||
return body.name
|
||||
|
||||
@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
from spoolman.ws import websocket_manager
|
||||
|
||||
from . import externaldb, field, filament, models, other, setting, spool, vendor
|
||||
from . import export, externaldb, field, filament, models, other, setting, spool, vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,3 +111,4 @@ app.include_router(setting.router)
|
||||
app.include_router(field.router)
|
||||
app.include_router(other.router)
|
||||
app.include_router(externaldb.router)
|
||||
app.include_router(export.router)
|
||||
|
||||
@@ -144,7 +144,10 @@ async def find(
|
||||
elif order == SortOrder.DESC:
|
||||
stmt = stmt.order_by(field.desc())
|
||||
|
||||
rows = await db.execute(stmt)
|
||||
rows = await db.execute(
|
||||
stmt,
|
||||
execution_options={"populate_existing": True},
|
||||
)
|
||||
result = list(rows.unique().scalars().all())
|
||||
if total_count is None:
|
||||
total_count = len(result)
|
||||
|
||||
@@ -198,7 +198,10 @@ async def find( # noqa: C901, PLR0912
|
||||
elif order == SortOrder.DESC:
|
||||
stmt = stmt.order_by(*(f.desc() for f in sorts))
|
||||
|
||||
rows = await db.execute(stmt)
|
||||
rows = await db.execute(
|
||||
stmt,
|
||||
execution_options={"populate_existing": True},
|
||||
)
|
||||
result = list(rows.unique().scalars().all())
|
||||
if total_count is None:
|
||||
total_count = len(result)
|
||||
@@ -459,3 +462,15 @@ async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -
|
||||
await db.commit()
|
||||
await spool_changed(spool, EventType.UPDATED)
|
||||
return spool
|
||||
|
||||
|
||||
async def rename_location(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
current_name: str,
|
||||
new_name: str,
|
||||
) -> None:
|
||||
"""Rename all spools with the current location name to the new name."""
|
||||
await db.execute(
|
||||
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name),
|
||||
)
|
||||
|
||||
@@ -83,7 +83,10 @@ async def find(
|
||||
elif order == SortOrder.DESC:
|
||||
stmt = stmt.order_by(field.desc())
|
||||
|
||||
rows = await db.execute(stmt)
|
||||
rows = await db.execute(
|
||||
stmt,
|
||||
execution_options={"populate_existing": True},
|
||||
)
|
||||
result = list(rows.unique().scalars().all())
|
||||
if total_count is None:
|
||||
total_count = len(result)
|
||||
|
||||
66
spoolman/export.py
Normal file
66
spoolman/export.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Functionality for exporting data in various format."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from spoolman.database import models
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import SupportsWrite
|
||||
|
||||
banned_attrs = {"awaitable_attrs", "metadata", "registry", "spools", "filaments"}
|
||||
|
||||
|
||||
async def flatten_sqlalchemy_object(obj: models.Base, parent_key: str = "", sep: str = ".") -> dict[str, Any]:
|
||||
"""Recursively flattens a SQLAlchemy object into a dictionary with dot-separated keys."""
|
||||
fields = {}
|
||||
for attr in dir(obj):
|
||||
# Check if the attribute is a column or a relationship
|
||||
if not attr.startswith("_") and attr not in banned_attrs:
|
||||
value = await getattr(obj.awaitable_attrs, attr)
|
||||
|
||||
if attr == "extra":
|
||||
# Handle extra fields
|
||||
for v in value:
|
||||
fields[f"{parent_key}extra.{v.key}"] = v.value
|
||||
continue
|
||||
|
||||
# Handle nested SQLAlchemy objects
|
||||
if isinstance(value, models.Base):
|
||||
nested_fields = await flatten_sqlalchemy_object(value, f"{parent_key}{attr}{sep}", sep=sep)
|
||||
fields.update(nested_fields)
|
||||
else:
|
||||
# Use only columns and simple data types
|
||||
fields[f"{parent_key}{attr}"] = value
|
||||
return fields
|
||||
|
||||
|
||||
async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
|
||||
"""Export a list of objects as CSV to a writer. Nested objects are flattened with dot-separated keys."""
|
||||
# Flatten each object and get all column names
|
||||
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
|
||||
|
||||
# Collect all unique headers across flattened objects
|
||||
headers = set()
|
||||
for flattened_obj in all_flattened:
|
||||
headers.update(flattened_obj.keys())
|
||||
|
||||
headers = sorted(headers) # Sort headers for consistent column ordering
|
||||
|
||||
# Write to CSV
|
||||
csv_writer = csv.DictWriter(writer, fieldnames=headers)
|
||||
csv_writer.writeheader()
|
||||
for flattened_obj in all_flattened:
|
||||
csv_writer.writerow(flattened_obj)
|
||||
|
||||
|
||||
async def dump_as_json(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
|
||||
"""Export a list of objects as JSON to a writer. Nested objects are flattened with dot-separated keys."""
|
||||
# Flatten each object and get all column names
|
||||
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
|
||||
|
||||
# Write to JSON
|
||||
json.dump(all_flattened, writer, default=str)
|
||||
@@ -24,10 +24,23 @@ console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(logging.Formatter("%(name)-26s %(levelname)-8s %(message)s"))
|
||||
|
||||
# Setup the spoolman logger, which all spoolman modules will use
|
||||
log_level = env.get_logging_level()
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(env.get_logging_level())
|
||||
root_logger.setLevel(log_level)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Fix uvicorn logging
|
||||
logging.getLogger("uvicorn").setLevel(log_level)
|
||||
logging.getLogger("uvicorn").removeHandler(logging.getLogger("uvicorn").handlers[0])
|
||||
logging.getLogger("uvicorn").addHandler(console_handler)
|
||||
|
||||
logging.getLogger("uvicorn.error").setLevel(log_level)
|
||||
logging.getLogger("uvicorn.error").addHandler(console_handler)
|
||||
|
||||
logging.getLogger("uvicorn.access").setLevel(log_level)
|
||||
logging.getLogger("uvicorn.access").removeHandler(logging.getLogger("uvicorn.access").handlers[0])
|
||||
logging.getLogger("uvicorn.access").addHandler(console_handler)
|
||||
|
||||
# Get logger instance for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -68,3 +68,6 @@ register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("base_url", SettingType.STRING, json.dumps(""))
|
||||
|
||||
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||
|
||||
Reference in New Issue
Block a user