Compare commits

...

30 commits

Author SHA1 Message Date
github-actions[bot]
68d3325a33 Update KiCad symbols and footprints lists
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-04-20 05:19:58 +00:00
Jan Böhmer
a82d515034
New Crowdin updates (#1325)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
* New translations messages.en.xlf (Danish)

* New translations validators.en.xlf (Danish)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (French)

* New translations validators.en.xlf (French)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)
2026-04-15 23:38:40 +02:00
Jan Böhmer
6a30b41688 Bumped version to 2.10.0 2026-04-15 23:27:30 +02:00
Jan Böhmer
ec05f9d8ab Fixed phpstan issues 2026-04-15 23:27:10 +02:00
Jan Böhmer
1c3dfa26bb Updated dependencies 2026-04-15 23:06:40 +02:00
Jan Böhmer
766665f9e5 Use big E for si value formatting output 2026-04-15 22:57:02 +02:00
Wieland Schopohl
29db029d69
Add SI-prefix-aware sorting column for parts tableFeature/si value sort (#1344)
* Add SI-prefix-aware sorting column for the parts table

Adds an optional "Name (SI)" column that parses numeric values with SI
prefixes (p, n, u/µ, m, k/K, M, G, T) from part names and sorts by the
resulting physical value. This is useful for electronic components where
alphabetical sorting produces wrong results — e.g. 100nF, 10pF, 1uF
should sort as 10pF < 100nF < 1uF.

Implementation:
- New SiValueSort DQL function with platform-specific SQL generation
  for PostgreSQL (POSIX regex), MySQL/MariaDB (REGEXP_SUBSTR), and
  SQLite (PHP callback registered via the existing middleware).
- The regex is start-anchored: only names beginning with a number are
  matched. Part numbers like "MCP2515" or "Crystal 20MHz" are ignored.
- When SI sort is active, NATSORT is appended as a secondary sort so
  that non-matching parts fall back to natural string ordering instead
  of appearing in arbitrary order.
- The column is opt-in (not in default columns) and displays the parsed
  float value, or an empty cell for non-matching names.

* Rename SI column from "Name (SI)" to "SI Value"

The column now shows the parsed numeric value rather than the part name,
so the label should reflect that.

* Support comma as decimal separator in SI value parsing

Part names using European decimal notation (e.g. "4,7 kΩ", "2,2uF")
were parsed incorrectly because the regex only recognized dots. Now
commas are normalized to dots before parsing, matching the existing
pattern used elsewhere in the codebase (PartNormalizer, price providers).
2026-04-15 22:56:34 +02:00
Jan Böhmer
146e85f84c
Update KiCad symbols and footprints lists (#1333)
* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 22:13:54 +02:00
Niklas
c17cf5e83c
Add price columns to project BOM table and build price summary (#1345)
* Add unit price and extended price columns to project BOM table

Adds two optional columns to the project BOM datatable (hidden by
default, toggleable via column visibility):

- **Price**: unit price for the BOM entry in the base currency,
  looked up via PricedetailHelper. For parts whose BOM quantity falls
  below the minimum order amount the minimum order amount is used for
  the price tier lookup so that a price is always returned.
- **Extended Price**: unit price multiplied by the BOM quantity.

Prices are rendered via MoneyFormatter (locale-aware, with currency
symbol). Both columns round up to 2 decimal places to avoid displaying
0.00 for very small prices.

* Add translation key for project.bom.ext_price

Adds the English translation "Extended Price" for the new BOM extended
price column. Other languages are marked needs-translation and will be
picked up by Crowdin.

* Add build price summary to project info tab

Displays the total BOM price for N builds on the project info page,
using the existing price-tier logic from PricedetailHelper. The user
can adjust the number of builds via a small form; the unit price is
also shown when N > 1.

New backend:
- ProjectBuildHelper gains calculateTotalBuildPrice(),
  calculateUnitBuildPrice(), roundedTotalBuildPrice(), and
  roundedUnitBuildPrice() — bulk-order quantities are factored in so
  that price tiers apply correctly across N builds.
- ProjectController::info() now reads ?n= and passes number_of_builds
  to the template.

Template (_info.html.twig):
- Adds price badge (hidden when no pricing data is available).
- Adds number-of-builds form that reloads the info page.

* Add tests for build price calculation in ProjectBuildHelper

Covers calculateTotalBuildPrice(), calculateUnitBuildPrice(),
roundedTotalBuildPrice(), and the private getBomEntryUnitPrice()
helper. Scenarios tested: empty project, no pricing data, non-part BOM
entries with manual prices, part entries with pricedetails, mixed
entries, rounding-up of sub-cent prices, and minimum order amount
floor for price tier lookup.

* Deduplicate BOM entry price logic into ProjectBuildHelper

The private getBomEntryUnitPrice() in ProjectBomEntriesDataTable was
identical to the one in ProjectBuildHelper. Replaced it with a new
public getEntryUnitPrice() on ProjectBuildHelper (returns BigDecimal,
never null) and delegate to it from the DataTable.

This eliminates the duplicate code and brings the DataTable lines under
the existing ProjectBuildHelper test coverage. Added three tests for
getEntryUnitPrice() covering the no-pricing, non-part, and part cases.

* Added type hint to service

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
Jan Böhmer
5b86d6f652 Require full authentication for the system settings, as some of the settings are quite critical
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-15 00:04:52 +02:00
DanTrackpaw
58a34e3628
Add custom KiCad autocomplete list settings (#1342)
* Add admin editor for KiCad autocomplete lists

* Add custom KiCad autocomplete list settings

* Ignore the footprints_custom.txt and symbols_custom.txt in git and create them on the fly if needed

Otherwise it breaks the update mechanism

* Added comments

* Include kicad custom files in config backup command

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 00:01:00 +02:00
Jan Böhmer
35dcb298e7 Updated dependencies
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-13 22:26:46 +02:00
Marc
0140c9a7b9
Fix #1305: Enable BOM sorting on part fields (Storage location, Manufacturing status) and fix BOM table query/pagination issues (#1338)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
* Fix identation

* Allow ordering of column Storage Locations in BOM fix-#1152

* Fix "[Semantical Error] line 0, col 274 near 'storageLocations.name))': Error: 'storageLocations' is not defined." when trying to sort by column Storage Locations

* Try to fix "Iterate with fetch join in class App\Entity\Parts\PartLot using association part not allowed." when opening BOM

* Revert "Try to fix "Iterate with fetch join in class App\Entity\Parts\PartLot using association part not allowed." when opening BOM"

This reverts commit 5c5c7cece1.

* Try to fix "Iterate with fetch join in class App\Entity\Parts\PartLot using association part not allowed." when opening BOM 2nd try

* Remove alias to fix: Unknown named parameter $alias

* Reformat code to allow easier diff between ProjectBomEntriesDataTable.php and PartsDataTable.php

* Try if 'data' es really needed as it is not used in PartDataTable.php

* Use TwoStepORMAdapter to enable sorting based on other columns like storage location, manufacturing status

* Add readonly hint to projectBom query

---------

Co-authored-by: root <root@part-db.fritz.box>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-06 15:15:15 +02:00
Albert Koczy
d25ac2622e
Fix creating parts from TME if the SPN contains percent signs (#1337)
* Fix creating TME parts with percent signs in SPN

The SPN ends up in the URL, which later causes validation errors n the
form. Solved by encoding the percent sign.

* Add TME provider unit tests.
2026-04-06 14:42:54 +02:00
Jan Böhmer
cee7e2a077 Fixed phpstan issues
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-05 23:55:07 +02:00
Jan Böhmer
2a6e5435e1 Use bookworm version of dunglas frankenphp base image to fix docker building 2026-04-05 23:51:39 +02:00
Jan Böhmer
05b1965957 Use truncatate purging during load fixtures to fix compatibility for postgres 2026-04-05 23:45:09 +02:00
dependabot[bot]
57ef3e06a7
Bump codecov/codecov-action from 5 to 6 (#1334)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 23:14:50 +02:00
Jan Böhmer
7d8a7ab471 Allow to disable the NoPrivateNetwork protection for attachment downloads via an env variable 2026-04-05 23:14:18 +02:00
Jan Böhmer
ad35ae6e9e Decorate hte attachment download and generic web provider with the NoPrivateNetworkHttpClient
This is for security hardening to prevent SSRF attacks
2026-04-05 23:07:24 +02:00
Jan Böhmer
f12f808b34 Upgraded dependencies 2026-04-05 22:47:25 +02:00
Jan Böhmer
0080aa9f25 Stay on Ckeditor 47 as with 48 seems something to break
47 is the LTS version anyway
2026-04-05 22:44:09 +02:00
Jan Böhmer
dc522d4795 Updated webpack-encore package 2026-04-03 22:08:08 +02:00
Jan Böhmer
f07eabd85a Updated documentation about node requirements 2026-04-03 21:58:02 +02:00
Jan Böhmer
70454e3a6d Require node 22 and bumped ckeditor dependencies 2026-04-03 21:55:11 +02:00
Jan Böhmer
8b3bebca7b Updated dependencies 2026-04-03 21:45:02 +02:00
Jan Böhmer
4d296d8f3a Merge remote-tracking branch 'origin/master' 2026-04-03 21:32:27 +02:00
Tobias Klausmann
96da2b9f1f
.gitignore: add public/.well-known directory (#1335)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
RFC 5785 defines /.well-known/ as the prefix for "well-known locations"
in URIs. This prefix is used for example by Certbot/Let's Encrypt to
request SSL certificates. PartDB doesn't need to care about this
directory, but also should not see its existence as the git tree being
"dirty".
2026-04-03 20:15:03 +02:00
Jan Böhmer
f9a8818e69 Updated dependencies 2026-03-30 20:09:16 +02:00
Jan Böhmer
52df554b29 Added more warnings about sudo -E to docker docs
Related to issue #1319
2026-03-30 20:03:25 +02:00
62 changed files with 13128 additions and 4993 deletions

4
.env
View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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 \

View file

@ -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

View file

@ -1 +1 @@
2.9.1
2.10.0

1164
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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:

View file

@ -105,6 +105,8 @@ parameters:
env(DATABASE_EMULATE_NATURAL_SORT): 0
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
######################################################################################################################
# Bulk Info Provider Import Configuration
######################################################################################################################

View file

@ -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{

View file

@ -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.

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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"

View file

@ -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
View file

@ -0,0 +1,3 @@
# They are user generated and should not be tracked by git
footprints_custom.txt
symbols_custom.txt

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;
}
}
}

View 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,
]);
}
}

View file

@ -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,
]);
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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
{

View 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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View 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');
}
}

View file

@ -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);
}

View file

@ -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;

View 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 [];
}
}

View file

@ -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,
]

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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>

View 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 %}

View file

@ -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">

View file

@ -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'];

View 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);
}
}

View 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));
}
}

View 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'));
}
}

View file

@ -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));
}
}

View file

@ -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
{

View file

@ -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>

View file

@ -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 &gt; 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>

View file

@ -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 -&gt; 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 -&gt; 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>

View file

@ -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 -&gt; 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 -&gt; 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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

5432
yarn.lock

File diff suppressed because it is too large Load diff