mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-06-27 21:11:34 +00:00
Compare commits
30 commits
f04280b820
...
68d3325a33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d3325a33 | ||
|
|
a82d515034 | ||
|
|
6a30b41688 | ||
|
|
ec05f9d8ab | ||
|
|
1c3dfa26bb | ||
|
|
766665f9e5 | ||
|
|
29db029d69 | ||
|
|
146e85f84c | ||
|
|
c17cf5e83c | ||
|
|
5b86d6f652 | ||
|
|
58a34e3628 | ||
|
|
35dcb298e7 | ||
|
|
0140c9a7b9 | ||
|
|
d25ac2622e | ||
|
|
cee7e2a077 | ||
|
|
2a6e5435e1 | ||
|
|
05b1965957 | ||
|
|
57ef3e06a7 | ||
|
|
7d8a7ab471 | ||
|
|
ad35ae6e9e | ||
|
|
f12f808b34 | ||
|
|
0080aa9f25 | ||
|
|
dc522d4795 | ||
|
|
f07eabd85a | ||
|
|
70454e3a6d | ||
|
|
8b3bebca7b | ||
|
|
4d296d8f3a | ||
|
|
96da2b9f1f | ||
|
|
f9a8818e69 | ||
|
|
52df554b29 |
62 changed files with 13128 additions and 4993 deletions
4
.env
4
.env
|
|
@ -121,6 +121,10 @@ SAML_SP_PRIVATE_KEY="MIIE..."
|
|||
# In demo mode things it is not possible for a user to change his password and his settings.
|
||||
DEMO_MODE=0
|
||||
|
||||
# When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file.
|
||||
# This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
|
||||
ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0
|
||||
|
||||
# Change this to true, if no url rewriting (like mod_rewrite for Apache) is available
|
||||
# In that case all URL contains the index.php front controller in URL
|
||||
NO_URL_REWRITE_AVAILABLE=0
|
||||
|
|
|
|||
2
.github/workflows/assets_artifact_build.yml
vendored
2
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
- name: Setup node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install yarn dependencies
|
||||
run: yarn install
|
||||
|
|
|
|||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -106,7 +106,7 @@ jobs:
|
|||
- name: Setup node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install yarn dependencies
|
||||
run: yarn install
|
||||
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
run: ./bin/phpunit --coverage-clover=coverage.xml
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
env_vars: PHP_VERSION,DB_TYPE
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
|
|||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -25,6 +25,10 @@
|
|||
uploads/*
|
||||
!uploads/.keep
|
||||
|
||||
# Some people use Certbot or similar tools to make SSL certificates.
|
||||
# Also see https://www.rfc-editor.org/rfc/rfc5785
|
||||
public/.well-known/
|
||||
|
||||
# Do not keep cache files
|
||||
.php_cs.cache
|
||||
.phpcs-cache
|
||||
|
|
@ -50,4 +54,11 @@ phpstan.neon
|
|||
###< phpstan/phpstan ###
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
|
||||
.codex
|
||||
migrations/.codex
|
||||
docker-data/
|
||||
scripts/
|
||||
db/
|
||||
docker-compose.yaml
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ RUN yarn build
|
|||
RUN yarn cache clean && rm -rf node_modules/
|
||||
|
||||
# FrankenPHP base stage
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream
|
||||
ARG TARGETARCH
|
||||
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
|
||||
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@ Part-DB is also used by small companies and universities for managing their inve
|
|||
## Requirements
|
||||
|
||||
* A **web server** (like Apache2 or nginx) that is capable of
|
||||
running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html),
|
||||
running [Symfony 7](https://symfony.com/doc/current/reference/requirements.html),
|
||||
this includes a minimum PHP version of **PHP 8.2**
|
||||
* A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite.
|
||||
* Shell access to your server is highly recommended!
|
||||
* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed.
|
||||
* For building the client-side assets **yarn** and **nodejs** (>= 22.0) is needed.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.9.1
|
||||
2.10.0
|
||||
|
|
|
|||
1164
composer.lock
generated
1164
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -56,6 +56,7 @@ doctrine:
|
|||
natsort: App\Doctrine\Functions\Natsort
|
||||
array_position: App\Doctrine\Functions\ArrayPosition
|
||||
ilike: App\Doctrine\Functions\ILike
|
||||
si_value_sort: App\Doctrine\Functions\SiValueSort
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ parameters:
|
|||
|
||||
env(DATABASE_EMULATE_NATURAL_SORT): 0
|
||||
|
||||
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
|
||||
|
||||
######################################################################################################################
|
||||
# Bulk Info Provider Import Configuration
|
||||
######################################################################################################################
|
||||
|
|
|
|||
|
|
@ -1550,7 +1550,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* template_parameters?: array{ // Default parameters to be passed to the template
|
||||
* className?: scalar|Param|null, // Default class attribute to apply to the root table elements // Default: "table table-bordered"
|
||||
* columnFilter?: "thead"|"tfoot"|"both"|Param|null, // If and where to enable the DataTables Filter module // Default: null
|
||||
* ...<mixed>
|
||||
* ...<string, mixed>
|
||||
* },
|
||||
* translation_domain?: scalar|Param|null, // Default translation domain to be used // Default: "messages"
|
||||
* }
|
||||
|
|
@ -1705,14 +1705,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* use_underscore?: bool|Param, // Default: true
|
||||
* unordered_list_markers?: list<scalar|Param|null>,
|
||||
* },
|
||||
* ...<mixed>
|
||||
* ...<string, mixed>
|
||||
* },
|
||||
* }
|
||||
* @psalm-type GregwarCaptchaConfig = array{
|
||||
* length?: scalar|Param|null, // Default: 5
|
||||
* width?: scalar|Param|null, // Default: 130
|
||||
* height?: scalar|Param|null, // Default: 50
|
||||
* font?: scalar|Param|null, // Default: "E:\\PHP\\Part-DB-server\\vendor\\gregwar\\captcha-bundle\\DependencyInjection/../Generator/Font/captcha.ttf"
|
||||
* font?: scalar|Param|null, // Default: "/home/jan/php/Part-DB-server/vendor/gregwar/captcha-bundle/DependencyInjection/../Generator/Font/captcha.ttf"
|
||||
* keep_value?: scalar|Param|null, // Default: false
|
||||
* charset?: scalar|Param|null, // Default: "abcdefhjkmnprstuvwxyz23456789"
|
||||
* as_file?: scalar|Param|null, // Default: false
|
||||
|
|
@ -2649,7 +2649,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* cast_fn?: mixed,
|
||||
* default?: mixed,
|
||||
* filter_class?: mixed,
|
||||
* ...<mixed>
|
||||
* ...<string, mixed>
|
||||
* }>,
|
||||
* strict_query_parameter_validation?: mixed,
|
||||
* hide_hydra_operation?: mixed,
|
||||
|
|
@ -2669,7 +2669,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* name?: mixed,
|
||||
* allow_create?: mixed,
|
||||
* item_uri_template?: mixed,
|
||||
* ...<mixed>
|
||||
* ...<string, mixed>
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
|||
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
|
||||
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
|
||||
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
|
||||
* `ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK` (default `0`): When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file. This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
|
||||
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser.
|
||||
Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled,
|
||||
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ services:
|
|||
docker-compose up -d
|
||||
```
|
||||
|
||||
{: .warning }
|
||||
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
|
||||
> Otherwise Part-DB console might use the wrong configuration to execute commands.
|
||||
|
||||
|
||||
6. Create the initial database with
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ fulfilled by the official Part-DB docker image.*
|
|||
|
||||
Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still
|
||||
running PHP 8.1, you will have to upgrade your PHP version first.
|
||||
The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too.
|
||||
The minimum required version of node.js is now 22.0 or newer, so if you are using 18.0, you will have to upgrade it too.
|
||||
|
||||
Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to
|
||||
easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which
|
||||
|
|
@ -60,6 +60,8 @@ The `php bin/console partdb:backup` command can help you with this.
|
|||
If you want to change them, you must migrate them to the settings interface as described below.
|
||||
|
||||
### Docker installation
|
||||
**When running the console commands from inside a docker container's shell as root, be sure to use `sudo -E` to preserve the environment variables, so that they are correctly passed to the command.**
|
||||
|
||||
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables.
|
||||
2. Stop the existing Part-DB container with `docker compose down`
|
||||
3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag).
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ You can define this on a per-part basis using the KiCad symbol and KiCad footpri
|
|||
For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew.
|
||||
|
||||
If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value.
|
||||
If you want to keep custom suggestions across updates, open the server settings page and use the "Autocomplete settings" page. There you can edit `public/kicad/footprints_custom.txt` and `public/kicad/symbols_custom.txt` and enable the "Use custom autocomplete lists" option to use those files instead of the autogenerated defaults.
|
||||
|
||||
### Parts and category visibility
|
||||
|
||||
|
|
|
|||
18
package.json
18
package.json
|
|
@ -9,16 +9,16 @@
|
|||
"@symfony/stimulus-bridge": "^4.0.0",
|
||||
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
|
||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
|
||||
"@symfony/webpack-encore": "^5.1.0",
|
||||
"@symfony/webpack-encore": "^6.0.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"core-js": "^3.38.0",
|
||||
"intl-messageformat": "^10.2.5",
|
||||
"intl-messageformat": "^10.5.11",
|
||||
"jquery": "^3.5.1",
|
||||
"popper.js": "^1.14.7",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-analyzer": "^5.1.1",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-cli": "^6.0.0",
|
||||
"webpack-notifier": "^1.15.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
|
@ -30,14 +30,14 @@
|
|||
"build": "encore production --progress"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-js": "^1.17.0",
|
||||
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
|
||||
"@algolia/autocomplete-theme-classic": "^1.17.0",
|
||||
"@ckeditor/ckeditor5-dev-translations": "^43.0.1",
|
||||
"@ckeditor/ckeditor5-dev-utils": "^43.0.1",
|
||||
"@ckeditor/ckeditor5-dev-translations": "^53",
|
||||
"@ckeditor/ckeditor5-dev-utils": "^53",
|
||||
"@jbtronics/bs-treeview": "^1.0.1",
|
||||
"@part-db/html5-qrcode": "^4.0.0",
|
||||
"@zxcvbn-ts/core": "^3.0.2",
|
||||
|
|
@ -69,11 +69,11 @@
|
|||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
"marked-mangle": "^1.0.1",
|
||||
"pdfmake": "^0.2.2",
|
||||
"pdfmake": "^0.3.7",
|
||||
"stimulus-use": "^0.52.0",
|
||||
"tom-select": "^2.1.0",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"jquery": "^3.5.1"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ parameters:
|
|||
- '#expects .*PartParameter, .*AbstractParameter given.#'
|
||||
- '#Part::getParameters\(\) should return .*AbstractParameter#'
|
||||
|
||||
# Fix some weird issue with how covariance with collections is solved
|
||||
- '#Method App\\Entity\\Base\\AbstractStructuralDBElement::getParameters\(\) should return Doctrine\\Common\\Collections\\Collection<int, App\\Entity\\Parameters\\AbstractParameter> but returns#'
|
||||
|
||||
# Ignore doctrine type mapping mismatch
|
||||
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
|
||||
|
||||
|
|
@ -70,3 +73,6 @@ parameters:
|
|||
|
||||
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
|
||||
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
|
||||
|
||||
-
|
||||
identifier: nullCoalesce.property
|
||||
|
|
|
|||
3
public/kicad/.gitignore
vendored
Normal file
3
public/kicad/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# They are user generated and should not be tracked by git
|
||||
footprints_custom.txt
|
||||
symbols_custom.txt
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Mon Mar 9 04:23:25 UTC 2026
|
||||
# Generated on Mon Apr 20 05:18:27 UTC 2026
|
||||
# This file contains all footprints available in the offical KiCAD library
|
||||
Audio_Module:Reverb_BTDR-1H
|
||||
Audio_Module:Reverb_BTDR-1V
|
||||
|
|
@ -12028,6 +12028,8 @@ Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm
|
|||
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm
|
||||
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm
|
||||
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm
|
||||
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Mon Mar 9 04:24:12 UTC 2026
|
||||
# Generated on Mon Apr 20 05:19:05 UTC 2026
|
||||
# This file contains all symbols available in the offical KiCAD library
|
||||
4xxx:14528
|
||||
4xxx:14529
|
||||
|
|
@ -899,6 +899,7 @@ Amplifier_Buffer:BUF634AxD
|
|||
Amplifier_Buffer:BUF634AxDDA
|
||||
Amplifier_Buffer:BUF634AxDRB
|
||||
Amplifier_Buffer:BUF634U
|
||||
Amplifier_Buffer:BUF802
|
||||
Amplifier_Buffer:EL2001CN
|
||||
Amplifier_Buffer:LH0002H
|
||||
Amplifier_Buffer:LM6321H
|
||||
|
|
@ -1667,7 +1668,6 @@ Analog_ADC:CA3300
|
|||
Analog_ADC:HX711
|
||||
Analog_ADC:ICL7106CPL
|
||||
Analog_ADC:ICL7107CPL
|
||||
Analog_ADC:INA234AxYBJ
|
||||
Analog_ADC:LTC1406CGN
|
||||
Analog_ADC:LTC1406IGN
|
||||
Analog_ADC:LTC1594CS
|
||||
|
|
@ -2198,6 +2198,7 @@ Audio:WM8731SEDS
|
|||
Audio:YM2149
|
||||
Audio:YM2612
|
||||
Audio:YM3438
|
||||
Auxiliary_Items:Generic_Outline
|
||||
Auxiliary_Items:Jumper_Shunt
|
||||
Auxiliary_Items:MountingScrew
|
||||
Battery_Management:ADP5063
|
||||
|
|
@ -2254,6 +2255,11 @@ Battery_Management:BQ76200PW
|
|||
Battery_Management:BQ76920PW
|
||||
Battery_Management:BQ76930DBT
|
||||
Battery_Management:BQ76940DBT
|
||||
Battery_Management:BQ7695201PFBR
|
||||
Battery_Management:BQ7695202PFBR
|
||||
Battery_Management:BQ7695203PFBR
|
||||
Battery_Management:BQ7695204PFBR
|
||||
Battery_Management:BQ76952PFBR
|
||||
Battery_Management:BQ78350DBT
|
||||
Battery_Management:BQ78350DBT-R1
|
||||
Battery_Management:CN3063
|
||||
|
|
@ -2763,6 +2769,8 @@ Connector:DIN41612_02x32_AC
|
|||
Connector:DIN41612_02x32_AE
|
||||
Connector:DIN41612_02x32_ZB
|
||||
Connector:DIN41612_03x32_C_Split
|
||||
Connector:DP_Sink
|
||||
Connector:DP_Source
|
||||
Connector:DVI-D_Dual_Link
|
||||
Connector:DVI-I_Dual_Link
|
||||
Connector:ExpressCard
|
||||
|
|
@ -2901,6 +2909,7 @@ Connector:TestPoint_Alt
|
|||
Connector:TestPoint_Flag
|
||||
Connector:TestPoint_Probe
|
||||
Connector:TestPoint_Small
|
||||
Connector:TestPoint_Square
|
||||
Connector:UEXT_Host
|
||||
Connector:UEXT_Slave
|
||||
Connector:USB3_A
|
||||
|
|
@ -7772,6 +7781,7 @@ FPGA_Lattice:ICE40HX1K-TQ144
|
|||
FPGA_Lattice:ICE40HX4K-BG121
|
||||
FPGA_Lattice:ICE40HX4K-TQ144
|
||||
FPGA_Lattice:ICE40HX8K-BG121
|
||||
FPGA_Lattice:ICE40LP384-SG32
|
||||
FPGA_Lattice:ICE40UL1K-SWG16
|
||||
FPGA_Lattice:ICE40UP5K-SG48ITR
|
||||
FPGA_Lattice:ICE5LP1K-SG48
|
||||
|
|
@ -15731,6 +15741,7 @@ Power_Management:RT9742AGJ5F
|
|||
Power_Management:RT9742ANGJ5F
|
||||
Power_Management:RT9742BGJ5F
|
||||
Power_Management:RT9742BNGJ5F
|
||||
Power_Management:RT9742SNGV
|
||||
Power_Management:SN6505ADBV
|
||||
Power_Management:SN6505BDBV
|
||||
Power_Management:SN6507DGQ
|
||||
|
|
@ -18692,6 +18703,7 @@ Regulator_Linear:TPS7A0530PDBZ
|
|||
Regulator_Linear:TPS7A0531PDBV
|
||||
Regulator_Linear:TPS7A0533PDBV
|
||||
Regulator_Linear:TPS7A0533PDBZ
|
||||
Regulator_Linear:TPS7A20xxxDBV
|
||||
Regulator_Linear:TPS7A20xxxDQN
|
||||
Regulator_Linear:TPS7A3301RGW
|
||||
Regulator_Linear:TPS7A39
|
||||
|
|
@ -20301,7 +20313,6 @@ Sensor:BME280
|
|||
Sensor:BME680
|
||||
Sensor:CHT11
|
||||
Sensor:DHT11
|
||||
Sensor:INA260
|
||||
Sensor:LTC2990
|
||||
Sensor:MAX30102
|
||||
Sensor:Nuclear-Radiation_Detector
|
||||
|
|
@ -20588,9 +20599,12 @@ Sensor_Energy:INA219BxD
|
|||
Sensor_Energy:INA219BxDCN
|
||||
Sensor_Energy:INA226
|
||||
Sensor_Energy:INA228
|
||||
Sensor_Energy:INA229
|
||||
Sensor_Energy:INA233
|
||||
Sensor_Energy:INA234AxYBJ
|
||||
Sensor_Energy:INA237
|
||||
Sensor_Energy:INA238
|
||||
Sensor_Energy:INA260
|
||||
Sensor_Energy:LTC4151xMS
|
||||
Sensor_Energy:MCP39F521
|
||||
Sensor_Energy:PAC1931x-xJ6CX
|
||||
|
|
@ -20872,6 +20886,7 @@ Sensor_Proximity:BPR-105
|
|||
Sensor_Proximity:BPR-105F
|
||||
Sensor_Proximity:BPR-205
|
||||
Sensor_Proximity:CNY70
|
||||
Sensor_Proximity:FDC1004DGS
|
||||
Sensor_Proximity:GP2S700HCP
|
||||
Sensor_Proximity:ITR1201SR10AR
|
||||
Sensor_Proximity:ITR8307
|
||||
|
|
@ -21791,6 +21806,7 @@ Transistor_BJT:Q_NPN_Darlington_ECBC
|
|||
Transistor_BJT:Q_NPN_EBC
|
||||
Transistor_BJT:Q_NPN_ECB
|
||||
Transistor_BJT:Q_NPN_ECBC
|
||||
Transistor_BJT:Q_PNP_ACAB
|
||||
Transistor_BJT:Q_PNP_BCE
|
||||
Transistor_BJT:Q_PNP_BCEC
|
||||
Transistor_BJT:Q_PNP_BEC
|
||||
|
|
|
|||
|
|
@ -201,6 +201,10 @@ class BackupCommand extends Command
|
|||
$config_dir = $this->project_dir.'/config';
|
||||
$zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml');
|
||||
$zip->addFile($config_dir.'/banner.md', 'config/banner.md');
|
||||
|
||||
//Add kicad custom footprints and symbols files
|
||||
$zip->addFile($this->project_dir . '/public/kicad/footprints_custom.txt', 'public/kicad/footprints_custom.txt');
|
||||
$zip->addFile($this->project_dir . '/public/kicad/symbols_custom.txt', 'public/kicad/symbols_custom.txt');
|
||||
}
|
||||
|
||||
protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void
|
||||
|
|
|
|||
|
|
@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command
|
|||
}
|
||||
|
||||
$factory = new ResetAutoIncrementPurgerFactory();
|
||||
$purger = $factory->createForEntityManager(null, $this->entityManager);
|
||||
|
||||
//Use truncate purging to fix compatibility with postgresql
|
||||
$purger = $factory->createForEntityManager(null, $this->entityManager, purgeWithTruncate: true);
|
||||
|
||||
$purger->purge();
|
||||
|
||||
//Afterwards run the load fixtures command as normal, but with the --append option
|
||||
$new_input = new ArrayInput([
|
||||
'command' => 'doctrine:fixtures:load',
|
||||
'--purge-with-truncate' => true,
|
||||
'--append' => true,
|
||||
]);
|
||||
|
||||
|
|
@ -70,4 +73,4 @@ class LoadFixturesCommand extends Command
|
|||
|
||||
return $returnCode ?? Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
src/Controller/KicadListEditorController.php
Normal file
88
src/Controller/KicadListEditorController.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Controller;
|
||||
|
||||
use App\Form\Settings\KicadListEditorType;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use App\Services\EDA\KicadListFileManager;
|
||||
use Jbtronics\SettingsBundle\Exception\SettingsNotValidException;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
final class KicadListEditorController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsManagerInterface $settingsManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/settings/misc/kicad-lists', name: 'settings_kicad_lists')]
|
||||
public function __invoke(Request $request, KicadListFileManager $fileManager): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
|
||||
/** @var KiCadEDASettings $settings */
|
||||
$settings = $this->settingsManager->createTemporaryCopy(KiCadEDASettings::class);
|
||||
$form = $this->createForm(KicadListEditorType::class, [
|
||||
'useCustomList' => $settings->useCustomList,
|
||||
'customFootprints' => $fileManager->getCustomFootprintsContent(),
|
||||
'customSymbols' => $fileManager->getCustomSymbolsContent(),
|
||||
], [
|
||||
'default_footprints' => $fileManager->getFootprintsContent(),
|
||||
'default_symbols' => $fileManager->getSymbolsContent(),
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$data = $form->getData();
|
||||
|
||||
try {
|
||||
$fileManager->saveCustom($data['customFootprints'], $data['customSymbols']);
|
||||
$settings->useCustomList = (bool) $data['useCustomList'];
|
||||
$this->settingsManager->mergeTemporaryCopy($settings);
|
||||
$this->settingsManager->save($settings);
|
||||
$this->addFlash('success', t('settings.flash.saved'));
|
||||
|
||||
return $this->redirectToRoute('settings_kicad_lists');
|
||||
} catch (RuntimeException|SettingsNotValidException $exception) {
|
||||
$this->addFlash('error', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$this->addFlash('error', t('settings.flash.invalid'));
|
||||
}
|
||||
|
||||
return $this->render('settings/kicad_list_editor.html.twig', [
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -69,10 +69,13 @@ class ProjectController extends AbstractController
|
|||
return $table->getResponse();
|
||||
}
|
||||
|
||||
$number_of_builds = max(1, $request->query->getInt('n', 1));
|
||||
|
||||
return $this->render('projects/info/info.html.twig', [
|
||||
'buildHelper' => $buildHelper,
|
||||
'datatable' => $table,
|
||||
'project' => $project,
|
||||
'number_of_builds' => $number_of_builds,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class SettingsController extends AbstractController
|
|||
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
//Create a clone of the settings object
|
||||
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ use App\DataTables\Filters\PartFilter;
|
|||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\DataTables\Helpers\ColumnSortHelper;
|
||||
use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Doctrine\Functions\SiValueSort;
|
||||
use App\Doctrine\Helpers\FieldHelper;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
|
|
@ -118,6 +119,18 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
|
||||
'orderField' => 'NATSORT(part.name)'
|
||||
])
|
||||
->add('si_value', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.si_value'),
|
||||
'render' => function ($value, Part $context): string {
|
||||
$siValue = SiValueSort::sqliteSiValue($context->getName());
|
||||
if ($siValue !== null) {
|
||||
//Output it as scientific number with a big E
|
||||
return htmlspecialchars(sprintf('%G', $siValue));
|
||||
}
|
||||
return '';
|
||||
},
|
||||
'orderField' => 'SI_VALUE_SORT(part.name)',
|
||||
])
|
||||
->add('id', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.id'),
|
||||
])
|
||||
|
|
@ -484,6 +497,19 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
//$builder->addGroupBy('_bulkImportJob');
|
||||
}
|
||||
|
||||
//When sorting by SI value, add NATSORT as a secondary sort so that parts without
|
||||
//an SI-prefixed value fall back to natural string ordering seamlessly.
|
||||
$orderByParts = $builder->getDQLPart('orderBy');
|
||||
foreach ($orderByParts as $orderBy) {
|
||||
foreach ($orderBy->getParts() as $part) {
|
||||
if (str_contains($part, 'SI_VALUE_SORT')) {
|
||||
$direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC';
|
||||
$builder->addOrderBy('NATSORT(part.name)', $direction);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
|
|
@ -20,23 +17,31 @@ declare(strict_types=1);
|
|||
* 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\DataTables;
|
||||
|
||||
use App\DataTables\Adapters\TwoStepORMAdapter;
|
||||
use App\DataTables\Column\EntityColumn;
|
||||
use App\DataTables\Column\EnumColumn;
|
||||
use App\DataTables\Column\LocaleDateTimeColumn;
|
||||
use App\DataTables\Column\MarkdownColumn;
|
||||
use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Doctrine\Helpers\FieldHelper;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use App\Services\Formatters\MoneyFormatter;
|
||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
|
||||
use Omines\DataTablesBundle\Column\TextColumn;
|
||||
use Omines\DataTablesBundle\DataTable;
|
||||
use Omines\DataTablesBundle\DataTableTypeInterface;
|
||||
|
|
@ -44,9 +49,14 @@ 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 EntityURLGenerator $entityURLGenerator,
|
||||
protected TranslatorInterface $translator,
|
||||
protected AmountFormatter $amountFormatter,
|
||||
protected PartDataTableHelper $partDataTableHelper,
|
||||
protected ProjectBuildHelper $projectBuildHelper,
|
||||
protected MoneyFormatter $moneyFormatter,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -62,7 +72,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return '';
|
||||
}
|
||||
return $this->partDataTableHelper->renderPicture($context->getPart());
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
->add('id', TextColumn::class, [
|
||||
|
|
@ -133,23 +143,24 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
->add('category', EntityColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.category'),
|
||||
'property' => 'part.category',
|
||||
'orderField' => 'NATSORT(category.name)',
|
||||
'orderField' => 'NATSORT(category.name)'
|
||||
])
|
||||
->add('footprint', EntityColumn::class, [
|
||||
'property' => 'part.footprint',
|
||||
'label' => $this->translator->trans('part.table.footprint'),
|
||||
'orderField' => 'NATSORT(footprint.name)',
|
||||
'orderField' => 'NATSORT(footprint.name)'
|
||||
])
|
||||
|
||||
->add('manufacturer', EntityColumn::class, [
|
||||
'property' => 'part.manufacturer',
|
||||
'label' => $this->translator->trans('part.table.manufacturer'),
|
||||
'orderField' => 'NATSORT(manufacturer.name)',
|
||||
'orderField' => 'NATSORT(manufacturer.name)'
|
||||
])
|
||||
|
||||
->add('manufacturing_status', EnumColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
||||
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
||||
'orderField' => 'part.manufacturing_status',
|
||||
'class' => ManufacturingStatus::class,
|
||||
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
||||
if ($status === null) {
|
||||
|
|
@ -183,8 +194,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return '';
|
||||
}
|
||||
])
|
||||
->add('storageLocations', TextColumn::class, [
|
||||
'label' => 'part.table.storeLocations',
|
||||
->add('storelocation', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
||||
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
if ($context->getPart() !== null) {
|
||||
|
|
@ -194,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return '';
|
||||
}
|
||||
])
|
||||
->add('price', TextColumn::class, [
|
||||
'label' => 'project.bom.price',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
||||
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
|
||||
},
|
||||
])
|
||||
->add('ext_price', TextColumn::class, [
|
||||
'label' => 'project.bom.ext_price',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
||||
return $this->moneyFormatter->format(
|
||||
$price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
|
||||
null,
|
||||
2,
|
||||
true
|
||||
);
|
||||
},
|
||||
])
|
||||
|
||||
->add('addedDate', LocaleDateTimeColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.addedDate'),
|
||||
|
|
@ -207,11 +241,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
|
||||
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
|
||||
|
||||
$dataTable->createAdapter(ORMAdapter::class, [
|
||||
'entity' => Attachment::class,
|
||||
'query' => function (QueryBuilder $builder) use ($options): void {
|
||||
$this->getQuery($builder, $options);
|
||||
$dataTable->createAdapter(TwoStepORMAdapter::class, [
|
||||
'entity' => ProjectBOMEntry::class,
|
||||
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
|
||||
'filter_query' => function (QueryBuilder $builder) use ($options): void {
|
||||
$this->getFilterQuery($builder, $options);
|
||||
},
|
||||
'detail_query' => $this->getDetailQuery(...),
|
||||
'criteria' => [
|
||||
function (QueryBuilder $builder) use ($options): void {
|
||||
$this->buildCriteria($builder, $options);
|
||||
|
|
@ -221,20 +257,71 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
]);
|
||||
}
|
||||
|
||||
private function getQuery(QueryBuilder $builder, array $options): void
|
||||
private function getFilterQuery(QueryBuilder $builder, array $options): void
|
||||
{
|
||||
$builder->select('bom_entry')
|
||||
->addSelect('part')
|
||||
$builder
|
||||
->select('bom_entry.id')
|
||||
->from(ProjectBOMEntry::class, 'bom_entry')
|
||||
->leftJoin('bom_entry.part', 'part')
|
||||
->leftJoin('part.category', 'category')
|
||||
->leftJoin('part.partLots', '_partLots')
|
||||
->leftJoin('_partLots.storage_location', '_storelocations')
|
||||
->leftJoin('part.footprint', 'footprint')
|
||||
->leftJoin('part.manufacturer', 'manufacturer')
|
||||
->leftJoin('part.partCustomState', 'partCustomState')
|
||||
->where('bom_entry.project = :project')
|
||||
->setParameter('project', $options['project'])
|
||||
->addGroupBy('bom_entry')
|
||||
->addGroupBy('part')
|
||||
->addGroupBy('category')
|
||||
->addGroupBy('footprint')
|
||||
->addGroupBy('manufacturer')
|
||||
->addGroupBy('partCustomState')
|
||||
;
|
||||
}
|
||||
|
||||
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
|
||||
{
|
||||
$ids = array_map(static fn (array $row) => $row['id'], $filter_results);
|
||||
if ($ids === []) {
|
||||
$ids = [-1];
|
||||
}
|
||||
|
||||
$builder
|
||||
->select('bom_entry')
|
||||
->addSelect('part')
|
||||
->addSelect('category')
|
||||
->addSelect('partLots')
|
||||
->addSelect('storelocations')
|
||||
->addSelect('footprint')
|
||||
->addSelect('manufacturer')
|
||||
->addSelect('partCustomState')
|
||||
->from(ProjectBOMEntry::class, 'bom_entry')
|
||||
->leftJoin('bom_entry.part', 'part')
|
||||
->leftJoin('part.category', 'category')
|
||||
->leftJoin('part.partLots', 'partLots')
|
||||
->leftJoin('partLots.storage_location', 'storelocations')
|
||||
->leftJoin('part.footprint', 'footprint')
|
||||
->leftJoin('part.manufacturer', 'manufacturer')
|
||||
->leftJoin('part.partCustomState', 'partCustomState')
|
||||
->where('bom_entry.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->addGroupBy('bom_entry')
|
||||
->addGroupBy('part')
|
||||
->addGroupBy('partLots')
|
||||
->addGroupBy('category')
|
||||
->addGroupBy('storelocations')
|
||||
->addGroupBy('footprint')
|
||||
->addGroupBy('manufacturer')
|
||||
->addGroupBy('partCustomState')
|
||||
|
||||
->setHint(Query::HINT_READ_ONLY, true)
|
||||
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
|
||||
;
|
||||
|
||||
FieldHelper::addOrderByFieldParam($builder, 'bom_entry.id', 'ids');
|
||||
}
|
||||
|
||||
private function buildCriteria(QueryBuilder $builder, array $options): void
|
||||
{
|
||||
|
||||
|
|
|
|||
196
src/Doctrine/Functions/SiValueSort.php
Normal file
196
src/Doctrine/Functions/SiValueSort.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Doctrine\Functions;
|
||||
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\SQLitePlatform;
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
use Doctrine\ORM\Query\AST\Node;
|
||||
use Doctrine\ORM\Query\Parser;
|
||||
use Doctrine\ORM\Query\SqlWalker;
|
||||
use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
/**
|
||||
* Custom DQL function that extracts the first numeric value with an optional SI prefix
|
||||
* from a string and returns the scaled numeric value for sorting.
|
||||
*
|
||||
* Usage: SI_VALUE_SORT(part.name)
|
||||
*
|
||||
* This enables sorting parts by their physical value. For example, capacitors
|
||||
* named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF.
|
||||
*
|
||||
* Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6),
|
||||
* m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12).
|
||||
*
|
||||
* Only matches numbers at the very beginning of the string (ignoring leading whitespace).
|
||||
* Names like "Crystal 20MHz" will NOT match since the number is not at the start.
|
||||
* Names without a recognizable numeric+prefix pattern return NULL and sort last.
|
||||
*/
|
||||
class SiValueSort extends FunctionNode
|
||||
{
|
||||
private ?Node $field = null;
|
||||
|
||||
/**
|
||||
* SI prefix multipliers. Used by the SQLite PHP callback.
|
||||
*/
|
||||
private const SI_MULTIPLIERS = [
|
||||
'p' => 1e-12,
|
||||
'n' => 1e-9,
|
||||
'u' => 1e-6,
|
||||
'µ' => 1e-6,
|
||||
'm' => 1e-3,
|
||||
'k' => 1e3,
|
||||
'K' => 1e3,
|
||||
'M' => 1e6,
|
||||
'G' => 1e9,
|
||||
'T' => 1e12,
|
||||
];
|
||||
|
||||
public function parse(Parser $parser): void
|
||||
{
|
||||
$parser->match(TokenType::T_IDENTIFIER);
|
||||
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||
|
||||
$this->field = $parser->ArithmeticExpression();
|
||||
|
||||
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
|
||||
public function getSql(SqlWalker $sqlWalker): string
|
||||
{
|
||||
assert($this->field !== null, 'Field is not set');
|
||||
|
||||
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
|
||||
$rawField = $this->field->dispatch($sqlWalker);
|
||||
|
||||
// Normalize comma decimal separator to dot for SQL platforms (European locale support)
|
||||
$fieldSql = "REPLACE({$rawField}, ',', '.')";
|
||||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
return $this->getPostgreSQLSql($fieldSql);
|
||||
}
|
||||
|
||||
if ($platform instanceof AbstractMySQLPlatform) {
|
||||
return $this->getMySQLSql($fieldSql);
|
||||
}
|
||||
|
||||
// SQLite: comma normalization is handled in the PHP callback
|
||||
$fieldSql = $rawField;
|
||||
|
||||
if ($platform instanceof SQLitePlatform) {
|
||||
return "SI_VALUE({$fieldSql})";
|
||||
}
|
||||
|
||||
// Fallback: return NULL (no SI sorting available)
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL implementation using substring() with POSIX regex.
|
||||
*/
|
||||
private function getPostgreSQLSql(string $field): string
|
||||
{
|
||||
// Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace)
|
||||
$numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)";
|
||||
|
||||
// Extract the SI prefix character
|
||||
$prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')";
|
||||
|
||||
return $this->buildCaseExpression($numericPart, $prefixPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL/MariaDB implementation using REGEXP_SUBSTR.
|
||||
*/
|
||||
private function getMySQLSql(string $field): string
|
||||
{
|
||||
// Extract the numeric part, anchored at start (with optional leading whitespace)
|
||||
$numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))";
|
||||
|
||||
// Extract the prefix: get the full number+prefix match anchored at start, then take the last char
|
||||
$fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')";
|
||||
$prefixPart = "RIGHT({$fullMatch}, 1)";
|
||||
|
||||
return $this->buildCaseExpression($numericPart, $prefixPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CASE expression that maps an SI prefix character to a multiplier
|
||||
* and multiplies it with the numeric value.
|
||||
*
|
||||
* @param string $numericExpr SQL expression that evaluates to the numeric part
|
||||
* @param string $prefixExpr SQL expression that evaluates to the SI prefix character
|
||||
* @return string SQL CASE expression
|
||||
*/
|
||||
private function buildCaseExpression(string $numericExpr, string $prefixExpr): string
|
||||
{
|
||||
return "(CASE" .
|
||||
" WHEN {$numericExpr} IS NULL THEN NULL" .
|
||||
" WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" .
|
||||
" WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" .
|
||||
" WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" .
|
||||
" WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" .
|
||||
" WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" .
|
||||
" WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" .
|
||||
" WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" .
|
||||
" WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" .
|
||||
" WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" .
|
||||
" WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" .
|
||||
" ELSE {$numericExpr} * 1" .
|
||||
" END)";
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP callback for SQLite's SI_VALUE function.
|
||||
* Extracts the first numeric value with an optional SI prefix and returns the scaled value.
|
||||
*
|
||||
* @param string|null $value The input string
|
||||
* @return float|null The scaled numeric value, or null if no number found
|
||||
*/
|
||||
public static function sqliteSiValue(?string $value): ?float
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize comma decimal separator to dot (European locale support)
|
||||
$value = str_replace(',', '.', $value);
|
||||
|
||||
// Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix
|
||||
if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$number = (float) $matches[1];
|
||||
$prefix = $matches[2] ?? '';
|
||||
|
||||
if ($prefix === '') {
|
||||
return $number;
|
||||
}
|
||||
|
||||
$multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0; //@phpstan-ignore-line - fallback to 1.0 if prefix is not recognized (should not happen due to regex)
|
||||
|
||||
return $number * $multiplier;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Doctrine\Middleware;
|
||||
|
||||
use App\Doctrine\Functions\SiValueSort;
|
||||
use App\Exceptions\InvalidRegexException;
|
||||
use Doctrine\DBAL\Driver\Connection;
|
||||
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
|
||||
|
|
@ -51,6 +52,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
|
|||
|
||||
//Create a new collation for natural sorting
|
||||
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
|
||||
|
||||
//Create a function for SI prefix value sorting
|
||||
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Form\Part\EDA;
|
||||
|
||||
use App\Form\Type\StaticFileAutocompleteType;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
|
@ -39,6 +40,13 @@ class KicadFieldAutocompleteType extends AbstractType
|
|||
//Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies
|
||||
public const FOOTPRINT_PATH = 'kicad/footprints.txt';
|
||||
public const SYMBOL_PATH = 'kicad/symbols.txt';
|
||||
public const CUSTOM_FOOTPRINT_PATH = 'kicad/footprints_custom.txt';
|
||||
public const CUSTOM_SYMBOL_PATH = 'kicad/symbols_custom.txt';
|
||||
|
||||
public function __construct(
|
||||
private readonly KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
|
|
@ -47,8 +55,8 @@ class KicadFieldAutocompleteType extends AbstractType
|
|||
|
||||
$resolver->setDefaults([
|
||||
'file' => fn(Options $options) => match ($options['type']) {
|
||||
self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH,
|
||||
self::TYPE_SYMBOL => self::SYMBOL_PATH,
|
||||
self::TYPE_FOOTPRINT => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_FOOTPRINT_PATH : self::FOOTPRINT_PATH,
|
||||
self::TYPE_SYMBOL => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_SYMBOL_PATH : self::SYMBOL_PATH,
|
||||
default => throw new \InvalidArgumentException('Invalid type'),
|
||||
}
|
||||
]);
|
||||
|
|
@ -58,4 +66,4 @@ class KicadFieldAutocompleteType extends AbstractType
|
|||
{
|
||||
return StaticFileAutocompleteType::class;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
src/Form/Settings/KicadListEditorType.php
Normal file
103
src/Form/Settings/KicadListEditorType.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Form\Settings;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type for editing the custom KiCad footprints and symbols lists.
|
||||
*/
|
||||
final class KicadListEditorType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('useCustomList', CheckboxType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.use_custom_list',
|
||||
'help' => 'settings.misc.kicad_eda.use_custom_list.help',
|
||||
'required' => false,
|
||||
])
|
||||
->add('customFootprints', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.custom_footprints',
|
||||
'help' => 'settings.misc.kicad_eda.editor.footprints.help',
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
],
|
||||
])
|
||||
->add('defaultFootprints', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.default_footprints',
|
||||
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
|
||||
'disabled' => true,
|
||||
'mapped' => false,
|
||||
'data' => $options['default_footprints'],
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
'readonly' => 'readonly',
|
||||
],
|
||||
])
|
||||
->add('customSymbols', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.custom_symbols',
|
||||
'help' => 'settings.misc.kicad_eda.editor.symbols.help',
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
],
|
||||
])
|
||||
->add('defaultSymbols', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.default_symbols',
|
||||
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
|
||||
'disabled' => true,
|
||||
'mapped' => false,
|
||||
'data' => $options['default_symbols'],
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
'readonly' => 'readonly',
|
||||
],
|
||||
])
|
||||
->add('save', SubmitType::class, [
|
||||
'label' => 'save',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'default_footprints' => '',
|
||||
'default_symbols' => '',
|
||||
]);
|
||||
$resolver->setAllowedTypes('default_footprints', 'string');
|
||||
$resolver->setAllowedTypes('default_symbols', 'string');
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType
|
|||
*/
|
||||
private function getPreferredLocales(): array
|
||||
{
|
||||
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
|
||||
$fromSettings = $this->localizationSettings->languageMenuEntries;
|
||||
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException;
|
|||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
|
||||
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
|
@ -76,6 +78,8 @@ class AttachmentSubmitHandler
|
|||
protected FileTypeFilterTools $filterTools,
|
||||
protected AttachmentsSettings $settings,
|
||||
protected readonly SVGSanitizer $SVGSanitizer,
|
||||
#[Autowire(env: "bool:ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK")]
|
||||
private readonly bool $allow_local_network_downloads = false,
|
||||
)
|
||||
{
|
||||
//The mapping used to determine which folder will be used for an attachment type
|
||||
|
|
@ -95,6 +99,10 @@ class AttachmentSubmitHandler
|
|||
UserAttachment::class => 'user',
|
||||
LabelAttachment::class => 'label_profile',
|
||||
];
|
||||
|
||||
if (!$this->allow_local_network_downloads) {
|
||||
$this->httpClient = new NoPrivateNetworkHttpClient($this->httpClient);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -373,6 +381,7 @@ class AttachmentSubmitHandler
|
|||
],
|
||||
|
||||
];
|
||||
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
||||
if ($response->getStatusCode() === 403) {
|
||||
|
|
@ -434,8 +443,8 @@ class AttachmentSubmitHandler
|
|||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||
//Save the path to the attachment
|
||||
$attachment->setInternalPath($new_path);
|
||||
} catch (TransportExceptionInterface) {
|
||||
throw new AttachmentDownloadException('Transport error!');
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw new AttachmentDownloadException('Transport error: '.$exception->getMessage());
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
|
|
|
|||
158
src/Services/EDA/KicadListFileManager.php
Normal file
158
src/Services/EDA/KicadListFileManager.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Services\EDA;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
|
||||
|
||||
/**
|
||||
* Manages the KiCad footprints and symbols list files, including reading, writing and ensuring their existence.
|
||||
*/
|
||||
final class KicadListFileManager implements CacheWarmerInterface
|
||||
{
|
||||
private const FOOTPRINTS_PATH = '/public/kicad/footprints.txt';
|
||||
private const SYMBOLS_PATH = '/public/kicad/symbols.txt';
|
||||
private const CUSTOM_FOOTPRINTS_PATH = '/public/kicad/footprints_custom.txt';
|
||||
private const CUSTOM_SYMBOLS_PATH = '/public/kicad/symbols_custom.txt';
|
||||
|
||||
private const CUSTOM_TEMPLATE = <<<'EOT'
|
||||
# Custom KiCad autocomplete entries. One entry per line.
|
||||
|
||||
EOT;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFootprintsContent(): string
|
||||
{
|
||||
return $this->readFile(self::FOOTPRINTS_PATH);
|
||||
}
|
||||
|
||||
public function getCustomFootprintsContent(): string
|
||||
{
|
||||
//Ensure that the custom file exists, so that the UI can always display it without error.
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
|
||||
return $this->readFile(self::CUSTOM_FOOTPRINTS_PATH);
|
||||
}
|
||||
|
||||
public function getSymbolsContent(): string
|
||||
{
|
||||
return $this->readFile(self::SYMBOLS_PATH);
|
||||
}
|
||||
|
||||
public function getCustomSymbolsContent(): string
|
||||
{
|
||||
//Ensure that the custom file exists, so that the UI can always display it without error.
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
|
||||
return $this->readFile(self::CUSTOM_SYMBOLS_PATH);
|
||||
}
|
||||
|
||||
public function saveCustom(string $footprints, string $symbols): void
|
||||
{
|
||||
$this->writeFile(self::CUSTOM_FOOTPRINTS_PATH, $this->normalizeContent($footprints));
|
||||
$this->writeFile(self::CUSTOM_SYMBOLS_PATH, $this->normalizeContent($symbols));
|
||||
}
|
||||
|
||||
private function readFile(string $path): string
|
||||
{
|
||||
$fullPath = $this->projectDir . $path;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = file_get_contents($fullPath);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException(sprintf('Failed to read KiCad list file "%s".', $fullPath));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function writeFile(string $path, string $content): void
|
||||
{
|
||||
$fullPath = $this->projectDir . $path;
|
||||
$tmpPath = $fullPath . '.tmp';
|
||||
|
||||
if (file_put_contents($tmpPath, $content, LOCK_EX) === false) {
|
||||
throw new RuntimeException(sprintf('Failed to write KiCad list file "%s".', $fullPath));
|
||||
}
|
||||
|
||||
if (!rename($tmpPath, $fullPath)) {
|
||||
@unlink($tmpPath);
|
||||
throw new RuntimeException(sprintf('Failed to replace KiCad list file "%s".', $fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeContent(string $content): string
|
||||
{
|
||||
$normalized = str_replace(["\r\n", "\r"], "\n", $content);
|
||||
|
||||
if ($normalized !== '' && !str_ends_with($normalized, "\n")) {
|
||||
$normalized .= "\n";
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function createCustomFileIfNotExists(string $path): void
|
||||
{
|
||||
$fullPath = $this->projectDir . $path;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
if (file_put_contents($fullPath, self::CUSTOM_TEMPLATE, LOCK_EX) === false) {
|
||||
throw new RuntimeException(sprintf('Failed to create custom footprints file "%s".', $fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the custom footprints and symbols files exist, so that the UI can always display them without error.
|
||||
* @return void
|
||||
*/
|
||||
public function createCustomFilesIfNotExist(): void
|
||||
{
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
|
||||
}
|
||||
|
||||
|
||||
public function isOptional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the custom footprints and symbols files exist and generate them on cache warmup, so that the frontend
|
||||
* can always display them without error, even if the user has not yet visited the settings page.
|
||||
*/
|
||||
public function warmUp(string $cacheDir, ?string $buildDir = null): array
|
||||
{
|
||||
$this->createCustomFilesIfNotExist();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ use Brick\Schema\Interfaces\Thing;
|
|||
use Brick\Schema\SchemaReader;
|
||||
use Brick\Schema\SchemaTypeList;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class GenericWebProvider implements InfoProviderInterface
|
||||
|
|
@ -55,7 +56,8 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
||||
)
|
||||
{
|
||||
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions(
|
||||
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
|
||||
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
|
||||
[
|
||||
'timeout' => 15,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -280,9 +280,13 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
{
|
||||
//If a URL starts with // we assume that it is a relative URL and we add the protocol
|
||||
if (str_starts_with($url, '//')) {
|
||||
return 'https:' . $url;
|
||||
$url = 'https:' . $url;
|
||||
}
|
||||
|
||||
//Encode bare % signs that are not already part of a valid percent-encoded sequence
|
||||
//Fixes part numbers with % in them e.g. SMD0603-5K1-1%
|
||||
$url = preg_replace('/%(?![0-9A-Fa-f]{2})/', '%25', $url);
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,16 +25,22 @@ namespace App\Services\ProjectSystem;
|
|||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Helpers\Projects\ProjectBuildRequest;
|
||||
use App\Services\Parts\PartLotWithdrawAddHelper;
|
||||
use App\Services\Parts\PricedetailHelper;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
|
||||
*/
|
||||
final readonly class ProjectBuildHelper
|
||||
{
|
||||
public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper)
|
||||
{
|
||||
public function __construct(
|
||||
private PartLotWithdrawAddHelper $withdraw_add_helper,
|
||||
private PricedetailHelper $pricedetailHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -168,4 +174,81 @@ final readonly class ProjectBuildHelper
|
|||
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total price to build the given project N times, taking bulk pricing into account.
|
||||
* Returns null if no BOM entry has any pricing information.
|
||||
*/
|
||||
public function calculateTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
||||
{
|
||||
$total = BigDecimal::zero();
|
||||
$has_price = false;
|
||||
|
||||
foreach ($project->getBomEntries() as $entry) {
|
||||
$unit_price = $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency);
|
||||
if ($unit_price === null) {
|
||||
continue;
|
||||
}
|
||||
$has_price = true;
|
||||
$total = $total->plus($unit_price->multipliedBy($entry->getQuantity())->multipliedBy($number_of_builds));
|
||||
}
|
||||
|
||||
return $has_price ? $total : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the price to build one unit of the given project when ordering for N builds in total.
|
||||
* Returns null if no BOM entry has any pricing information.
|
||||
*/
|
||||
public function calculateUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
||||
{
|
||||
$total = $this->calculateTotalBuildPrice($project, $number_of_builds, $currency);
|
||||
if ($total === null) {
|
||||
return null;
|
||||
}
|
||||
return $total->dividedBy($number_of_builds, 10, RoundingMode::HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total build price rounded up to 2 decimal places, ready for display.
|
||||
*/
|
||||
public function roundedTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
||||
{
|
||||
return $this->calculateTotalBuildPrice($project, $number_of_builds, $currency)
|
||||
?->toScale(2, RoundingMode::UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unit build price rounded up to 2 decimal places, ready for display.
|
||||
*/
|
||||
public function roundedUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
|
||||
{
|
||||
return $this->calculateUnitBuildPrice($project, $number_of_builds, $currency)
|
||||
?->toScale(2, RoundingMode::UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective unit price for a single piece of the given BOM entry,
|
||||
* taking bulk pricing and minimum order amounts into account for N builds.
|
||||
* Returns BigDecimal::zero() when no pricing data is available.
|
||||
*/
|
||||
public function getEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds = 1, ?Currency $currency = null): BigDecimal
|
||||
{
|
||||
return $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency) ?? BigDecimal::zero();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective unit price for a single piece of the given BOM entry,
|
||||
* taking bulk pricing into account for N builds.
|
||||
*/
|
||||
private function getBomEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds, ?Currency $currency): ?BigDecimal
|
||||
{
|
||||
if ($entry->getPart() instanceof Part) {
|
||||
$total_qty = $entry->getQuantity() * $number_of_builds;
|
||||
$min_order = $this->pricedetailHelper->getMinOrderAmount($entry->getPart());
|
||||
$effective_qty = ($min_order !== null) ? max($total_qty, $min_order) : $total_qty;
|
||||
return $this->pricedetailHelper->calculateAvgPrice($entry->getPart(), $effective_qty, $currency);
|
||||
}
|
||||
return $entry->getPrice();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ enum PartTableColumns : string implements TranslatableInterface
|
|||
case TAGS = "tags";
|
||||
case ATTACHMENTS = "attachments";
|
||||
|
||||
case SI_VALUE = "si_value";
|
||||
|
||||
case EDA_REFERENCE = "eda_reference";
|
||||
|
||||
case EDA_VALUE = "eda_value";
|
||||
|
|
|
|||
|
|
@ -62,4 +62,10 @@ class KiCadEDASettings
|
|||
|
||||
)]
|
||||
public bool $defaultOrderdetailsVisibility = false;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.kicad_eda.use_custom_list"),
|
||||
description: new TM("settings.misc.kicad_eda.use_custom_list.help"),
|
||||
)]
|
||||
public bool $useCustomList = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,32 @@
|
|||
</span>
|
||||
</h6>
|
||||
</div>
|
||||
{% set n = number_of_builds ?? 1 %}
|
||||
{% set total_build_price = buildHelper.roundedTotalBuildPrice(project, n, app.user.currency ?? null) %}
|
||||
{% set unit_build_price = buildHelper.roundedUnitBuildPrice(project, n, app.user.currency ?? null) %}
|
||||
{% if total_build_price is not null %}
|
||||
<div class="mt-1">
|
||||
<h6>
|
||||
<span class="badge badge-primary bg-success">
|
||||
<i class="fa-solid fa-money-bill-wave fa-fw"></i>
|
||||
{% trans %}project.info.total_build_price{% endtrans %}:
|
||||
{{ total_build_price | format_money(app.user.currency ?? null, 2) }}
|
||||
{% if n > 1 and unit_build_price is not null %}
|
||||
<span class="ms-1">
|
||||
({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }})
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</h6>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="get" action="{{ path('project_info', {'id': project.id}) }}" class="mt-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">{% trans %}project.builds.number_of_builds{% endtrans %}</span>
|
||||
<input type="number" min="1" class="form-control" name="n" required value="{{ n }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">{% trans %}project.build.btn_build{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if project.children is not empty %}
|
||||
<div class="mt-1">
|
||||
<h6>
|
||||
|
|
@ -69,9 +95,9 @@
|
|||
</div>
|
||||
|
||||
{% if project.comment is not empty %}
|
||||
<p>
|
||||
<h5>{% trans %}comment.label{% endtrans %}:</h5>
|
||||
{{ project.comment|format_markdown }}
|
||||
</p>
|
||||
<div class="col-12 mt-2">
|
||||
<h5>{% trans %}comment.label{% endtrans %}:</h5>
|
||||
{{ project.comment|format_markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
28
templates/settings/kicad_list_editor.html.twig
Normal file
28
templates/settings/kicad_list_editor.html.twig
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}<i class="fa-solid fa-pen-to-square fa-fw"></i> {% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<p class="text-muted">
|
||||
{% trans %}settings.misc.kicad_eda.editor.description{% endtrans %}
|
||||
</p>
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.useCustomList) }}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-6">
|
||||
{{ form_row(form.customFootprints) }}
|
||||
{{ form_row(form.customSymbols) }}
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
{{ form_row(form.defaultFootprints) }}
|
||||
{{ form_row(form.defaultSymbols) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_row(form.save) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
|
@ -49,6 +49,15 @@
|
|||
</div>
|
||||
</div>
|
||||
{{ form_widget(section_widget) }}
|
||||
{% if section_widget.vars.name == 'kicadEDA' %}
|
||||
<div class="row">
|
||||
<div class="{{ offset_label }} col mt-2 ps-2">
|
||||
<a href="{{ path('settings_kicad_lists') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fa-solid fa-pen-to-square fa-fw"></i> {% trans %}settings.misc.kicad_eda.editor.link{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% if not loop.last %}
|
||||
<hr class="mx-0 mb-2 mt-2">
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ final class ApplicationAvailabilityFunctionalTest extends WebTestCase
|
|||
//User related things
|
||||
yield ['/user/settings'];
|
||||
yield ['/user/info'];
|
||||
yield ['/settings/misc/kicad-lists'];
|
||||
|
||||
//Login/logout
|
||||
yield ['/login'];
|
||||
|
|
|
|||
162
tests/Controller/KicadListEditorControllerTest.php
Normal file
162
tests/Controller/KicadListEditorControllerTest.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Tests\Controller;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
#[Group('slow')]
|
||||
#[Group('DB')]
|
||||
final class KicadListEditorControllerTest extends WebTestCase
|
||||
{
|
||||
private string $footprintsPath;
|
||||
private string $symbolsPath;
|
||||
private string $customFootprintsPath;
|
||||
private string $customSymbolsPath;
|
||||
private string $originalFootprints;
|
||||
private string $originalSymbols;
|
||||
private string $originalCustomFootprints;
|
||||
private string $originalCustomSymbols;
|
||||
private bool $originalUseCustomList;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$projectDir = dirname(__DIR__, 2);
|
||||
$this->footprintsPath = $projectDir . '/public/kicad/footprints.txt';
|
||||
$this->symbolsPath = $projectDir . '/public/kicad/symbols.txt';
|
||||
$this->customFootprintsPath = $projectDir . '/public/kicad/footprints_custom.txt';
|
||||
$this->customSymbolsPath = $projectDir . '/public/kicad/symbols_custom.txt';
|
||||
$this->originalFootprints = (string) file_get_contents($this->footprintsPath);
|
||||
$this->originalSymbols = (string) file_get_contents($this->symbolsPath);
|
||||
$this->originalCustomFootprints = is_file($this->customFootprintsPath) ? (string) file_get_contents($this->customFootprintsPath) : '';
|
||||
$this->originalCustomSymbols = is_file($this->customSymbolsPath) ? (string) file_get_contents($this->customSymbolsPath) : '';
|
||||
|
||||
static::bootKernel();
|
||||
/** @var SettingsManagerInterface $settingsManager */
|
||||
$settingsManager = static::getContainer()->get(SettingsManagerInterface::class);
|
||||
/** @var KiCadEDASettings $settings */
|
||||
$settings = $settingsManager->get(KiCadEDASettings::class);
|
||||
$this->originalUseCustomList = $settings->useCustomList;
|
||||
static::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
file_put_contents($this->footprintsPath, $this->originalFootprints);
|
||||
file_put_contents($this->symbolsPath, $this->originalSymbols);
|
||||
file_put_contents($this->customFootprintsPath, $this->originalCustomFootprints);
|
||||
file_put_contents($this->customSymbolsPath, $this->originalCustomSymbols);
|
||||
|
||||
static::bootKernel();
|
||||
/** @var SettingsManagerInterface $settingsManager */
|
||||
$settingsManager = static::getContainer()->get(SettingsManagerInterface::class);
|
||||
/** @var KiCadEDASettings $settings */
|
||||
$settings = $settingsManager->get(KiCadEDASettings::class);
|
||||
$settings->useCustomList = $this->originalUseCustomList;
|
||||
$settingsManager->save($settings);
|
||||
static::ensureKernelShutdown();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testEditorRequiresAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/en/settings/misc/kicad-lists');
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testEditorAccessibleByAdmin(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/en/settings/misc/kicad-lists');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSelectorExists('form[name="kicad_list_editor"]');
|
||||
}
|
||||
|
||||
public function testEditorShowsDefaultAndCustomFiles(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
file_put_contents($this->footprintsPath, "DefaultFootprint\n");
|
||||
file_put_contents($this->symbolsPath, "DefaultSymbol\n");
|
||||
file_put_contents($this->customFootprintsPath, "CustomFootprint\n");
|
||||
file_put_contents($this->customSymbolsPath, "CustomSymbol\n");
|
||||
|
||||
$crawler = $client->request('GET', '/en/settings/misc/kicad-lists');
|
||||
|
||||
$this->assertSame("CustomFootprint\n", $crawler->filter('#kicad_list_editor_customFootprints')->getNode(0)->nodeValue);
|
||||
$this->assertSame("CustomSymbol\n", $crawler->filter('#kicad_list_editor_customSymbols')->getNode(0)->nodeValue);
|
||||
$this->assertSame("DefaultFootprint\n", $crawler->filter('#kicad_list_editor_defaultFootprints')->getNode(0)->nodeValue);
|
||||
$this->assertSame("DefaultSymbol\n", $crawler->filter('#kicad_list_editor_defaultSymbols')->getNode(0)->nodeValue);
|
||||
}
|
||||
|
||||
public function testEditorSavesCustomFilesAndSetting(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$crawler = $client->request('GET', '/en/settings/misc/kicad-lists');
|
||||
$form = $crawler->filter('form[name="kicad_list_editor"]')->form();
|
||||
$form['kicad_list_editor[customFootprints]'] = "Package_DIP:DIP-8_W7.62mm\n";
|
||||
$form['kicad_list_editor[customSymbols]'] = "Device:R\n";
|
||||
$form['kicad_list_editor[useCustomList]']->tick();
|
||||
|
||||
$client->submit($form);
|
||||
|
||||
$this->assertResponseRedirects('/en/settings/misc/kicad-lists');
|
||||
$this->assertSame("Package_DIP:DIP-8_W7.62mm\n", (string) file_get_contents($this->customFootprintsPath));
|
||||
$this->assertSame("Device:R\n", (string) file_get_contents($this->customSymbolsPath));
|
||||
$this->assertSame($this->originalFootprints, (string) file_get_contents($this->footprintsPath));
|
||||
$this->assertSame($this->originalSymbols, (string) file_get_contents($this->symbolsPath));
|
||||
|
||||
/** @var SettingsManagerInterface $settingsManager */
|
||||
$settingsManager = $client->getContainer()->get(SettingsManagerInterface::class);
|
||||
/** @var KiCadEDASettings $settings */
|
||||
$settings = $settingsManager->reload(KiCadEDASettings::class);
|
||||
$this->assertTrue($settings->useCustomList);
|
||||
}
|
||||
|
||||
private function loginAsUser($client, string $username): void
|
||||
{
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => $username]);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped(sprintf('User "%s" not found in fixtures', $username));
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
}
|
||||
}
|
||||
193
tests/Doctrine/Functions/SiValueSortTest.php
Normal file
193
tests/Doctrine/Functions/SiValueSortTest.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?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\Tests\Doctrine\Functions;
|
||||
|
||||
use App\Doctrine\Functions\SiValueSort;
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\SQLitePlatform;
|
||||
|
||||
final class SiValueSortTest extends AbstractDoctrineFunctionTestCase
|
||||
{
|
||||
public function testPostgreSQLGeneratesCaseExpression(): void
|
||||
{
|
||||
$function = new SiValueSort('SI_VALUE_SORT');
|
||||
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
|
||||
|
||||
$sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
|
||||
|
||||
$this->assertStringContainsString('CASE', $sql);
|
||||
$this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql);
|
||||
$this->assertStringContainsString('1e-12', $sql);
|
||||
$this->assertStringContainsString('1e-9', $sql);
|
||||
$this->assertStringContainsString('1e-6', $sql);
|
||||
$this->assertStringContainsString('1e-3', $sql);
|
||||
$this->assertStringContainsString('1e3', $sql);
|
||||
$this->assertStringContainsString('1e6', $sql);
|
||||
$this->assertStringContainsString('1e9', $sql);
|
||||
$this->assertStringContainsString('1e12', $sql);
|
||||
}
|
||||
|
||||
public function testMySQLGeneratesCaseExpression(): void
|
||||
{
|
||||
$function = new SiValueSort('SI_VALUE_SORT');
|
||||
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
|
||||
|
||||
$sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
|
||||
|
||||
$this->assertStringContainsString('CASE', $sql);
|
||||
$this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql);
|
||||
$this->assertStringContainsString('1e-12', $sql);
|
||||
$this->assertStringContainsString('1e6', $sql);
|
||||
}
|
||||
|
||||
public function testSQLiteUsesSiValueFunction(): void
|
||||
{
|
||||
$function = new SiValueSort('SI_VALUE_SORT');
|
||||
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
|
||||
|
||||
$sql = $function->getSql($this->createSqlWalker(new SQLitePlatform()));
|
||||
|
||||
$this->assertSame('SI_VALUE(part_name)', $sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider sqliteSiValueProvider
|
||||
*/
|
||||
public function testSqliteSiValue(?string $input, ?float $expected): void
|
||||
{
|
||||
$result = SiValueSort::sqliteSiValue($input);
|
||||
|
||||
if ($expected === null) {
|
||||
$this->assertNull($result);
|
||||
} else {
|
||||
$this->assertEqualsWithDelta($expected, $result, $expected * 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{?string, ?float}>
|
||||
*/
|
||||
public static function sqliteSiValueProvider(): iterable
|
||||
{
|
||||
// Basic SI prefix values
|
||||
yield 'pico' => ['10pF', 10e-12];
|
||||
yield 'nano' => ['100nF', 100e-9];
|
||||
yield 'micro_u' => ['1uF', 1e-6];
|
||||
yield 'micro_µ' => ['1µF', 1e-6];
|
||||
yield 'milli' => ['4.7mH', 4.7e-3];
|
||||
yield 'kilo_lower' => ['4.7k', 4.7e3];
|
||||
yield 'kilo_upper' => ['4.7K', 4.7e3];
|
||||
yield 'mega' => ['1M', 1e6];
|
||||
yield 'giga' => ['2.2G', 2.2e9];
|
||||
yield 'tera' => ['1T', 1e12];
|
||||
|
||||
// No prefix (plain number)
|
||||
yield 'plain_integer' => ['100', 100.0];
|
||||
yield 'plain_decimal' => ['4.7', 4.7];
|
||||
|
||||
// Decimal values with prefix (dot separator)
|
||||
yield 'decimal_nano' => ['4.7nF', 4.7e-9];
|
||||
yield 'decimal_micro' => ['0.1uF', 0.1e-6];
|
||||
yield 'decimal_kilo' => ['2.2k', 2.2e3];
|
||||
|
||||
// Comma decimal separator (European locale)
|
||||
yield 'comma_kilo' => ['4,7k', 4.7e3];
|
||||
yield 'comma_micro' => ['2,2uF', 2.2e-6];
|
||||
yield 'comma_kilo_space' => ['1,2 kΩ', 1.2e3];
|
||||
|
||||
// Number NOT at the start — should return NULL
|
||||
yield 'prefixed_name' => ['CAP-100nF', null];
|
||||
yield 'name_with_number' => ['R 4.7k 1%', null];
|
||||
yield 'crystal' => ['Crystal 20MHz', null];
|
||||
|
||||
// Number at start with trailing text
|
||||
yield 'number_with_suffix' => ['10nF 25V', 10e-9];
|
||||
|
||||
// Space between number and prefix
|
||||
yield 'space_before_prefix' => ['100 nF', 100e-9];
|
||||
|
||||
// Leading whitespace before number
|
||||
yield 'leading_whitespace' => [' 10uF', 10e-6];
|
||||
|
||||
// No number at all
|
||||
yield 'no_number' => ['Connector', null];
|
||||
yield 'text_only' => ['LED red', null];
|
||||
|
||||
// Null input
|
||||
yield 'null' => [null, null];
|
||||
|
||||
// Empty string
|
||||
yield 'empty' => ['', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the sort order is correct by comparing sqliteSiValue results.
|
||||
*/
|
||||
public function testSortOrder(): void
|
||||
{
|
||||
$parts = ['1uF', '100nF', '10pF', '10uF', '0.1mF', '1F', '10kF', '1MF'];
|
||||
$expected = ['10pF', '100nF', '1uF', '10uF', '0.1mF', '1F', '10kF', '1MF'];
|
||||
|
||||
// Sort using sqliteSiValue
|
||||
usort($parts, static function (string $a, string $b): int {
|
||||
$va = SiValueSort::sqliteSiValue($a);
|
||||
$vb = SiValueSort::sqliteSiValue($b);
|
||||
return $va <=> $vb;
|
||||
});
|
||||
|
||||
$this->assertSame($expected, $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that NULL values sort last (after all numeric values).
|
||||
*/
|
||||
public function testNullSortsLast(): void
|
||||
{
|
||||
$parts = ['Connector', '100nF', 'LED red', '10pF'];
|
||||
|
||||
usort($parts, static function (string $a, string $b): int {
|
||||
$va = SiValueSort::sqliteSiValue($a);
|
||||
$vb = SiValueSort::sqliteSiValue($b);
|
||||
|
||||
// NULL sorts last
|
||||
if ($va === null && $vb === null) {
|
||||
return 0;
|
||||
}
|
||||
if ($va === null) {
|
||||
return 1;
|
||||
}
|
||||
if ($vb === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $va <=> $vb;
|
||||
});
|
||||
|
||||
$this->assertSame('10pF', $parts[0]);
|
||||
$this->assertSame('100nF', $parts[1]);
|
||||
// Last two should be the non-numeric names
|
||||
$this->assertContains('Connector', array_slice($parts, 2));
|
||||
$this->assertContains('LED red', array_slice($parts, 2));
|
||||
}
|
||||
}
|
||||
391
tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
Normal file
391
tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
<?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)
|
||||
*
|
||||
* 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\Tests\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||
use App\Services\InfoProviderSystem\Providers\TMEClient;
|
||||
use App\Services\InfoProviderSystem\Providers\TMEProvider;
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
use App\Tests\SettingsTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
final class TMEProviderTest extends TestCase
|
||||
{
|
||||
private TMESettings $settings;
|
||||
private TMEProvider $provider;
|
||||
private MockHttpClient $httpClient;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->httpClient = new MockHttpClient();
|
||||
$this->settings = SettingsTestHelper::createSettingsDummy(TMESettings::class);
|
||||
// Use a short (anonymous-style) token so grossPrices is read from settings
|
||||
$this->settings->apiToken = 'test_token_000000000000000000000000000000000000000';
|
||||
$this->settings->apiSecret = 'test_secret';
|
||||
$this->settings->currency = 'EUR';
|
||||
$this->settings->language = 'en';
|
||||
$this->settings->country = 'DE';
|
||||
$this->settings->grossPrices = false;
|
||||
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
|
||||
}
|
||||
|
||||
// --- Mock response helpers ---
|
||||
// Only fields actually read by TMEProvider are included.
|
||||
|
||||
private function mockProductList(array $products): MockResponse
|
||||
{
|
||||
return new MockResponse(json_encode([
|
||||
'Status' => 'OK',
|
||||
'Data' => ['ProductList' => $products],
|
||||
]));
|
||||
}
|
||||
|
||||
private function mockFilesList(array $products): MockResponse
|
||||
{
|
||||
return new MockResponse(json_encode([
|
||||
'Status' => 'OK',
|
||||
'Data' => ['ProductList' => $products],
|
||||
]));
|
||||
}
|
||||
|
||||
private function mockParametersList(array $products): MockResponse
|
||||
{
|
||||
return new MockResponse(json_encode([
|
||||
'Status' => 'OK',
|
||||
'Data' => ['ProductList' => $products],
|
||||
]));
|
||||
}
|
||||
|
||||
private function mockPrices(string $currency, string $priceType, array $products): MockResponse
|
||||
{
|
||||
return new MockResponse(json_encode([
|
||||
'Status' => 'OK',
|
||||
'Data' => [
|
||||
'Currency' => $currency,
|
||||
'PriceType' => $priceType,
|
||||
'ProductList' => $products,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
// --- Mock data ---
|
||||
|
||||
private function smd0603Products(): MockResponse
|
||||
{
|
||||
return $this->mockProductList([[
|
||||
'Symbol' => 'SMD0603-5K1-1%',
|
||||
'OriginalSymbol' => '0603SAF5101T5E',
|
||||
'Producer' => 'ROYALOHM',
|
||||
'Description' => 'Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C',
|
||||
'Category' => 'SMD resistors',
|
||||
'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/E9/C2/B0/00/0/732318_1.jpg',
|
||||
'ProductStatusList' => [],
|
||||
'ProductInformationPage' => '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/',
|
||||
'Weight' => 0.021,
|
||||
'WeightUnit' => 'g',
|
||||
]]);
|
||||
}
|
||||
|
||||
private function smd0603Files(): MockResponse
|
||||
{
|
||||
return $this->mockFilesList([[
|
||||
'Symbol' => 'SMD0603-5K1-1%',
|
||||
'Files' => [
|
||||
'AdditionalPhotoList' => [],
|
||||
'DocumentList' => [
|
||||
['DocumentUrl' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf'],
|
||||
['DocumentUrl' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf'],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
private function smd0603Parameters(): MockResponse
|
||||
{
|
||||
return $this->mockParametersList([[
|
||||
'Symbol' => 'SMD0603-5K1-1%',
|
||||
'ParameterList' => [
|
||||
['ParameterId' => 34, 'ParameterName' => 'Type of resistor', 'ParameterValue' => 'thick film'],
|
||||
['ParameterId' => 35, 'ParameterName' => 'Case - mm', 'ParameterValue' => '1608'],
|
||||
['ParameterId' => 38, 'ParameterName' => 'Resistance', 'ParameterValue' => '5.1kΩ'],
|
||||
['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±1%'],
|
||||
['ParameterId' => 120, 'ParameterName' => 'Operating voltage', 'ParameterValue' => '50V'],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
private function smd0603Prices(): MockResponse
|
||||
{
|
||||
return $this->mockPrices('EUR', 'NET', [[
|
||||
'Symbol' => 'SMD0603-5K1-1%',
|
||||
'PriceList' => [
|
||||
['Amount' => 100, 'PriceValue' => 0.01077],
|
||||
['Amount' => 1000, 'PriceValue' => 0.00291],
|
||||
['Amount' => 5000, 'PriceValue' => 0.00150],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
private function etqp3mProducts(): MockResponse
|
||||
{
|
||||
return $this->mockProductList([[
|
||||
'Symbol' => 'ETQP3M6R8KVP',
|
||||
'OriginalSymbol' => 'ETQP3M6R8KVP',
|
||||
'Producer' => 'PANASONIC',
|
||||
'Description' => 'Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm',
|
||||
'Category' => 'Inductors',
|
||||
'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/9E/27/A0/00/0/684777_1.jpg',
|
||||
'ProductStatusList' => [],
|
||||
'ProductInformationPage' => '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
|
||||
'Weight' => 0.44,
|
||||
'WeightUnit' => 'g',
|
||||
]]);
|
||||
}
|
||||
|
||||
private function etqp3mFiles(): MockResponse
|
||||
{
|
||||
return $this->mockFilesList([[
|
||||
'Symbol' => 'ETQP3M6R8KVP',
|
||||
'Files' => [
|
||||
'AdditionalPhotoList' => [],
|
||||
'DocumentList' => [
|
||||
['DocumentUrl' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'],
|
||||
['DocumentUrl' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
private function etqp3mParameters(): MockResponse
|
||||
{
|
||||
return $this->mockParametersList([[
|
||||
'Symbol' => 'ETQP3M6R8KVP',
|
||||
'ParameterList' => [
|
||||
['ParameterId' => 566, 'ParameterName' => 'Inductance', 'ParameterValue' => '6.8µH'],
|
||||
['ParameterId' => 370, 'ParameterName' => 'Operating current', 'ParameterValue' => '2.9A'],
|
||||
['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±20%'],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
private function etqp3mPrices(): MockResponse
|
||||
{
|
||||
return $this->mockPrices('EUR', 'NET', [[
|
||||
'Symbol' => 'ETQP3M6R8KVP',
|
||||
'PriceList' => [
|
||||
['Amount' => 1, 'PriceValue' => 0.589],
|
||||
['Amount' => 5, 'PriceValue' => 0.429],
|
||||
['Amount' => 10, 'PriceValue' => 0.399],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
public function testGetProviderInfo(): void
|
||||
{
|
||||
$info = $this->provider->getProviderInfo();
|
||||
|
||||
$this->assertIsArray($info);
|
||||
$this->assertArrayHasKey('name', $info);
|
||||
$this->assertArrayHasKey('description', $info);
|
||||
$this->assertArrayHasKey('url', $info);
|
||||
$this->assertEquals('TME', $info['name']);
|
||||
$this->assertEquals('https://tme.eu/', $info['url']);
|
||||
}
|
||||
|
||||
public function testGetProviderKey(): void
|
||||
{
|
||||
$this->assertSame('tme', $this->provider->getProviderKey());
|
||||
}
|
||||
|
||||
public function testIsActiveWithCredentials(): void
|
||||
{
|
||||
$this->assertTrue($this->provider->isActive());
|
||||
}
|
||||
|
||||
public function testIsActiveWithoutCredentials(): void
|
||||
{
|
||||
$this->settings->apiToken = null;
|
||||
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
|
||||
$this->assertFalse($provider->isActive());
|
||||
}
|
||||
|
||||
public function testGetCapabilities(): void
|
||||
{
|
||||
$capabilities = $this->provider->getCapabilities();
|
||||
|
||||
$this->assertIsArray($capabilities);
|
||||
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::PICTURE, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::DATASHEET, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::PRICE, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities);
|
||||
}
|
||||
|
||||
public function testGetHandledDomains(): void
|
||||
{
|
||||
$this->assertContains('tme.eu', $this->provider->getHandledDomains());
|
||||
}
|
||||
|
||||
public function testGetIDFromURL(): void
|
||||
{
|
||||
$this->assertSame('fi321_se', $this->provider->getIDFromURL('https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/'));
|
||||
$this->assertSame('smd0603-5k1-1%25', $this->provider->getIDFromURL('https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/'));
|
||||
$this->assertNull($this->provider->getIDFromURL('https://www.tme.eu/en/'));
|
||||
}
|
||||
|
||||
public function testSearchByKeyword(): void
|
||||
{
|
||||
$this->httpClient->setResponseFactory([$this->smd0603Products()]);
|
||||
|
||||
$results = $this->provider->searchByKeyword('SMD0603-5K1-1%');
|
||||
|
||||
$this->assertIsArray($results);
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(SearchResultDTO::class, $results[0]);
|
||||
$this->assertSame('SMD0603-5K1-1%', $results[0]->provider_id);
|
||||
$this->assertSame('0603SAF5101T5E', $results[0]->name);
|
||||
$this->assertSame('ROYALOHM', $results[0]->manufacturer);
|
||||
$this->assertSame('SMD resistors', $results[0]->category);
|
||||
$this->assertSame(ManufacturingStatus::ACTIVE, $results[0]->manufacturing_status);
|
||||
$this->assertSame(
|
||||
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
|
||||
$results[0]->provider_url
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetDetailsWithPercentInPartNumber(): void
|
||||
{
|
||||
$this->httpClient->setResponseFactory([
|
||||
$this->smd0603Products(),
|
||||
$this->smd0603Files(),
|
||||
$this->smd0603Parameters(),
|
||||
$this->smd0603Prices(),
|
||||
]);
|
||||
|
||||
$result = $this->provider->getDetails('SMD0603-5K1-1%');
|
||||
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $result);
|
||||
$this->assertSame('SMD0603-5K1-1%', $result->provider_id);
|
||||
$this->assertSame('0603SAF5101T5E', $result->name);
|
||||
$this->assertSame('Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C', $result->description);
|
||||
$this->assertSame('ROYALOHM', $result->manufacturer);
|
||||
$this->assertSame('0603SAF5101T5E', $result->mpn);
|
||||
$this->assertSame('SMD resistors', $result->category);
|
||||
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
|
||||
$this->assertSame(0.021, $result->mass);
|
||||
$this->assertSame('1608', $result->footprint);
|
||||
$this->assertSame(
|
||||
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
|
||||
$result->provider_url
|
||||
);
|
||||
|
||||
$this->assertCount(2, $result->datasheets);
|
||||
$this->assertSame('https://www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf', $result->datasheets[0]->url);
|
||||
$this->assertCount(0, $result->images);
|
||||
|
||||
$this->assertCount(1, $result->vendor_infos);
|
||||
$vendorInfo = $result->vendor_infos[0];
|
||||
$this->assertInstanceOf(PurchaseInfoDTO::class, $vendorInfo);
|
||||
$this->assertSame('TME', $vendorInfo->distributor_name);
|
||||
$this->assertSame('SMD0603-5K1-1%', $vendorInfo->order_number);
|
||||
$this->assertSame(
|
||||
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
|
||||
$vendorInfo->product_url
|
||||
);
|
||||
$this->assertCount(3, $vendorInfo->prices);
|
||||
$this->assertSame(100.0, $vendorInfo->prices[0]->minimum_discount_amount);
|
||||
$this->assertSame('0.01077', $vendorInfo->prices[0]->price);
|
||||
$this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code);
|
||||
$this->assertFalse($vendorInfo->prices[0]->includes_tax);
|
||||
|
||||
$this->assertCount(5, $result->parameters);
|
||||
}
|
||||
|
||||
public function testGetDetailsForEtqp3m6r8kvp(): void
|
||||
{
|
||||
$this->httpClient->setResponseFactory([
|
||||
$this->etqp3mProducts(),
|
||||
$this->etqp3mFiles(),
|
||||
$this->etqp3mParameters(),
|
||||
$this->etqp3mPrices(),
|
||||
]);
|
||||
|
||||
$result = $this->provider->getDetails('ETQP3M6R8KVP');
|
||||
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $result);
|
||||
$this->assertSame('ETQP3M6R8KVP', $result->provider_id);
|
||||
$this->assertSame('ETQP3M6R8KVP', $result->name);
|
||||
$this->assertSame('Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm', $result->description);
|
||||
$this->assertSame('PANASONIC', $result->manufacturer);
|
||||
$this->assertSame('ETQP3M6R8KVP', $result->mpn);
|
||||
$this->assertSame('Inductors', $result->category);
|
||||
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
|
||||
$this->assertSame(0.44, $result->mass);
|
||||
$this->assertNull($result->footprint);
|
||||
$this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $result->provider_url);
|
||||
|
||||
$this->assertCount(2, $result->datasheets);
|
||||
$this->assertSame('https://www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', $result->datasheets[0]->url);
|
||||
$this->assertCount(0, $result->images);
|
||||
|
||||
$this->assertCount(1, $result->vendor_infos);
|
||||
$vendorInfo = $result->vendor_infos[0];
|
||||
$this->assertSame('TME', $vendorInfo->distributor_name);
|
||||
$this->assertSame('ETQP3M6R8KVP', $vendorInfo->order_number);
|
||||
$this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $vendorInfo->product_url);
|
||||
$this->assertCount(3, $vendorInfo->prices);
|
||||
$this->assertSame(1.0, $vendorInfo->prices[0]->minimum_discount_amount);
|
||||
$this->assertSame('0.589', $vendorInfo->prices[0]->price);
|
||||
$this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code);
|
||||
$this->assertFalse($vendorInfo->prices[0]->includes_tax);
|
||||
|
||||
$this->assertCount(3, $result->parameters);
|
||||
}
|
||||
|
||||
public function testNormalizeURLEncodesBarePctSign(): void
|
||||
{
|
||||
$method = (new \ReflectionClass($this->provider))->getMethod('normalizeURL');
|
||||
|
||||
$this->assertSame(
|
||||
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
|
||||
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/')
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
|
||||
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/')
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
|
||||
$method->invoke($this->provider, '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/')
|
||||
);
|
||||
$this->assertSame('https://example.com/path', $method->invoke($this->provider, 'https://example.com/path'));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,13 +26,15 @@ use App\Entity\Parts\Part;
|
|||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
final class ProjectBuildHelperTest extends WebTestCase
|
||||
{
|
||||
/** @var ProjectBuildHelper */
|
||||
protected $service;
|
||||
protected ProjectBuildHelper $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
|
@ -130,6 +132,180 @@ final class ProjectBuildHelperTest extends WebTestCase
|
|||
$project->addBomEntry($bom_entry1);
|
||||
|
||||
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
|
||||
}
|
||||
|
||||
// --- Build price tests ---
|
||||
|
||||
private function makePartWithPrice(float $pricePerPiece, float $minQty = 1.0): Part
|
||||
{
|
||||
$part = new Part();
|
||||
$orderdetail = new Orderdetail();
|
||||
$pricedetail = (new Pricedetail())
|
||||
->setMinDiscountQuantity($minQty)
|
||||
->setPrice(BigDecimal::of((string) $pricePerPiece));
|
||||
$orderdetail->addPricedetail($pricedetail);
|
||||
$part->addOrderdetail($orderdetail);
|
||||
return $part;
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceEmptyProject(): void
|
||||
{
|
||||
$project = new Project();
|
||||
$this->assertNull($this->service->calculateTotalBuildPrice($project));
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceNoPricingData(): void
|
||||
{
|
||||
$project = new Project();
|
||||
// Part with no orderdetails — no pricing
|
||||
$entry = (new ProjectBOMEntry())->setPart(new Part())->setQuantity(2);
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
$this->assertNull($this->service->calculateTotalBuildPrice($project));
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceNonPartEntry(): void
|
||||
{
|
||||
$project = new Project();
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setName('Custom wire');
|
||||
$entry->setQuantity(3);
|
||||
$entry->setPrice(BigDecimal::of('2.00'));
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
// 3 × 2.00 = 6.00 for 1 build
|
||||
$result = $this->service->calculateTotalBuildPrice($project, 1);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue(BigDecimal::of('6.00')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceNonPartEntryMultipleBuilds(): void
|
||||
{
|
||||
$project = new Project();
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setName('Custom wire');
|
||||
$entry->setQuantity(3);
|
||||
$entry->setPrice(BigDecimal::of('2.00'));
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
// 3 × 2.00 × 5 = 30.00 for 5 builds
|
||||
$result = $this->service->calculateTotalBuildPrice($project, 5);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue(BigDecimal::of('30.00')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceWithPart(): void
|
||||
{
|
||||
$project = new Project();
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setPart($this->makePartWithPrice(1.50));
|
||||
$entry->setQuantity(4);
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
// 4 × 1.50 = 6.00 for 1 build
|
||||
$result = $this->service->calculateTotalBuildPrice($project, 1);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue(BigDecimal::of('6.00')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testCalculateUnitBuildPriceEqualsTotal(): void
|
||||
{
|
||||
$project = new Project();
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setName('Screw');
|
||||
$entry->setQuantity(10);
|
||||
$entry->setPrice(BigDecimal::of('0.10'));
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
// unit = 10 × 0.10 = 1.00; total for 3 builds = 3.00
|
||||
$unit = $this->service->calculateUnitBuildPrice($project, 3);
|
||||
$total = $this->service->calculateTotalBuildPrice($project, 3);
|
||||
$this->assertNotNull($unit);
|
||||
$this->assertNotNull($total);
|
||||
$this->assertTrue($total->isEqualTo($unit->multipliedBy(3)));
|
||||
}
|
||||
|
||||
public function testRoundedTotalBuildPriceRoundsUp(): void
|
||||
{
|
||||
$project = new Project();
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setName('Tiny part');
|
||||
$entry->setQuantity(1);
|
||||
$entry->setPrice(BigDecimal::of('0.001'));
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
// 0.001 rounded up to 2dp = 0.01
|
||||
$result = $this->service->roundedTotalBuildPrice($project, 1);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceMixedEntries(): void
|
||||
{
|
||||
$project = new Project();
|
||||
|
||||
// Part entry: 2 × 3.00 = 6.00
|
||||
$partEntry = new ProjectBOMEntry();
|
||||
$partEntry->setPart($this->makePartWithPrice(3.00));
|
||||
$partEntry->setQuantity(2);
|
||||
$project->addBomEntry($partEntry);
|
||||
|
||||
// Non-part entry with price: 5 × 1.00 = 5.00
|
||||
$nonPartEntry = new ProjectBOMEntry();
|
||||
$nonPartEntry->setName('Solder');
|
||||
$nonPartEntry->setQuantity(5);
|
||||
$nonPartEntry->setPrice(BigDecimal::of('1.00'));
|
||||
$project->addBomEntry($nonPartEntry);
|
||||
|
||||
// Total = 11.00
|
||||
$result = $this->service->calculateTotalBuildPrice($project, 1);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue(BigDecimal::of('11.00')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testGetEntryUnitPriceReturnsZeroForNoPricingData(): void
|
||||
{
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setPart(new Part()); // part with no orderdetails
|
||||
$entry->setQuantity(5);
|
||||
|
||||
$result = $this->service->getEntryUnitPrice($entry);
|
||||
$this->assertTrue(BigDecimal::zero()->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testGetEntryUnitPriceNonPartEntry(): void
|
||||
{
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setName('Wire');
|
||||
$entry->setQuantity(2);
|
||||
$entry->setPrice(BigDecimal::of('1.25'));
|
||||
|
||||
$result = $this->service->getEntryUnitPrice($entry);
|
||||
$this->assertTrue(BigDecimal::of('1.25')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testGetEntryUnitPriceWithPart(): void
|
||||
{
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setPart($this->makePartWithPrice(2.00));
|
||||
$entry->setQuantity(3);
|
||||
|
||||
$result = $this->service->getEntryUnitPrice($entry);
|
||||
$this->assertTrue(BigDecimal::of('2.00')->isEqualTo($result));
|
||||
}
|
||||
|
||||
public function testCalculateTotalBuildPriceRespectsMinOrderAmount(): void
|
||||
{
|
||||
$project = new Project();
|
||||
// Part has a minimum order quantity of 10 at 0.50/piece
|
||||
$entry = new ProjectBOMEntry();
|
||||
$entry->setPart($this->makePartWithPrice(0.50, 10.0));
|
||||
$entry->setQuantity(1); // BOM only needs 1, but MOQ is 10
|
||||
$project->addBomEntry($entry);
|
||||
|
||||
// Price lookup uses qty=10 (MOQ), returns 0.50. Cost = 1 × 0.50 = 0.50
|
||||
$result = $this->service->calculateTotalBuildPrice($project, 1);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue(BigDecimal::of('0.50')->isEqualTo($result));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|||
|
||||
final class ProjectBuildPartHelperTest extends WebTestCase
|
||||
{
|
||||
/** @var ProjectBuildPartHelper */
|
||||
protected $service;
|
||||
protected ProjectBuildPartHelper $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7241,6 +7241,12 @@ Element 3</target>
|
|||
<target>Cena</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
|
|
@ -642,6 +642,12 @@ Underelementer vil blive flyttet opad.</target>
|
|||
<target>Gruppe</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8rz303Z" name="specifications.eda_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>specifications.eda_visibility.help</source>
|
||||
<target>Eksporter denne parameter som et EDA felt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XclPxI9" name="specification.create">
|
||||
<segment state="translated">
|
||||
<source>specification.create</source>
|
||||
|
|
@ -2923,6 +2929,42 @@ Bemærk også, at uden to-faktor-godkendelse er din konto ikke længere så godt
|
|||
<target>Bilag</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f3Dggp6" name="part.table.eda_status">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_status</source>
|
||||
<target>EDA</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q_myBuD" name="eda.status.symbol_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.symbol_set</source>
|
||||
<target>KiCad symbolsæt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QGLfvit" name="eda.status.footprint_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.footprint_set</source>
|
||||
<target>KiCad footprintsæt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hkze9M." name="eda.status.reference_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.reference_set</source>
|
||||
<target>eda. status.reference_set</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="OTXbAfL" name="eda.status.complete">
|
||||
<segment state="translated">
|
||||
<source>eda.status.complete</source>
|
||||
<target>EDA felter udfyldt (symbol, footprint, reference)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="z9E5RB." name="eda.status.partial">
|
||||
<segment state="translated">
|
||||
<source>eda.status.partial</source>
|
||||
<target>EDA felter delvist udfyldt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bMkafCp" name="flash.login_successful">
|
||||
<segment state="translated">
|
||||
<source>flash.login_successful</source>
|
||||
|
|
@ -3265,6 +3307,12 @@ Bemærk også, at uden to-faktor-godkendelse er din konto ikke længere så godt
|
|||
<target>Ikke længere tilgængelig</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6H0WQWq" name="orderdetails.edit.eda_visibility">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.eda_visibility</source>
|
||||
<target>Synlige i EDA</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.supplierpartnr.placeholder</source>
|
||||
|
|
@ -7184,6 +7232,12 @@ Element 3</target>
|
|||
<target>Pris</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
@ -9502,6 +9556,12 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
|
|||
<target>EIGP 114 stregkode (f.eks. Datamatrix-kode fra Digikey og Mouser dele)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BnqcKWx" name="scan_dialog.mode.lcsc">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.mode.lcsc</source>
|
||||
<target>LCSC.com barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.info_mode</source>
|
||||
|
|
@ -9514,6 +9574,24 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
|
|||
<target>Afkodet information</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kQnodbA" name="label_scanner.target_found">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.target_found</source>
|
||||
<target>Genstand fundet i database</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7Arfw2q" name="label_scanner.scan_result.title">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.scan_result.title</source>
|
||||
<target>Scan-resultat</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PTh4EK_" name="label_scanner.no_locations">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.no_locations</source>
|
||||
<target>Part er ikke gemt på nogen lokation.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nmXQWcS" name="label_generator.edit_profiles">
|
||||
<segment state="translated">
|
||||
<source>label_generator.edit_profiles</source>
|
||||
|
|
@ -9948,6 +10026,18 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
|
|||
<target>Denne værdi bestemmer dybden af kategoritræet, der er synligt i KiCad. 0 betyder, at kun kategorierne på øverste niveau er synlige. Indstil værdien til > 0 for at vise yderligere niveauer. Indstil værdien til -1 for at vise alle dele af deldatabasen inden for en enkelt kategori i KiCad.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="X5.rQdO" name="settings.misc.kicad_eda.datasheet_link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.datasheet_link</source>
|
||||
<target>Databladsfelt linker til PDF</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Fm1QTCs" name="settings.misc.kicad_eda.datasheet_link.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.datasheet_link.help</source>
|
||||
<target>Når det er aktiveret, vil dataarkfeltet i KiCad linke til den faktiske PDF-fil (hvis den findes). Når det er deaktiveret, vil det i stedet linke til Part-DB-siden. Linket til Part-DB-siden er altid tilgængeligt som et separat felt "Part-DB URL".</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar</source>
|
||||
|
|
@ -10290,6 +10380,24 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
|
|||
<target>Vis billedoverlejringen med detaljer om vedhæftet fil, når du holder musen over billedgalleriet med dele.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0iYdzdk" name="settings.behavior.keybindings">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.keybindings</source>
|
||||
<target>Tastaturgenveje</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_x13bMa" name="settings.behavior.keybindings.enable_special_characters">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.keybindings.enable_special_characters</source>
|
||||
<target>Aktivér tastaturgenveje for specialtegn</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Af8Zzqr" name="settings.behavior.keybindings.enable_special_characters.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.keybindings.enable_special_characters.help</source>
|
||||
<target>Aktivér genvejstasten Alt+ for at indsætte specialtegn (græske bogstaver, matematiske symboler osv.) i tekstfelter. Deaktiver dette, hvis genvejene er i konflikt med dit tastaturlayout eller systemgenveje.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ALfPkeR" name="perm.config.change_system_settings">
|
||||
<segment state="translated">
|
||||
<source>perm.config.change_system_settings</source>
|
||||
|
|
@ -10914,6 +11022,84 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
|
|||
<target>Masseimport af datakilder</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VtS1yT7" name="part_list.action.group.eda">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.group.eda</source>
|
||||
<target>EDA / KiCad</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="swU1Rp2" name="part_list.action.batch_edit_eda">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.batch_edit_eda</source>
|
||||
<target>Batchredigering af EDA-felter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZaS_Hg5" name="batch_eda.title">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.title</source>
|
||||
<target>Batchredigering af EDA-felter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k2FDo7A" name="batch_eda.description">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.description</source>
|
||||
<target>Rediger EDA/KiCad-felter for %count% valgte dele. Markér feltet "Anvend" ud for hvert felt, du vil ændre.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WVHbic3" name="batch_eda.show_parts">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.show_parts</source>
|
||||
<target>Vis valgte dele</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ubQd6G4" name="batch_eda.apply_hint">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.apply_hint</source>
|
||||
<target>Kun felter, hvor afkrydsningsfeltet "Anvend" er markeret, ændres. Felter, der ikke er markeret, ændres ikke.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="w.5FGYL" name="batch_eda.apply">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.apply</source>
|
||||
<target>Anvend</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9EmHp5C" name="batch_eda.field">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.field</source>
|
||||
<target>Felt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xHaCnEQ" name="batch_eda.value">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.value</source>
|
||||
<target>Værdi</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PLqIBvC" name="batch_eda.submit">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.submit</source>
|
||||
<target>Anvend på udvalgte dele</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5nO7Fpq" name="batch_eda.cancel">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.cancel</source>
|
||||
<target>Annullér</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vhlPBNU" name="batch_eda.success">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.success</source>
|
||||
<target>EDA felter er nu opdateret</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2fMo760" name="batch_eda.no_parts_selected">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.no_parts_selected</source>
|
||||
<target>Ingen dele blev valgt til batchredigering.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yzpXFkB" name="info_providers.bulk_import.step1.spn_recommendation">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.step1.spn_recommendation</source>
|
||||
|
|
@ -12227,7 +12413,7 @@ Buerklin API-godkendelsesserver: 10 anmodninger/minut pr. IP-adresse</target>
|
|||
<unit id="aSHDhOi" name="update_manager.progress.downgrade_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.progress.downgrade_title</source>
|
||||
<target>Downgrade fremskridt</target>
|
||||
<target>Downgrade fremskridtPart-DB er blevet nedgraderet! Du skal muligvis opdatere siden for at se den nye version.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XYR1vvR" name="update_manager.progress.downgrade_completed">
|
||||
|
|
@ -12314,6 +12500,102 @@ Buerklin API-godkendelsesserver: 10 anmodninger/minut pr. IP-adresse</target>
|
|||
<target>Gendannelse af sikkerhedskopi er deaktiveret af serverkonfigurationen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="oAb35wU" name="update_manager.backup.create">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.create</source>
|
||||
<target>Opret sikkerhedskopi</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ms26oI0" name="update_manager.backup.create.confirm">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.create.confirm</source>
|
||||
<target>Vil du lave en fuld sikkerhedskopi nu? Det kan tage et stykke tid.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="H9y0eLa" name="update_manager.backup.created">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.created</source>
|
||||
<target>Sikkerhedskopi er oprettet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bMhXPVB" name="update_manager.backup.delete.confirm">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.delete.confirm</source>
|
||||
<target>Er du sikker på at du vil slette denne backup?</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tw67c_" name="update_manager.backup.deleted">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.deleted</source>
|
||||
<target>Sikkerhedskopi er slettet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BzBBuqk" name="update_manager.backup.delete_error">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.delete_error</source>
|
||||
<target>Sikkerhedskopi kunne ikke udføres.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2olmcSs" name="update_manager.log.delete.confirm">
|
||||
<segment state="translated">
|
||||
<source>update_manager.log.delete.confirm</source>
|
||||
<target>Er du sikker på at du vil slette denne log?</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".ZrVHpp" name="update_manager.log.deleted">
|
||||
<segment state="translated">
|
||||
<source>update_manager.log.deleted</source>
|
||||
<target>Log slettet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P2JI5Yw" name="update_manager.log.delete_error">
|
||||
<segment state="translated">
|
||||
<source>update_manager.log.delete_error</source>
|
||||
<target>Kunne ikke slette loggen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yos9FWk" name="update_manager.view_log">
|
||||
<segment state="translated">
|
||||
<source>update_manager.view_log</source>
|
||||
<target>Vis log.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="B9uA2va" name="update_manager.delete">
|
||||
<segment state="translated">
|
||||
<source>update_manager.delete</source>
|
||||
<target>Slet</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZtgvnXB" name="update_manager.backup.download">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.download</source>
|
||||
<target>Download sikkerhedskopi</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wxtmrnP" name="update_manager.backup.download.password_label">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.download.password_label</source>
|
||||
<target>Bekræft password for at downloade</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MIlTTgL" name="update_manager.backup.download.security_warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.download.security_warning</source>
|
||||
<target>Sikkerhedskopier indeholder følsomme data, herunder password-hashes og hemmeligheder. Bekræft venligst dit password for at fortsætte med download.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kZPHBRt" name="update_manager.backup.download.invalid_password">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.download.invalid_password</source>
|
||||
<target>Ugyldigt password. Download af sikkerhedskopi er afvist.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AZOjnE0" name="update_manager.backup.docker_warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup.docker_warning</source>
|
||||
<target>Docker-installation registreret. Sikkerhedskopier gemmes i var/backups/, som ikke er en persistent enhed. Brug downloadknappen til at gemme sikkerhedskopier eksternt, eller montér var/backups/ som en enhed i din docker-compose.yml.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kHKChQB" name="settings.ips.conrad">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.conrad</source>
|
||||
|
|
@ -12404,5 +12686,281 @@ Buerklin API-godkendelsesserver: 10 anmodninger/minut pr. IP-adresse</target>
|
|||
<target>Opdatér til</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XPhnMxn" name="part.gtin">
|
||||
<segment state="translated">
|
||||
<source>part.gtin</source>
|
||||
<target>GTIN / EAN</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TyykD7B" name="info_providers.capabilities.gtin">
|
||||
<segment state="translated">
|
||||
<source>info_providers.capabilities.gtin</source>
|
||||
<target>GTIN / EAN</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="JBGly8p" name="part.table.gtin">
|
||||
<segment state="translated">
|
||||
<source>part.table.gtin</source>
|
||||
<target>GTIN</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0qHQof." name="scan_dialog.mode.gtin">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.mode.gtin</source>
|
||||
<target>GTIN / EAN barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cmchX59" name="attachment_type.edit.allowed_targets">
|
||||
<segment state="translated">
|
||||
<source>attachment_type.edit.allowed_targets</source>
|
||||
<target>Anvend kun til</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="t5R8p1l" name="attachment_type.edit.allowed_targets.help">
|
||||
<segment state="translated">
|
||||
<source>attachment_type.edit.allowed_targets.help</source>
|
||||
<target>Gør kun denne bilagstype tilgængelig for bestemte elementklasser. Lad feltet stå tomt for at vise denne bilagstype for alle elementklasser.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="LvlEUjC" name="orderdetails.edit.prices_includes_vat">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.prices_includes_vat</source>
|
||||
<target>Pris inklusiv moms.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GUsVh5T" name="prices.incl_vat">
|
||||
<segment state="translated">
|
||||
<source>prices.incl_vat</source>
|
||||
<target>Inkl. moms</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3ipwaVQ" name="prices.excl_vat">
|
||||
<segment state="translated">
|
||||
<source>prices.excl_vat</source>
|
||||
<target>Ekskl. moms</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WDJ7EeF" name="settings.system.localization.prices_include_tax_by_default">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.prices_include_tax_by_default</source>
|
||||
<target>Priserne er som standard inklusive moms</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="01oGY_r" name="settings.system.localization.prices_include_tax_by_default.description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.prices_include_tax_by_default.description</source>
|
||||
<target>Standardværdien for nyoprettede købsoplysninger, uanset om priserne inkluderer moms eller ej.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="heWSnAH" name="part_lot.edit.last_stocktake_at">
|
||||
<segment state="translated">
|
||||
<source>part_lot.edit.last_stocktake_at</source>
|
||||
<target>Seneste optælling</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".LP93kG" name="perm.parts_stock.stocktake">
|
||||
<segment state="translated">
|
||||
<source>perm.parts_stock.stocktake</source>
|
||||
<target>Lageropgørelse</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Vnhrb5R" name="part.info.stocktake_modal.title">
|
||||
<segment state="translated">
|
||||
<source>part.info.stocktake_modal.title</source>
|
||||
<target>Lagerbeholdning</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WqOG7RK" name="part.info.stocktake_modal.expected_amount">
|
||||
<segment state="translated">
|
||||
<source>part.info.stocktake_modal.expected_amount</source>
|
||||
<target>Forventet mængde</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="E7IbVN6" name="part.info.stocktake_modal.actual_amount">
|
||||
<segment state="translated">
|
||||
<source>part.info.stocktake_modal.actual_amount</source>
|
||||
<target>Aktuel mængde</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4GwSma7" name="log.part_stock_changed.stock_take">
|
||||
<segment state="translated">
|
||||
<source>log.part_stock_changed.stock_take</source>
|
||||
<target>Lagerbeholdning</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aRQPMW7" name="log.element_edited.changed_fields.last_stocktake_at">
|
||||
<segment state="translated">
|
||||
<source>log.element_edited.changed_fields.last_stocktake_at</source>
|
||||
<target>Sidste lagerbeholdning</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GNWhoTW" name="part.table.eda_reference">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_reference</source>
|
||||
<target>EDA reference</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tW4yCbf" name="part.table.eda_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_value</source>
|
||||
<target>EDA-værdi</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s1pgReC" name="settings.misc.kicad_eda.default_parameter_visibility">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_parameter_visibility</source>
|
||||
<target>Standard EDA-synlighed for parametre</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Z78QunV" name="settings.misc.kicad_eda.default_parameter_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_parameter_visibility.help</source>
|
||||
<target>EDA-synlighed for alle [Part]-parametre, som ikke har en eksplicit synlighedsindstilling. Når den er aktiveret, vil alle parametre som standard være synlige i EDA-softwaren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="J6pYnaC" name="settings.misc.kicad_eda.default_orderdetails_visibility">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_orderdetails_visibility</source>
|
||||
<target>Standard EDA-synlighed for købsoplysninger</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Hiye4C." name="settings.misc.kicad_eda.default_orderdetails_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_orderdetails_visibility.help</source>
|
||||
<target>EDA-synlighed for alle købsoplysninger, som ikke har en eksplicit synlighedsindstilling. Når den er aktiveret, vil alle købsoplysninger som standard være synlige i EDA-softwaren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aEgd0if" name="label_scanner.open">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.open</source>
|
||||
<target>Vis detaljer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vw_0Qws" name="label_scanner.db_part_found">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.db_part_found</source>
|
||||
<target>Database [part] fundet for barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zntajcd" name="label_scanner.part_can_be_created">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.part_can_be_created</source>
|
||||
<target>[Part] kan oprettes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cLTbd9w" name="label_scanner.part_can_be_created.help">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.part_can_be_created.help</source>
|
||||
<target>Der blev ikke fundet nogen matchende [part] i databasen, men du kan oprette en ny [part] baseret på denne stregkode.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="FfHA3Yf" name="label_scanner.part_create_btn">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.part_create_btn</source>
|
||||
<target>Opret [part] fra barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xH258F." name="parts.create_from_scan.title">
|
||||
<segment state="translated">
|
||||
<source>parts.create_from_scan.title</source>
|
||||
<target>Opret [part] ud fra labelscanning</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8WZYwRJ" name="scan_dialog.mode.amazon">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.mode.amazon</source>
|
||||
<target>Amazon barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BQWuR_G" name="settings.ips.canopy">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy</source>
|
||||
<target>Canopy</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="44BfYzy" name="settings.ips.canopy.alwaysGetDetails">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy.alwaysGetDetails</source>
|
||||
<target>Hent altid detaljer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="so_ms3t" name="settings.ips.canopy.alwaysGetDetails.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy.alwaysGetDetails.help</source>
|
||||
<target>Når dette er valgt, hentes flere detaljer fra canopy, når en del oprettes. Dette forårsager en yderligere API-anmodning, men giver produktpunkter og kategorioplysninger.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="D055xh8" name="attachment.sandbox.warning">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.warning</source>
|
||||
<target>ADVARSEL: Du ser en brugeruploadet vedhæftet fil. Dette er indhold, der ikke er tillid til. Vær forsigtig.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bRcdnJK" name="attachment.sandbox.back_to_partdb">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.back_to_partdb</source>
|
||||
<target>Tilbage til Part-DB</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MzyA7N8" name="settings.system.attachments.showHTMLAttachments">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.showHTMLAttachments</source>
|
||||
<target>Vis uploadede HTML-filvedhæftninger (sandboxed)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="V_LJkRy" name="settings.system.attachments.showHTMLAttachments.help">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.showHTMLAttachments.help</source>
|
||||
<target>⚠️ Når det er aktiveret, kan brugeruploadede HTML-vedhæftninger ses direkte i browseren. Mange potentielt skadelige funktioner er begrænsede, men dette er stadig en potentiel sikkerhedsrisiko og bør kun aktiveres, hvis du har tillid til de brugere, der kan uploade filer.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BQo2xWi" name="attachment.sandbox.title">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.title</source>
|
||||
<target>HTML [Vedhæftning]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sJ6v9uJ" name="attachment.sandbox.as_plain_text">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.as_plain_text</source>
|
||||
<target>Vis som alm. tekst</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ehsj93c" name="modal.cancel">
|
||||
<segment state="translated">
|
||||
<source>modal.cancel</source>
|
||||
<target>Annuller</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="jdpoFf2" name="update_manager.web_updates_allowed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.web_updates_allowed</source>
|
||||
<target>Web-opdateringer tilladt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bdWa7is" name="update_manager.backup_restore_allowed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup_restore_allowed</source>
|
||||
<target>Indlæsning af sikkerhedskopi (backup) tilladt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kllGQEN" name="update_manager.backup_download_allowed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.backup_download_allowed</source>
|
||||
<target>Download af sikkerhedskopi tilladt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
|
||||
<segment state="translated">
|
||||
<source>part.create_from_info_provider.lot_filled_from_barcode</source>
|
||||
<target>[Part_lot] oprettet fra stregkode: Kontroller venligst, om dataene er korrekte og ønskede.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="F8pQuL9" name="project.bom_import.field_mapping.error.check_delimiter">
|
||||
<segment state="translated">
|
||||
<source>project.bom_import.field_mapping.error.check_delimiter</source>
|
||||
<target>Felttilknytningsfejl: Kontroller, om du har valgt den rigtige tegn-afgrænser!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
|
|
@ -2779,6 +2779,12 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
|||
<target>Name</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sIvAlUe" name="part.table.si_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.si_value</source>
|
||||
<target>SI-Wert</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rW_SFJE" name="part.table.id">
|
||||
<segment state="translated">
|
||||
<source>part.table.id</source>
|
||||
|
|
@ -7211,6 +7217,18 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Unterprojekte</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjTtlBP" name="project.info.total_build_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.total_build_price</source>
|
||||
<target>Gesamterstellpreis</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjUntBP" name="project.info.per_unit_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.per_unit_price</source>
|
||||
<target>pro Einheit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7nV.Cmd" name="project.info.bom_add_parts">
|
||||
<segment state="translated">
|
||||
<source>project.info.bom_add_parts</source>
|
||||
|
|
@ -7235,6 +7253,12 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Preis</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="translated">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Gesamtpreis</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
@ -10028,6 +10052,90 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
|||
<target>Wenn aktiviert, verlinkt das Datenblatt-Feld in KiCad auf die tatsächliche PDF-Datei (sofern gefunden). Wenn deaktiviert, führt es stattdessen zur Part-DB-Seite. Der Link zur Part-DB-Seite ist immer als separates "Part-DB URL"-Feld verfügbar.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="e2e7mR1" name="settings.misc.kicad_eda.editor.title">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.title</source>
|
||||
<target>KiCad Autovervollständigungslisten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qjv1VVx" name="settings.misc.kicad_eda.editor.link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.link</source>
|
||||
<target>Autovervollständigungseinstellungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f0qkcqg" name="settings.misc.kicad_eda.editor.description">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.description</source>
|
||||
<target>Konfigurieren Sie, ob KiCad Autovervollständigung die automatisch generierten Standardlisten oder Ihre benutzerdefinierten Überschreibungsdateien verwendet. Die benutzerdefinierten Dateien sind hier bearbeitbar, während die Standarddateien nur lesbar zur Referenz angezeigt werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AS3yDlb" name="settings.misc.kicad_eda.editor.footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints</source>
|
||||
<target>Footprint-Liste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Jj_YR7n" name="settings.misc.kicad_eda.editor.footprints.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints.help</source>
|
||||
<target>Ein Eintrag pro Zeile. Wird als Autovervollständigungsvorschlag für KiCad-Footprintfelder verwendet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ELd3KQK" name="settings.misc.kicad_eda.editor.symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols</source>
|
||||
<target>Symbolliste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A9TOJgM" name="settings.misc.kicad_eda.editor.symbols.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols.help</source>
|
||||
<target>Ein Eintrag pro Zeile. Wird als Autovervollständigungsvorschlag für KiCad-Symbolfelder verwendet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tWYlL0u" name="settings.misc.kicad_eda.use_custom_list">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list</source>
|
||||
<target>Benutzerdefinierte Autovervollständigungslisten verwenden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v0LK7n6" name="settings.misc.kicad_eda.use_custom_list.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list.help</source>
|
||||
<target>Wenn aktiviert, verwendet die KiCad Autovervollständigung public/kicad/footprints_custom.txt und public/kicad/symbols_custom.txt anstelle der automatisch generierten Standarddateien.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yl_fqfV" name="settings.misc.kicad_eda.editor.custom_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_footprints</source>
|
||||
<target>Benutzerdefinierte Footprint-Liste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GuD2JcQ" name="settings.misc.kicad_eda.editor.custom_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_symbols</source>
|
||||
<target>Benutzerdefinierte Symbolliste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k6m9b5F" name="settings.misc.kicad_eda.editor.default_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_footprints</source>
|
||||
<target>Standard Footprint-Liste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bKkF8mM" name="settings.misc.kicad_eda.editor.default_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_symbols</source>
|
||||
<target>Standardsymboliste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mIj_i4E" name="settings.misc.kicad_eda.editor.default_files_help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_files_help</source>
|
||||
<target>Automatisch generierte Datei wird nur zur Referenz angezeigt. Änderungen müssen in der benutzerdefinierten Liste vorgenommen werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar</source>
|
||||
|
|
|
|||
|
|
@ -2780,6 +2780,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
|
|||
<target>Name</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sIvAlUe" name="part.table.si_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.si_value</source>
|
||||
<target>SI Value</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rW_SFJE" name="part.table.id">
|
||||
<segment state="translated">
|
||||
<source>part.table.id</source>
|
||||
|
|
@ -7212,6 +7218,18 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Subprojects</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjTtlBP" name="project.info.total_build_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.total_build_price</source>
|
||||
<target>Total build price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjUntBP" name="project.info.per_unit_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.per_unit_price</source>
|
||||
<target>per unit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7nV.Cmd" name="project.info.bom_add_parts">
|
||||
<segment state="translated">
|
||||
<source>project.info.bom_add_parts</source>
|
||||
|
|
@ -7236,6 +7254,12 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="translated">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
@ -10029,6 +10053,90 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>When enabled, the datasheet field in KiCad will link to the actual PDF file (if found). When disabled, it will link to the Part-DB page instead. The Part-DB page link is always available as a separate "Part-DB URL" field.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="e2e7mR1" name="settings.misc.kicad_eda.editor.title">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.title</source>
|
||||
<target>KiCad autocomplete lists</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qjv1VVx" name="settings.misc.kicad_eda.editor.link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.link</source>
|
||||
<target>Autocomplete settings</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f0qkcqg" name="settings.misc.kicad_eda.editor.description">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.description</source>
|
||||
<target>Configure whether KiCad autocomplete uses the autogenerated default lists or your custom override files. The custom files are editable here, while the default files are shown read-only for reference.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AS3yDlb" name="settings.misc.kicad_eda.editor.footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints</source>
|
||||
<target>Footprints list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Jj_YR7n" name="settings.misc.kicad_eda.editor.footprints.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints.help</source>
|
||||
<target>One entry per line. Used as autocomplete suggestions for KiCad footprint fields.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ELd3KQK" name="settings.misc.kicad_eda.editor.symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols</source>
|
||||
<target>Symbols list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A9TOJgM" name="settings.misc.kicad_eda.editor.symbols.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols.help</source>
|
||||
<target>One entry per line. Used as autocomplete suggestions for KiCad symbol fields.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tWYlL0u" name="settings.misc.kicad_eda.use_custom_list">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list</source>
|
||||
<target>Use custom autocomplete lists</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v0LK7n6" name="settings.misc.kicad_eda.use_custom_list.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list.help</source>
|
||||
<target>When enabled, KiCad autocomplete uses public/kicad/footprints_custom.txt and public/kicad/symbols_custom.txt instead of the autogenerated default files.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yl_fqfV" name="settings.misc.kicad_eda.editor.custom_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_footprints</source>
|
||||
<target>Custom footprints list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GuD2JcQ" name="settings.misc.kicad_eda.editor.custom_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_symbols</source>
|
||||
<target>Custom symbols list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k6m9b5F" name="settings.misc.kicad_eda.editor.default_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_footprints</source>
|
||||
<target>Default footprints list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bKkF8mM" name="settings.misc.kicad_eda.editor.default_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_symbols</source>
|
||||
<target>Default symbols list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mIj_i4E" name="settings.misc.kicad_eda.editor.default_files_help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_files_help</source>
|
||||
<target>Autogenerated file shown for reference only. Changes must be made in the custom list.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar</source>
|
||||
|
|
|
|||
|
|
@ -7259,6 +7259,12 @@ Elemento 3</target>
|
|||
<target>Precio</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7198,6 +7198,12 @@
|
|||
<target>Ár</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
|
|
@ -7186,6 +7186,12 @@ Element 3</target>
|
|||
<target>Prezzo</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
|
|
@ -7256,6 +7256,12 @@ Element 3</target>
|
|||
<target>Cena</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
|
|
@ -7260,6 +7260,12 @@
|
|||
<target>Цена</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
|
|
@ -7259,6 +7259,12 @@ Element 3</target>
|
|||
<target>价格</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<segment state="initial">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hO.xnng" name="part.info.withdraw_modal.title.withdraw">
|
||||
<segment state="translated">
|
||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||
|
|
|
|||
|
|
@ -4,31 +4,31 @@
|
|||
<unit id="cRbk.cm" name="part.master_attachment.must_be_picture">
|
||||
<segment state="translated">
|
||||
<source>part.master_attachment.must_be_picture</source>
|
||||
<target>Forhåndsvisnings-bilaget skal være et rigtigt billede!</target>
|
||||
<target>Forhåndsvisningsvedhæftningen skal være et gyldigt billede!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v8HkcJB" name="structural.entity.unique_name">
|
||||
<segment state="translated">
|
||||
<source>structural.entity.unique_name</source>
|
||||
<target>Der eksisterer allerede et element med dette navn på dette niveau!</target>
|
||||
<target>Et element med dette navn findes allerede på dette niveau!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dW7b2B_" name="parameters.validator.min_lesser_typical">
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.min_lesser_typical</source>
|
||||
<target>Værdi skal være mindre end eller lig med den typiske værdi ({{ compared_value }}).</target>
|
||||
<target>Værdien skal være mindre end eller lig med den typiske værdi ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yfp2uC5" name="parameters.validator.min_lesser_max">
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.min_lesser_max</source>
|
||||
<target>Værdi skal være mindre end maksumumværdien ({{ compared_value }}).</target>
|
||||
<target>Værdien skal være mindre end den maksimale værdi ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P6b.8Ou" name="parameters.validator.max_greater_typical">
|
||||
<segment state="translated">
|
||||
<source>parameters.validator.max_greater_typical</source>
|
||||
<target>Værdi skal være større eller lig med den typiske værdi ({{ compared_value }}).</target>
|
||||
<target>Værdien skal være større end eller lig med den typiske værdi ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P41193Y" name="validator.user.username_already_used">
|
||||
|
|
@ -247,5 +247,11 @@
|
|||
<target>Der er allerede defineret en oversættelse for denne type og sprog!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_gtin</source>
|
||||
<target>Dette er ikke en gyldig GTIN / EAN!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
<unit id="gZ5FFL1" name="part.ipn.must_be_unique">
|
||||
<segment state="translated">
|
||||
<source>part.ipn.must_be_unique</source>
|
||||
<target>Le numéro de pièce interne doit être unique.{{ value }} est déjà utilisé !</target>
|
||||
<target>Le numéro de pièce interne doit être unique. {{ value }} est déjà utilisé !</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="P31Yg.d" name="validator.project.bom_entry.name_or_part_needed">
|
||||
|
|
@ -223,13 +223,13 @@
|
|||
<target>Suite à des limitations techniques, il n'est pas possible de sélectionner une date après le 19-01-2038 sur les systèmes 32-bit !</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
|
||||
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>Taille de fichier invalide. Utilisez un nombre avec le suffixe K, G, M pour Kilo, Mega ou Gigabytes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iXcU7ce" name="validator.invalid_range">
|
||||
<unit id="ZFxQ0BZ" name="validator.invalid_range">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
<target>L'écart fournit est invalide !</target>
|
||||
|
|
@ -241,5 +241,17 @@
|
|||
<target>Code invalide. Vérifiez que votre application d'authentification est paramétrée correctement que le serveur et périphérique d'authentification ont l'heure correcte.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
|
||||
<segment state="translated">
|
||||
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
|
||||
<target>Il existe déjà une traduction définit pour ce type et langage !</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_gtin</source>
|
||||
<target>Cela n'est pas un GTIN / EAN valide !</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue