mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-21 17:22:28 +01:00
Compare commits
59 Commits
copilot/wr
...
db_convert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c3e4d7880 | ||
|
|
aefb69c51e | ||
|
|
300ee33be2 | ||
|
|
ddbfc87ce1 | ||
|
|
3454fa51de | ||
|
|
343ad6beff | ||
|
|
d385303a52 | ||
|
|
00b35e3306 | ||
|
|
e0a25009d9 | ||
|
|
3f0e4b09ce | ||
|
|
96a37a0cb0 | ||
|
|
2157916e9b | ||
|
|
be35c36c58 | ||
|
|
7116c2ceb9 | ||
|
|
c1d4ce77db | ||
|
|
bba3bd90a9 | ||
|
|
eaaf3ac75c | ||
|
|
8957e55a9e | ||
|
|
a232671302 | ||
|
|
5a53423594 | ||
|
|
390206f529 | ||
|
|
74862c7bb8 | ||
|
|
0e61a84ea6 | ||
|
|
3e380f82d2 | ||
|
|
a5d7a5f1d3 | ||
|
|
876cfc0375 | ||
|
|
641c8388c1 | ||
|
|
2f580c92d1 | ||
|
|
402edf096d | ||
|
|
f467002619 | ||
|
|
98b8c5b788 | ||
|
|
e0feda4e46 | ||
|
|
9565a9d548 | ||
|
|
b457298152 | ||
|
|
319ac406a8 | ||
|
|
065396d1e9 | ||
|
|
15243dbcc8 | ||
|
|
e1090d46e3 | ||
|
|
8d903c9586 | ||
|
|
39ff4f81c0 | ||
|
|
c60b406157 | ||
|
|
a66a1b1c33 | ||
|
|
b1bf70c531 | ||
|
|
5ab31a84e4 | ||
|
|
fb51548ecc | ||
|
|
061bd9fd10 | ||
|
|
0ac23cdf21 | ||
|
|
6fcdc0b0c3 | ||
|
|
60ff727896 | ||
|
|
225e347c24 | ||
|
|
fb805e2e0a | ||
|
|
8548237522 | ||
|
|
77819af9a8 | ||
|
|
68217f50c4 | ||
|
|
d42f728fad | ||
|
|
b1210bc3b5 | ||
|
|
045362de0e | ||
|
|
6a5039326c | ||
|
|
bee1542cce |
@@ -26,6 +26,28 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
|
||||
composer install --prefer-dist --no-progress --no-interaction
|
||||
fi
|
||||
|
||||
# Install additional composer packages if COMPOSER_EXTRA_PACKAGES is set
|
||||
if [ -n "$COMPOSER_EXTRA_PACKAGES" ]; then
|
||||
echo "Installing additional composer packages: $COMPOSER_EXTRA_PACKAGES"
|
||||
# Note: COMPOSER_EXTRA_PACKAGES is intentionally not quoted to allow word splitting
|
||||
# This enables passing multiple package names separated by spaces
|
||||
# shellcheck disable=SC2086
|
||||
composer require $COMPOSER_EXTRA_PACKAGES --no-install --no-interaction --no-progress
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Running composer install to install packages without dev dependencies..."
|
||||
composer install --no-dev --no-interaction --no-progress --optimize-autoloader
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Successfully installed additional composer packages"
|
||||
else
|
||||
echo "Failed to install composer dependencies"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Failed to add additional composer packages to composer.json"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -q ^DATABASE_URL= .env; then
|
||||
echo "Waiting for database to be ready..."
|
||||
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
|
||||
|
||||
@@ -39,6 +39,28 @@ if [ -d /var/www/html/var/db ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install additional composer packages if COMPOSER_EXTRA_PACKAGES is set
|
||||
if [ -n "$COMPOSER_EXTRA_PACKAGES" ]; then
|
||||
echo "Installing additional composer packages: $COMPOSER_EXTRA_PACKAGES"
|
||||
# Note: COMPOSER_EXTRA_PACKAGES is intentionally not quoted to allow word splitting
|
||||
# This enables passing multiple package names separated by spaces
|
||||
# shellcheck disable=SC2086
|
||||
sudo -E -u www-data composer require $COMPOSER_EXTRA_PACKAGES --no-install --no-interaction --no-progress
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Running composer install to install packages without dev dependencies..."
|
||||
sudo -E -u www-data composer install --no-dev --no-interaction --no-progress --optimize-autoloader
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Successfully installed additional composer packages"
|
||||
else
|
||||
echo "Failed to install composer dependencies"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Failed to add additional composer packages to composer.json"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
|
||||
php-fpmPHP_VERSION -F &
|
||||
|
||||
|
||||
8
.github/workflows/assets_artifact_build.yml
vendored
8
.github/workflows/assets_artifact_build.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
@@ -80,13 +80,13 @@ jobs:
|
||||
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
||||
|
||||
- name: Upload assets artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Only dependencies and built assets
|
||||
path: /tmp/partdb_assets.zip
|
||||
|
||||
- name: Upload full artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Full Part-DB including dependencies and built assets
|
||||
path: /tmp/partdb_with_assets.zip
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/type-info": "7.4.0",
|
||||
"symfony/ux-translator": "^2.10",
|
||||
"symfony/ux-turbo": "^2.0",
|
||||
"symfony/validator": "7.4.*",
|
||||
|
||||
1405
composer.lock
generated
1405
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -35,4 +35,4 @@ knpu_oauth2_client:
|
||||
provider_options:
|
||||
urlAuthorize: 'https://identity.nexar.com/connect/authorize'
|
||||
urlAccessToken: 'https://identity.nexar.com/connect/token'
|
||||
urlResourceOwnerDetails: ''
|
||||
urlResourceOwnerDetails: ''
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
framework:
|
||||
default_locale: 'en'
|
||||
# Just enable the locales we need for performance reasons.
|
||||
enabled_locale: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl']
|
||||
enabled_locale: '%partdb.locale_menu%'
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
fallbacks:
|
||||
|
||||
3281
config/reference.php
3281
config/reference.php
File diff suppressed because it is too large
Load Diff
@@ -33,8 +33,8 @@ services:
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/DataFixtures/'
|
||||
- '../src/Doctrine/Purger/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Helpers/'
|
||||
|
||||
# controllers are imported separately to make sure services can be injected
|
||||
# as action arguments even if you don't extend any base controller class
|
||||
@@ -274,21 +274,12 @@ services:
|
||||
tags:
|
||||
- { name: monolog.processor }
|
||||
|
||||
App\Doctrine\Purger\ResetAutoIncrementPurgerFactory:
|
||||
tags:
|
||||
- { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' }
|
||||
|
||||
when@test: &test
|
||||
services:
|
||||
|
||||
App\DataFixtures\:
|
||||
resource: '../src/DataFixtures/'
|
||||
autoconfigure: true
|
||||
autowire: true
|
||||
|
||||
App\Doctrine\Purger\:
|
||||
resource: '../src/Doctrine/Purger/'
|
||||
|
||||
App\Doctrine\Purger\ResetAutoIncrementPurgerFactory:
|
||||
tags:
|
||||
- { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' }
|
||||
|
||||
# Decorate the doctrine fixtures load command to use our custom purger by default
|
||||
doctrine.fixtures_load_command.custom:
|
||||
decorates: doctrine.fixtures_load_command
|
||||
@@ -297,6 +288,3 @@ when@test: &test
|
||||
- '@doctrine.fixtures.loader'
|
||||
- '@doctrine'
|
||||
- { default: '@App\Doctrine\Purger\DoNotUsePurgerFactory' }
|
||||
|
||||
when@dev:
|
||||
*test
|
||||
|
||||
@@ -50,10 +50,7 @@ A part entity has many fields, which can be used to describe it better. Most of
|
||||
* **Mass**: The mass of a single piece of this part (so of a single transistor). Given in grams.
|
||||
* **Internal Part Number** (IPN): Each part is automatically assigned a numerical ID that identifies a part in the
|
||||
database. This ID depends on when a part was created and cannot be changed. If you want to assign your own unique
|
||||
identifiers, or sync parts identifiers with the identifiers of another database, you can use this field. Part-DB
|
||||
can automatically suggest IPNs based on category prefixes and sequential numbering. See the
|
||||
[IPN Generation documentation]({% link usage/ipn_generation.md %}) for detailed information on how to set up and use
|
||||
this feature.
|
||||
identifiers, or sync parts identifiers with the identifiers of another database, you can use this field.
|
||||
|
||||
### Stock / Part lot
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ It is installed on a web server and so can be accessed with any browser without
|
||||
* Inventory management of your electronic parts. Each part can be assigned to a category, footprint, manufacturer,
|
||||
and multiple store locations and price information. Parts can be grouped using tags. You can associate various files
|
||||
like datasheets or pictures with the parts.
|
||||
* Automatic Internal Part Number (IPN) generation with customizable prefixes and numbering schemes (see [IPN documentation]({% link usage/ipn_generation.md %}))
|
||||
* Synonym system to customize terminology throughout the application (see [Synonyms documentation]({% link usage/synonyms.md %}))
|
||||
* Multi-language support (currently German, English, Russian, Japanese, French, Czech, Danish, and Chinese)
|
||||
* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the built-in barcode scanner
|
||||
* User system with groups and detailed (fine-grained) permissions.
|
||||
|
||||
@@ -21,8 +21,8 @@ differences between them, which might be important for you. Therefore the pros a
|
||||
are listed here.
|
||||
|
||||
{: .important }
|
||||
You have to choose between the database types before you start using Part-DB and **you can not change it (easily) after
|
||||
you have started creating data**. So you should choose the database type for your use case (and possible future uses).
|
||||
While you can change the database platform later (see below), it is still experimental and not recommended.
|
||||
So you should choose the database type for your use case (and possible future uses).
|
||||
|
||||
## Comparison
|
||||
|
||||
@@ -180,3 +180,23 @@ and it is automatically used if available.
|
||||
For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicitly enabled by setting the
|
||||
`DATABASE_EMULATE_NATURAL_SORT` environment variable to `1`. If it is 0 the classical binary sorting is used, on these databases. The emulations
|
||||
might have some quirks and issues, so it is recommended to use a database which supports natural sorting natively, if you want to use it.
|
||||
|
||||
## Converting between database platforms
|
||||
|
||||
{: .important }
|
||||
The database conversion is still experimental. Therefore it is recommended to backup your database before performing a conversion, and check if everything works as expected afterwards.
|
||||
|
||||
If you want to change the database platform of your Part-DB installation (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa), there is the `partdb:migrations:convert-db-platform` console command, which can help you with that:
|
||||
|
||||
1. Make a backup of your current database to be safe if something goes wrong (see the backup documentation).
|
||||
2. Ensure that your database is at the latest schema by running the migrations: `php bin/console doctrine:migrations:migrate`
|
||||
3. Change the `DATABASE_URL` environment variable to the new database platform and connection information. Copy the old `DATABASE_URL` as you will need it later.
|
||||
4. Run `php bin/console doctrine:migrations:migrate` again to create the database schema in the new database. You will not need the admin password, that is shown when running the migrations.
|
||||
5. Run the conversion command, where you have to provide the old `DATABASE_URL` as parameter: `php bin/console partdb:migrations:convert-db-platform <OLD_DATABASE_URL>`
|
||||
Replace `<OLD_DATABASE_URL` with the actual old `DATABASE_URL` value (e.g. `sqlite:///%kernel.project_dir%/var/app.db`):
|
||||
```bash
|
||||
php bin/console partdb:migrations:convert-db-platform sqlite:///%kernel.project_dir%/var/app.db
|
||||
```
|
||||
6. The command will purge all data in the new database and copy all data from the old database to the new one. This might take some time and memory depending on the size of your database.
|
||||
7. Clear the cache: `php bin/console partdb:cache:clear`
|
||||
8. You can login with your existing user accounts in the new database now. Check if everything works as expected.
|
||||
|
||||
@@ -15,13 +15,75 @@ To make emails work you have to properly configure a mail provider in Part-DB.
|
||||
## Configuration
|
||||
|
||||
Part-DB uses [Symfony Mailer](https://symfony.com/doc/current/mailer.html) to send emails, which supports multiple
|
||||
automatic mail providers (like MailChimp or SendGrid). If you want to use one of these providers, check the Symfony
|
||||
mail providers (like Mailgun, SendGrid, or Brevo). If you want to use one of these providers, check the Symfony
|
||||
Mailer documentation for more information.
|
||||
|
||||
We will only cover the configuration of an SMTP provider here, which is sufficient for most use-cases.
|
||||
You will need an email account, which you can use to send emails from via password-based SMTP authentication, this account
|
||||
should be dedicated to Part-DB.
|
||||
|
||||
### Using specialized mail providers (Mailgun, SendGrid, etc.)
|
||||
|
||||
If you want to use a specialized mail provider like Mailgun, SendGrid, Brevo (formerly Sendinblue), Amazon SES, or
|
||||
Postmark instead of SMTP, you need to install the corresponding Symfony mailer package first.
|
||||
|
||||
#### Docker installation
|
||||
|
||||
If you are using Part-DB in Docker, you can install additional mailer packages by setting the `COMPOSER_EXTRA_PACKAGES`
|
||||
environment variable in your `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer
|
||||
- MAILER_DSN=mailgun+api://API_KEY:DOMAIN@default
|
||||
- EMAIL_SENDER_EMAIL=noreply@yourdomain.com
|
||||
- EMAIL_SENDER_NAME=Part-DB
|
||||
- ALLOW_EMAIL_PW_RESET=1
|
||||
```
|
||||
|
||||
You can install multiple packages by separating them with spaces:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
```
|
||||
|
||||
The packages will be installed automatically when the container starts.
|
||||
|
||||
Common mailer packages:
|
||||
- `symfony/mailgun-mailer` - For [Mailgun](https://www.mailgun.com/)
|
||||
- `symfony/sendgrid-mailer` - For [SendGrid](https://sendgrid.com/)
|
||||
- `symfony/brevo-mailer` - For [Brevo](https://www.brevo.com/) (formerly Sendinblue)
|
||||
- `symfony/amazon-mailer` - For [Amazon SES](https://aws.amazon.com/ses/)
|
||||
- `symfony/postmark-mailer` - For [Postmark](https://postmarkapp.com/)
|
||||
|
||||
#### Direct installation (non-Docker)
|
||||
|
||||
If you have installed Part-DB directly on your server (not in Docker), you need to manually install the required
|
||||
mailer package using composer.
|
||||
|
||||
Navigate to your Part-DB installation directory and run:
|
||||
|
||||
```bash
|
||||
# Install the package as the web server user
|
||||
sudo -u www-data composer require symfony/mailgun-mailer
|
||||
|
||||
# Clear the cache
|
||||
sudo -u www-data php bin/console cache:clear
|
||||
```
|
||||
|
||||
Replace `symfony/mailgun-mailer` with the package you need. You can install multiple packages at once:
|
||||
|
||||
```bash
|
||||
sudo -u www-data composer require symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
```
|
||||
|
||||
After installing the package, configure the `MAILER_DSN` in your `.env.local` file according to the provider's
|
||||
documentation (see [Symfony Mailer documentation](https://symfony.com/doc/current/mailer.html) for DSN format for
|
||||
each provider).
|
||||
|
||||
## SMTP Configuration
|
||||
|
||||
To configure the SMTP provider, you have to set the following environment variables:
|
||||
|
||||
`MAILER_DSN`: You have to provide the SMTP server address and the credentials for the email account here. The format is
|
||||
|
||||
@@ -80,7 +80,11 @@ services:
|
||||
#- BANNER=This is a test banner<br>with a line break
|
||||
|
||||
# If you use a reverse proxy in front of Part-DB, you must configure the trusted proxies IP addresses here (see reverse proxy documentation for more information):
|
||||
# - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
# - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
|
||||
# The packages will be installed automatically when the container starts
|
||||
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
```
|
||||
|
||||
4. Customize the settings by changing the environment variables (or adding new ones). See [Configuration]({% link
|
||||
@@ -149,6 +153,9 @@ services:
|
||||
# Override value if you want to show a given text on homepage.
|
||||
# When this is commented out the webUI can be used to configure the banner
|
||||
#- BANNER=This is a test banner<br>with a line break
|
||||
|
||||
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
|
||||
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
|
||||
database:
|
||||
container_name: partdb_database
|
||||
@@ -169,6 +176,38 @@ services:
|
||||
|
||||
```
|
||||
|
||||
### Installing additional composer packages
|
||||
|
||||
If you need to use specific mailer transports or other functionality that requires additional composer packages, you can
|
||||
install them automatically at container startup using the `COMPOSER_EXTRA_PACKAGES` environment variable.
|
||||
|
||||
For example, if you want to use Mailgun as your email provider, you need to install the `symfony/mailgun-mailer` package.
|
||||
Add the following to your docker-compose.yaml environment section:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer
|
||||
- MAILER_DSN=mailgun+api://API_KEY:DOMAIN@default
|
||||
```
|
||||
|
||||
You can specify multiple packages by separating them with spaces:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
|
||||
```
|
||||
|
||||
{: .info }
|
||||
> The packages will be installed when the container starts. This may increase the container startup time on the first run.
|
||||
> The installed packages will persist in the container until it is recreated.
|
||||
|
||||
Common mailer packages you might need:
|
||||
- `symfony/mailgun-mailer` - For Mailgun email service
|
||||
- `symfony/sendgrid-mailer` - For SendGrid email service
|
||||
- `symfony/brevo-mailer` - For Brevo (formerly Sendinblue) email service
|
||||
- `symfony/amazon-mailer` - For Amazon SES email service
|
||||
- `symfony/postmark-mailer` - For Postmark email service
|
||||
|
||||
### Update Part-DB
|
||||
|
||||
You can update Part-DB by pulling the latest image and restarting the container.
|
||||
|
||||
@@ -50,6 +50,21 @@ docker-compose logs -f
|
||||
|
||||
Please include the error logs in your issue on GitHub, if you open an issue.
|
||||
|
||||
## KiCad Integration Issues
|
||||
|
||||
### "API responded with error code: 0: Unknown"
|
||||
|
||||
If you get this error when trying to connect KiCad to Part-DB, it is most likely caused by KiCad not trusting your SSL/TLS certificate.
|
||||
|
||||
**Cause:** KiCad does not trust self-signed SSL/TLS certificates.
|
||||
|
||||
**Solutions:**
|
||||
- Use HTTP instead of HTTPS for the `root_url` in your KiCad library configuration (only recommended for local networks)
|
||||
- Use a certificate from a trusted Certificate Authority (CA) like [Let's Encrypt](https://letsencrypt.org/)
|
||||
- Add your self-signed certificate to the system's trusted certificate store on the computer running KiCad (the exact steps depend on your operating system)
|
||||
|
||||
For more information about KiCad integration, see the [EDA / KiCad integration](../usage/eda_integration.md) documentation.
|
||||
|
||||
## Report Issue
|
||||
|
||||
If an error occurs, or you found a bug, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-server).
|
||||
|
||||
@@ -68,6 +68,7 @@ docker exec --user=www-data partdb php bin/console cache:clear
|
||||
deleted!*
|
||||
* `settings:migrate-env-to-settings`: Migrate configuration from environment variables to the settings interface.
|
||||
The value of the environment variable is copied to the settings database, so the environment variable can be removed afterwards without losing the configuration.
|
||||
* `partdb:migrations:convert-db-platform`: Convert the database platform (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa).
|
||||
|
||||
## Database commands
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ This also allows to configure available and usable parts and their properties in
|
||||
Part-DB should be accessible from the PCs with KiCad. The URL should be stable (so no dynamically changing IP).
|
||||
You require a user account in Part-DB, which has permission to access the Part-DB API and create API tokens. Every user can have their own account, or you set up a shared read-only account.
|
||||
|
||||
{: .warning }
|
||||
> **HTTPS with Self-Signed Certificates**
|
||||
>
|
||||
> KiCad does not trust self-signed SSL/TLS certificates. If your Part-DB instance uses HTTPS with a self-signed certificate, KiCad will fail to connect and show an error like: `API responded with error code: 0: Unknown`.
|
||||
>
|
||||
> To resolve this issue, you have the following options:
|
||||
> - Use HTTP instead of HTTPS for the `root_url` (only recommended for local networks)
|
||||
> - Use a certificate from a trusted Certificate Authority (CA) like [Let's Encrypt](https://letsencrypt.org/)
|
||||
> - Add your self-signed certificate to the system's trusted certificate store on the computer running KiCad (the exact steps depend on your operating system)
|
||||
|
||||
To connect KiCad with Part-DB do the following steps:
|
||||
|
||||
1. Create an API token on the user settings page for the KiCad application and copy/save it when it is shown. Currently, KiCad can only read the Part-DB database, so a token with a read-only scope is enough.
|
||||
|
||||
@@ -260,6 +260,24 @@ This is not an official API and could break at any time. So use it at your own r
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider
|
||||
|
||||
### Buerklin
|
||||
|
||||
The Buerklin provider uses the [Buerklin API](https://www.buerklin.com/en/services/eprocurement/) to search for parts and get information.
|
||||
To use it you have to request access to the API.
|
||||
You will get an e-mail with the client ID and client secret, which you have to put in the Part-DB configuration (see below).
|
||||
|
||||
Please note that the Buerklin API is limited to 100 requests/minute per IP address and
|
||||
access to the Authentication server is limited to 10 requests/minute per IP address
|
||||
|
||||
The following env configuration options are available:
|
||||
|
||||
* `PROVIDER_BUERKLIN_CLIENT_ID`: The client ID you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_SECRET`: The client secret you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_USERNAME`: The username you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_PASSWORD`: The password you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`).
|
||||
* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`)
|
||||
|
||||
### Custom provider
|
||||
|
||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
---
|
||||
title: Internal Part Number (IPN) Generation
|
||||
layout: default
|
||||
parent: Usage
|
||||
nav_order: 12
|
||||
---
|
||||
|
||||
# Internal Part Number (IPN) Generation
|
||||
|
||||
Part-DB supports automatic generation and management of Internal Part Numbers (IPNs) for your parts. IPNs are unique identifiers that help you organize and track your inventory in a structured way.
|
||||
|
||||
1. TOC
|
||||
{:toc}
|
||||
|
||||
## What is an IPN?
|
||||
|
||||
An Internal Part Number (IPN) is a unique identifier assigned to each part in your inventory. Unlike manufacturer part numbers (MPNs), IPNs are defined and controlled by you, following your own naming conventions and organizational structure.
|
||||
|
||||
IPNs are useful for:
|
||||
- Creating a consistent numbering scheme across your entire inventory
|
||||
- Organizing parts hierarchically based on categories
|
||||
- Quickly identifying and locating parts
|
||||
- Generating barcodes for parts
|
||||
- Integrating with external systems (like EDA tools)
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
### IPN Structure
|
||||
|
||||
An IPN typically consists of several components:
|
||||
- **Prefix**: Identifies the category or type of part (e.g., "RES" for resistors, "CAP" for capacitors)
|
||||
- **Separator**: Divides different parts of the IPN (default is `-`)
|
||||
- **Number**: A sequential number that makes the IPN unique
|
||||
|
||||
Example: `RES-0001`, `CAP-IC-0042`, `MCU-ARM-1234`
|
||||
|
||||
### Category-Based IPN Prefixes
|
||||
|
||||
Categories in Part-DB can have their own IPN prefix. When creating a new part in a category, Part-DB can automatically suggest IPNs based on the category's prefix.
|
||||
|
||||
To set an IPN prefix for a category:
|
||||
1. Navigate to the category edit page
|
||||
2. Find the "Part IPN Prefix" field
|
||||
3. Enter your desired prefix (e.g., "RES", "CAP", "IC")
|
||||
|
||||
### Hierarchical Prefixes
|
||||
|
||||
Part-DB supports hierarchical IPN generation based on parent categories. For example:
|
||||
- Parent category "IC" with prefix "IC"
|
||||
- Child category "Microcontrollers" with prefix "MCU"
|
||||
- Generated IPN could be: `IC-MCU-0001`
|
||||
|
||||
This allows you to create deeply nested categorization schemes while maintaining clear IPNs.
|
||||
|
||||
## Configuring IPN Generation
|
||||
|
||||
You can configure IPN generation in the system settings under `Tools -> System -> Settings -> Miscellaneous -> IPN Suggest Settings`.
|
||||
|
||||
### Available Settings
|
||||
|
||||
#### Regex Pattern
|
||||
Define a regular expression pattern that valid IPNs must match. This helps enforce consistency across your inventory.
|
||||
|
||||
Example: `^[A-Za-z0-9]{3,4}(?:-[A-Za-z0-9]{3,4})*-\d{4}$`
|
||||
|
||||
This pattern requires:
|
||||
- 3-4 alphanumeric characters for prefixes
|
||||
- Optional additional prefix groups separated by `-`
|
||||
- Ending with a 4-digit number
|
||||
|
||||
#### Regex Help Text
|
||||
Provide custom help text that explains your IPN format to users. This text is shown when users are creating or editing parts.
|
||||
|
||||
#### Auto-Append Suffix
|
||||
When enabled, Part-DB automatically appends a suffix (`_1`, `_2`, etc.) to IPNs that would otherwise be duplicates. This prevents IPN collisions when multiple parts might generate the same IPN.
|
||||
|
||||
**Example:**
|
||||
- First part: `RES-0001`
|
||||
- Duplicate attempt: automatically becomes `RES-0001_1`
|
||||
- Next duplicate: automatically becomes `RES-0001_2`
|
||||
|
||||
#### Suggest Part Digits
|
||||
Defines how many digits should be used for the sequential part number (default: 4).
|
||||
- 4 digits: `0001` to `9999`
|
||||
- 6 digits: `000001` to `999999`
|
||||
|
||||
#### Use Duplicate Description
|
||||
When enabled, Part-DB will suggest the same IPN for parts with identical descriptions. This is useful when you want to track variants of the same component with the same IPN scheme.
|
||||
|
||||
#### Fallback Prefix
|
||||
The prefix to use when a category has no IPN prefix defined (default: `N.A.`). This ensures all parts can get an IPN suggestion even without category-specific prefixes.
|
||||
|
||||
#### Number Separator
|
||||
The character that separates the prefix from the number (default: `-`).
|
||||
|
||||
Example: With separator `-`, you get `RES-0001`. With separator `.`, you get `RES.0001`.
|
||||
|
||||
#### Category Separator
|
||||
The character that separates hierarchical category prefixes (default: `-`).
|
||||
|
||||
Example: With separator `-`, you get `IC-MCU-0001`. With separator `.`, you get `IC.MCU.0001`.
|
||||
|
||||
#### Global Prefix
|
||||
An optional prefix that is prepended to all IPNs in your system. Useful if you want to distinguish your inventory from other systems.
|
||||
|
||||
Example: With global prefix `ACME`, IPNs become `ACME-RES-0001`, `ACME-CAP-0042`, etc.
|
||||
|
||||
## Using IPN Suggestions
|
||||
|
||||
### When Creating a New Part
|
||||
|
||||
When you create a new part, Part-DB provides IPN suggestions based on:
|
||||
|
||||
1. **Global Prefix** (if configured): Suggestions using your global prefix
|
||||
2. **Description Matching** (if enabled): If another part has the same description, its IPN is suggested
|
||||
3. **Direct Category Prefix**: The IPN prefix of the part's assigned category
|
||||
4. **Hierarchical Prefixes**: IPNs combining parent category prefixes with the current category
|
||||
|
||||
Each suggestion includes:
|
||||
- The suggested IPN
|
||||
- A description of how it was generated
|
||||
- An auto-incremented version (ending with the next available number)
|
||||
|
||||
### IPN Suggestion Types
|
||||
|
||||
#### Common Prefixes
|
||||
These show just the prefix part without a number. Use these as a starting point to manually add your own number.
|
||||
|
||||
Example: `RES-` (you then type `RES-1234`)
|
||||
|
||||
#### Prefixes with Part Increment
|
||||
These show complete IPNs with automatically incremented numbers. The system finds the highest existing number with that prefix and suggests the next one.
|
||||
|
||||
Example: If `RES-0001` through `RES-0005` exist, the system suggests `RES-0006`.
|
||||
|
||||
### Manual IPN Entry
|
||||
|
||||
You can always manually enter any IPN you want. If you've configured a regex pattern, Part-DB will validate your IPN against it and show an error if it doesn't match.
|
||||
|
||||
## IPN Uniqueness
|
||||
|
||||
IPNs must be unique across your entire Part-DB instance. Part-DB enforces this constraint:
|
||||
|
||||
- When manually entering an IPN, you'll see an error if it already exists
|
||||
- When auto-append suffix is enabled, duplicate IPNs are automatically made unique
|
||||
- Existing parts retain their IPNs even if you change their category
|
||||
|
||||
## IPNs in Labels and Barcodes
|
||||
|
||||
IPNs can be used in label templates through placeholders:
|
||||
|
||||
- `[[IPN]]` - The IPN as text
|
||||
- `[[IPN_BARCODE_C39]]` - IPN as Code 39 barcode
|
||||
- `[[IPN_BARCODE_C128]]` - IPN as Code 128 barcode
|
||||
- `[[IPN_BARCODE_QR]]` - IPN as QR code
|
||||
|
||||
See the [Labels documentation]({% link usage/labels.md %}) for more information.
|
||||
|
||||
## IPNs in Barcode Scanning
|
||||
|
||||
Part-DB can scan barcodes containing IPNs to quickly find parts. When a barcode is scanned, Part-DB:
|
||||
1. Attempts to parse it as an IPN
|
||||
2. Searches for the part with that IPN
|
||||
3. Displays the part information
|
||||
|
||||
This enables quick inventory operations using barcode scanners.
|
||||
|
||||
## IPNs in EDA Integration
|
||||
|
||||
When using Part-DB with EDA tools like KiCad, the IPN is automatically added to the component fields as "Part-DB IPN". This creates a direct link between your schematic components and your Part-DB inventory.
|
||||
|
||||
See the [EDA Integration documentation]({% link usage/eda_integration.md %}) for more information.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Choosing Prefixes
|
||||
|
||||
- **Keep them short**: 2-4 characters work well (e.g., "RES", "CAP", "IC")
|
||||
- **Make them memorable**: Use abbreviations that are obvious (avoid "XYZ" or "ABC")
|
||||
- **Be consistent**: Use the same style across all categories (all caps or all lowercase)
|
||||
- **Avoid ambiguity**: Don't use similar prefixes like "IC" and "1C"
|
||||
|
||||
### Numbering Schemes
|
||||
|
||||
- **Pad with zeros**: Use leading zeros for cleaner sorting (0001, 0042 instead of 1, 42)
|
||||
- **Leave room for growth**: If you have 50 parts now, use 4 digits (up to 9999) instead of 2
|
||||
- **Don't encode information**: Let the prefix and category do the work, not the number
|
||||
- **Sequential is fine**: You don't need gaps - 0001, 0002, 0003 is perfectly valid
|
||||
|
||||
### Hierarchical Categories
|
||||
|
||||
- **Limit depth**: 2-3 levels is usually sufficient (IC-MCU vs IC-MCU-ARM-STM32)
|
||||
- **Balance specificity**: More levels = longer IPNs but more precise categorization
|
||||
- **Consider searching**: Very specific categories are harder to search across
|
||||
|
||||
### Changing Your Scheme
|
||||
|
||||
- **Plan ahead**: Changing IPN schemes later is difficult
|
||||
- **Document your convention**: Add your IPN format to your regex help text
|
||||
- **Existing parts**: Don't feel obligated to renumber existing parts if you change schemes
|
||||
- **Migration**: Use import/export to batch-update IPNs if needed
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### "IPN already exists"
|
||||
|
||||
**Problem**: You're trying to use an IPN that's already assigned to another part.
|
||||
|
||||
**Solutions**:
|
||||
- Choose a different number
|
||||
- Enable "Auto-Append Suffix" to automatically handle duplicates
|
||||
- Search for the existing part to see if it's a duplicate you should merge
|
||||
|
||||
### "IPN doesn't match regex pattern"
|
||||
|
||||
**Problem**: Your IPN doesn't follow the configured format.
|
||||
|
||||
**Solutions**:
|
||||
- Check the regex help text to understand the expected format
|
||||
- Contact your administrator if the regex is too restrictive
|
||||
- Use the suggested IPNs which are guaranteed to match
|
||||
|
||||
### Suggestions not showing
|
||||
|
||||
**Problem**: IPN suggestions are empty or not appearing.
|
||||
|
||||
**Solutions**:
|
||||
- Ensure the part has a category assigned
|
||||
- Check that the category has an IPN prefix defined
|
||||
- Verify that a fallback prefix is configured in settings
|
||||
- Save the part first before getting suggestions (for new parts)
|
||||
|
||||
### Wrong prefix being suggested
|
||||
|
||||
**Problem**: Part-DB suggests an IPN with the wrong prefix.
|
||||
|
||||
**Solutions**:
|
||||
- Check the part's category - suggestions are based on the assigned category
|
||||
- Verify parent categories and their prefixes if using hierarchical structure
|
||||
- Set the correct IPN prefix in the category settings
|
||||
- Use manual entry with your desired prefix
|
||||
|
||||
## Example Scenarios
|
||||
|
||||
### Simple Electronic Components Inventory
|
||||
|
||||
**Setup**:
|
||||
- Categories: Resistors, Capacitors, ICs, etc.
|
||||
- Prefixes: RES, CAP, IC
|
||||
- 4-digit numbering
|
||||
|
||||
**Results**:
|
||||
- `RES-0001` - 10kΩ resistor
|
||||
- `CAP-0001` - 100nF capacitor
|
||||
- `IC-0001` - ATmega328
|
||||
|
||||
### Professional Lab with Detailed Categories
|
||||
|
||||
**Setup**:
|
||||
- Hierarchical categories: Components > Passive > Resistors > Surface Mount
|
||||
- Prefixes: COMP, PAS, RES, SMD
|
||||
- Global prefix: LAB
|
||||
- 6-digit numbering
|
||||
|
||||
**Results**:
|
||||
- `LAB-COMP-PAS-RES-SMD-000001` - 0805 10kΩ resistor
|
||||
- `LAB-COMP-PAS-CAP-SMD-000001` - 0805 100nF capacitor
|
||||
|
||||
### Makerspace with Mixed Inventory
|
||||
|
||||
**Setup**:
|
||||
- Categories for electronics, mechanical parts, tools
|
||||
- Simple prefixes: ELEC, MECH, TOOL
|
||||
- Fallback prefix for miscellaneous: MISC
|
||||
- 4-digit numbering
|
||||
|
||||
**Results**:
|
||||
- `ELEC-0001` - Arduino Uno
|
||||
- `MECH-0001` - M3 screw set
|
||||
- `TOOL-0001` - Soldering iron
|
||||
- `MISC-0001` - Cable ties
|
||||
|
||||
## Environment Variables
|
||||
|
||||
IPN settings can be configured via environment variables (useful for Docker deployments):
|
||||
|
||||
- `IPN_SUGGEST_REGEX` - Override the regex pattern
|
||||
- `IPN_SUGGEST_REGEX_HELP` - Override the regex help text
|
||||
- `IPN_AUTO_APPEND_SUFFIX` - Enable/disable auto-append suffix (boolean)
|
||||
- `IPN_SUGGEST_PART_DIGITS` - Number of digits for part numbers (integer)
|
||||
- `IPN_USE_DUPLICATE_DESCRIPTION` - Enable/disable duplicate description matching (boolean)
|
||||
|
||||
Example in docker-compose.yaml:
|
||||
```yaml
|
||||
environment:
|
||||
IPN_SUGGEST_REGEX: "^[A-Z]{3}-\d{4}$"
|
||||
IPN_AUTO_APPEND_SUFFIX: "true"
|
||||
IPN_SUGGEST_PART_DIGITS: "4"
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Getting Started]({% link usage/getting_started.md %}) - Initial setup guide
|
||||
- [Concepts]({% link concepts.md %}) - Understanding Part-DB concepts
|
||||
- [Labels]({% link usage/labels.md %}) - Using IPNs in labels
|
||||
- [EDA Integration]({% link usage/eda_integration.md %}) - IPNs in electronic design tools
|
||||
@@ -1,346 +0,0 @@
|
||||
---
|
||||
title: Synonym System
|
||||
layout: default
|
||||
parent: Usage
|
||||
nav_order: 13
|
||||
---
|
||||
|
||||
# Synonym System
|
||||
|
||||
Part-DB includes a powerful synonym system that allows you to customize the terminology used throughout the application. This is especially useful when using Part-DB in contexts other than electronics, or when you want to adapt the interface to your organization's specific vocabulary.
|
||||
|
||||
1. TOC
|
||||
{:toc}
|
||||
|
||||
## What is the Synonym System?
|
||||
|
||||
The synonym system allows you to replace Part-DB's standard terminology with your own preferred terms. For example:
|
||||
- Change "Part" to "Product", "Component", or "Item"
|
||||
- Change "Category" to "Group", "Type", or "Class"
|
||||
- Change "Manufacturer" to "Supplier", "Vendor", or "Brand"
|
||||
|
||||
These custom terms (synonyms) are applied throughout the user interface, making Part-DB feel more natural for your specific use case.
|
||||
|
||||
## Important Notes
|
||||
|
||||
{: .warning-title }
|
||||
> Experimental Feature
|
||||
>
|
||||
> The synonym system is currently **experimental**. While it works in most places throughout Part-DB, there may be some locations where the default terms still appear. The synonym system is being continuously improved.
|
||||
|
||||
## Configuring Synonyms
|
||||
|
||||
To configure synonyms, you need administrator permissions:
|
||||
|
||||
1. Navigate to `Tools -> System -> Settings`
|
||||
2. Find and click on "Synonyms" in the settings menu
|
||||
3. You'll see the synonym configuration interface
|
||||
|
||||
### Adding a Synonym
|
||||
|
||||
To add a new synonym:
|
||||
|
||||
1. Click the "Add Entry" button in the synonym settings
|
||||
2. Select the **Type** (element type) you want to create a synonym for
|
||||
3. Select the **Language** for which the synonym applies
|
||||
4. Enter the **Singular** form of your synonym
|
||||
5. Enter the **Plural** form of your synonym
|
||||
6. Click "Save" to apply the changes
|
||||
|
||||
### Available Element Types
|
||||
|
||||
You can create synonyms for the following element types:
|
||||
|
||||
| Element Type | Default Term (EN) | Example Use Cases |
|
||||
|--------------------|-----------------------|-----------------------------------------------|
|
||||
| **attachment** | Attachment | Document, File, Asset |
|
||||
| **attachment_type**| Attachment Type | Document Type, File Category |
|
||||
| **category** | Category | Group, Class, Type, Collection |
|
||||
| **currency** | Currency | Monetary Unit, Money Type |
|
||||
| **footprint** | Footprint | Package, Form Factor, Physical Type |
|
||||
| **group** | Group | Team, Department, Role |
|
||||
| **label_profile** | Label Profile | Label Template, Print Template |
|
||||
| **manufacturer** | Manufacturer | Brand, Vendor, Supplier, Maker |
|
||||
| **measurement_unit**| Measurement Unit | Unit of Measure, Unit, Measurement |
|
||||
| **parameter** | Parameter | Specification, Property, Attribute |
|
||||
| **part** | Part | Component, Item, Product, Article, SKU |
|
||||
| **part_lot** | Part Lot | Stock Item, Inventory Item, Batch |
|
||||
| **project** | Project | Assembly, Build, Work Order |
|
||||
| **storage_location**| Storage Location | Warehouse, Bin, Location, Place |
|
||||
| **supplier** | Supplier | Vendor, Distributor, Reseller |
|
||||
| **user** | User | Member, Account, Person |
|
||||
|
||||
## How Synonyms Work
|
||||
|
||||
### Translation Mechanism
|
||||
|
||||
The synonym system works by integrating with Part-DB's translation system. When you define a synonym:
|
||||
|
||||
1. Part-DB creates translation placeholders for the element type
|
||||
2. These placeholders are available in both capitalized and lowercase forms
|
||||
3. The placeholders are used throughout the application where these terms appear
|
||||
|
||||
### Placeholder Format
|
||||
|
||||
Synonyms use special placeholders in translations:
|
||||
|
||||
- `[elementtype]` - Singular, lowercase (e.g., "part" → "item")
|
||||
- `[Elementtype]` - Singular, capitalized (e.g., "Part" → "Item")
|
||||
- `[[elementtype]]` - Plural, lowercase (e.g., "parts" → "items")
|
||||
- `[[Elementtype]]` - Plural, capitalized (e.g., "Parts" → "Items")
|
||||
|
||||
### Language-Specific Synonyms
|
||||
|
||||
Synonyms are language-specific, meaning you can define different terms for different languages:
|
||||
|
||||
- English users see: "Component" and "Components"
|
||||
- German users see: "Bauteil" and "Bauteile"
|
||||
- French users see: "Composant" and "Composants"
|
||||
|
||||
This allows Part-DB to maintain proper multilingual support even with custom terminology.
|
||||
|
||||
## Use Cases and Examples
|
||||
|
||||
### Non-Electronics Inventory
|
||||
|
||||
**Scenario**: Using Part-DB for a library
|
||||
|
||||
**Synonyms**:
|
||||
- Part → Book
|
||||
- Category → Genre
|
||||
- Manufacturer → Publisher
|
||||
- Supplier → Distributor
|
||||
- Storage Location → Shelf
|
||||
|
||||
**Result**: The interface now speaks library language: "Add a new Book", "Select a Genre", etc.
|
||||
|
||||
### Manufacturing Environment
|
||||
|
||||
**Scenario**: Managing production inventory
|
||||
|
||||
**Synonyms**:
|
||||
- Part → Material
|
||||
- Category → Material Type
|
||||
- Part Lot → Batch
|
||||
- Storage Location → Warehouse Zone
|
||||
- Project → Assembly
|
||||
|
||||
**Result**: The interface uses manufacturing terminology: "Materials", "Batches", "Warehouse Zones", "Assemblies"
|
||||
|
||||
### Small Business Retail
|
||||
|
||||
**Scenario**: Managing store inventory
|
||||
|
||||
**Synonyms**:
|
||||
- Part → Product
|
||||
- Category → Department
|
||||
- Manufacturer → Brand
|
||||
- Supplier → Vendor
|
||||
- Part Lot → Stock Item
|
||||
- Storage Location → Store Location
|
||||
|
||||
**Result**: The interface matches retail terminology: "Products", "Departments", "Brands"
|
||||
|
||||
### Laboratory Setting
|
||||
|
||||
**Scenario**: Managing lab supplies and chemicals
|
||||
|
||||
**Synonyms**:
|
||||
- Part → Reagent
|
||||
- Category → Substance Type
|
||||
- Manufacturer → Chemical Supplier
|
||||
- Storage Location → Cabinet
|
||||
- Part Lot → Bottle
|
||||
|
||||
**Result**: Lab-appropriate language: "Reagents", "Substance Types", "Cabinets"
|
||||
|
||||
### Educational Makerspace
|
||||
|
||||
**Scenario**: Managing shared tools and components
|
||||
|
||||
**Synonyms**:
|
||||
- Part → Resource
|
||||
- Category → Resource Type
|
||||
- Storage Location → Area
|
||||
- Project → Activity
|
||||
- Part Lot → Available Unit
|
||||
|
||||
**Result**: Educational context: "Resources", "Resource Types", "Areas", "Activities"
|
||||
|
||||
## Managing Synonyms
|
||||
|
||||
### Editing Synonyms
|
||||
|
||||
To edit an existing synonym:
|
||||
1. Find the synonym entry in the list
|
||||
2. Modify the singular or plural form as needed
|
||||
3. Click "Save" to apply changes
|
||||
|
||||
### Removing Synonyms
|
||||
|
||||
To remove a synonym:
|
||||
1. Find the synonym entry in the list
|
||||
2. Click the "Remove Entry" button (usually a trash icon)
|
||||
3. Click "Save" to apply changes
|
||||
|
||||
After removal, Part-DB will revert to using the default term for that element type and language.
|
||||
|
||||
### Bulk Configuration
|
||||
|
||||
If you need to set up many synonyms at once (e.g., for a complete custom terminology set):
|
||||
|
||||
1. Define all your synonyms in the settings page
|
||||
2. Each element type can have synonyms in multiple languages
|
||||
3. Save once when all entries are configured
|
||||
|
||||
### Duplicate Prevention
|
||||
|
||||
The system prevents duplicate entries:
|
||||
- You cannot have multiple synonyms for the same element type and language combination
|
||||
- If you try to add a duplicate, you'll see a validation error
|
||||
- Edit the existing entry instead of creating a new one
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Consistency
|
||||
|
||||
- **Use consistent terminology**: If you change "Part" to "Product", consider changing "Part Lot" to "Product Item" or similar
|
||||
- **Think holistically**: Consider how terms relate to each other in your domain
|
||||
- **Test thoroughly**: Check various pages to ensure your terms make sense in context
|
||||
|
||||
### Singular and Plural Forms
|
||||
|
||||
- **Provide both forms**: Always define both singular and plural forms
|
||||
- **Use proper grammar**: Ensure plurals are grammatically correct
|
||||
- **Consider irregular plurals**: Some terms have non-standard plurals (e.g., "Box" → "Boxes", not "Boxs")
|
||||
|
||||
### Language Considerations
|
||||
|
||||
- **Match user expectations**: Use terms your users are familiar with in their language
|
||||
- **Be culturally appropriate**: Some terms may have different connotations in different languages
|
||||
- **Maintain professionalism**: Choose terms appropriate for your organizational context
|
||||
|
||||
### Planning Your Terminology
|
||||
|
||||
Before implementing synonyms:
|
||||
|
||||
1. **List all terms**: Identify which Part-DB terms don't fit your context
|
||||
2. **Define replacements**: Decide on appropriate alternatives
|
||||
3. **Check relationships**: Ensure related terms work together logically
|
||||
4. **Get feedback**: Consult with users about proposed terminology
|
||||
5. **Document decisions**: Keep a record of your synonym choices for future reference
|
||||
|
||||
## Limitations
|
||||
|
||||
### Not All Locations Covered
|
||||
|
||||
As an experimental feature, synonyms may not appear in:
|
||||
- Some error messages
|
||||
- Technical logs
|
||||
- Email templates (depending on configuration)
|
||||
- API responses
|
||||
- Some administrative interfaces
|
||||
|
||||
The development team is working to expand synonym coverage.
|
||||
|
||||
### No Automatic Propagation
|
||||
|
||||
Synonyms only affect the user interface:
|
||||
- Database values remain unchanged
|
||||
- Export files use original terms
|
||||
- API endpoints keep original names
|
||||
- URLs and routes remain the same
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
The synonym system:
|
||||
- Caches translations for performance
|
||||
- Minimal performance impact in normal usage
|
||||
- Cache is automatically updated when synonyms change
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Cache Management
|
||||
|
||||
Synonyms are cached for performance:
|
||||
- Cache is automatically cleared when synonyms are saved
|
||||
- No manual cache clearing needed
|
||||
- Changes appear immediately after saving
|
||||
|
||||
### Translation Priority
|
||||
|
||||
When displaying text, Part-DB checks in this order:
|
||||
1. Synonym (if defined for current language and element type)
|
||||
2. Standard translation (from translation files)
|
||||
3. Fallback to English default
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Currently, synonyms can only be configured through the web interface. Future versions may support environment variable configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Synonyms Not Appearing
|
||||
|
||||
**Problem**: You've configured synonyms but still see original terms.
|
||||
|
||||
**Solutions**:
|
||||
- Clear your browser cache and reload the page
|
||||
- Check that you've configured the synonym for the correct language
|
||||
- Verify that you saved the settings after adding the synonym
|
||||
- Remember this is an experimental feature - some locations may not be covered yet
|
||||
|
||||
### Inconsistent Terminology
|
||||
|
||||
**Problem**: Some pages show your synonym, others show the original term.
|
||||
|
||||
**Solutions**:
|
||||
- This is expected behavior for the experimental feature
|
||||
- Check if you've defined both singular and plural forms
|
||||
- Report inconsistencies to help improve the system
|
||||
|
||||
### Wrong Language Displaying
|
||||
|
||||
**Problem**: Seeing synonyms from the wrong language.
|
||||
|
||||
**Solutions**:
|
||||
- Check your user language preference in user settings
|
||||
- Verify you've configured synonyms for the correct language code
|
||||
- Ensure the language code matches exactly (e.g., "en" not "en_US")
|
||||
|
||||
### Synonyms Lost After Update
|
||||
|
||||
**Problem**: Synonyms disappeared after updating Part-DB.
|
||||
|
||||
**Solutions**:
|
||||
- Check the settings page - they should still be there
|
||||
- Database migrations preserve synonym settings
|
||||
- If truly lost, restore from backup or reconfigure
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
The synonym system is under active development. Planned improvements include:
|
||||
- Coverage of more interface elements
|
||||
- Synonym suggestions based on common use cases
|
||||
- Import/export of synonym configurations
|
||||
- Synonym templates for different industries
|
||||
- More granular control over term usage
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Getting Started]({% link usage/getting_started.md %}) - Initial Part-DB setup
|
||||
- [Configuration]({% link configuration.md %}) - System configuration options
|
||||
- [Concepts]({% link concepts.md %}) - Understanding Part-DB terminology
|
||||
|
||||
## Feedback
|
||||
|
||||
Since the synonym system is experimental, feedback is valuable:
|
||||
- Report locations where synonyms don't appear
|
||||
- Suggest new element types that should support synonyms
|
||||
- Share your use cases to help improve the system
|
||||
- Report bugs or unexpected behavior
|
||||
|
||||
You can provide feedback through:
|
||||
- GitHub issues on the Part-DB repository
|
||||
- Community forums and discussions
|
||||
- Direct contact with the development team
|
||||
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command\Migrations;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\ImportExportSystem\PartKeeprImporter\PKImportHelper;
|
||||
use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
|
||||
use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
use Doctrine\ORM\Id\AssignedGenerator;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand('partdb:migrations:convert-db-platform', 'Convert the database to a different platform')]
|
||||
class DBPlatformConvertCommand extends Command
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $targetEM,
|
||||
private readonly PKImportHelper $importHelper,
|
||||
private readonly DependencyFactory $dependencyFactory,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $kernelProjectDir,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp('This command allows you to migrate the database from one database platform to another (e.g. from MySQL to PostgreSQL).')
|
||||
->addArgument('url', InputArgument::REQUIRED, 'The database connection URL of the source database to migrate from');
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$sourceEM = $this->getSourceEm($input->getArgument('url'));
|
||||
|
||||
//Check that both databases are not using the same driver
|
||||
if ($sourceEM->getConnection()->getDatabasePlatform()::class === $this->targetEM->getConnection()->getDatabasePlatform()::class) {
|
||||
$io->warning('Source and target database are using the same database platform / driver. This command is only intended to migrate between different database platforms (e.g. from MySQL to PostgreSQL).');
|
||||
if (!$io->confirm('Do you want to continue anyway?', false)) {
|
||||
$io->info('Aborting migration process.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->ensureVersionUpToDate($sourceEM);
|
||||
|
||||
$io->note('This command is still in development. If you encounter any problems, please report them to the issue tracker on GitHub.');
|
||||
$io->warning(sprintf('This command will delete all existing data in the target database "%s". Make sure that you have no important data in the database before you continue!',
|
||||
$this->targetEM->getConnection()->getDatabase() ?? 'unknown'
|
||||
));
|
||||
|
||||
//$users = $sourceEM->getRepository(User::class)->findAll();
|
||||
//dump($users);
|
||||
|
||||
$io->ask('Please type "DELETE ALL DATA" to continue.', '', function ($answer) {
|
||||
if (strtoupper($answer) !== 'DELETE ALL DATA') {
|
||||
throw new \RuntimeException('You did not type "DELETE ALL DATA"!');
|
||||
}
|
||||
return $answer;
|
||||
});
|
||||
|
||||
|
||||
// Example migration logic (to be replaced with actual migration code)
|
||||
$io->info('Starting database migration...');
|
||||
|
||||
//Disable all event listeners on target EM to avoid unwanted side effects
|
||||
$eventManager = $this->targetEM->getEventManager();
|
||||
foreach ($eventManager->getAllListeners() as $event => $listeners) {
|
||||
foreach ($listeners as $listener) {
|
||||
$eventManager->removeEventListener($event, $listener);
|
||||
}
|
||||
}
|
||||
|
||||
$io->info('Clear target database...');
|
||||
$this->importHelper->purgeDatabaseForImport($this->targetEM, ['internal', 'migration_versions']);
|
||||
|
||||
$metadata = $this->targetEM->getMetadataFactory()->getAllMetadata();
|
||||
|
||||
$io->info('Modifying entity metadata for migration...');
|
||||
//First we modify each entity metadata to have an persist cascade on all relations
|
||||
foreach ($metadata as $metadatum) {
|
||||
$entityClass = $metadatum->getName();
|
||||
$io->writeln('Modifying cascade and ID settings for entity: ' . $entityClass, OutputInterface::VERBOSITY_VERBOSE);
|
||||
|
||||
foreach ($metadatum->getAssociationNames() as $fieldName) {
|
||||
$mapping = $metadatum->getAssociationMapping($fieldName);
|
||||
$mapping->cascade = array_unique(array_merge($mapping->cascade, ['persist']));
|
||||
$mapping->fetch = ClassMetadata::FETCH_EAGER; //Avoid lazy loading issues during migration
|
||||
|
||||
$metadatum->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE);
|
||||
$metadatum->setIdGenerator(new AssignedGenerator());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$io->progressStart(count($metadata));
|
||||
|
||||
//First we migrate users to avoid foreign key constraint issues
|
||||
$io->info('Migrating users first to avoid foreign key constraint issues...');
|
||||
$this->fixUsers($sourceEM);
|
||||
|
||||
//Afterward we migrate all entities
|
||||
foreach ($metadata as $metadatum) {
|
||||
//skip all superclasses
|
||||
if ($metadatum->isMappedSuperclass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityClass = $metadatum->getName();
|
||||
|
||||
$io->note('Migrating entity: ' . $entityClass);
|
||||
|
||||
$repo = $sourceEM->getRepository($entityClass);
|
||||
$items = $repo->findAll();
|
||||
foreach ($items as $index => $item) {
|
||||
$this->targetEM->persist($item);
|
||||
}
|
||||
$this->targetEM->flush();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
|
||||
//Fix sequences / auto increment values on target database
|
||||
$io->info('Fixing sequences / auto increment values on target database...');
|
||||
$this->fixAutoIncrements($this->targetEM);
|
||||
|
||||
$io->success('Database migration completed successfully.');
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$io->info('Process took peak memory: ' . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ' MB');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a source EntityManager based on the given connection URL
|
||||
* @param string $url
|
||||
* @return EntityManagerInterface
|
||||
*/
|
||||
private function getSourceEm(string $url): EntityManagerInterface
|
||||
{
|
||||
//Replace any %kernel.project_dir% placeholders
|
||||
$url = str_replace('%kernel.project_dir%', $this->kernelProjectDir, $url);
|
||||
|
||||
$connectionFactory = new ConnectionFactory();
|
||||
$connection = $connectionFactory->createConnection(['url' => $url]);
|
||||
return new EntityManager($connection, $this->targetEM->getConfiguration());
|
||||
}
|
||||
|
||||
private function ensureVersionUpToDate(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//Ensure that target database is up to date
|
||||
$migrationStatusCalculator = $this->dependencyFactory->getMigrationStatusCalculator();
|
||||
$newMigrations = $migrationStatusCalculator->getNewMigrations();
|
||||
if (count($newMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Target database is not up to date. Please run all migrations (with doctrine:migrations:migrate) before starting the migration process.");
|
||||
}
|
||||
|
||||
$sourceDependencyLoader = DependencyFactory::fromEntityManager(new ExistingConfiguration($this->dependencyFactory->getConfiguration()), new ExistingEntityManager($sourceEM));
|
||||
$sourceMigrationStatusCalculator = $sourceDependencyLoader->getMigrationStatusCalculator();
|
||||
$sourceNewMigrations = $sourceMigrationStatusCalculator->getNewMigrations();
|
||||
if (count($sourceNewMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Source database is not up to date. Please run all migrations (with doctrine:migrations:migrate) on the source database before starting the migration process.");
|
||||
}
|
||||
}
|
||||
|
||||
private function fixUsers(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//To avoid a problem with (Column 'settings' cannot be null) in MySQL we need to migrate the user entities first
|
||||
//and fix the settings and backupCodes fields
|
||||
|
||||
$reflClass = new \ReflectionClass(User::class);
|
||||
foreach ($sourceEM->getRepository(User::class)->findAll() as $user) {
|
||||
foreach (['settings', 'backupCodes'] as $field) {
|
||||
$property = $reflClass->getProperty($field);
|
||||
if (!$property->isInitialized($user) || $property->getValue($user) === null) {
|
||||
$property->setValue($user, []);
|
||||
}
|
||||
}
|
||||
$this->targetEM->persist($user);
|
||||
}
|
||||
}
|
||||
|
||||
private function fixAutoIncrements(EntityManagerInterface $em): void
|
||||
{
|
||||
$connection = $em->getConnection();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
$connection->executeStatement(
|
||||
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences
|
||||
<<<SQL
|
||||
SELECT 'SELECT SETVAL(' ||
|
||||
quote_literal(quote_ident(PGT.schemaname) || '.' || quote_ident(S.relname)) ||
|
||||
', COALESCE(MAX(' ||quote_ident(C.attname)|| '), 1) ) FROM ' ||
|
||||
quote_ident(PGT.schemaname)|| '.'||quote_ident(T.relname)|| ';'
|
||||
FROM pg_class AS S,
|
||||
pg_depend AS D,
|
||||
pg_class AS T,
|
||||
pg_attribute AS C,
|
||||
pg_tables AS PGT
|
||||
WHERE S.relkind = 'S'
|
||||
AND S.oid = D.objid
|
||||
AND D.refobjid = T.oid
|
||||
AND D.refobjid = C.attrelid
|
||||
AND D.refobjsubid = C.attnum
|
||||
AND T.relname = PGT.tablename
|
||||
ORDER BY S.relname;
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,14 @@ abstract class BaseAdminController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
//Count how many actual new entities were created (id is null until persisted)
|
||||
$created_count = 0;
|
||||
foreach ($results as $result) {
|
||||
if (null === $result->getID()) {
|
||||
$created_count++;
|
||||
}
|
||||
}
|
||||
|
||||
//Persist valid entities to DB
|
||||
foreach ($results as $result) {
|
||||
$em->persist($result);
|
||||
@@ -373,8 +381,14 @@ abstract class BaseAdminController extends AbstractController
|
||||
$em->flush();
|
||||
|
||||
if (count($results) > 0) {
|
||||
$this->addFlash('success', t('entity.mass_creation_flash', ['%COUNT%' => count($results)]));
|
||||
$this->addFlash('success', t('entity.mass_creation_flash', ['%COUNT%' => $created_count]));
|
||||
}
|
||||
|
||||
if (count($errors)) {
|
||||
//Recreate mass creation form, so we get the updated parent list and empty lines
|
||||
$mass_creation_form = $this->createForm(MassCreationForm::class, ['entity_class' => $this->entity_class]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return $this->render($this->twig_template, [
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -41,7 +42,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
{
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
|
||||
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -79,7 +81,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
|
||||
},
|
||||
])
|
||||
|
||||
->add('partId', TextColumn::class, [
|
||||
'label' => $this->translator->trans('project.bom.part_id'),
|
||||
'visible' => true,
|
||||
'orderField' => 'part.id',
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
|
||||
},
|
||||
])
|
||||
->add('name', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.name'),
|
||||
'orderField' => 'NATSORT(part.name)',
|
||||
|
||||
@@ -104,7 +104,7 @@ final class FieldHelper
|
||||
{
|
||||
$db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
|
||||
|
||||
$key = 'field2_' . md5($field_expr);
|
||||
$key = 'field2_' . hash('xxh3', $field_expr);
|
||||
|
||||
//If we are on MySQL, we can just use the FIELD function
|
||||
if ($db_platform instanceof AbstractMySQLPlatform) {
|
||||
@@ -121,4 +121,4 @@ final class FieldHelper
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
||||
#[ORM\Column(type: Types::STRING, length: 2048, nullable: true)]
|
||||
#[Groups(['attachment:read'])]
|
||||
#[ApiProperty(example: 'http://example.com/image.jpg')]
|
||||
#[Assert\Length(2048)]
|
||||
#[Assert\Length(max: 2048)]
|
||||
protected ?string $external_path = null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,9 +50,9 @@ readonly class RegisterSynonymsAsTranslationParametersListener
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
public function getSynonymPlaceholders(): array
|
||||
public function getSynonymPlaceholders(string $locale): array
|
||||
{
|
||||
return $this->cache->get('partdb_synonym_placeholders', function (ItemInterface $item) {
|
||||
return $this->cache->get('partdb_synonym_placeholders' . '_' . $locale, function (ItemInterface $item) use ($locale) {
|
||||
$item->tag('synonyms');
|
||||
|
||||
|
||||
@@ -62,12 +62,12 @@ readonly class RegisterSynonymsAsTranslationParametersListener
|
||||
foreach (ElementTypes::cases() as $elementType) {
|
||||
//Versions with capitalized first letter
|
||||
$capitalized = ucfirst($elementType->value); //We have only ASCII element type values, so this is sufficient
|
||||
$placeholders['[' . $capitalized . ']'] = $this->typeNameGenerator->typeLabel($elementType);
|
||||
$placeholders['[[' . $capitalized . ']]'] = $this->typeNameGenerator->typeLabelPlural($elementType);
|
||||
$placeholders['[' . $capitalized . ']'] = $this->typeNameGenerator->typeLabel($elementType, $locale);
|
||||
$placeholders['[[' . $capitalized . ']]'] = $this->typeNameGenerator->typeLabelPlural($elementType, $locale);
|
||||
|
||||
//And we have lowercase versions for both
|
||||
$placeholders['[' . $elementType->value . ']'] = mb_strtolower($this->typeNameGenerator->typeLabel($elementType));
|
||||
$placeholders['[[' . $elementType->value . ']]'] = mb_strtolower($this->typeNameGenerator->typeLabelPlural($elementType));
|
||||
$placeholders['[' . $elementType->value . ']'] = mb_strtolower($this->typeNameGenerator->typeLabel($elementType, $locale));
|
||||
$placeholders['[[' . $elementType->value . ']]'] = mb_strtolower($this->typeNameGenerator->typeLabelPlural($elementType, $locale));
|
||||
}
|
||||
|
||||
return $placeholders;
|
||||
@@ -82,7 +82,7 @@ readonly class RegisterSynonymsAsTranslationParametersListener
|
||||
}
|
||||
|
||||
//Register all placeholders for synonyms
|
||||
$placeholders = $this->getSynonymPlaceholders();
|
||||
$placeholders = $this->getSynonymPlaceholders($event->getRequest()->getLocale());
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$this->translator->addGlobalParameter($key, $value);
|
||||
}
|
||||
|
||||
@@ -243,6 +243,14 @@ class StructuralDBElementRepository extends AttachmentContainingDBElementReposit
|
||||
return $result[0];
|
||||
}
|
||||
|
||||
//If the name contains category delimiters like ->, try to find the element by its full path
|
||||
if (str_contains($name, '->')) {
|
||||
$tmp = $this->getEntityByPath($name, '->');
|
||||
if (count($tmp) > 0) {
|
||||
return $tmp[count($tmp) - 1];
|
||||
}
|
||||
}
|
||||
|
||||
//If we find nothing, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class FileTypeFilterTools
|
||||
{
|
||||
$filter = trim($filter);
|
||||
|
||||
return $this->cache->get('filter_exts_'.md5($filter), function (ItemInterface $item) use ($filter) {
|
||||
return $this->cache->get('filter_exts_'.hash('xxh3', $filter), function (ItemInterface $item) use ($filter) {
|
||||
$elements = explode(',', $filter);
|
||||
$extensions = [];
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ class EntityImporter
|
||||
}
|
||||
|
||||
//Only return objects once
|
||||
return array_values(array_unique($valid_entities));
|
||||
return array_values(array_unique($valid_entities, SORT_REGULAR));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,7 +152,7 @@ class PKDatastructureImporter
|
||||
public function importPartCustomStates(array $data): int
|
||||
{
|
||||
if (!isset($data['partcustomstate'])) {
|
||||
throw new \RuntimeException('$data must contain a "partcustomstate" key!');
|
||||
return 0; //Not all PartKeepr installations have custom states
|
||||
}
|
||||
|
||||
$partCustomStateData = $data['partcustomstate'];
|
||||
|
||||
@@ -39,10 +39,10 @@ class PKImportHelper
|
||||
* Existing users and groups are not purged.
|
||||
* This is needed to avoid ID collisions.
|
||||
*/
|
||||
public function purgeDatabaseForImport(): void
|
||||
public function purgeDatabaseForImport(?EntityManagerInterface $entityManager = null, array $excluded_tables = ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']): void
|
||||
{
|
||||
//We use the ResetAutoIncrementORMPurger to reset the auto increment values of the tables. Also it normalizes table names before checking for exclusion.
|
||||
$purger = new ResetAutoIncrementORMPurger($this->em, ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']);
|
||||
$purger = new ResetAutoIncrementORMPurger($entityManager ?? $this->em, $excluded_tables);
|
||||
$purger->purge();
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,11 @@ trait PKImportHelperTrait
|
||||
|
||||
$target->addAttachment($attachment);
|
||||
$this->em->persist($attachment);
|
||||
|
||||
//If the attachment is an image, and the target has no master picture yet, set it
|
||||
if ($attachment->isPicture() && $target->getMasterPictureAttachment() === null) {
|
||||
$target->setMasterPictureAttachment($attachment);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -91,7 +91,10 @@ class PKPartImporter
|
||||
$this->setAssociationField($entity, 'partUnit', MeasurementUnit::class, $part['partUnit_id']);
|
||||
}
|
||||
|
||||
$this->setAssociationField($entity, 'partCustomState', MeasurementUnit::class, $part['partCustomState_id']);
|
||||
if (isset($part['partCustomState_id'])) {
|
||||
$this->setAssociationField($entity, 'partCustomState', MeasurementUnit::class,
|
||||
$part['partCustomState_id']);
|
||||
}
|
||||
|
||||
//Create a part lot to store the stock level and location
|
||||
$lot = new PartLot();
|
||||
|
||||
639
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
639
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class BuerklinProvider implements BatchInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Buerklin';
|
||||
|
||||
private const CACHE_TTL = 600;
|
||||
/**
|
||||
* Local in-request cache to avoid hitting the PSR cache repeatedly for the same product.
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $productCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly CacheItemPoolInterface $partInfoCache,
|
||||
private readonly BuerklinSettings $settings,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest OAuth token for the Buerklin API, or creates a new one if none is available
|
||||
* TODO: Rework this to use the OAuth token manager system in the database...
|
||||
* @return string
|
||||
*/
|
||||
private function getToken(): string
|
||||
{
|
||||
// Cache token to avoid hammering the auth server on every request
|
||||
$cacheKey = 'buerklin.oauth.token';
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
|
||||
if ($item->isHit()) {
|
||||
$token = $item->get();
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
// Buerklin OAuth2 password grant (ROPC)
|
||||
$resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => [
|
||||
'grant_type' => 'password',
|
||||
'client_id' => $this->settings->clientId,
|
||||
'client_secret' => $this->settings->secret,
|
||||
'username' => $this->settings->username,
|
||||
'password' => $this->settings->password,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $resp->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
throw new \RuntimeException(
|
||||
'Invalid token response from Buerklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false)
|
||||
);
|
||||
}
|
||||
|
||||
$token = (string) $data['access_token'];
|
||||
|
||||
// Cache for (expires_in - 30s) if available
|
||||
$ttl = 300;
|
||||
if (isset($data['expires_in']) && is_numeric($data['expires_in'])) {
|
||||
$ttl = max(60, (int) $data['expires_in'] - 30);
|
||||
}
|
||||
|
||||
$item->set($token);
|
||||
$item->expiresAfter($ttl);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function getDefaultQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'curr' => $this->settings->currency ?: 'EUR',
|
||||
'language' => $this->settings->language ?: 'en',
|
||||
];
|
||||
}
|
||||
|
||||
private function getProduct(string $code): array
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
throw new \InvalidArgumentException('Product code must not be empty.');
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'buerklin.product.%s',
|
||||
md5($code . '|' . $this->settings->language . '|' . $this->settings->currency)
|
||||
);
|
||||
|
||||
if (isset($this->productCache[$cacheKey])) {
|
||||
return $this->productCache[$cacheKey];
|
||||
}
|
||||
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
if ($item->isHit() && is_array($cached = $item->get())) {
|
||||
return $this->productCache[$cacheKey] = $cached;
|
||||
}
|
||||
|
||||
$product = $this->makeAPICall('/products/' . rawurlencode($code) . '/');
|
||||
|
||||
$item->set($product);
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $this->productCache[$cacheKey] = $product;
|
||||
}
|
||||
|
||||
private function makeAPICall(string $endpoint, array $queryParams = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [
|
||||
'auth_bearer' => $this->getToken(),
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'query' => array_merge($this->getDefaultQueryParams(), $queryParams),
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException("Buerklin API request failed: " .
|
||||
"Endpoint: " . $endpoint .
|
||||
"Token: [redacted] " .
|
||||
"QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
|
||||
"Exception message: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Buerklin',
|
||||
'description' => 'This provider uses the Buerklin API to search for parts.',
|
||||
'url' => 'https://www.buerklin.com/',
|
||||
'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.',
|
||||
'settings_class' => BuerklinSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'buerklin';
|
||||
}
|
||||
|
||||
// This provider is considered active if settings are present
|
||||
public function isActive(): bool
|
||||
{
|
||||
// The client credentials and user credentials must be set
|
||||
return $this->settings->clientId !== null && $this->settings->clientId !== ''
|
||||
&& $this->settings->secret !== null && $this->settings->secret !== ''
|
||||
&& $this->settings->username !== null && $this->settings->username !== ''
|
||||
&& $this->settings->password !== null && $this->settings->password !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a field by removing any HTML tags and other unwanted characters
|
||||
* @param string|null $field
|
||||
* @return string|null
|
||||
*/
|
||||
private function sanitizeField(?string $field): ?string
|
||||
{
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strip_tags($field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a deserialized JSON object of the product and returns a PartDetailDTO
|
||||
* @param array $product
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
private function getPartDetail(array $product): PartDetailDTO
|
||||
{
|
||||
// If this is a search-result object, it may not contain prices/features/images -> reload full details.
|
||||
if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
|
||||
try {
|
||||
$product = $this->getProduct((string) $product['code']);
|
||||
} catch (\Throwable $e) {
|
||||
// If reload fails, keep the partial product data and continue.
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from API response
|
||||
$productImages = $this->getProductImages($product['images'] ?? null);
|
||||
|
||||
// Set preview image
|
||||
$preview = $productImages[0]->url ?? null;
|
||||
|
||||
// Extract features (parameters) from classifications[0].features of Buerklin JSON response
|
||||
$features = $product['classifications'][0]['features'] ?? [];
|
||||
|
||||
// Feature parameters (from classifications->features)
|
||||
$featureParams = $this->attributesToParameters($features, ''); // leave group empty for normal parameters
|
||||
|
||||
// Compliance parameters (from top-level fields like RoHS/SVHC/…)
|
||||
$complianceParams = $this->complianceToParameters($product, 'Compliance');
|
||||
|
||||
// Merge all parameters
|
||||
$allParams = array_merge($featureParams, $complianceParams);
|
||||
|
||||
// Assign footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehäuse" (de)
|
||||
$footprint = null;
|
||||
if (is_array($features)) {
|
||||
foreach ($features as $feature) {
|
||||
$name = $feature['name'] ?? null;
|
||||
if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehäuse') {
|
||||
$footprint = $feature['featureValues'][0]['value'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prices: prefer volumePrices, fallback to single price
|
||||
$code = (string) ($product['orderNumber'] ?? $product['code'] ?? '');
|
||||
$prices = $product['volumePrices'] ?? null;
|
||||
|
||||
if (!is_array($prices) || count($prices) === 0) {
|
||||
$pVal = $product['price']['value'] ?? null;
|
||||
$pCur = $product['price']['currencyIso'] ?? ($this->settings->currency ?: 'EUR');
|
||||
|
||||
if (is_numeric($pVal)) {
|
||||
$prices = [
|
||||
[
|
||||
'minQuantity' => 1,
|
||||
'value' => (float) $pVal,
|
||||
'currencyIso' => (string) $pCur,
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$prices = [];
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: (string) ($product['code'] ?? $code),
|
||||
|
||||
name: (string) ($product['manufacturerProductId'] ?? $code),
|
||||
description: $this->sanitizeField($product['description'] ?? null),
|
||||
|
||||
category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)),
|
||||
manufacturer: $this->sanitizeField($product['manufacturer'] ?? null),
|
||||
mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null),
|
||||
|
||||
preview_image_url: $preview,
|
||||
manufacturing_status: null,
|
||||
|
||||
provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
|
||||
footprint: $footprint,
|
||||
|
||||
datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets
|
||||
images: $productImages,
|
||||
|
||||
parameters: $allParams,
|
||||
|
||||
vendor_infos: $this->pricesToVendorInfo(
|
||||
sku: $code,
|
||||
url: $this->getProductShortURL($code),
|
||||
prices: $prices
|
||||
),
|
||||
|
||||
mass: $product['weight'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO
|
||||
* @param string $sku
|
||||
* @param string $url
|
||||
* @param array $prices
|
||||
* @return array
|
||||
*/
|
||||
private function pricesToVendorInfo(string $sku, string $url, array $prices): array
|
||||
{
|
||||
$priceDTOs = array_map(function ($price) {
|
||||
$val = $price['value'] ?? null;
|
||||
$valStr = is_numeric($val)
|
||||
? number_format((float) $val, 6, '.', '') // 6 decimal places, trailing zeros are fine
|
||||
: (string) $val;
|
||||
|
||||
// Optional: softly trim unnecessary trailing zeros (e.g. 75.550000 -> 75.55)
|
||||
$valStr = rtrim(rtrim($valStr, '0'), '.');
|
||||
|
||||
return new PriceDTO(
|
||||
minimum_discount_amount: (float) ($price['minQuantity'] ?? 1),
|
||||
price: $valStr,
|
||||
currency_iso_code: (string) ($price['currencyIso'] ?? $this->settings->currency ?? 'EUR'),
|
||||
includes_tax: false
|
||||
);
|
||||
}, $prices);
|
||||
|
||||
return [
|
||||
new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $sku,
|
||||
prices: $priceDTOs,
|
||||
product_url: $url,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a valid Buerklin product short URL from product code
|
||||
* @param string $product_code
|
||||
* @return string
|
||||
*/
|
||||
private function getProductShortURL(string $product_code): string
|
||||
{
|
||||
return 'https://www.buerklin.com/' . $this->settings->language . '/p/' . $product_code . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deduplicated list of product images as FileDTOs.
|
||||
*
|
||||
* - takes only real image arrays (with 'url' field)
|
||||
* - makes relative URLs absolute
|
||||
* - deduplicates using URL
|
||||
* - prefers 'zoom' format, then 'product' format, then all others
|
||||
*
|
||||
* @param array|null $images
|
||||
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
|
||||
*/
|
||||
private function getProductImages(?array $images): array
|
||||
{
|
||||
if (!is_array($images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) Only real image entries with URL
|
||||
$imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url'])));
|
||||
|
||||
// 2) Prefer zoom images
|
||||
$zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom'));
|
||||
$chosen = count($zoom) > 0
|
||||
? $zoom
|
||||
: array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product'));
|
||||
|
||||
// 3) If still none, take all
|
||||
if (count($chosen) === 0) {
|
||||
$chosen = $imgs;
|
||||
}
|
||||
|
||||
// 4) Deduplicate by URL (after making absolute)
|
||||
$byUrl = [];
|
||||
foreach ($chosen as $img) {
|
||||
$url = (string) $img['url'];
|
||||
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
$url = 'https://www.buerklin.com' . $url;
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byUrl[$url] = $url;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn($url) => new FileDTO($url),
|
||||
array_values($byUrl)
|
||||
);
|
||||
}
|
||||
|
||||
private function attributesToParameters(array $features, ?string $group = null): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($features as $f) {
|
||||
if (!is_array($f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $f['name'] ?? null;
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vals = [];
|
||||
foreach (($f['featureValues'] ?? []) as $fv) {
|
||||
if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') {
|
||||
$vals[] = trim($fv['value']);
|
||||
}
|
||||
}
|
||||
if (empty($vals)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiple values: join with comma
|
||||
$value = implode(', ', array_values(array_unique($vals)));
|
||||
|
||||
// Unit/symbol from Buerklin feature
|
||||
$unit = $f['featureUnit']['symbol'] ?? null;
|
||||
if (!is_string($unit) || trim($unit) === '') {
|
||||
$unit = null;
|
||||
}
|
||||
|
||||
// ParameterDTO parses value field (handles value + unit)
|
||||
$out[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $unit,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate by name
|
||||
$byName = [];
|
||||
foreach ($out as $p) {
|
||||
$byName[$p->name] ??= $p;
|
||||
}
|
||||
|
||||
return array_values($byName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PartDetailDTO[]
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$keyword = strtoupper(trim($keyword));
|
||||
if ($keyword === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = $this->makeAPICall('/products/search/', [
|
||||
'pageSize' => 50,
|
||||
'currentPage' => 0,
|
||||
'query' => $keyword,
|
||||
'sort' => 'relevance',
|
||||
]);
|
||||
|
||||
$products = $response['products'] ?? [];
|
||||
|
||||
// Normal case: products found in search results
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return array_map(fn($p) => $this->getPartDetail($p), $products);
|
||||
}
|
||||
|
||||
// Fallback: try direct lookup by code
|
||||
try {
|
||||
$product = $this->getProduct($keyword);
|
||||
return [$this->getPartDetail($product)];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
// Detail endpoint is /products/{code}/
|
||||
$response = $this->getProduct($id);
|
||||
|
||||
return $this->getPartDetail($response);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
//ProviderCapabilities::DATASHEET, // currently not implemented
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
private function complianceToParameters(array $product, ?string $group = 'Compliance'): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
$add = function (string $name, $value) use (&$params, $group) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'Yes' : 'No';
|
||||
} elseif (is_array($value) || is_object($value)) {
|
||||
// Avoid dumping large or complex structures
|
||||
return;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$params[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: (string) $value,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
};
|
||||
|
||||
$add('RoHS conform', $product['labelRoHS'] ?? null); // "yes"/"no"
|
||||
|
||||
$rawRoHsDate = $product['dateRoHS'] ?? null;
|
||||
// Try to parse and reformat date to Y-m-d (do not use language-dependent formats)
|
||||
if (is_string($rawRoHsDate) && $rawRoHsDate !== '') {
|
||||
try {
|
||||
$dt = new \DateTimeImmutable($rawRoHsDate);
|
||||
$formatted = $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
$formatted = $rawRoHsDate;
|
||||
}
|
||||
// Always use the same parameter name (do not use language-dependent names)
|
||||
$add('RoHS date', $formatted);
|
||||
}
|
||||
$add('SVHC free', $product['SVHC'] ?? null); // bool
|
||||
$add('Hazardous good', $product['hazardousGood'] ?? null); // bool
|
||||
$add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool
|
||||
|
||||
$add('Country of origin', $product['countryOfOrigin'] ?? null);
|
||||
// Customs tariff code must always be stored as string, otherwise "85411000" may be stored as "8.5411e+7"
|
||||
if (isset($product['articleCustomsCode'])) {
|
||||
// Raw value as string
|
||||
$codeRaw = (string) $product['articleCustomsCode'];
|
||||
|
||||
// Optionally keep only digits (in case of spaces or other characters)
|
||||
$code = preg_replace('/\D/', '', $codeRaw) ?? $codeRaw;
|
||||
$code = trim($code);
|
||||
|
||||
if ($code !== '') {
|
||||
$params[] = new ParameterDTO(
|
||||
name: 'Customs code',
|
||||
value_text: $code,
|
||||
value_typ: null,
|
||||
value_min: null,
|
||||
value_max: null,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $keywords
|
||||
* @return array<string, SearchResultDTO[]>
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
{
|
||||
/** @var array<string, SearchResultDTO[]> $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$keyword = strtoupper(trim((string) $keyword));
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse existing single search -> returns PartDetailDTO[]
|
||||
/** @var PartDetailDTO[] $partDetails */
|
||||
$partDetails = $this->searchByKeyword($keyword);
|
||||
|
||||
// Convert to SearchResultDTO[]
|
||||
$results[$keyword] = array_map(
|
||||
fn(PartDetailDTO $detail) => $this->convertPartDetailToSearchResult($detail),
|
||||
$partDetails
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PartDetailDTO into a SearchResultDTO for bulk search.
|
||||
*/
|
||||
private function convertPartDetailToSearchResult(PartDetailDTO $detail): SearchResultDTO
|
||||
{
|
||||
return new SearchResultDTO(
|
||||
provider_key: $detail->provider_key,
|
||||
provider_id: $detail->provider_id,
|
||||
name: $detail->name,
|
||||
description: $detail->description ?? '',
|
||||
category: $detail->category ?? null,
|
||||
manufacturer: $detail->manufacturer ?? null,
|
||||
mpn: $detail->mpn ?? null,
|
||||
preview_image_url: $detail->preview_image_url ?? null,
|
||||
manufacturing_status: $detail->manufacturing_status ?? null,
|
||||
provider_url: $detail->provider_url ?? null,
|
||||
footprint: $detail->footprint ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -311,6 +311,14 @@ class DigikeyProvider implements InfoProviderInterface
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() === 404) {
|
||||
//No media found
|
||||
return [
|
||||
'datasheets' => [],
|
||||
'images' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
|
||||
@@ -397,13 +397,13 @@ class OEMSecretsProvider implements InfoProviderInterface
|
||||
* Generates a cache key for storing part details based on the provided provider ID.
|
||||
*
|
||||
* This method creates a unique cache key by prefixing the provider ID with 'part_details_'
|
||||
* and hashing the provider ID using MD5 to ensure a consistent and compact key format.
|
||||
* and hashing the provider ID using XXH3 to ensure a consistent and compact key format.
|
||||
*
|
||||
* @param string $provider_id The unique identifier of the provider or part.
|
||||
* @return string The generated cache key.
|
||||
*/
|
||||
private function getCacheKey(string $provider_id): string {
|
||||
return 'oemsecrets_part_' . md5($provider_id);
|
||||
return 'oemsecrets_part_' . hash('xxh3', $provider_id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -248,4 +248,4 @@ class PollinProvider implements InfoProviderInterface
|
||||
ProviderCapabilities::DATASHEET
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,6 @@ enum ProviderCapabilities
|
||||
/** Basic information about a part, like the name, description, part number, manufacturer etc */
|
||||
case BASIC;
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/** Provider can provide a picture for a part */
|
||||
case PICTURE;
|
||||
|
||||
@@ -43,6 +40,24 @@ enum ProviderCapabilities
|
||||
/** Provider can provide prices for a part */
|
||||
case PRICE;
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/**
|
||||
* Get the order index for displaying capabilities in a stable order.
|
||||
* @return int
|
||||
*/
|
||||
public function getOrderIndex(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::BASIC => 1,
|
||||
self::PICTURE => 2,
|
||||
self::DATASHEET => 3,
|
||||
self::PRICE => 4,
|
||||
self::FOOTPRINT => 5,
|
||||
};
|
||||
}
|
||||
|
||||
public function getTranslationKey(): string
|
||||
{
|
||||
return 'info_providers.capabilities.' . match($this) {
|
||||
|
||||
84
src/Settings/InfoProviderSystem/BuerklinSettings.php
Normal file
84
src/Settings/InfoProviderSystem/BuerklinSettings.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.buerklin"), description: new TM("settings.ips.buerklin.help"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class BuerklinSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.digikey.client_id"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $clientId = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.digikey.secret"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_SECRET", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $secret = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.buerklin.username"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_USER", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $username = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("user.edit.password"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_PASSWORD", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $password = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class,
|
||||
formOptions: ["preferred_choices" => ["EUR"]],
|
||||
envVar: "PROVIDER_BUERKLIN_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Currency()]
|
||||
public string $currency = "EUR";
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class,
|
||||
formOptions: ["preferred_choices" => ["en", "de"]],
|
||||
envVar: "PROVIDER_BUERKLIN_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Language]
|
||||
public string $language = "en";
|
||||
}
|
||||
@@ -63,4 +63,7 @@ class InfoProviderSettings
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?PollinSettings $pollin = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?BuerklinSettings $buerklin = null;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
title="{% trans %}info_providers.settings.title{% endtrans %}"
|
||||
><i class="fa-solid fa-cog"></i></a>
|
||||
{% endif %}
|
||||
{% for capability in provider.capabilities %}
|
||||
{% for capability in provider.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %}
|
||||
{# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #}
|
||||
<span class="badge text-bg-secondary">
|
||||
<i class="{{ capability.fAIconClass }} fa-fw"></i>
|
||||
|
||||
@@ -135,8 +135,8 @@
|
||||
|
||||
{% block additional_content %}
|
||||
{% if pdf_data %}
|
||||
<div class="card mt-2 p-1 border-secondary" style="resize: vertical; overflow: scroll; height: 250px">
|
||||
<object id="pdf_preview" data="{{ pdf_data | data_uri(mime='application/pdf') }}"style="height: inherit">
|
||||
<div class="card mt-2 p-1 border-secondary" style="resize: vertical; overflow: scroll; height: 280px">
|
||||
<object id="pdf_preview" data="{{ pdf_data | data_uri(mime='application/pdf') }}" style="height: inherit">
|
||||
</object>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -37,7 +37,7 @@ class RegisterSynonymsAsTranslationParametersTest extends KernelTestCase
|
||||
|
||||
public function testGetSynonymPlaceholders(): void
|
||||
{
|
||||
$placeholders = $this->listener->getSynonymPlaceholders();
|
||||
$placeholders = $this->listener->getSynonymPlaceholders('en');
|
||||
|
||||
$this->assertIsArray($placeholders);
|
||||
// Curly braces for lowercase versions
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\BuerklinProvider;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Full behavioral test suite for BuerklinProvider.
|
||||
* Includes parameter parsing, compliance parsing, images, prices and batch mode.
|
||||
*/
|
||||
class BuerklinProviderTest extends TestCase
|
||||
{
|
||||
private HttpClientInterface $httpClient;
|
||||
private CacheItemPoolInterface $cache;
|
||||
private BuerklinSettings $settings;
|
||||
private BuerklinProvider $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->httpClient = $this->createMock(HttpClientInterface::class);
|
||||
|
||||
// Cache mock
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn(false);
|
||||
$cacheItem->method('set')->willReturn($cacheItem);
|
||||
|
||||
$this->cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$this->cache->method('getItem')->willReturn($cacheItem);
|
||||
|
||||
// IMPORTANT: Settings must not be instantiated directly (SettingsBundle forbids constructor)
|
||||
$ref = new \ReflectionClass(BuerklinSettings::class);
|
||||
/** @var BuerklinSettings $settings */
|
||||
$settings = $ref->newInstanceWithoutConstructor();
|
||||
|
||||
$settings->clientId = 'CID';
|
||||
$settings->secret = 'SECRET';
|
||||
$settings->username = 'USER';
|
||||
$settings->password = 'PASS';
|
||||
$settings->language = 'en';
|
||||
$settings->currency = 'EUR';
|
||||
|
||||
$this->settings = $settings;
|
||||
|
||||
$this->provider = new BuerklinProvider(
|
||||
client: $this->httpClient,
|
||||
partInfoCache: $this->cache,
|
||||
settings: $this->settings,
|
||||
);
|
||||
}
|
||||
|
||||
private function mockApi(string $expectedUrl, array $jsonResponse): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('toArray')->willReturn($jsonResponse);
|
||||
|
||||
$this->httpClient
|
||||
->method('request')
|
||||
->with(
|
||||
'GET',
|
||||
$this->callback(fn($url) => str_contains((string) $url, $expectedUrl)),
|
||||
$this->anything()
|
||||
)
|
||||
->willReturn($response);
|
||||
}
|
||||
|
||||
public function testAttributesToParametersParsesUnitsAndValues(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(BuerklinProvider::class, 'attributesToParameters');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$features = [
|
||||
[
|
||||
'name' => 'Zener voltage',
|
||||
'featureUnit' => ['symbol' => 'V'],
|
||||
'featureValues' => [
|
||||
['value' => '12']
|
||||
]
|
||||
],
|
||||
[
|
||||
'name' => 'Length',
|
||||
'featureUnit' => ['symbol' => 'mm'],
|
||||
'featureValues' => [
|
||||
['value' => '2.9']
|
||||
]
|
||||
],
|
||||
[
|
||||
'name' => 'Assembly',
|
||||
'featureUnit' => [],
|
||||
'featureValues' => [
|
||||
['value' => 'SMD']
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$params = $method->invoke($this->provider, $features, '');
|
||||
|
||||
$this->assertCount(3, $params);
|
||||
|
||||
$this->assertSame('Zener voltage', $params[0]->name);
|
||||
$this->assertNull($params[0]->value_text);
|
||||
$this->assertSame(12.0, $params[0]->value_typ);
|
||||
$this->assertNull($params[0]->value_min);
|
||||
$this->assertNull($params[0]->value_max);
|
||||
$this->assertSame('V', $params[0]->unit);
|
||||
|
||||
$this->assertSame('Length', $params[1]->name);
|
||||
$this->assertNull($params[1]->value_text);
|
||||
$this->assertSame(2.9, $params[1]->value_typ);
|
||||
$this->assertSame('mm', $params[1]->unit);
|
||||
|
||||
$this->assertSame('Assembly', $params[2]->name);
|
||||
$this->assertSame('SMD', $params[2]->value_text);
|
||||
$this->assertNull($params[2]->unit);
|
||||
}
|
||||
|
||||
public function testComplianceParameters(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(BuerklinProvider::class, 'complianceToParameters');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$product = [
|
||||
'labelRoHS' => 'Yes',
|
||||
'dateRoHS' => '2015-03-31T00:00+0000',
|
||||
'SVHC' => true,
|
||||
'hazardousGood' => false,
|
||||
'hazardousMaterials' => false,
|
||||
'countryOfOrigin' => 'China',
|
||||
'articleCustomsCode' => '85411000'
|
||||
];
|
||||
|
||||
$params = $method->invoke($this->provider, $product, 'Compliance');
|
||||
|
||||
$map = [];
|
||||
foreach ($params as $p) {
|
||||
$map[$p->name] = $p->value_text;
|
||||
}
|
||||
|
||||
$this->assertSame('Yes', $map['RoHS conform']);
|
||||
$this->assertSame('2015-03-31', $map['RoHS date']);
|
||||
$this->assertSame('Yes', $map['SVHC free']);
|
||||
$this->assertSame('No', $map['Hazardous good']);
|
||||
$this->assertSame('No', $map['Hazardous materials']);
|
||||
$this->assertSame('China', $map['Country of origin']);
|
||||
$this->assertSame('85411000', $map['Customs code']);
|
||||
}
|
||||
|
||||
public function testImageSelectionPrefersZoomAndDeduplicates(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(BuerklinProvider::class, 'getProductImages');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$images = [
|
||||
['format' => 'product', 'url' => '/img/a.webp'],
|
||||
['format' => 'zoom', 'url' => '/img/z.webp'],
|
||||
['format' => 'zoom', 'url' => '/img/z.webp'], // duplicate
|
||||
['format' => 'thumbnail', 'url' => '/img/t.webp']
|
||||
];
|
||||
|
||||
$results = $method->invoke($this->provider, $images);
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertSame('https://www.buerklin.com/img/z.webp', $results[0]->url);
|
||||
}
|
||||
|
||||
public function testFootprintExtraction(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(BuerklinProvider::class, 'getPartDetail');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$product = [
|
||||
'code' => 'TEST1',
|
||||
'manufacturerProductId' => 'ABC',
|
||||
'description' => 'X',
|
||||
'images' => [],
|
||||
'classifications' => [
|
||||
[
|
||||
'name' => 'Cat',
|
||||
'features' => [
|
||||
[
|
||||
'name' => 'Enclosure',
|
||||
'featureValues' => [['value' => 'SOT-23']]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'price' => ['value' => 1, 'currencyIso' => 'EUR']
|
||||
];
|
||||
|
||||
$dto = $method->invoke($this->provider, $product);
|
||||
$this->assertSame('SOT-23', $dto->footprint);
|
||||
}
|
||||
|
||||
public function testPriceFormatting(): void
|
||||
{
|
||||
$detailPrice = [
|
||||
[
|
||||
'minQuantity' => 1,
|
||||
'value' => 0.0885,
|
||||
'currencyIso' => 'EUR'
|
||||
]
|
||||
];
|
||||
|
||||
$method = new \ReflectionMethod(BuerklinProvider::class, 'pricesToVendorInfo');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$vendorInfo = $method->invoke($this->provider, 'SKU1', 'https://x', $detailPrice);
|
||||
|
||||
$price = $vendorInfo[0]->prices[0];
|
||||
$this->assertSame('0.0885', $price->price);
|
||||
}
|
||||
|
||||
public function testBatchSearchReturnsSearchResultDTO(): void
|
||||
{
|
||||
$mockDetail = new PartDetailDTO(
|
||||
provider_key: 'buerklin',
|
||||
provider_id: 'TESTID',
|
||||
name: 'Zener',
|
||||
description: 'Desc'
|
||||
);
|
||||
|
||||
$provider = $this->getMockBuilder(BuerklinProvider::class)
|
||||
->setConstructorArgs([
|
||||
$this->httpClient,
|
||||
$this->cache,
|
||||
$this->settings
|
||||
])
|
||||
->onlyMethods(['searchByKeyword'])
|
||||
->getMock();
|
||||
|
||||
$provider->method('searchByKeyword')->willReturn([$mockDetail]);
|
||||
|
||||
$result = $provider->searchByKeywordsBatch(['ABC']);
|
||||
|
||||
$this->assertArrayHasKey('ABC', $result);
|
||||
$this->assertIsArray($result['ABC']);
|
||||
$this->assertCount(1, $result['ABC']);
|
||||
$this->assertInstanceOf(SearchResultDTO::class, $result['ABC'][0]);
|
||||
$this->assertSame('Zener', $result['ABC'][0]->name);
|
||||
}
|
||||
|
||||
public function testConvertPartDetailToSearchResult(): void
|
||||
{
|
||||
$detail = new PartDetailDTO(
|
||||
provider_key: 'buerklin',
|
||||
provider_id: 'X1',
|
||||
name: 'PartX',
|
||||
description: 'D',
|
||||
preview_image_url: 'https://img'
|
||||
);
|
||||
|
||||
$method = new \ReflectionMethod(BuerklinProvider::class, 'convertPartDetailToSearchResult');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$dto = $method->invoke($this->provider, $detail);
|
||||
|
||||
$this->assertInstanceOf(SearchResultDTO::class, $dto);
|
||||
$this->assertSame('X1', $dto->provider_id);
|
||||
$this->assertSame('PartX', $dto->name);
|
||||
$this->assertSame('https://img', $dto->preview_image_url);
|
||||
}
|
||||
}
|
||||
@@ -13495,5 +13495,14 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
|
||||
<target>Uživatelé</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -12164,5 +12164,14 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
|
||||
<target>Brugere</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
|
||||
<file id="messages.de">
|
||||
<unit id="x_wTSQS" name="attachment_type.caption">
|
||||
@@ -19,7 +19,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>attachment_type.edit</source>
|
||||
<target>Bearbeite Dateityp</target>
|
||||
<target>Bearbeite [Attachment_type]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wyou6GD" name="attachment_type.new">
|
||||
@@ -29,7 +29,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>attachment_type.new</source>
|
||||
<target>Neuer Dateityp</target>
|
||||
<target>Neuer [Attachment_type]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="JHaxw0a" name="category.labelp">
|
||||
@@ -84,7 +84,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>category.edit</source>
|
||||
<target>Bearbeite Kategorie</target>
|
||||
<target>Bearbeite [Category]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6.rAXsX" name="category.new">
|
||||
@@ -94,7 +94,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>category.new</source>
|
||||
<target>Neue Kategorie</target>
|
||||
<target>Neue [Category]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="z1GMBc_" name="currency.caption">
|
||||
@@ -134,7 +134,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>currency.edit</source>
|
||||
<target>Bearbeite Währung</target>
|
||||
<target>Bearbeite [Currency]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aTtTsUc" name="currency.new">
|
||||
@@ -144,7 +144,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>currency.new</source>
|
||||
<target>Neue Währung</target>
|
||||
<target>Neue [Currency]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pe43jlV" name="project.edit">
|
||||
@@ -154,7 +154,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>project.edit</source>
|
||||
<target>Bearbeite Projekt</target>
|
||||
<target>Bearbeite [Project]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sYgrSg9" name="project.new">
|
||||
@@ -164,7 +164,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>project.new</source>
|
||||
<target>Neues Projekt</target>
|
||||
<target>Neues [Project]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eLrezdb" name="search.placeholder">
|
||||
@@ -231,7 +231,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>part.info.timetravel_hint</source>
|
||||
<target>So sah das Bauteil vor %timestamp% aus. <i>Beachten Sie, dass dieses Feature experimentell ist und die angezeigten Infos daher nicht unbedingt korrekt sind.</i></target>
|
||||
<target><![CDATA[So sah das Bauteil vor %timestamp% aus. <i>Beachten Sie, dass dieses Feature experimentell ist und die angezeigten Infos daher nicht unbedingt korrekt sind.</i>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3exvSpl" name="standard.label">
|
||||
@@ -394,7 +394,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>footprint.edit</source>
|
||||
<target>Bearbeite Footprint</target>
|
||||
<target>Bearbeite [Footprint]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9RgpRoK" name="footprint.new">
|
||||
@@ -404,7 +404,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>footprint.new</source>
|
||||
<target>Neuer Footprint</target>
|
||||
<target>Neuer [Footprint]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tvm4F9e" name="group.edit.caption">
|
||||
@@ -436,7 +436,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>group.edit</source>
|
||||
<target>Bearbeite Gruppe</target>
|
||||
<target>Bearbeite [Group]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_RN3Wph" name="group.new">
|
||||
@@ -446,7 +446,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>group.new</source>
|
||||
<target>Neue Gruppe</target>
|
||||
<target>Neue [Group]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="jXqdnm_" name="label_profile.caption">
|
||||
@@ -483,7 +483,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>label_profile.edit</source>
|
||||
<target>Bearbeite Labelprofil</target>
|
||||
<target>Bearbeite [Label_profile]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WkNFJjx" name="label_profile.new">
|
||||
@@ -493,7 +493,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>label_profile.new</source>
|
||||
<target>Neues Labelprofil</target>
|
||||
<target>Neues [Label_profile]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="r3pQ31P" name="manufacturer.caption">
|
||||
@@ -514,7 +514,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>manufacturer.edit</source>
|
||||
<target>Bearbeite Hersteller</target>
|
||||
<target>Bearbeite [Manufacturer]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dRX_cvD" name="manufacturer.new">
|
||||
@@ -524,7 +524,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>manufacturer.new</source>
|
||||
<target>Neuer Hersteller</target>
|
||||
<target>Neuer [Manufacturer]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3ra2AyY" name="measurement_unit.caption">
|
||||
@@ -537,7 +537,7 @@
|
||||
<target>Maßeinheit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3bcKBzY" name="part_custom_state.caption">
|
||||
<unit id="IqR.a4n" name="part_custom_state.caption">
|
||||
<segment state="translated">
|
||||
<source>part_custom_state.caption</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
@@ -565,7 +565,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>storelocation.edit</source>
|
||||
<target>Bearbeite Lagerort</target>
|
||||
<target>Bearbeite [Storage_location]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="am0iTCO" name="storelocation.new">
|
||||
@@ -575,7 +575,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>storelocation.new</source>
|
||||
<target>Neuer Lagerort</target>
|
||||
<target>Neuer [Storage_location]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ozZU_B5" name="supplier.edit">
|
||||
@@ -585,7 +585,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>supplier.edit</source>
|
||||
<target>Bearbeite Lieferant</target>
|
||||
<target>Bearbeite [Supplier]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b8jlkMd" name="supplier.new">
|
||||
@@ -595,7 +595,7 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>supplier.new</source>
|
||||
<target>Neuer Lieferant</target>
|
||||
<target>Neuer [Supplier]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vX.dhjK" name="user.edit.caption">
|
||||
@@ -715,9 +715,9 @@
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>user.edit.tfa.disable_tfa_message</source>
|
||||
<target>Dies wird <b>alle aktiven Zwei-Faktor-Authentifizierungsmethoden des Nutzers deaktivieren</b> und die <b>Backupcodes löschen</b>! <br>
|
||||
Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müssen und neue Backupcodes ausdrucken müssen! <br><br>
|
||||
<b>Führen sie dies nur durch, wenn Sie über die Identität des (um Hilfe suchenden) Benutzers absolut sicher sind, da ansonsten eine Kompromittierung des Accounts durch einen Angreifer erfolgen könnte!</b></target>
|
||||
<target><![CDATA[Dies wird <b>alle aktiven Zwei-Faktor-Authentifizierungsmethoden des Nutzers deaktivieren</b> und die <b>Backupcodes löschen</b>! <br>
|
||||
Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müssen und neue Backupcodes ausdrucken müssen! <br><br>
|
||||
<b>Führen sie dies nur durch, wenn Sie über die Identität des (um Hilfe suchenden) Benutzers absolut sicher sind, da ansonsten eine Kompromittierung des Accounts durch einen Angreifer erfolgen könnte!</b>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="APsHYu0" name="user.edit.tfa.disable_tfa.btn">
|
||||
@@ -737,7 +737,7 @@ Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müs
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>user.edit</source>
|
||||
<target>Bearbeite Benutzer</target>
|
||||
<target>Bearbeite [User]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="esEoaa_" name="user.new">
|
||||
@@ -747,7 +747,7 @@ Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müs
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>user.new</source>
|
||||
<target>Neuer Benutzer</target>
|
||||
<target>Neuer [User]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KqHffuc" name="attachment.delete">
|
||||
@@ -1424,7 +1424,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>homepage.github.text</source>
|
||||
<target>Quellcode, Downloads, Bugreports, ToDo-Liste usw. gibts auf der <a class="link-external" target="_blank" href="%href%">GitHub Projektseite</a></target>
|
||||
<target><![CDATA[Quellcode, Downloads, Bugreports, ToDo-Liste usw. gibts auf der <a class="link-external" target="_blank" href="%href%">GitHub Projektseite</a>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="D5OKsgU" name="homepage.help.caption">
|
||||
@@ -1446,7 +1446,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>homepage.help.text</source>
|
||||
<target>Hilfe und Tipps finden sie im <a class="link-external" rel="noopener" target="_blank" href="%href%">Wiki</a> der GitHub Seite.</target>
|
||||
<target><![CDATA[Hilfe und Tipps finden sie im <a class="link-external" rel="noopener" target="_blank" href="%href%">Wiki</a> der GitHub Seite.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dnirx4v" name="homepage.forum.caption">
|
||||
@@ -1688,7 +1688,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>email.pw_reset.fallback</source>
|
||||
<target>Wenn dies nicht funktioniert, rufen Sie <a href="%url%">%url%</a> auf und geben Sie die folgenden Daten ein</target>
|
||||
<target><![CDATA[Wenn dies nicht funktioniert, rufen Sie <a href="%url%">%url%</a> auf und geben Sie die folgenden Daten ein]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="DduL9Hu" name="email.pw_reset.username">
|
||||
@@ -1718,7 +1718,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>email.pw_reset.valid_unit %date%</source>
|
||||
<target>Das Reset-Token ist gültig bis <i>%date%</i></target>
|
||||
<target><![CDATA[Das Reset-Token ist gültig bis <i>%date%</i>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8sBnjRy" name="orderdetail.delete">
|
||||
@@ -1781,7 +1781,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>part.edit.title</source>
|
||||
<target>Bearbeite Bauteil %name%</target>
|
||||
<target>Bearbeite [Part] %name%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yFxHuAp" name="part.edit.card_title">
|
||||
@@ -1942,7 +1942,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>part.new.card_title</source>
|
||||
<target>Neues Bauteil erstellen</target>
|
||||
<target>Neues [Part] erstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5TCcXwk" name="part_lot.delete">
|
||||
@@ -3124,7 +3124,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.distinct_parts_count</source>
|
||||
<target>Anzahl verschiedener Bauteile</target>
|
||||
<target>Anzahl verschiedener [[Part]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sIGqnJ0" name="statistics.parts_instock_sum">
|
||||
@@ -3135,7 +3135,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.parts_instock_sum</source>
|
||||
<target>Summe aller vorhanden Bauteilebestände</target>
|
||||
<target>Summe aller vorhandenen Bestände an [[Part]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="uHmvfnI" name="statistics.parts_with_price">
|
||||
@@ -3146,7 +3146,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.parts_with_price</source>
|
||||
<target>Bauteile mit Preisinformationen</target>
|
||||
<target>[[Part]] mit Preisinformationen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7x89_xL" name="statistics.categories_count">
|
||||
@@ -3157,7 +3157,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.categories_count</source>
|
||||
<target>Anzahl Kategorien</target>
|
||||
<target>Anzahl [[Category]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s0nLRjN" name="statistics.footprints_count">
|
||||
@@ -3168,7 +3168,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.footprints_count</source>
|
||||
<target>Anzahl Footprints</target>
|
||||
<target>Anzahl [[Footprint]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f0gHZzl" name="statistics.manufacturers_count">
|
||||
@@ -3179,7 +3179,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.manufacturers_count</source>
|
||||
<target>Anzahl Hersteller</target>
|
||||
<target>Anzahl [[Manufacturer]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_4rvCd3" name="statistics.storelocations_count">
|
||||
@@ -3190,7 +3190,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.storelocations_count</source>
|
||||
<target>Anzahl Lagerorte</target>
|
||||
<target>Anzahl [[Storage_location]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tzzUvrm" name="statistics.suppliers_count">
|
||||
@@ -3201,7 +3201,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.suppliers_count</source>
|
||||
<target>Anzahl Lieferanten</target>
|
||||
<target>Anzahl [[Supplier]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QEk.sHE" name="statistics.currencies_count">
|
||||
@@ -3212,7 +3212,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.currencies_count</source>
|
||||
<target>Anzahl Währungen</target>
|
||||
<target>Anzahl [[Currency]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MTCnGlN" name="statistics.measurement_units_count">
|
||||
@@ -3223,7 +3223,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.measurement_units_count</source>
|
||||
<target>Anzahl Maßeinheiten</target>
|
||||
<target>Anzahl [[Measurement_unit]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7sRXll2" name="statistics.devices_count">
|
||||
@@ -3234,7 +3234,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.devices_count</source>
|
||||
<target>Anzahl Baugruppen</target>
|
||||
<target>Anzahl [[Project]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2u7zTMF" name="statistics.attachment_types_count">
|
||||
@@ -3245,7 +3245,7 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>statistics.attachment_types_count</source>
|
||||
<target>Anzahl Anhangstypen</target>
|
||||
<target>Anzahl [[Attachment_type]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="C0XsLQc" name="statistics.all_attachments_count">
|
||||
@@ -3591,8 +3591,8 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tfa_google.disable.confirm_message</source>
|
||||
<target>Wenn Sie die Authenticator App deaktivieren, werden alle Backupcodes gelöscht, daher sie müssen sie evtl. neu ausdrucken.<br>
|
||||
Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nicht mehr so gut gegen Angreifer geschützt ist!</target>
|
||||
<target><![CDATA[Wenn Sie die Authenticator App deaktivieren, werden alle Backupcodes gelöscht, daher sie müssen sie evtl. neu ausdrucken.<br>
|
||||
Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nicht mehr so gut gegen Angreifer geschützt ist!]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yu9MSt5" name="tfa_google.disabled_message">
|
||||
@@ -3612,7 +3612,7 @@ Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nich
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tfa_google.step.download</source>
|
||||
<target>Laden Sie eine Authenticator App herunter (z.B. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)</target>
|
||||
<target><![CDATA[Laden Sie eine Authenticator App herunter (z.B. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eriwJoR" name="tfa_google.step.scan">
|
||||
@@ -3854,8 +3854,8 @@ Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nich
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tfa_trustedDevices.explanation</source>
|
||||
<target>Bei der Überprüfung des zweiten Faktors, kann der aktuelle Computer als vertrauenswürdig gekennzeichnet werden, daher werden keine Zwei-Faktor-Überprüfungen mehr an diesem Computer benötigt.
|
||||
Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertrauenswürdig ist, können Sie hier den Status <i>aller </i>Computer zurücksetzen.</target>
|
||||
<target><![CDATA[Bei der Überprüfung des zweiten Faktors, kann der aktuelle Computer als vertrauenswürdig gekennzeichnet werden, daher werden keine Zwei-Faktor-Überprüfungen mehr an diesem Computer benötigt.
|
||||
Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertrauenswürdig ist, können Sie hier den Status <i>aller </i>Computer zurücksetzen.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="FZINq8z" name="tfa_trustedDevices.invalidate.confirm_title">
|
||||
@@ -4813,7 +4813,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
<target>Maßeinheit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="G1hmQdb" name="part.table.partCustomState">
|
||||
<unit id="JjTO6Nq" name="part.table.partCustomState">
|
||||
<segment state="translated">
|
||||
<source>part.table.partCustomState</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
@@ -5301,7 +5301,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>label_options.lines_mode.help</source>
|
||||
<target>Wenn Sie hier Twig auswählen, wird das Contentfeld als Twig-Template interpretiert. Weitere Hilfe gibt es in der <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig Dokumentation</a> und dem <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a>.</target>
|
||||
<target><![CDATA[Wenn Sie hier Twig auswählen, wird das Contentfeld als Twig-Template interpretiert. Weitere Hilfe gibt es in der <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig Dokumentation</a> und dem <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a>.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="isvxbiX" name="label_options.page_size.label">
|
||||
@@ -5683,7 +5683,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
<target>Maßeinheit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kE1wJ1a" name="part.edit.partCustomState">
|
||||
<unit id="ro8Iwr_" name="part.edit.partCustomState">
|
||||
<segment state="translated">
|
||||
<source>part.edit.partCustomState</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
@@ -5976,7 +5976,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
<target>Maßeinheit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="a1mPcMw" name="part_custom_state.label">
|
||||
<unit id="NpDx4rr" name="part_custom_state.label">
|
||||
<segment state="translated">
|
||||
<source>part_custom_state.label</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
@@ -6138,7 +6138,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.attachment_types</source>
|
||||
<target>Dateitypen</target>
|
||||
<target>[[Attachment_type]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="u8jQbAc" name="tree.tools.edit.categories">
|
||||
@@ -6149,7 +6149,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.categories</source>
|
||||
<target>Kategorien</target>
|
||||
<target>[[Category]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3n2K_az" name="tree.tools.edit.projects">
|
||||
@@ -6160,7 +6160,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.projects</source>
|
||||
<target>Projekte</target>
|
||||
<target>[[Project]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="raK7qaK" name="tree.tools.edit.suppliers">
|
||||
@@ -6171,7 +6171,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.suppliers</source>
|
||||
<target>Lieferanten</target>
|
||||
<target>[[Supplier]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="1IJ48Y0" name="tree.tools.edit.manufacturer">
|
||||
@@ -6182,7 +6182,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.manufacturer</source>
|
||||
<target>Hersteller</target>
|
||||
<target>[[Manufacturer]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GNbWH_O" name="tree.tools.edit.storelocation">
|
||||
@@ -6192,7 +6192,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.storelocation</source>
|
||||
<target>Lagerorte</target>
|
||||
<target>[[Storage_location]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7ZOhkd." name="tree.tools.edit.footprint">
|
||||
@@ -6202,7 +6202,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.footprint</source>
|
||||
<target>Footprints</target>
|
||||
<target>[[Footprint]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U1zYjzD" name="tree.tools.edit.currency">
|
||||
@@ -6212,7 +6212,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.currency</source>
|
||||
<target>Währungen</target>
|
||||
<target>[[Currency]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NnzEujm" name="tree.tools.edit.measurement_unit">
|
||||
@@ -6222,13 +6222,13 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.measurement_unit</source>
|
||||
<target>Maßeinheiten</target>
|
||||
<target>[[Measurement_unit]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5adacKb" name="tree.tools.edit.part_custom_state">
|
||||
<unit id="oYLWbbv" name="tree.tools.edit.part_custom_state">
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.part_custom_state</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
<target>[[Part_custom_state]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".Ux4R3T" name="tree.tools.edit.label_profile">
|
||||
@@ -6237,7 +6237,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.label_profile</source>
|
||||
<target>Labelprofil</target>
|
||||
<target>[[Label_profile]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eyvi0Zt" name="tree.tools.edit.part">
|
||||
@@ -6247,7 +6247,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.edit.part</source>
|
||||
<target>Neues Bauteil</target>
|
||||
<target>Neues [Part]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nIHj_yk" name="tree.tools.show.all_parts">
|
||||
@@ -6289,7 +6289,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.system.users</source>
|
||||
<target>Benutzer</target>
|
||||
<target>[[User]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dTEQQ3T" name="tree.tools.system.groups">
|
||||
@@ -6299,7 +6299,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>tree.tools.system.groups</source>
|
||||
<target>Gruppen</target>
|
||||
<target>[[Group]]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NWWki1R" name="tree.tools.system.event_log">
|
||||
@@ -7149,15 +7149,15 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>mass_creation.lines.placeholder</source>
|
||||
<target>Element 1
|
||||
<target><![CDATA[Element 1
|
||||
Element 1.1
|
||||
Element 1.1.1
|
||||
Element 1.2
|
||||
Element 2
|
||||
Element 3
|
||||
|
||||
Element 1 -> Element 1.1
|
||||
Element 1 -> Element 1.2</target>
|
||||
Element 1 -> Element 1.1
|
||||
Element 1 -> Element 1.2]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TWSqPFi" name="entity.mass_creation.btn">
|
||||
@@ -8372,7 +8372,7 @@ Element 1 -> Element 1.2</target>
|
||||
<target>Maßeinheiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="1b5ja1c" name="perm.part_custom_states">
|
||||
<unit id="zckNn8G" name="perm.part_custom_states">
|
||||
<segment state="translated">
|
||||
<source>perm.part_custom_states</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
@@ -9303,25 +9303,25 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="r4vDLAt" name="filter.parameter_value_constraint.operator.<">
|
||||
<segment state="translated">
|
||||
<source>filter.parameter_value_constraint.operator.<</source>
|
||||
<target>Typ. Wert <</target>
|
||||
<target><![CDATA[Typ. Wert <]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="X9SA3UP" name="filter.parameter_value_constraint.operator.>">
|
||||
<segment state="translated">
|
||||
<source>filter.parameter_value_constraint.operator.></source>
|
||||
<target>Typ. Wert ></target>
|
||||
<target><![CDATA[Typ. Wert >]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BQGaoQS" name="filter.parameter_value_constraint.operator.<=">
|
||||
<segment state="translated">
|
||||
<source>filter.parameter_value_constraint.operator.<=</source>
|
||||
<target>Typ. Wert <=</target>
|
||||
<target><![CDATA[Typ. Wert <=]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2ha3P6g" name="filter.parameter_value_constraint.operator.>=">
|
||||
<segment state="translated">
|
||||
<source>filter.parameter_value_constraint.operator.>=</source>
|
||||
<target>Typ. Wert >=</target>
|
||||
<target><![CDATA[Typ. Wert >=]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4DaBace" name="filter.parameter_value_constraint.operator.BETWEEN">
|
||||
@@ -9429,7 +9429,7 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="4tHhDtU" name="parts_list.search.searching_for">
|
||||
<segment state="translated">
|
||||
<source>parts_list.search.searching_for</source>
|
||||
<target>Suche Teile mit dem Suchbegriff <b>%keyword%</b></target>
|
||||
<target><![CDATA[Suche Teile mit dem Suchbegriff <b>%keyword%</b>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4vomKLa" name="parts_list.search_options.caption">
|
||||
@@ -10089,13 +10089,13 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="NdZ1t7a" name="project.builds.number_of_builds_possible">
|
||||
<segment state="translated">
|
||||
<source>project.builds.number_of_builds_possible</source>
|
||||
<target>Sie haben genug Bauteile auf Lager, um <b>%max_builds%</b> Exemplare dieses Projektes zu bauen.</target>
|
||||
<target><![CDATA[Sie haben genug Bauteile auf Lager, um <b>%max_builds%</b> Exemplare dieses Projektes zu bauen.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iuSpPbg" name="project.builds.check_project_status">
|
||||
<segment state="translated">
|
||||
<source>project.builds.check_project_status</source>
|
||||
<target>Der aktuelle Projektstatus ist <b>"%project_status%"</b>. Sie sollten überprüfen, ob sie das Projekt mit diesem Status wirklich bauen wollen!</target>
|
||||
<target><![CDATA[Der aktuelle Projektstatus ist <b>"%project_status%"</b>. Sie sollten überprüfen, ob sie das Projekt mit diesem Status wirklich bauen wollen!]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n">
|
||||
@@ -10209,7 +10209,7 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="GzqIwHH" name="entity.select.add_hint">
|
||||
<segment state="translated">
|
||||
<source>entity.select.add_hint</source>
|
||||
<target>Nutzen Sie -> um verschachtelte Strukturen anzulegen, z.B. "Element 1->Element 1.1"</target>
|
||||
<target><![CDATA[Nutzen Sie -> um verschachtelte Strukturen anzulegen, z.B. "Element 1->Element 1.1"]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
|
||||
@@ -10233,13 +10233,13 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="XLnXtsR" name="homepage.first_steps.introduction">
|
||||
<segment state="translated">
|
||||
<source>homepage.first_steps.introduction</source>
|
||||
<target>Die Datenbank ist momentan noch leer. Sie möchten möglicherweise die <a href="%url%">Dokumentation</a> lesen oder anfangen, die folgenden Datenstrukturen anzulegen.</target>
|
||||
<target><![CDATA[Die Datenbank ist momentan noch leer. Sie möchten möglicherweise die <a href="%url%">Dokumentation</a> lesen oder anfangen, die folgenden Datenstrukturen anzulegen.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q79MOIk" name="homepage.first_steps.create_part">
|
||||
<segment state="translated">
|
||||
<source>homepage.first_steps.create_part</source>
|
||||
<target>Oder Sie können direkt ein <a href="%url%">neues Bauteil erstellen</a>.</target>
|
||||
<target><![CDATA[Oder Sie können direkt ein <a href="%url%">neues Bauteil erstellen</a>.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vplYq4f" name="homepage.first_steps.hide_hint">
|
||||
@@ -10251,7 +10251,7 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="MJoZl4f" name="homepage.forum.text">
|
||||
<segment state="translated">
|
||||
<source>homepage.forum.text</source>
|
||||
<target>Für Fragen rund um Part-DB, nutze das <a class="link-external" rel="noopener" target="_blank" href="%href%">Diskussionsforum</a></target>
|
||||
<target><![CDATA[Für Fragen rund um Part-DB, nutze das <a class="link-external" rel="noopener" target="_blank" href="%href%">Diskussionsforum</a>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="YsukbnK" name="log.element_edited.changed_fields.category">
|
||||
@@ -10752,7 +10752,7 @@ Element 1 -> Element 1.2</target>
|
||||
<target>Maßeinheit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2COnw1k" name="log.element_edited.changed_fields.partCustomState">
|
||||
<unit id="8QD.2.r" name="log.element_edited.changed_fields.partCustomState">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.partCustomState</source>
|
||||
<target>Benutzerdefinierter Bauteilstatus</target>
|
||||
@@ -10917,7 +10917,7 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="p_IxB9K" name="parts.import.help_documentation">
|
||||
<segment state="translated">
|
||||
<source>parts.import.help_documentation</source>
|
||||
<target>Konsultieren Sie die <a href="%link%">Dokumentation</a> für weiter Informationen über das Dateiformat.</target>
|
||||
<target><![CDATA[Konsultieren Sie die <a href="%link%">Dokumentation</a> für weiter Informationen über das Dateiformat.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="awbvhVq" name="parts.import.help">
|
||||
@@ -11013,25 +11013,25 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="r5F3f_G" name="measurement_unit.new">
|
||||
<segment state="translated">
|
||||
<source>measurement_unit.new</source>
|
||||
<target>Neue Maßeinheit</target>
|
||||
<target>Neue [Measurement_unit]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="W.vDRLw" name="measurement_unit.edit">
|
||||
<segment state="translated">
|
||||
<source>measurement_unit.edit</source>
|
||||
<target>Bearbeite Maßeinheit</target>
|
||||
<target>Bearbeite [Measurement_unit]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ba52d.g" name="part_custom_state.new">
|
||||
<unit id="Ae0GMtY" name="part_custom_state.new">
|
||||
<segment state="translated">
|
||||
<source>part_custom_state.new</source>
|
||||
<target>Neuer benutzerdefinierter Bauteilstatus</target>
|
||||
<target>Neuer [Part_custom_state]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="c1.gb2d" name="part_custom_state.edit">
|
||||
<unit id="5uZ23wR" name="part_custom_state.edit">
|
||||
<segment state="translated">
|
||||
<source>part_custom_state.edit</source>
|
||||
<target>Bearbeite benutzerdefinierten Bauteilstatus</target>
|
||||
<target>Bearbeite [Part_custom_state]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="uW2WHHC" name="user.aboutMe.label">
|
||||
@@ -11109,7 +11109,7 @@ Element 1 -> Element 1.2</target>
|
||||
<unit id="o5u.Nnz" name="part.filter.lessThanDesired">
|
||||
<segment state="translated">
|
||||
<source>part.filter.lessThanDesired</source>
|
||||
<target>Weniger vorhanden als gewünscht (Gesamtmenge < Mindestmenge)</target>
|
||||
<target><![CDATA[Weniger vorhanden als gewünscht (Gesamtmenge < Mindestmenge)]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="YN9eLcZ" name="part.filter.lotOwner">
|
||||
@@ -11915,13 +11915,13 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="i68lU5x" name="part.merge.confirm.title">
|
||||
<segment state="translated">
|
||||
<source>part.merge.confirm.title</source>
|
||||
<target>Möchten Sie wirklich <b>%other%</b> in <b>%target%</b> zusammenführen?</target>
|
||||
<target><![CDATA[Möchten Sie wirklich <b>%other%</b> in <b>%target%</b> zusammenführen?]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k0anzYV" name="part.merge.confirm.message">
|
||||
<segment state="translated">
|
||||
<source>part.merge.confirm.message</source>
|
||||
<target><b>%other%</b> wird gelöscht, und das aktuelle Bauteil wird mit den angezeigten Daten gespeichert.</target>
|
||||
<target><![CDATA[<b>%other%</b> wird gelöscht, und das aktuelle Bauteil wird mit den angezeigten Daten gespeichert.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mmW5Yl1" name="part.info.merge_modal.title">
|
||||
@@ -12275,7 +12275,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="p7LGAIX" name="settings.ips.element14.apiKey.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14.apiKey.help</source>
|
||||
<target>Sie können sich unter <a href="https://partner.element14.com/">https://partner.element14.com/</a> für einen API-Schlüssel registrieren.</target>
|
||||
<target><![CDATA[Sie können sich unter <a href="https://partner.element14.com/">https://partner.element14.com/</a> für einen API-Schlüssel registrieren.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZdUHpZc" name="settings.ips.element14.storeId">
|
||||
@@ -12287,7 +12287,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="XXGUxF6" name="settings.ips.element14.storeId.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14.storeId.help</source>
|
||||
<target>Die Domain des Shops, aus dem die Daten abgerufen werden sollen. Diese bestimmt die Sprache und Währung der Ergebnisse. Eine Liste der gültigen Domains finden Sie <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">hier</a>.</target>
|
||||
<target><![CDATA[Die Domain des Shops, aus dem die Daten abgerufen werden sollen. Diese bestimmt die Sprache und Währung der Ergebnisse. Eine Liste der gültigen Domains finden Sie <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">hier</a>.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WKWZIm2" name="settings.ips.tme">
|
||||
@@ -12305,7 +12305,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="_pYLrPT" name="settings.ips.tme.token.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.token.help</source>
|
||||
<target>Sie können einen API-Token und einen geheimen Schlüssel unter <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a> erhalten.</target>
|
||||
<target><![CDATA[Sie können einen API-Token und einen geheimen Schlüssel unter <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a> erhalten.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yswx4bq" name="settings.ips.tme.secret">
|
||||
@@ -12353,7 +12353,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="gu.JlpT" name="settings.ips.mouser.apiKey.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.apiKey.help</source>
|
||||
<target>Sie können sich unter <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a> für einen API-Schlüssel registrieren.</target>
|
||||
<target><![CDATA[Sie können sich unter <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a> für einen API-Schlüssel registrieren.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q66CNjw" name="settings.ips.mouser.searchLimit">
|
||||
@@ -12401,7 +12401,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="xU8_Qw." name="settings.ips.mouser.searchOptions.rohsAndInStock">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions.rohsAndInStock</source>
|
||||
<target>Sofort verfügbar & RoHS konform</target>
|
||||
<target><![CDATA[Sofort verfügbar & RoHS konform]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fQYt0Om" name="settings.ips.lcsc">
|
||||
@@ -12431,7 +12431,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="kKv0J3." name="settings.system.attachments">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments</source>
|
||||
<target>Anhänge & Dateien</target>
|
||||
<target><![CDATA[Anhänge & Dateien]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dsRff8T" name="settings.system.attachments.maxFileSize">
|
||||
@@ -12455,7 +12455,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="T.PBu5P" name="settings.system.attachments.allowDownloads.help">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.allowDownloads.help</source>
|
||||
<target>Mit dieser Option können Benutzer externe Dateien in die Part-DB herunterladen, indem sie eine URL angeben. <b>Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!</b></target>
|
||||
<target><![CDATA[Mit dieser Option können Benutzer externe Dateien in die Part-DB herunterladen, indem sie eine URL angeben. <b>Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!</b>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".OyihML" name="settings.system.attachments.downloadByDefault">
|
||||
@@ -12629,8 +12629,8 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="0GRlEe5" name="settings.system.localization.base_currency_description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.base_currency_description</source>
|
||||
<target>Die Währung, in der Preisinformationen und Wechselkurse gespeichert werden. Diese Währung wird angenommen, wenn für eine Preisinformation keine Währung festgelegt ist.
|
||||
<b>Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!</b></target>
|
||||
<target><![CDATA[Die Währung, in der Preisinformationen und Wechselkurse gespeichert werden. Diese Währung wird angenommen, wenn für eine Preisinformation keine Währung festgelegt ist.
|
||||
<b>Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!</b>]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cvpTUeY" name="settings.system.privacy">
|
||||
@@ -12660,7 +12660,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="w07P3Dt" name="settings.misc.kicad_eda.category_depth.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.category_depth.help</source>
|
||||
<target>Dieser Wert bestimmt die Tiefe des Kategoriebaums, der in KiCad sichtbar ist. 0 bedeutet, dass nur die Kategorien der obersten Ebene sichtbar sind. Setzen Sie den Wert auf > 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen.</target>
|
||||
<target><![CDATA[Dieser Wert bestimmt die Tiefe des Kategoriebaums, der in KiCad sichtbar ist. 0 bedeutet, dass nur die Kategorien der obersten Ebene sichtbar sind. Setzen Sie den Wert auf > 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
@@ -12678,7 +12678,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="jc0JTvL" name="settings.behavior.sidebar.items.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.items.help</source>
|
||||
<target>Die Menüs, die standardmäßig in der Seitenleiste angezeigt werden. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden.</target>
|
||||
<target><![CDATA[Die Menüs, die standardmäßig in der Seitenleiste angezeigt werden. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gVSWDkE" name="settings.behavior.sidebar.rootNodeEnabled">
|
||||
@@ -12726,7 +12726,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="SUD8H3b" name="settings.behavior.table.parts_default_columns.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.table.parts_default_columns.help</source>
|
||||
<target>Die Spalten, die standardmäßig in Bauteiltabellen angezeigt werden sollen. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden.</target>
|
||||
<target><![CDATA[Die Spalten, die standardmäßig in Bauteiltabellen angezeigt werden sollen. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hazr_g5" name="settings.ips.oemsecrets">
|
||||
@@ -12780,7 +12780,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="KLJYfJ0" name="settings.ips.oemsecrets.sortMode.M">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.sortMode.M</source>
|
||||
<target>Vollständigkeit & Herstellername</target>
|
||||
<target><![CDATA[Vollständigkeit & Herstellername]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8C9ijHM" name="entity.export.flash.error.no_entities">
|
||||
@@ -13440,7 +13440,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="FsrRdkp" name="settings.behavior.homepage.items.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.homepage.items.help</source>
|
||||
<target>Die Elemente, die auf der Startseite angezeigt werden sollen. Die Reihenfolge kann per Drag & Drop geändert werden.</target>
|
||||
<target><![CDATA[Die Elemente, die auf der Startseite angezeigt werden sollen. Die Reihenfolge kann per Drag & Drop geändert werden.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CYw3_pS" name="settings.system.customization.showVersionOnHomepage">
|
||||
@@ -14154,7 +14154,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<unit id="Ej2znKK" name="settings.system.localization.language_menu_entries.description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.language_menu_entries.description</source>
|
||||
<target>Die Sprachen, die im Sprachen Dropdown-Menü angezeigt werden sollen. Die Reihenfolge kann via Drag&Drop geändert werden. Lassen Sie das Feld leer, um alle verfügbaren Sprachen anzuzeigen.</target>
|
||||
<target><![CDATA[Die Sprachen, die im Sprachen Dropdown-Menü angezeigt werden sollen. Die Reihenfolge kann via Drag&Drop geändert werden. Lassen Sie das Feld leer, um alle verfügbaren Sprachen anzuzeigen.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xIZ_mEX" name="project.builds.no_bom_entries">
|
||||
@@ -14429,5 +14429,34 @@ Bitte beachten Sie, dass dieses System derzeit experimentell ist und die hier de
|
||||
<target>Wenn aktiviert, wird eine Option zur Generierung einer IPN mit diesem globalen Präfix angeboten, das für Bauteile in allen Kategorien gilt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ae8pGfM" name="settings.ips.buerklin">
|
||||
<segment>
|
||||
<source>settings.ips.buerklin</source>
|
||||
<target>Buerklin</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nlVH1Nb" name="settings.ips.buerklin.username">
|
||||
<segment>
|
||||
<source>settings.ips.buerklin.username</source>
|
||||
<target>Benutzername</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BlR_EQc" name="settings.ips.buerklin.help">
|
||||
<segment>
|
||||
<source>settings.ips.buerklin.help</source>
|
||||
<target>Buerklin-API-Zugriffsbeschränkungen: 100 Requests/Minute pro IP-Adresse
|
||||
Buerklin-API-Authentication-Server:
|
||||
10 Requests/Minute pro IP-Adresse</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -1638,5 +1638,14 @@
|
||||
<target>Κατασκευαστές</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12336,5 +12336,14 @@ Por favor ten en cuenta que no puedes personificar a un usuario deshabilitado. S
|
||||
<target>Usuarios</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -9076,5 +9076,14 @@ exemple de ville</target>
|
||||
<target>Utilisateurs</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -14049,5 +14049,14 @@
|
||||
<target>Tömeges importálási feladat alkatrészek</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -12338,5 +12338,14 @@ Notare che non è possibile impersonare un utente disattivato. Quando si prova a
|
||||
<target>Utenti</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -8813,5 +8813,14 @@ Exampletown</target>
|
||||
<target>ユーザー</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -840,5 +840,14 @@
|
||||
<target>Aangepaste staten van onderdelen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -12191,5 +12191,14 @@ Należy pamiętać, że nie możesz udawać nieaktywnych użytkowników. Jeśli
|
||||
<target>Użytkownicy</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -12291,5 +12291,14 @@
|
||||
<target>Пользователи</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -12176,5 +12176,14 @@ Element 3</target>
|
||||
<target>用户</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MxKRRx_" name="datatable.datatable.lengthMenu">
|
||||
<notes>
|
||||
<note priority="1">Do not remove! Used for datatables rendering.</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>datatable.datatable.lengthMenu</source>
|
||||
<target>_MENU_</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
|
||||
<target>There is already a translation defined for this type and language!</target>
|
||||
</segment>
|
||||
|
||||
Reference in New Issue
Block a user