diff --git a/assets/controllers/common/markdown_controller.js b/assets/controllers/common/markdown_controller.js
index b6ef0034..c6cb97df 100644
--- a/assets/controllers/common/markdown_controller.js
+++ b/assets/controllers/common/markdown_controller.js
@@ -56,12 +56,16 @@ export default class MarkdownController extends Controller {
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
for(let a of this.element.querySelectorAll('a')) {
- //Mark all links as external
- a.classList.add('link-external');
- //Open links in new tag
- a.setAttribute('target', '_blank');
- //Dont track
- a.setAttribute('rel', 'noopener');
+ // test if link is absolute
+ var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
+ if (r.test(a.getAttribute('href'))) {
+ //Mark all links as external
+ a.classList.add('link-external');
+ //Open links in new tag
+ a.setAttribute('target', '_blank');
+ //Dont track
+ a.setAttribute('rel', 'noopener');
+ }
}
//Apply bootstrap styles to tables
@@ -108,4 +112,4 @@ export default class MarkdownController extends Controller {
gfm: true,
});
}*/
-}
\ No newline at end of file
+}
diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js
index f8bc301e..0175b284 100644
--- a/assets/controllers/elements/attachment_autocomplete_controller.js
+++ b/assets/controllers/elements/attachment_autocomplete_controller.js
@@ -42,6 +42,7 @@ export default class extends Controller {
selectOnTab: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
+ dropdownParent: 'body',
render: {
item: (data, escape) => {
return '' + escape(data.label) + '';
diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js
index 5abd5ba3..0658f4b4 100644
--- a/assets/controllers/elements/part_select_controller.js
+++ b/assets/controllers/elements/part_select_controller.js
@@ -16,6 +16,7 @@ export default class extends Controller {
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
+ dropdownParent: 'body',
preload: "focus",
render: {
item: (data, escape) => {
@@ -71,4 +72,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
-}
\ No newline at end of file
+}
diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js
index cdafe4d0..f933731a 100644
--- a/assets/controllers/elements/select_controller.js
+++ b/assets/controllers/elements/select_controller.js
@@ -44,6 +44,7 @@ export default class extends Controller {
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,
+ dropdownParent: 'body',
render: {
item: this.renderItem.bind(this),
@@ -108,4 +109,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
-}
\ No newline at end of file
+}
diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js
index df37871d..daa6b0a1 100644
--- a/assets/controllers/elements/select_multiple_controller.js
+++ b/assets/controllers/elements/select_multiple_controller.js
@@ -29,6 +29,7 @@ export default class extends Controller {
this._tomSelect = new TomSelect(this.element, {
maxItems: 1000,
allowEmptyOption: true,
+ dropdownParent: 'body',
plugins: ['remove_button'],
});
}
@@ -39,4 +40,4 @@ export default class extends Controller {
this._tomSelect.destroy();
}
-}
\ No newline at end of file
+}
diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js
index 31ca0314..0421a26d 100644
--- a/assets/controllers/elements/static_file_autocomplete_controller.js
+++ b/assets/controllers/elements/static_file_autocomplete_controller.js
@@ -50,6 +50,7 @@ export default class extends Controller {
valueField: 'text',
searchField: 'text',
orderField: 'text',
+ dropdownParent: 'body',
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js
index a1114a97..5c6f9490 100644
--- a/assets/controllers/elements/structural_entity_select_controller.js
+++ b/assets/controllers/elements/structural_entity_select_controller.js
@@ -54,6 +54,7 @@ export default class extends Controller {
maxItems: 1,
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
+ dropdownParent: 'body',
searchField: [
{field: "text", weight : 2},
diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js
index 1f10c457..53bf7608 100644
--- a/assets/controllers/elements/tagsinput_controller.js
+++ b/assets/controllers/elements/tagsinput_controller.js
@@ -43,6 +43,7 @@ export default class extends Controller {
selectOnTab: true,
createOnBlur: true,
create: true,
+ dropdownParent: 'body',
};
if(this.element.dataset.autocomplete) {
@@ -73,4 +74,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
-}
\ No newline at end of file
+}
diff --git a/assets/css/components/ckeditor.css b/assets/css/components/ckeditor.css
index d6b3def4..5f093bf2 100644
--- a/assets/css/components/ckeditor.css
+++ b/assets/css/components/ckeditor.css
@@ -71,6 +71,8 @@
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
--ck-color-button-on-active-background: var(--bs-secondary-bg);
--ck-color-button-on-disabled-background: var(--bs-secondary-bg);
- --ck-color-button-on-color: var(--bs-primary)
+ --ck-color-button-on-color: var(--bs-primary);
-}
\ No newline at end of file
+ --ck-content-font-color: var(--ck-color-base-text);
+
+}
diff --git a/composer.json b/composer.json
index 8e3d1194..80b413f8 100644
--- a/composer.json
+++ b/composer.json
@@ -25,8 +25,7 @@
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^v3.0.0",
- "florianv/swap": "^4.0",
- "florianv/swap-bundle": "dev-master",
+ "part-db/swap-bundle": "^6.0.0",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"jbtronics/2fa-webauthn": "^3.0.0",
diff --git a/composer.lock b/composer.lock
index 6b9888d7..6de15830 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "09b78f345ea8115b5b29ea3e67dcb579",
+ "content-hash": "fe6dfc229f551945cfa6be8ca26a437e",
"packages": [
{
"name": "amphp/amp",
@@ -968,7 +968,7 @@
},
{
"name": "api-platform/doctrine-common",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/doctrine-common.git",
@@ -1050,13 +1050,13 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/doctrine-common/tree/v4.1.22"
+ "source": "https://github.com/api-platform/doctrine-common/tree/v4.1.23"
},
"time": "2025-08-18T13:30:43+00:00"
},
{
"name": "api-platform/doctrine-orm",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/doctrine-orm.git",
@@ -1135,13 +1135,13 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/doctrine-orm/tree/v4.1.22"
+ "source": "https://github.com/api-platform/doctrine-orm/tree/v4.1.23"
},
"time": "2025-06-06T14:56:47+00:00"
},
{
"name": "api-platform/documentation",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/documentation.git",
@@ -1203,7 +1203,7 @@
},
{
"name": "api-platform/http-cache",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/http-cache.git",
@@ -1275,13 +1275,13 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/http-cache/tree/v4.1.22"
+ "source": "https://github.com/api-platform/http-cache/tree/v4.1.23"
},
"time": "2025-06-06T14:56:47+00:00"
},
{
"name": "api-platform/hydra",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/hydra.git",
@@ -1360,13 +1360,13 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/hydra/tree/v4.1.22"
+ "source": "https://github.com/api-platform/hydra/tree/v4.1.23"
},
"time": "2025-07-15T14:10:59+00:00"
},
{
"name": "api-platform/json-api",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/json-api.git",
@@ -1439,13 +1439,13 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/json-api/tree/v4.1.22"
+ "source": "https://github.com/api-platform/json-api/tree/v4.1.23"
},
"time": "2025-08-06T07:56:58+00:00"
},
{
"name": "api-platform/json-schema",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/json-schema.git",
@@ -1518,13 +1518,13 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/json-schema/tree/v4.1.22"
+ "source": "https://github.com/api-platform/json-schema/tree/v4.1.23"
},
"time": "2025-06-29T12:24:14+00:00"
},
{
"name": "api-platform/jsonld",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/jsonld.git",
@@ -1596,22 +1596,22 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/jsonld/tree/v4.1.22"
+ "source": "https://github.com/api-platform/jsonld/tree/v4.1.23"
},
"time": "2025-07-25T10:05:30+00:00"
},
{
"name": "api-platform/metadata",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/metadata.git",
- "reference": "782477dd28cc675909597bfa47af7c1f85659ffa"
+ "reference": "58b25f9a82c12727afab09b5a311828aacff8e88"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/metadata/zipball/782477dd28cc675909597bfa47af7c1f85659ffa",
- "reference": "782477dd28cc675909597bfa47af7c1f85659ffa",
+ "url": "https://api.github.com/repos/api-platform/metadata/zipball/58b25f9a82c12727afab09b5a311828aacff8e88",
+ "reference": "58b25f9a82c12727afab09b5a311828aacff8e88",
"shasum": ""
},
"require": {
@@ -1693,13 +1693,13 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/metadata/tree/v4.1.22"
+ "source": "https://github.com/api-platform/metadata/tree/v4.1.23"
},
- "time": "2025-08-29T15:13:26+00:00"
+ "time": "2025-09-05T09:06:52+00:00"
},
{
"name": "api-platform/openapi",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/openapi.git",
@@ -1780,13 +1780,13 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/openapi/tree/v4.1.22"
+ "source": "https://github.com/api-platform/openapi/tree/v4.1.23"
},
"time": "2025-07-29T08:53:27+00:00"
},
{
"name": "api-platform/serializer",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/serializer.git",
@@ -1871,13 +1871,13 @@
"serializer"
],
"support": {
- "source": "https://github.com/api-platform/serializer/tree/v4.1.22"
+ "source": "https://github.com/api-platform/serializer/tree/v4.1.23"
},
"time": "2025-08-29T15:13:26+00:00"
},
{
"name": "api-platform/state",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/state.git",
@@ -1963,22 +1963,22 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/state/tree/v4.1.22"
+ "source": "https://github.com/api-platform/state/tree/v4.1.23"
},
"time": "2025-07-16T14:01:52+00:00"
},
{
"name": "api-platform/symfony",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/symfony.git",
- "reference": "735b9a80f3b7a5f528b663cd28489fbe52f52416"
+ "reference": "e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/symfony/zipball/735b9a80f3b7a5f528b663cd28489fbe52f52416",
- "reference": "735b9a80f3b7a5f528b663cd28489fbe52f52416",
+ "url": "https://api.github.com/repos/api-platform/symfony/zipball/e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1",
+ "reference": "e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1",
"shasum": ""
},
"require": {
@@ -2087,13 +2087,13 @@
"symfony"
],
"support": {
- "source": "https://github.com/api-platform/symfony/tree/v4.1.22"
+ "source": "https://github.com/api-platform/symfony/tree/v4.1.23"
},
- "time": "2025-07-16T14:01:52+00:00"
+ "time": "2025-09-05T07:30:37+00:00"
},
{
"name": "api-platform/validator",
- "version": "v4.1.22",
+ "version": "v4.1.23",
"source": {
"type": "git",
"url": "https://github.com/api-platform/validator.git",
@@ -2162,7 +2162,7 @@
"validator"
],
"support": {
- "source": "https://github.com/api-platform/validator/tree/v4.1.22"
+ "source": "https://github.com/api-platform/validator/tree/v4.1.23"
},
"time": "2025-07-16T14:01:52+00:00"
},
@@ -2881,16 +2881,16 @@
},
{
"name": "doctrine/dbal",
- "version": "4.3.2",
+ "version": "4.3.3",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
- "reference": "7669f131d43b880de168b2d2df9687d152d6c762"
+ "reference": "231959669bb2173194c95636eae7f1b41b2a8b19"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/dbal/zipball/7669f131d43b880de168b2d2df9687d152d6c762",
- "reference": "7669f131d43b880de168b2d2df9687d152d6c762",
+ "url": "https://api.github.com/repos/doctrine/dbal/zipball/231959669bb2173194c95636eae7f1b41b2a8b19",
+ "reference": "231959669bb2173194c95636eae7f1b41b2a8b19",
"shasum": ""
},
"require": {
@@ -2900,10 +2900,10 @@
"psr/log": "^1|^2|^3"
},
"require-dev": {
- "doctrine/coding-standard": "13.0.0",
+ "doctrine/coding-standard": "13.0.1",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.2",
- "phpstan/phpstan": "2.1.17",
+ "phpstan/phpstan": "2.1.22",
"phpstan/phpstan-phpunit": "2.0.6",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "11.5.23",
@@ -2967,7 +2967,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
- "source": "https://github.com/doctrine/dbal/tree/4.3.2"
+ "source": "https://github.com/doctrine/dbal/tree/4.3.3"
},
"funding": [
{
@@ -2983,7 +2983,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-05T13:30:38+00:00"
+ "time": "2025-09-04T23:52:42+00:00"
},
{
"name": "doctrine/deprecations",
@@ -3035,16 +3035,16 @@
},
{
"name": "doctrine/doctrine-bundle",
- "version": "2.15.1",
+ "version": "2.16.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineBundle.git",
- "reference": "5a305c5e776f9d3eb87f5b94d40d50aff439211d"
+ "reference": "152d5083f0cd205a278131dc4351a8c94d007fe1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/5a305c5e776f9d3eb87f5b94d40d50aff439211d",
- "reference": "5a305c5e776f9d3eb87f5b94d40d50aff439211d",
+ "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/152d5083f0cd205a278131dc4351a8c94d007fe1",
+ "reference": "152d5083f0cd205a278131dc4351a8c94d007fe1",
"shasum": ""
},
"require": {
@@ -3081,6 +3081,7 @@
"phpunit/phpunit": "^9.6.22",
"psr/log": "^1.1.4 || ^2.0 || ^3.0",
"symfony/doctrine-messenger": "^6.4 || ^7.0",
+ "symfony/expression-language": "^6.4 || ^7.0",
"symfony/messenger": "^6.4 || ^7.0",
"symfony/phpunit-bridge": "^7.2",
"symfony/property-info": "^6.4 || ^7.0",
@@ -3137,7 +3138,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
- "source": "https://github.com/doctrine/DoctrineBundle/tree/2.15.1"
+ "source": "https://github.com/doctrine/DoctrineBundle/tree/2.16.1"
},
"funding": [
{
@@ -3153,7 +3154,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-30T15:48:28+00:00"
+ "time": "2025-09-05T15:24:53+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
@@ -4133,16 +4134,16 @@
},
{
"name": "ergebnis/classy",
- "version": "1.8.0",
+ "version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/ergebnis/classy.git",
- "reference": "e5a695e44b083d4a4b4f2a40427301cd2916699d"
+ "reference": "05c3ac7d8d9d337c4cf1d5602a339f57cb2a27ef"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ergebnis/classy/zipball/e5a695e44b083d4a4b4f2a40427301cd2916699d",
- "reference": "e5a695e44b083d4a4b4f2a40427301cd2916699d",
+ "url": "https://api.github.com/repos/ergebnis/classy/zipball/05c3ac7d8d9d337c4cf1d5602a339f57cb2a27ef",
+ "reference": "05c3ac7d8d9d337c4cf1d5602a339f57cb2a27ef",
"shasum": ""
},
"require": {
@@ -4150,11 +4151,11 @@
"php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
- "ergebnis/composer-normalize": "^2.47.0",
- "ergebnis/license": "^2.6.0",
- "ergebnis/php-cs-fixer-config": "^6.51.0",
- "ergebnis/phpstan-rules": "^2.10.5",
- "ergebnis/phpunit-slow-test-detector": "^2.19.1",
+ "ergebnis/composer-normalize": "^2.48.1",
+ "ergebnis/license": "^2.7.0",
+ "ergebnis/php-cs-fixer-config": "^6.54.0",
+ "ergebnis/phpstan-rules": "^2.11.0",
+ "ergebnis/phpunit-slow-test-detector": "^2.20.0",
"fakerphp/faker": "^1.24.1",
"infection/infection": "~0.26.6",
"phpstan/extension-installer": "^1.4.3",
@@ -4163,7 +4164,7 @@
"phpstan/phpstan-phpunit": "^2.0.7",
"phpstan/phpstan-strict-rules": "^2.0.6",
"phpunit/phpunit": "^9.6.19",
- "rector/rector": "^2.1.2"
+ "rector/rector": "^2.1.4"
},
"type": "library",
"autoload": {
@@ -4196,208 +4197,7 @@
"issues": "https://github.com/ergebnis/classy/issues",
"source": "https://github.com/ergebnis/classy"
},
- "time": "2025-08-19T06:14:25+00:00"
- },
- {
- "name": "florianv/exchanger",
- "version": "2.8.1",
- "source": {
- "type": "git",
- "url": "https://github.com/florianv/exchanger.git",
- "reference": "9214f51665fb907e7aa2397e21a90c456eb0c448"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/florianv/exchanger/zipball/9214f51665fb907e7aa2397e21a90c456eb0c448",
- "reference": "9214f51665fb907e7aa2397e21a90c456eb0c448",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-libxml": "*",
- "ext-simplexml": "*",
- "php": "^7.1.3 || ^8.0",
- "php-http/client-implementation": "^1.0",
- "php-http/discovery": "^1.6",
- "php-http/httplug": "^1.0 || ^2.0",
- "psr/http-factory": "^1.0.2",
- "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
- },
- "require-dev": {
- "nyholm/psr7": "^1.0",
- "php-http/message": "^1.7",
- "php-http/mock-client": "^1.0",
- "phpunit/phpunit": "^7 || ^8 || ^9.4"
- },
- "suggest": {
- "php-http/guzzle6-adapter": "Required to use Guzzle for sending HTTP requests",
- "php-http/message": "Required to use Guzzle for sending HTTP requests"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Exchanger\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Florian Voutzinos",
- "email": "florian@voutzinos.com",
- "homepage": "https://voutzinos.com"
- }
- ],
- "description": "Currency exchange rates framework for PHP",
- "homepage": "https://github.com/florianv/exchanger",
- "keywords": [
- "Rate",
- "conversion",
- "currency",
- "exchange rates",
- "money"
- ],
- "support": {
- "issues": "https://github.com/florianv/exchanger/issues",
- "source": "https://github.com/florianv/exchanger/tree/2.8.1"
- },
- "time": "2023-11-03T17:11:52+00:00"
- },
- {
- "name": "florianv/swap",
- "version": "4.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/florianv/swap.git",
- "reference": "88edd27fcb95bdc58bbbf9e4b00539a2843d97fd"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/florianv/swap/zipball/88edd27fcb95bdc58bbbf9e4b00539a2843d97fd",
- "reference": "88edd27fcb95bdc58bbbf9e4b00539a2843d97fd",
- "shasum": ""
- },
- "require": {
- "florianv/exchanger": "^2.0",
- "php": "^7.1.3 || ^8.0"
- },
- "require-dev": {
- "nyholm/psr7": "^1.0",
- "php-http/message": "^1.7",
- "php-http/mock-client": "^1.0",
- "phpunit/phpunit": "^7 || ^8 || ^9"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.0-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Swap\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Florian Voutzinos",
- "email": "florian@voutzinos.com",
- "homepage": "https://voutzinos.com"
- }
- ],
- "description": "Exchange rates library for PHP",
- "keywords": [
- "Rate",
- "conversion",
- "currency",
- "exchange rates",
- "money"
- ],
- "support": {
- "issues": "https://github.com/florianv/swap/issues",
- "source": "https://github.com/florianv/swap/tree/4.3.0"
- },
- "time": "2020-12-28T10:14:12+00:00"
- },
- {
- "name": "florianv/swap-bundle",
- "version": "dev-master",
- "source": {
- "type": "git",
- "url": "https://github.com/florianv/symfony-swap.git",
- "reference": "c8cd268ad6e2f636f10b91df9850e3941d7f5807"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/florianv/symfony-swap/zipball/c8cd268ad6e2f636f10b91df9850e3941d7f5807",
- "reference": "c8cd268ad6e2f636f10b91df9850e3941d7f5807",
- "shasum": ""
- },
- "require": {
- "florianv/swap": "^4.0",
- "php": "^7.1.3|^8.0",
- "symfony/framework-bundle": "~3.0|~4.0|~5.0|~6.0|~7.0"
- },
- "require-dev": {
- "nyholm/psr7": "^1.1",
- "php-http/guzzle6-adapter": "^1.0",
- "php-http/message": "^1.7",
- "phpunit/phpunit": "~5.7|~6.0|~7.0|~8.0|~9.0",
- "symfony/cache": "~3.0|~4.0|~5.0|~6.0|~7.0"
- },
- "suggest": {
- "symfony/cache": "For caching"
- },
- "default-branch": true,
- "type": "symfony-bundle",
- "extra": {
- "branch-alias": {
- "dev-master": "5.0-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Florianv\\SwapBundle\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Florian Voutzinos",
- "email": "florian@voutzinos.com",
- "homepage": "http://florian.voutzinos.com"
- }
- ],
- "description": "Integrates the Swap library with Symfony",
- "homepage": "https://github.com/florianv/FlorianvSwapBundle",
- "keywords": [
- "Rate",
- "bundle",
- "conversion",
- "currency",
- "exchange",
- "money",
- "symfony"
- ],
- "support": {
- "issues": "https://github.com/florianv/symfony-swap/issues",
- "source": "https://github.com/florianv/symfony-swap/tree/master"
- },
- "time": "2024-07-09T13:51:01+00:00"
+ "time": "2025-09-04T10:17:22+00:00"
},
{
"name": "gregwar/captcha",
@@ -6357,22 +6157,23 @@
},
{
"name": "liip/imagine-bundle",
- "version": "2.13.3",
+ "version": "2.14.0",
"source": {
"type": "git",
"url": "https://github.com/liip/LiipImagineBundle.git",
- "reference": "3faccde327f91368e51d05ecad49a9cd915abd81"
+ "reference": "f80dc13e9a454682b8c2255b3487829d2f8a7fe4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/3faccde327f91368e51d05ecad49a9cd915abd81",
- "reference": "3faccde327f91368e51d05ecad49a9cd915abd81",
+ "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/f80dc13e9a454682b8c2255b3487829d2f8a7fe4",
+ "reference": "f80dc13e9a454682b8c2255b3487829d2f8a7fe4",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"imagine/imagine": "^1.3.2",
"php": "^7.2|^8.0",
+ "symfony/deprecation-contracts": "^2.5 || ^3",
"symfony/filesystem": "^3.4|^4.4|^5.3|^6.0|^7.0",
"symfony/finder": "^3.4|^4.4|^5.3|^6.0|^7.0",
"symfony/framework-bundle": "^3.4.23|^4.4|^5.3|^6.0|^7.0",
@@ -6457,9 +6258,9 @@
],
"support": {
"issues": "https://github.com/liip/LiipImagineBundle/issues",
- "source": "https://github.com/liip/LiipImagineBundle/tree/2.13.3"
+ "source": "https://github.com/liip/LiipImagineBundle/tree/2.14.0"
},
- "time": "2024-12-12T09:38:23+00:00"
+ "time": "2025-09-03T06:33:10+00:00"
},
{
"name": "lorenzo/pinky",
@@ -7633,6 +7434,83 @@
},
"time": "2024-04-22T22:05:04+00:00"
},
+ {
+ "name": "part-db/exchanger",
+ "version": "v3.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Part-DB/exchanger.git",
+ "reference": "a43fe79a082e331ec2b24f3579e4fba153743757"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Part-DB/exchanger/zipball/a43fe79a082e331ec2b24f3579e4fba153743757",
+ "reference": "a43fe79a082e331ec2b24f3579e4fba153743757",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-simplexml": "*",
+ "php": "^7.1.3 || ^8.0",
+ "php-http/client-implementation": "^1.0",
+ "php-http/discovery": "^1.6",
+ "php-http/httplug": "^1.0 || ^2.0",
+ "psr/http-factory": "^1.0.2",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "nyholm/psr7": "^1.0",
+ "php-http/message": "^1.7",
+ "php-http/message-factory": "^1.1",
+ "php-http/mock-client": "^1.0",
+ "phpunit/phpunit": "^7 || ^8 || ^9.4 || ^10.5",
+ "symfony/http-client": "^5.4 || ^6.4 || ^7.0"
+ },
+ "suggest": {
+ "php-http/guzzle6-adapter": "Required to use Guzzle for sending HTTP requests",
+ "php-http/message": "Required to use Guzzle for sending HTTP requests"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Exchanger\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florian Voutzinos",
+ "email": "florian@voutzinos.com",
+ "homepage": "https://voutzinos.com"
+ },
+ {
+ "name": "Jan Böhmer",
+ "email": "mail@jan-boehmer.de"
+ }
+ ],
+ "description": "Fork of florianv/exchanger, a library to convert currencies using different exchange rate providers. Modernized to be compatible with Part-DB.",
+ "homepage": "https://github.com/Part-DB/exchanger",
+ "keywords": [
+ "Rate",
+ "conversion",
+ "currency",
+ "exchange rates",
+ "money"
+ ],
+ "support": {
+ "source": "https://github.com/Part-DB/exchanger/tree/v3.1.0"
+ },
+ "time": "2025-09-05T19:48:23+00:00"
+ },
{
"name": "part-db/label-fonts",
"version": "v1.1.0",
@@ -7671,6 +7549,149 @@
},
"time": "2024-02-08T21:44:38+00:00"
},
+ {
+ "name": "part-db/swap",
+ "version": "v5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Part-DB/swap.git",
+ "reference": "4fa57dec2eb1cbe0f6b8c92a2c250ecbe80688fe"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Part-DB/swap/zipball/4fa57dec2eb1cbe0f6b8c92a2c250ecbe80688fe",
+ "reference": "4fa57dec2eb1cbe0f6b8c92a2c250ecbe80688fe",
+ "shasum": ""
+ },
+ "require": {
+ "part-db/exchanger": "^3.0",
+ "php": "^7.1.3 || ^8.0",
+ "php-http/message-factory": "^1.1"
+ },
+ "require-dev": {
+ "nyholm/psr7": "^1.0",
+ "php-http/discovery": "^1.0",
+ "php-http/message": "^1.7",
+ "php-http/mock-client": "^1.0",
+ "phpunit/phpunit": "^7 || ^8 || ^9",
+ "symfony/http-client": "^5.4||^6.0||^7.0"
+ },
+ "suggest": {
+ "php-http/discovery": "If you are not using `useHttpClient` but instead want to auto-discover HttpClient"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Swap\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florian Voutzinos",
+ "email": "florian@voutzinos.com",
+ "homepage": "https://voutzinos.com"
+ },
+ {
+ "name": "Jan Böhmer",
+ "email": "mail@jan-boehmer.de"
+ }
+ ],
+ "description": "Fork of florianv/swap modernized for use in Part-DB. Exchange rates library for PHP",
+ "keywords": [
+ "Rate",
+ "conversion",
+ "currency",
+ "exchange rates",
+ "money"
+ ],
+ "support": {
+ "source": "https://github.com/Part-DB/swap/tree/v5.0.0"
+ },
+ "time": "2025-09-05T17:10:01+00:00"
+ },
+ {
+ "name": "part-db/swap-bundle",
+ "version": "v6.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Part-DB/symfony-swap.git",
+ "reference": "fd78ebfbd762b1d76b4d71f713f39add63dec62b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Part-DB/symfony-swap/zipball/fd78ebfbd762b1d76b4d71f713f39add63dec62b",
+ "reference": "fd78ebfbd762b1d76b4d71f713f39add63dec62b",
+ "shasum": ""
+ },
+ "require": {
+ "part-db/exchanger": "^3.1.0",
+ "part-db/swap": "^5.0",
+ "php": "^7.1.3|^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/framework-bundle": "~3.0|~4.0|~5.0|~6.0|~7.0"
+ },
+ "require-dev": {
+ "nyholm/psr7": "^1.1",
+ "php-http/guzzle6-adapter": "^1.0",
+ "php-http/message": "^1.7",
+ "phpunit/phpunit": "~5.7|~6.0|~7.0|~8.0|~9.0",
+ "symfony/cache": "~3.0|~4.0|~5.0|~6.0|~7.0",
+ "symfony/http-client": "~7.0|~6.0|~5.0"
+ },
+ "suggest": {
+ "symfony/cache": "For caching"
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Florianv\\SwapBundle\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florian Voutzinos",
+ "email": "florian@voutzinos.com",
+ "homepage": "http://florian.voutzinos.com"
+ },
+ {
+ "name": "Jan Böhmer",
+ "email": "mail@jan-boehmer.de"
+ }
+ ],
+ "description": "Fork of florianv/swap-bundle, modernized for use with Part-DB. Integrates the Swap library with Symfony",
+ "homepage": "https://github.com/florianv/FlorianvSwapBundle",
+ "keywords": [
+ "Rate",
+ "bundle",
+ "conversion",
+ "currency",
+ "exchange",
+ "money",
+ "symfony"
+ ],
+ "support": {
+ "source": "https://github.com/Part-DB/symfony-swap/tree/v6.1.0"
+ },
+ "time": "2025-09-05T19:52:56+00:00"
+ },
{
"name": "php-http/discovery",
"version": "1.20.0",
@@ -7807,6 +7828,61 @@
},
"time": "2024-09-23T11:39:58+00:00"
},
+ {
+ "name": "php-http/message-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-http/message-factory.git",
+ "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57",
+ "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com"
+ }
+ ],
+ "description": "Factory interfaces for PSR-7 HTTP Message",
+ "homepage": "http://php-http.org",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "stream",
+ "uri"
+ ],
+ "support": {
+ "issues": "https://github.com/php-http/message-factory/issues",
+ "source": "https://github.com/php-http/message-factory/tree/1.1.0"
+ },
+ "abandoned": "psr/http-factory",
+ "time": "2023-04-14T14:16:17+00:00"
+ },
{
"name": "php-http/promise",
"version": "1.3.1",
@@ -18333,16 +18409,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "11.5.35",
+ "version": "11.5.36",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "d341ee94ee5007b286fc7907b383aae6b5b3cc91"
+ "reference": "264a87c7ef68b1ab9af7172357740dc266df5957"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d341ee94ee5007b286fc7907b383aae6b5b3cc91",
- "reference": "d341ee94ee5007b286fc7907b383aae6b5b3cc91",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/264a87c7ef68b1ab9af7172357740dc266df5957",
+ "reference": "264a87c7ef68b1ab9af7172357740dc266df5957",
"shasum": ""
},
"require": {
@@ -18414,7 +18490,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.35"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.36"
},
"funding": [
{
@@ -18438,20 +18514,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-28T05:13:54+00:00"
+ "time": "2025-09-03T06:24:17+00:00"
},
{
"name": "rector/rector",
- "version": "2.1.4",
+ "version": "2.1.6",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
- "reference": "fe613c528819222f8686a9a037a315ef9d4915b3"
+ "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/rectorphp/rector/zipball/fe613c528819222f8686a9a037a315ef9d4915b3",
- "reference": "fe613c528819222f8686a9a037a315ef9d4915b3",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/729aabc0ec66e700ef164e26454a1357f222a2f3",
+ "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3",
"shasum": ""
},
"require": {
@@ -18490,7 +18566,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
- "source": "https://github.com/rectorphp/rector/tree/2.1.4"
+ "source": "https://github.com/rectorphp/rector/tree/2.1.6"
},
"funding": [
{
@@ -18498,7 +18574,7 @@
"type": "github"
}
],
- "time": "2025-08-15T14:41:36+00:00"
+ "time": "2025-09-05T15:43:08+00:00"
},
{
"name": "roave/security-advisories",
@@ -18506,12 +18582,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "e7589e01dc8452bfecb4c8df977346cd3132650f"
+ "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e7589e01dc8452bfecb4c8df977346cd3132650f",
- "reference": "e7589e01dc8452bfecb4c8df977346cd3132650f",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/dc5c4ede5c331ae21fb68947ff89672df9b7cc7d",
+ "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d",
"shasum": ""
},
"conflict": {
@@ -18935,7 +19011,7 @@
"marshmallow/nova-tiptap": "<5.7",
"matomo/matomo": "<1.11",
"matyhtf/framework": "<3.0.6",
- "mautic/core": "<5.2.6|>=6.0.0.0-alpha,<6.0.2",
+ "mautic/core": "<5.2.8|>=6.0.0.0-alpha,<6.0.5",
"mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1",
"maximebf/debugbar": "<1.19",
"mdanter/ecc": "<2",
@@ -19081,7 +19157,7 @@
"pixelfed/pixelfed": "<0.12.5",
"plotly/plotly.js": "<2.25.2",
"pocketmine/bedrock-protocol": "<8.0.2",
- "pocketmine/pocketmine-mp": "<5.25.2",
+ "pocketmine/pocketmine-mp": "<5.32.1",
"pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1",
"pressbooks/pressbooks": "<5.18",
"prestashop/autoupgrade": ">=4,<4.10.1",
@@ -19089,7 +19165,7 @@
"prestashop/blockwishlist": ">=2,<2.1.1",
"prestashop/contactform": ">=1.0.1,<4.3",
"prestashop/gamification": "<2.3.2",
- "prestashop/prestashop": "<8.1.6",
+ "prestashop/prestashop": "<8.2.3",
"prestashop/productcomments": "<5.0.2",
"prestashop/ps_contactinfo": "<=3.3.2",
"prestashop/ps_emailsubscription": "<2.6.1",
@@ -19463,7 +19539,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-29T15:04:47+00:00"
+ "time": "2025-09-04T20:05:35+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -20959,7 +21035,6 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
- "florianv/swap-bundle": 20,
"roave/security-advisories": 20
},
"prefer-stable": false,
diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml
index 1cb74da7..c283cd8e 100644
--- a/config/packages/nelmio_security.yaml
+++ b/config/packages/nelmio_security.yaml
@@ -69,9 +69,3 @@ nelmio_security:
- 'data:'
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
-
-when@dev:
- # disables the Content-Security-Policy header
- nelmio_security:
- csp:
- enabled: false
\ No newline at end of file
diff --git a/config/packages/swap.yaml b/config/packages/swap.yaml
index beb41d26..4ef8fbdf 100644
--- a/config/packages/swap.yaml
+++ b/config/packages/swap.yaml
@@ -5,6 +5,12 @@ florianv_swap:
providers:
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
- fixer: # Fixer.io (needs an API key)
- access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%"
- #exchange_rates_api: ~
\ No newline at end of file
+ central_bank_of_czech_republic: ~
+ central_bank_of_republic_turkey: ~
+ national_bank_of_romania: ~
+
+ fixer: # Fixer.io (needs an API key)
+ access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
+
+ frankfurter: ~
+ fawazahmed_currency_api: ~
diff --git a/config/permissions.yaml b/config/permissions.yaml
index e5a1d65b..8cbd60c3 100644
--- a/config/permissions.yaml
+++ b/config/permissions.yaml
@@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
label: "perm.revert_elements"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
apiTokenRole: ROLE_API_EDIT
+ import:
+ label: "perm.import"
+ alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
+ apiTokenRole: ROLE_API_EDIT
api:
label: "perm.api"
diff --git a/docs/installation/installation_guide-debian.md b/docs/installation/installation_guide-debian.md
index 312fe21e..b3c61126 100644
--- a/docs/installation/installation_guide-debian.md
+++ b/docs/installation/installation_guide-debian.md
@@ -28,9 +28,14 @@ It is recommended to install Part-DB on a 64-bit system, as the 32-bit version o
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
```bash
-sudo apt install git curl zip ca-certificates software-properties-common apt-transport-https lsb-release nano wget
+sudo apt update && apt upgrade
+sudo apt install git curl zip ca-certificates software-properties-common \
+ apt-transport-https lsb-release nano wget sqlite3
```
+Please run `sqlite3 --version` to assert that the SQLite version is 3.35 or higher.
+Otherwise some database migrations will not succeed.
+
### Install PHP and apache2
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or
diff --git a/docs/usage/bom_import.md b/docs/usage/bom_import.md
index 94a06d55..b4bcb2be 100644
--- a/docs/usage/bom_import.md
+++ b/docs/usage/bom_import.md
@@ -34,3 +34,12 @@ select the BOM file you want to import and some options for the import process:
has a different format and does not work with this type.
You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save
the file to your desired location.
+* **KiCAD Schematic BOM (CSV file)**: A CSV file of the Bill of Material (BOM) generated
+ by [KiCAD Eeschema](https://www.kicad.org/).
+ You can generate this BOM file by going to "Tools" -> "Generate Bill of Materials" in Eeschema and save the file to your
+ desired location. In the next step you can customize the mapping of the fields in Part-DB, if you have any special fields
+ in your BOM to locate your fields correctly.
+* **Generic CSV file**: A generic CSV file. You can use this option if you use some different ECAD software or wanna create
+ your own CSV file. You will need to specify at least the designators, quantity and value fields in the CSV. In the next
+ step you can customize the mapping of the fields in Part-DB, if you have any special fields in your BOM to locate your
+ parts correctly.
diff --git a/makefile b/makefile
new file mode 100644
index 00000000..9041ba0f
--- /dev/null
+++ b/makefile
@@ -0,0 +1,112 @@
+# PartDB Makefile for Test Environment Management
+
+.PHONY: help test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset deps-install
+
+# Default target
+help:
+ @echo "PartDB Test Environment Management"
+ @echo "=================================="
+ @echo ""
+ @echo "Available targets:"
+ @echo " deps-install - Install PHP dependencies with unlimited memory"
+ @echo ""
+ @echo "Development Environment:"
+ @echo " dev-setup - Complete development environment setup (clean, create DB, migrate, warmup)"
+ @echo " dev-clean - Clean development cache and database files"
+ @echo " dev-db-create - Create development database (if not exists)"
+ @echo " dev-db-migrate - Run database migrations for development environment"
+ @echo " dev-cache-clear - Clear development cache"
+ @echo " dev-warmup - Warm up development cache"
+ @echo " dev-reset - Quick development reset (clean + migrate)"
+ @echo ""
+ @echo "Test Environment:"
+ @echo " test-setup - Complete test environment setup (clean, create DB, migrate, load fixtures)"
+ @echo " test-clean - Clean test cache and database files"
+ @echo " test-db-create - Create test database (if not exists)"
+ @echo " test-db-migrate - Run database migrations for test environment"
+ @echo " test-cache-clear- Clear test cache"
+ @echo " test-fixtures - Load test fixtures"
+ @echo " test-run - Run PHPUnit tests"
+ @echo ""
+ @echo " help - Show this help message"
+
+# Install PHP dependencies with unlimited memory
+deps-install:
+ @echo "📦 Installing PHP dependencies..."
+ COMPOSER_MEMORY_LIMIT=-1 composer install
+ @echo "✅ Dependencies installed"
+
+# Complete test environment setup
+test-setup: deps-install test-clean test-db-create test-db-migrate test-fixtures
+ @echo "✅ Test environment setup complete!"
+
+# Clean test environment
+test-clean:
+ @echo "🧹 Cleaning test environment..."
+ rm -rf var/cache/test
+ rm -f var/app_test.db
+ @echo "✅ Test environment cleaned"
+
+# Create test database
+test-db-create:
+ @echo "🗄️ Creating test database..."
+ -php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
+
+# Run database migrations for test environment
+test-db-migrate:
+ @echo "🔄 Running database migrations..."
+ php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test
+
+# Clear test cache
+test-cache-clear:
+ @echo "🗑️ Clearing test cache..."
+ rm -rf var/cache/test
+ @echo "✅ Test cache cleared"
+
+# Load test fixtures
+test-fixtures:
+ @echo "📦 Loading test fixtures..."
+ php bin/console partdb:fixtures:load -n --env test
+
+# Run PHPUnit tests
+test-run:
+ @echo "🧪 Running tests..."
+ php bin/phpunit
+
+test-typecheck:
+ @echo "🧪 Running type checks..."
+ COMPOSER_MEMORY_LIMIT=-1 composer phpstan
+
+# Quick test reset (clean + migrate + fixtures, skip DB creation)
+test-reset: test-cache-clear test-db-migrate test-fixtures
+ @echo "✅ Test environment reset complete!"
+
+# Development helpers
+dev-setup: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup
+ @echo "✅ Development environment setup complete!"
+
+dev-clean:
+ @echo "🧹 Cleaning development environment..."
+ rm -rf var/cache/dev
+ rm -f var/app_dev.db
+ @echo "✅ Development environment cleaned"
+
+dev-db-create:
+ @echo "🗄️ Creating development database..."
+ -php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
+
+dev-db-migrate:
+ @echo "🔄 Running database migrations..."
+ php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev
+
+dev-cache-clear:
+ @echo "🗑️ Clearing development cache..."
+ php -d memory_limit=1G bin/console cache:clear --env dev -n
+ @echo "✅ Development cache cleared"
+
+dev-warmup:
+ @echo "🔥 Warming up development cache..."
+ php -d memory_limit=1G bin/console cache:warmup --env dev -n
+
+dev-reset: dev-cache-clear dev-db-migrate
+ @echo "✅ Development environment reset complete!"
\ No newline at end of file
diff --git a/src/Controller/LabelController.php b/src/Controller/LabelController.php
index 4950628b..90a6715b 100644
--- a/src/Controller/LabelController.php
+++ b/src/Controller/LabelController.php
@@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/label')]
class LabelController extends AbstractController
{
- public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
+ public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
+ private readonly ValidatorInterface $validator
+ )
{
}
@@ -85,6 +88,7 @@ class LabelController extends AbstractController
$form = $this->createForm(LabelDialogType::class, null, [
'disable_options' => $disable_options,
+ 'profile' => $profile
]);
//Try to parse given target_type and target_id
@@ -120,13 +124,50 @@ class LabelController extends AbstractController
goto render;
}
- $profile = new LabelProfile();
- $profile->setName($form->get('save_profile_name')->getData());
- $profile->setOptions($form_options);
- $this->em->persist($profile);
+ $new_profile = new LabelProfile();
+ $new_profile->setName($form->get('save_profile_name')->getData());
+ $new_profile->setOptions($form_options);
+
+ //Validate the profile name
+ $errors = $this->validator->validate($new_profile);
+ if (count($errors) > 0) {
+ foreach ($errors as $error) {
+ $form->get('save_profile_name')->addError(new FormError($error->getMessage()));
+ }
+ goto render;
+ }
+
+ $this->em->persist($new_profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
+ return $this->redirectToRoute('label_dialog_profile', [
+ 'profile' => $new_profile->getID(),
+ 'target_id' => (string) $form->get('target_id')->getData()
+ ]);
+ }
+
+ //Check if the current profile should be updated
+ if ($form->has('update_profile')
+ && $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
+ && $profile instanceof LabelProfile
+ && $this->isGranted('edit', $profile)) {
+ //Update the profile options
+ $profile->setOptions($form_options);
+
+ //Validate the profile name
+ $errors = $this->validator->validate($profile);
+ if (count($errors) > 0) {
+ foreach ($errors as $error) {
+ $this->addFlash('error', $error->getMessage());
+ }
+ goto render;
+ }
+
+ $this->em->persist($profile);
+ $this->em->flush();
+ $this->addFlash('success', 'label_generator.profile_updated');
+
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php
index a64c1851..2a6d19ee 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory;
+use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -102,9 +103,14 @@ class ProjectController extends AbstractController
$this->addFlash('success', 'project.build.flash.success');
return $this->redirect(
- $request->get('_redirect',
- $this->generateUrl('project_info', ['id' => $project->getID()]
- )));
+ $request->get(
+ '_redirect',
+ $this->generateUrl(
+ 'project_info',
+ ['id' => $project->getID()]
+ )
+ )
+ );
}
$this->addFlash('error', 'project.build.flash.invalid_input');
@@ -120,9 +126,13 @@ class ProjectController extends AbstractController
}
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
- public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
- BOMImporter $BOMImporter, ValidatorInterface $validator): Response
- {
+ public function importBOM(
+ Request $request,
+ EntityManagerInterface $entityManager,
+ Project $project,
+ BOMImporter $BOMImporter,
+ ValidatorInterface $validator
+ ): Response {
$this->denyAccessUnlessGranted('edit', $project);
$builder = $this->createFormBuilder();
@@ -138,6 +148,8 @@ class ProjectController extends AbstractController
'required' => true,
'choices' => [
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
+ 'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
+ 'project.bom_import.type.generic_csv' => 'generic_csv',
]
]);
$builder->add('clear_existing_bom', CheckboxType::class, [
@@ -161,25 +173,40 @@ class ProjectController extends AbstractController
$entityManager->flush();
}
+ $import_type = $form->get('type')->getData();
+
try {
+ // For schematic imports, redirect to field mapping step
+ if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
+ // Store file content and options in session for field mapping step
+ $file_content = $form->get('file')->getData()->getContent();
+ $clear_existing = $form->get('clear_existing_bom')->getData();
+
+ $request->getSession()->set('bom_import_data', $file_content);
+ $request->getSession()->set('bom_import_clear', $clear_existing);
+
+ return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
+ }
+
+ // For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
- 'type' => $form->get('type')->getData(),
+ 'type' => $import_type,
]);
- //Validate the project entries
+ // Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
- //If no validation errors occured, save the changes and redirect to edit page
- if (count ($errors) === 0) {
+ // If no validation errors occurred, save the changes and redirect to edit page
+ if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
- //When we get here, there were validation errors
+ // When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
- } catch (\UnexpectedValueException|SyntaxError $e) {
+ } catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
@@ -191,11 +218,267 @@ class ProjectController extends AbstractController
]);
}
+ #[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
+ public function importBOMMapFields(
+ Request $request,
+ EntityManagerInterface $entityManager,
+ Project $project,
+ BOMImporter $BOMImporter,
+ ValidatorInterface $validator,
+ LoggerInterface $logger
+ ): Response {
+ $this->denyAccessUnlessGranted('edit', $project);
+
+ // Get stored data from session
+ $file_content = $request->getSession()->get('bom_import_data');
+ $clear_existing = $request->getSession()->get('bom_import_clear', false);
+
+
+ if (!$file_content) {
+ $this->addFlash('error', 'project.bom_import.flash.session_expired');
+ return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
+ }
+
+ // Detect fields and get suggestions
+ $detected_fields = $BOMImporter->detectFields($file_content);
+ $suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
+
+ // Create mapping of original field names to sanitized field names for template
+ $field_name_mapping = [];
+ foreach ($detected_fields as $field) {
+ $sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
+ $field_name_mapping[$field] = $sanitized_field;
+ }
+
+ // Create form for field mapping
+ $builder = $this->createFormBuilder();
+
+ // Add delimiter selection
+ $builder->add('delimiter', ChoiceType::class, [
+ 'label' => 'project.bom_import.delimiter',
+ 'required' => true,
+ 'data' => ',',
+ 'choices' => [
+ 'project.bom_import.delimiter.comma' => ',',
+ 'project.bom_import.delimiter.semicolon' => ';',
+ 'project.bom_import.delimiter.tab' => "\t",
+ ]
+ ]);
+
+ // Get dynamic field mapping targets from BOMImporter
+ $available_targets = $BOMImporter->getAvailableFieldTargets();
+ $target_fields = ['project.bom_import.field_mapping.ignore' => ''];
+
+ foreach ($available_targets as $target_key => $target_info) {
+ $target_fields[$target_info['label']] = $target_key;
+ }
+
+ foreach ($detected_fields as $field) {
+ // Sanitize field name for form use - replace invalid characters with underscores
+ $sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
+ $builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
+ 'label' => $field,
+ 'required' => false,
+ 'choices' => $target_fields,
+ 'data' => $suggested_mapping[$field] ?? '',
+ ]);
+ }
+
+ $builder->add('submit', SubmitType::class, [
+ 'label' => 'project.bom_import.preview',
+ ]);
+
+ $form = $builder->getForm();
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ // Build field mapping array with priority support
+ $field_mapping = [];
+ $field_priorities = [];
+ $delimiter = $form->get('delimiter')->getData();
+
+ foreach ($detected_fields as $field) {
+ $sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
+ $target = $form->get('mapping_' . $sanitized_field)->getData();
+ if (!empty($target)) {
+ $field_mapping[$field] = $target;
+
+ // Get priority from request (default to 10)
+ $priority = $request->request->get('priority_' . $sanitized_field, 10);
+ $field_priorities[$field] = (int) $priority;
+ }
+ }
+
+ // Validate field mapping
+ $validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
+
+ if (!$validation['is_valid']) {
+ foreach ($validation['errors'] as $error) {
+ $this->addFlash('error', $error);
+ }
+ foreach ($validation['warnings'] as $warning) {
+ $this->addFlash('warning', $warning);
+ }
+
+ return $this->render('projects/import_bom_map_fields.html.twig', [
+ 'project' => $project,
+ 'form' => $form->createView(),
+ 'detected_fields' => $detected_fields,
+ 'suggested_mapping' => $suggested_mapping,
+ 'field_name_mapping' => $field_name_mapping,
+ ]);
+ }
+
+ // Show warnings but continue
+ foreach ($validation['warnings'] as $warning) {
+ $this->addFlash('warning', $warning);
+ }
+
+ try {
+ // Re-detect fields with chosen delimiter
+ $detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
+
+ // Clear existing BOM entries if requested
+ if ($clear_existing) {
+ $existing_count = $project->getBomEntries()->count();
+ $logger->info('Clearing existing BOM entries', [
+ 'existing_count' => $existing_count,
+ 'project_id' => $project->getID(),
+ ]);
+ $project->getBomEntries()->clear();
+ $entityManager->flush();
+ $logger->info('Existing BOM entries cleared');
+ } else {
+ $existing_count = $project->getBomEntries()->count();
+ $logger->info('Keeping existing BOM entries', [
+ 'existing_count' => $existing_count,
+ 'project_id' => $project->getID(),
+ ]);
+ }
+
+ // Validate data before importing
+ $validation_result = $BOMImporter->validateBOMData($file_content, [
+ 'type' => 'kicad_schematic',
+ 'field_mapping' => $field_mapping,
+ 'field_priorities' => $field_priorities,
+ 'delimiter' => $delimiter,
+ ]);
+
+ // Log validation results
+ $logger->info('BOM import validation completed', [
+ 'total_entries' => $validation_result['total_entries'],
+ 'valid_entries' => $validation_result['valid_entries'],
+ 'invalid_entries' => $validation_result['invalid_entries'],
+ 'error_count' => count($validation_result['errors']),
+ 'warning_count' => count($validation_result['warnings']),
+ ]);
+
+ // Show validation warnings to user
+ foreach ($validation_result['warnings'] as $warning) {
+ $this->addFlash('warning', $warning);
+ }
+
+ // If there are validation errors, show them and stop
+ if (!empty($validation_result['errors'])) {
+ foreach ($validation_result['errors'] as $error) {
+ $this->addFlash('error', $error);
+ }
+
+ return $this->render('projects/import_bom_map_fields.html.twig', [
+ 'project' => $project,
+ 'form' => $form->createView(),
+ 'detected_fields' => $detected_fields,
+ 'suggested_mapping' => $suggested_mapping,
+ 'field_name_mapping' => $field_name_mapping,
+ 'validation_result' => $validation_result,
+ ]);
+ }
+
+ // Import with field mapping and priorities (validation already passed)
+ $entries = $BOMImporter->stringToBOMEntries($file_content, [
+ 'type' => 'kicad_schematic',
+ 'field_mapping' => $field_mapping,
+ 'field_priorities' => $field_priorities,
+ 'delimiter' => $delimiter,
+ ]);
+
+ // Log entry details for debugging
+ $logger->info('BOM entries created', [
+ 'total_entries' => count($entries),
+ ]);
+
+ foreach ($entries as $index => $entry) {
+ $logger->debug("BOM entry {$index}", [
+ 'name' => $entry->getName(),
+ 'mountnames' => $entry->getMountnames(),
+ 'quantity' => $entry->getQuantity(),
+ 'comment' => $entry->getComment(),
+ 'part_id' => $entry->getPart()?->getID(),
+ ]);
+ }
+
+ // Assign entries to project
+ $logger->info('Adding BOM entries to project', [
+ 'entries_count' => count($entries),
+ 'project_id' => $project->getID(),
+ ]);
+
+ foreach ($entries as $index => $entry) {
+ $logger->debug("Adding BOM entry {$index} to project", [
+ 'name' => $entry->getName(),
+ 'part_id' => $entry->getPart()?->getID(),
+ 'quantity' => $entry->getQuantity(),
+ ]);
+ $project->addBomEntry($entry);
+ }
+
+ // Validate the project entries (includes collection constraints)
+ $errors = $validator->validateProperty($project, 'bom_entries');
+
+ // If no validation errors occurred, save and redirect
+ if (count($errors) === 0) {
+ $this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
+ $entityManager->flush();
+
+ // Clear session data
+ $request->getSession()->remove('bom_import_data');
+ $request->getSession()->remove('bom_import_clear');
+
+ return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
+ }
+
+ // When we get here, there were validation errors
+ $this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
+
+ //Print validation errors to log for debugging
+ foreach ($errors as $error) {
+ $logger->error('BOM entry validation error', [
+ 'message' => $error->getMessage(),
+ 'invalid_value' => $error->getInvalidValue(),
+ ]);
+ //And show as flash message
+ $this->addFlash('error', $error->getMessage(),);
+ }
+
+ } catch (\UnexpectedValueException | SyntaxError $e) {
+ $this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
+ }
+ }
+
+ return $this->render('projects/import_bom_map_fields.html.twig', [
+ 'project' => $project,
+ 'form' => $form,
+ 'detected_fields' => $detected_fields,
+ 'suggested_mapping' => $suggested_mapping,
+ 'field_name_mapping' => $field_name_mapping,
+ ]);
+ }
+
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
{
- if($project instanceof Project) {
+ if ($project instanceof Project) {
$this->denyAccessUnlessGranted('edit', $project);
} else {
$this->denyAccessUnlessGranted('@projects.edit');
@@ -242,7 +525,7 @@ class ProjectController extends AbstractController
$data = $form->getData();
$bom_entries = $data['bom_entries'];
- foreach ($bom_entries as $bom_entry){
+ foreach ($bom_entries as $bom_entry) {
$target_project->addBOMEntry($bom_entry);
}
diff --git a/src/DataFixtures/CurrencyFixtures.php b/src/DataFixtures/CurrencyFixtures.php
new file mode 100644
index 00000000..2de5b277
--- /dev/null
+++ b/src/DataFixtures/CurrencyFixtures.php
@@ -0,0 +1,64 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\DataFixtures;
+
+use App\Entity\PriceInformations\Currency;
+use Brick\Math\BigDecimal;
+use Doctrine\Bundle\FixturesBundle\Fixture;
+use Doctrine\Persistence\ObjectManager;
+
+class CurrencyFixtures extends Fixture
+{
+ public function load(ObjectManager $manager): void
+ {
+ $currency1 = new Currency();
+ $currency1->setName('US-Dollar');
+ $currency1->setIsoCode('USD');
+ $manager->persist($currency1);
+
+ $currency2 = new Currency();
+ $currency2->setName('Swiss Franc');
+ $currency2->setIsoCode('CHF');
+ $currency2->setExchangeRate(BigDecimal::of('0.91'));
+ $manager->persist($currency2);
+
+ $currency3 = new Currency();
+ $currency3->setName('Great British Pound');
+ $currency3->setIsoCode('GBP');
+ $currency3->setExchangeRate(BigDecimal::of('0.78'));
+ $manager->persist($currency3);
+
+ $currency7 = new Currency();
+ $currency7->setName('Test Currency with long name');
+ $currency7->setIsoCode('CNY');
+ $manager->persist($currency7);
+
+ $manager->flush();
+
+
+ //Ensure that currency 7 gets ID 7
+ $manager->getRepository(Currency::class)->changeID($currency7, 7);
+ $manager->flush();
+ }
+}
diff --git a/src/Form/LabelSystem/LabelDialogType.php b/src/Form/LabelSystem/LabelDialogType.php
index f2710b19..d79d01f6 100644
--- a/src/Form/LabelSystem/LabelDialogType.php
+++ b/src/Form/LabelSystem/LabelDialogType.php
@@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
]
]);
+ if ($options['profile'] !== null) {
+ $builder->add('update_profile', SubmitType::class, [
+ 'label' => 'label_generator.update_profile',
+ 'disabled' => !$this->security->isGranted('edit', $options['profile']),
+ 'attr' => [
+ 'class' => 'btn btn-outline-success'
+ ]
+ ]);
+ }
+
$builder->add('update', SubmitType::class, [
'label' => 'label_generator.update',
]);
@@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
parent::configureOptions($resolver);
$resolver->setDefault('mapped', false);
$resolver->setDefault('disable_options', false);
+ $resolver->setDefault('profile', null);
}
}
diff --git a/src/Security/Voter/AttachmentVoter.php b/src/Security/Voter/AttachmentVoter.php
index c2b17053..bd7ae4df 100644
--- a/src/Security/Voter/AttachmentVoter.php
+++ b/src/Security/Voter/AttachmentVoter.php
@@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
@@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
{
}
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//This voter only works for attachments
@@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
}
if ($attribute === 'show_private') {
- return $this->helper->isGranted($token, 'attachments', 'show_private');
+ $vote?->addReason('User is not allowed to view private attachments.');
+ return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
}
@@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
}
- return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
+ $vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
+ return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
}
return false;
diff --git a/src/Security/Voter/GroupVoter.php b/src/Security/Voter/GroupVoter.php
index 34839d38..f2ce6953 100644
--- a/src/Security/Voter/GroupVoter.php
+++ b/src/Security/Voter/GroupVoter.php
@@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\Group;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -43,9 +44,9 @@ final class GroupVoter extends Voter
*
* @param string $attribute
*/
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
- return $this->helper->isGranted($token, 'groups', $attribute);
+ return $this->helper->isGranted($token, 'groups', $attribute, $vote);
}
/**
diff --git a/src/Security/Voter/ImpersonateUserVoter.php b/src/Security/Voter/ImpersonateUserVoter.php
index edf55c62..1f8a70c6 100644
--- a/src/Security/Voter/ImpersonateUserVoter.php
+++ b/src/Security/Voter/ImpersonateUserVoter.php
@@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
&& $subject instanceof UserInterface;
}
- protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
- return $this->helper->isGranted($token, 'users', 'impersonate');
+ $result = $this->helper->isGranted($token, 'users', 'impersonate');
+
+ if ($result === false) {
+ $vote?->addReason('User is not allowed to impersonate other users.');
+ $this->helper->addReason($vote, 'users', 'impersonate');
+ }
+
+ return $result;
}
public function supportsAttribute(string $attribute): bool
@@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
{
return is_a($subjectType, User::class, true);
}
-}
\ No newline at end of file
+}
diff --git a/src/Security/Voter/LabelProfileVoter.php b/src/Security/Voter/LabelProfileVoter.php
index 47505bf9..1687bf45 100644
--- a/src/Security/Voter/LabelProfileVoter.php
+++ b/src/Security/Voter/LabelProfileVoter.php
@@ -44,6 +44,7 @@ namespace App\Security\Voter;
use App\Entity\LabelSystem\LabelProfile;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
'delete' => 'delete_profiles',
'show_history' => 'show_history',
'revert_element' => 'revert_element',
+ 'import' => 'import',
];
public function __construct(private readonly VoterHelper $helper)
{}
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
- return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
+ return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
}
protected function supports($attribute, $subject): bool
diff --git a/src/Security/Voter/LogEntryVoter.php b/src/Security/Voter/LogEntryVoter.php
index 08bc3b70..dcb75a7a 100644
--- a/src/Security/Voter/LogEntryVoter.php
+++ b/src/Security/Voter/LogEntryVoter.php
@@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LogSystem\AbstractLogEntry;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
{
}
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
}
if ('delete' === $attribute) {
- return $this->helper->isGranted($token, 'system', 'delete_logs');
+ return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
}
if ('read' === $attribute) {
//Allow read of the users own log entries
if (
$subject->getUser() === $user
- && $this->helper->isGranted($token, 'self', 'show_logs')
+ && $this->helper->isGranted($token, 'self', 'show_logs', $vote)
) {
return true;
}
- return $this->helper->isGranted($token, 'system', 'show_logs');
+ return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
}
if ('show_details' === $attribute) {
diff --git a/src/Security/Voter/OrderdetailVoter.php b/src/Security/Voter/OrderdetailVoter.php
index 20843b9a..3bb2a3a3 100644
--- a/src/Security/Voter/OrderdetailVoter.php
+++ b/src/Security/Voter/OrderdetailVoter.php
@@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (! is_a($subject, Orderdetail::class, true)) {
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
@@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
- return $this->helper->isGranted($token, 'parts', $operation);
+ return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part
diff --git a/src/Security/Voter/ParameterVoter.php b/src/Security/Voter/ParameterVoter.php
index 8ee2b9f5..f59bdeaf 100644
--- a/src/Security/Voter/ParameterVoter.php
+++ b/src/Security/Voter/ParameterVoter.php
@@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
{
}
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
@@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
}
- return $this->helper->isGranted($token, $param, $attribute);
+ return $this->helper->isGranted($token, $param, $attribute, $vote);
}
protected function supports(string $attribute, $subject): bool
diff --git a/src/Security/Voter/PartAssociationVoter.php b/src/Security/Voter/PartAssociationVoter.php
index 7678b67a..f1eb83c7 100644
--- a/src/Security/Voter/PartAssociationVoter.php
+++ b/src/Security/Voter/PartAssociationVoter.php
@@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_string($subject) && !$subject instanceof PartAssociation) {
throw new \RuntimeException('Invalid subject type!');
@@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
- return $this->helper->isGranted($token, 'parts', $operation);
+ return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part
diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php
index a64473c8..87c3d135 100644
--- a/src/Security/Voter/PartLotVoter.php
+++ b/src/Security/Voter/PartLotVoter.php
@@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -59,13 +60,13 @@ final class PartLotVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
{
- $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute);
+ $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
$lot_permission = true;
//If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
@@ -73,6 +74,10 @@ final class PartLotVoter extends Voter
$lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
}
+ if (!$lot_permission) {
+ $vote->addReason('User is not the owner of the lot.');
+ }
+
return $base_permission && $lot_permission;
}
@@ -86,7 +91,7 @@ final class PartLotVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
- return $this->helper->isGranted($token, 'parts', $operation);
+ return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part
diff --git a/src/Security/Voter/PartVoter.php b/src/Security/Voter/PartVoter.php
index ef70b6ce..159e6893 100644
--- a/src/Security/Voter/PartVoter.php
+++ b/src/Security/Voter/PartVoter.php
@@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\Parts\Part;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -52,10 +53,9 @@ final class PartVoter extends Voter
return false;
}
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
- //Null concealing operator means, that no
- return $this->helper->isGranted($token, 'parts', $attribute);
+ return $this->helper->isGranted($token, 'parts', $attribute, $vote);
}
public function supportsAttribute(string $attribute): bool
diff --git a/src/Security/Voter/PermissionVoter.php b/src/Security/Voter/PermissionVoter.php
index c6ec1b3d..8c304d86 100644
--- a/src/Security/Voter/PermissionVoter.php
+++ b/src/Security/Voter/PermissionVoter.php
@@ -24,6 +24,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -39,12 +40,17 @@ final class PermissionVoter extends Voter
}
- protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute);
- return $this->helper->isGranted($token, $perm, $op);
+ $result = $this->helper->isGranted($token, $perm, $op);
+ if ($result === false) {
+ $this->helper->addReason($vote, $perm, $op);
+ }
+
+ return $result;
}
public function supportsAttribute(string $attribute): bool
diff --git a/src/Security/Voter/PricedetailVoter.php b/src/Security/Voter/PricedetailVoter.php
index 681b73b7..ca86f1ce 100644
--- a/src/Security/Voter/PricedetailVoter.php
+++ b/src/Security/Voter/PricedetailVoter.php
@@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Pricedetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$operation = match ($attribute) {
'read' => 'read',
@@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
- return $this->helper->isGranted($token, 'parts', $operation);
+ return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part
diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php
index 2417b796..ad0299a7 100644
--- a/src/Security/Voter/StructureVoter.php
+++ b/src/Security/Voter/StructureVoter.php
@@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function is_object;
@@ -113,10 +114,10 @@ final class StructureVoter extends Voter
*
* @param string $attribute
*/
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$permission_name = $this->instanceToPermissionName($subject);
//Just resolve the permission
- return $this->helper->isGranted($token, $permission_name, $attribute);
+ return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
}
}
diff --git a/src/Security/Voter/UserVoter.php b/src/Security/Voter/UserVoter.php
index b41c1a40..97f8e4fb 100644
--- a/src/Security/Voter/UserVoter.php
+++ b/src/Security/Voter/UserVoter.php
@@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
@@ -79,7 +80,7 @@ final class UserVoter extends Voter
*
* @param string $attribute
*/
- protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@@ -97,7 +98,7 @@ final class UserVoter extends Voter
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
$this->helper->isValidOperation('self', $attribute)) {
//Then we also need to check the self permission
- $tmp = $this->helper->isGranted($token, 'self', $attribute);
+ $tmp = $this->helper->isGranted($token, 'self', $attribute, $vote);
//But if the self value is not allowed then use just the user value:
if ($tmp) {
return $tmp;
@@ -106,7 +107,7 @@ final class UserVoter extends Voter
//Else just check user permission:
if ($this->helper->isValidOperation('users', $attribute)) {
- return $this->helper->isGranted($token, 'users', $attribute);
+ return $this->helper->isGranted($token, 'users', $attribute, $vote);
}
return false;
diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php
index d4876445..862fa463 100644
--- a/src/Services/ImportExportSystem/BOMImporter.php
+++ b/src/Services/ImportExportSystem/BOMImporter.php
@@ -22,10 +22,13 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
+use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
+use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use League\Csv\Reader;
+use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -44,14 +47,25 @@ class BOMImporter
5 => 'Supplier and ref',
];
- public function __construct()
- {
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly LoggerInterface $logger,
+ private readonly BOMValidationService $validationService
+ ) {
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setRequired('type');
- $resolver->setAllowedValues('type', ['kicad_pcbnew']);
+ $resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
+
+ // For flexible schematic import with field mapping
+ $resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
+ $resolver->setDefault('delimiter', ',');
+ $resolver->setDefault('field_priorities', []);
+ $resolver->setAllowedTypes('field_mapping', 'array');
+ $resolver->setAllowedTypes('field_priorities', 'array');
+ $resolver->setAllowedTypes('delimiter', 'string');
return $resolver;
}
@@ -82,6 +96,23 @@ class BOMImporter
return $this->stringToBOMEntries($file->getContent(), $options);
}
+ /**
+ * Validate BOM data before importing
+ * @return array Validation result with errors, warnings, and info
+ */
+ public function validateBOMData(string $data, array $options): array
+ {
+ $resolver = new OptionsResolver();
+ $resolver = $this->configureOptions($resolver);
+ $options = $resolver->resolve($options);
+
+ return match ($options['type']) {
+ 'kicad_pcbnew' => $this->validateKiCADPCB($data),
+ 'kicad_schematic' => $this->validateKiCADSchematicData($data, $options),
+ default => throw new InvalidArgumentException('Invalid import type!'),
+ };
+ }
+
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
* @param string $data The data to import
@@ -95,12 +126,13 @@ class BOMImporter
$options = $resolver->resolve($options);
return match ($options['type']) {
- 'kicad_pcbnew' => $this->parseKiCADPCB($data, $options),
+ 'kicad_pcbnew' => $this->parseKiCADPCB($data),
+ 'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
- private function parseKiCADPCB(string $data, array $options = []): array
+ private function parseKiCADPCB(string $data): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
@@ -113,17 +145,17 @@ class BOMImporter
$entry = $this->normalizeColumnNames($entry);
//Ensure that the entry has all required fields
- if (!isset ($entry['Designator'])) {
- throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
+ if (!isset($entry['Designator'])) {
+ throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!');
}
- if (!isset ($entry['Package'])) {
- throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
+ if (!isset($entry['Package'])) {
+ throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!');
}
- if (!isset ($entry['Designation'])) {
- throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
+ if (!isset($entry['Designation'])) {
+ throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!');
}
- if (!isset ($entry['Quantity'])) {
- throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
+ if (!isset($entry['Quantity'])) {
+ throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
}
$bom_entry = new ProjectBOMEntry();
@@ -138,6 +170,63 @@ class BOMImporter
return $bom_entries;
}
+ /**
+ * Validate KiCad PCB data
+ */
+ private function validateKiCADPCB(string $data): array
+ {
+ $csv = Reader::createFromString($data);
+ $csv->setDelimiter(';');
+ $csv->setHeaderOffset(0);
+
+ $mapped_entries = [];
+
+ foreach ($csv->getRecords() as $offset => $entry) {
+ // Translate the german field names to english
+ $entry = $this->normalizeColumnNames($entry);
+ $mapped_entries[] = $entry;
+ }
+
+ return $this->validationService->validateBOMEntries($mapped_entries);
+ }
+
+ /**
+ * Validate KiCad schematic data
+ */
+ private function validateKiCADSchematicData(string $data, array $options): array
+ {
+ $delimiter = $options['delimiter'] ?? ',';
+ $field_mapping = $options['field_mapping'] ?? [];
+ $field_priorities = $options['field_priorities'] ?? [];
+
+ // Handle potential BOM (Byte Order Mark) at the beginning
+ $data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
+
+ $csv = Reader::createFromString($data);
+ $csv->setDelimiter($delimiter);
+ $csv->setHeaderOffset(0);
+
+ // Handle quoted fields properly
+ $csv->setEscape('\\');
+ $csv->setEnclosure('"');
+
+ $mapped_entries = [];
+
+ foreach ($csv->getRecords() as $offset => $entry) {
+ // Apply field mapping to translate column names
+ $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
+
+ // Extract footprint package name if it contains library prefix
+ if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
+ $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
+ }
+
+ $mapped_entries[] = $mapped_entry;
+ }
+
+ return $this->validationService->validateBOMEntries($mapped_entries, $options);
+ }
+
/**
* This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry
@@ -160,4 +249,482 @@ class BOMImporter
return $out;
}
+
+ /**
+ * Parse KiCad schematic BOM with flexible field mapping
+ */
+ private function parseKiCADSchematic(string $data, array $options = []): array
+ {
+ $delimiter = $options['delimiter'] ?? ',';
+ $field_mapping = $options['field_mapping'] ?? [];
+ $field_priorities = $options['field_priorities'] ?? [];
+
+ // Handle potential BOM (Byte Order Mark) at the beginning
+ $data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
+
+ $csv = Reader::createFromString($data);
+ $csv->setDelimiter($delimiter);
+ $csv->setHeaderOffset(0);
+
+ // Handle quoted fields properly
+ $csv->setEscape('\\');
+ $csv->setEnclosure('"');
+
+ $bom_entries = [];
+ $entries_by_key = []; // Track entries by name+part combination
+ $mapped_entries = []; // Collect all mapped entries for validation
+
+ foreach ($csv->getRecords() as $offset => $entry) {
+ // Apply field mapping to translate column names
+ $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
+
+ // Extract footprint package name if it contains library prefix
+ if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
+ $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
+ }
+
+ $mapped_entries[] = $mapped_entry;
+ }
+
+ // Validate all entries before processing
+ $validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options);
+
+ // Log validation results
+ $this->logger->info('BOM import validation completed', [
+ 'total_entries' => $validation_result['total_entries'],
+ 'valid_entries' => $validation_result['valid_entries'],
+ 'invalid_entries' => $validation_result['invalid_entries'],
+ 'error_count' => count($validation_result['errors']),
+ 'warning_count' => count($validation_result['warnings']),
+ ]);
+
+ // If there are validation errors, throw an exception with detailed messages
+ if (!empty($validation_result['errors'])) {
+ $error_message = $this->validationService->getErrorMessage($validation_result);
+ throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message);
+ }
+
+ // Process validated entries
+ foreach ($mapped_entries as $offset => $mapped_entry) {
+
+ // Set name - prefer MPN, fall back to Value, then default format
+ $mpn = trim($mapped_entry['MPN'] ?? '');
+ $designation = trim($mapped_entry['Designation'] ?? '');
+ $value = trim($mapped_entry['Value'] ?? '');
+
+ // Use the first non-empty value, or 'Unknown Component' if all are empty
+ $name = '';
+ if (!empty($mpn)) {
+ $name = $mpn;
+ } elseif (!empty($designation)) {
+ $name = $designation;
+ } elseif (!empty($value)) {
+ $name = $value;
+ } else {
+ $name = 'Unknown Component';
+ }
+
+ if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) {
+ $name .= ' (' . trim($mapped_entry['Package']) . ')';
+ }
+
+ // Set mountnames and quantity
+ // The Designator field contains comma-separated mount names for all instances
+ $designator = trim($mapped_entry['Designator']);
+ $quantity = (float) $mapped_entry['Quantity'];
+
+ // Get mountnames array (validation already ensured they match quantity)
+ $mountnames_array = array_map('trim', explode(',', $designator));
+
+ // Try to link existing Part-DB part if ID is provided
+ $part = null;
+ if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
+ $partDbId = (int) $mapped_entry['Part-DB ID'];
+ $existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId);
+
+ if ($existingPart) {
+ $part = $existingPart;
+ // Update name with actual part name
+ $name = $existingPart->getName();
+ }
+ }
+
+ // Create unique key for this entry (name + part ID)
+ $entry_key = $name . '|' . ($part ? $part->getID() : 'null');
+
+ // Check if we already have an entry with the same name and part
+ if (isset($entries_by_key[$entry_key])) {
+ // Merge with existing entry
+ $existing_entry = $entries_by_key[$entry_key];
+
+ // Combine mountnames
+ $existing_mountnames = $existing_entry->getMountnames();
+ $combined_mountnames = $existing_mountnames . ',' . $designator;
+ $existing_entry->setMountnames($combined_mountnames);
+
+ // Add quantities
+ $existing_quantity = $existing_entry->getQuantity();
+ $existing_entry->setQuantity($existing_quantity + $quantity);
+
+ $this->logger->info('Merged duplicate BOM entry', [
+ 'name' => $name,
+ 'part_id' => $part ? $part->getID() : null,
+ 'original_quantity' => $existing_quantity,
+ 'added_quantity' => $quantity,
+ 'new_quantity' => $existing_quantity + $quantity,
+ 'original_mountnames' => $existing_mountnames,
+ 'added_mountnames' => $designator,
+ ]);
+
+ continue; // Skip creating new entry
+ }
+
+ // Create new BOM entry
+ $bom_entry = new ProjectBOMEntry();
+ $bom_entry->setName($name);
+ $bom_entry->setMountnames($designator);
+ $bom_entry->setQuantity($quantity);
+
+ if ($part) {
+ $bom_entry->setPart($part);
+ }
+
+ // Set comment with additional info
+ $comment_parts = [];
+ if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) {
+ $comment_parts[] = 'Value: ' . $mapped_entry['Value'];
+ }
+ if (isset($mapped_entry['MPN'])) {
+ $comment_parts[] = 'MPN: ' . $mapped_entry['MPN'];
+ }
+ if (isset($mapped_entry['Manufacturer'])) {
+ $comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
+ }
+ if (isset($mapped_entry['LCSC'])) {
+ $comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
+ }
+ if (isset($mapped_entry['Supplier and ref'])) {
+ $comment_parts[] = $mapped_entry['Supplier and ref'];
+ }
+
+ if ($part) {
+ $comment_parts[] = "Part-DB ID: " . $part->getID();
+ } elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
+ $comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)";
+ }
+
+ $bom_entry->setComment(implode(', ', $comment_parts));
+
+ $bom_entries[] = $bom_entry;
+ $entries_by_key[$entry_key] = $bom_entry;
+ }
+
+ return $bom_entries;
+ }
+
+ /**
+ * Get all available field mapping targets with descriptions
+ */
+ public function getAvailableFieldTargets(): array
+ {
+ $targets = [
+ 'Designator' => [
+ 'label' => 'Designator',
+ 'description' => 'Component reference designators (e.g., R1, C2, U3)',
+ 'required' => true,
+ 'multiple' => false,
+ ],
+ 'Quantity' => [
+ 'label' => 'Quantity',
+ 'description' => 'Number of components',
+ 'required' => true,
+ 'multiple' => false,
+ ],
+ 'Designation' => [
+ 'label' => 'Designation',
+ 'description' => 'Component designation/part number',
+ 'required' => false,
+ 'multiple' => true,
+ ],
+ 'Value' => [
+ 'label' => 'Value',
+ 'description' => 'Component value (e.g., 10k, 100nF)',
+ 'required' => false,
+ 'multiple' => true,
+ ],
+ 'Package' => [
+ 'label' => 'Package',
+ 'description' => 'Component package/footprint',
+ 'required' => false,
+ 'multiple' => true,
+ ],
+ 'MPN' => [
+ 'label' => 'MPN',
+ 'description' => 'Manufacturer Part Number',
+ 'required' => false,
+ 'multiple' => true,
+ ],
+ 'Manufacturer' => [
+ 'label' => 'Manufacturer',
+ 'description' => 'Component manufacturer name',
+ 'required' => false,
+ 'multiple' => true,
+ ],
+ 'Part-DB ID' => [
+ 'label' => 'Part-DB ID',
+ 'description' => 'Existing Part-DB part ID for linking',
+ 'required' => false,
+ 'multiple' => false,
+ ],
+ 'Comment' => [
+ 'label' => 'Comment',
+ 'description' => 'Additional component information',
+ 'required' => false,
+ 'multiple' => true,
+ ],
+ ];
+
+ // Add dynamic supplier fields based on available suppliers in the database
+ $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
+ foreach ($suppliers as $supplier) {
+ $supplierName = $supplier->getName();
+ $targets[$supplierName . ' SPN'] = [
+ 'label' => $supplierName . ' SPN',
+ 'description' => "Supplier part number for {$supplierName}",
+ 'required' => false,
+ 'multiple' => true,
+ 'supplier_id' => $supplier->getID(),
+ ];
+ }
+
+ return $targets;
+ }
+
+ /**
+ * Get suggested field mappings based on common field names
+ */
+ public function getSuggestedFieldMapping(array $detected_fields): array
+ {
+ $suggestions = [];
+
+ $field_patterns = [
+ 'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'],
+ 'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'],
+ 'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'],
+ 'Value' => ['value', 'val', 'component_value'],
+ 'Designation' => ['designation', 'part_number', 'partnumber', 'part'],
+ 'Package' => ['footprint', 'package', 'housing', 'fp'],
+ 'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'],
+ 'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'],
+ 'Comment' => ['comment', 'comments', 'note', 'notes', 'description'],
+ ];
+
+ // Add supplier-specific patterns
+ $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
+ foreach ($suppliers as $supplier) {
+ $supplierName = $supplier->getName();
+ $supplierLower = strtolower($supplierName);
+
+ // Create patterns for each supplier
+ $field_patterns[$supplierName . ' SPN'] = [
+ $supplierLower,
+ $supplierLower . '#',
+ $supplierLower . '_part',
+ $supplierLower . '_number',
+ $supplierLower . 'pn',
+ $supplierLower . '_spn',
+ $supplierLower . ' spn',
+ // Common abbreviations
+ $supplierLower === 'mouser' ? 'mouser' : null,
+ $supplierLower === 'digikey' ? 'dk' : null,
+ $supplierLower === 'farnell' ? 'farnell' : null,
+ $supplierLower === 'rs' ? 'rs' : null,
+ $supplierLower === 'lcsc' ? 'lcsc' : null,
+ ];
+
+ // Remove null values
+ $field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null);
+ }
+
+ foreach ($detected_fields as $field) {
+ $field_lower = strtolower(trim($field));
+
+ foreach ($field_patterns as $target => $patterns) {
+ foreach ($patterns as $pattern) {
+ if (str_contains($field_lower, $pattern)) {
+ $suggestions[$field] = $target;
+ break 2; // Break both loops
+ }
+ }
+ }
+ }
+
+ return $suggestions;
+ }
+
+ /**
+ * Validate field mapping configuration
+ */
+ public function validateFieldMapping(array $field_mapping, array $detected_fields): array
+ {
+ $errors = [];
+ $warnings = [];
+ $available_targets = $this->getAvailableFieldTargets();
+
+ // Check for required fields
+ $mapped_targets = array_values($field_mapping);
+ $required_fields = ['Designator', 'Quantity'];
+
+ foreach ($required_fields as $required) {
+ if (!in_array($required, $mapped_targets, true)) {
+ $errors[] = "Required field '{$required}' is not mapped from any CSV column.";
+ }
+ }
+
+ // Check for invalid target fields
+ foreach ($field_mapping as $csv_field => $target) {
+ if (!empty($target) && !isset($available_targets[$target])) {
+ $errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'.";
+ }
+ }
+
+ // Check for unmapped fields (warnings)
+ $unmapped_fields = array_diff($detected_fields, array_keys($field_mapping));
+ if (!empty($unmapped_fields)) {
+ $warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields);
+ }
+
+ return [
+ 'errors' => $errors,
+ 'warnings' => $warnings,
+ 'is_valid' => empty($errors),
+ ];
+ }
+
+ /**
+ * Apply field mapping with support for multiple fields and priority
+ */
+ private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array
+ {
+ $mapped = [];
+ $field_groups = [];
+
+ // Group fields by target with priority information
+ foreach ($field_mapping as $csv_field => $target) {
+ if (!empty($target)) {
+ if (!isset($field_groups[$target])) {
+ $field_groups[$target] = [];
+ }
+ $priority = $field_priorities[$csv_field] ?? 10;
+ $field_groups[$target][] = [
+ 'field' => $csv_field,
+ 'priority' => $priority,
+ 'value' => $entry[$csv_field] ?? ''
+ ];
+ }
+ }
+
+ // Process each target field
+ foreach ($field_groups as $target => $field_data) {
+ // Sort by priority (lower number = higher priority)
+ usort($field_data, function ($a, $b) {
+ return $a['priority'] <=> $b['priority'];
+ });
+
+ $values = [];
+ $non_empty_values = [];
+
+ // Collect all non-empty values for this target
+ foreach ($field_data as $data) {
+ $value = trim($data['value']);
+ if (!empty($value)) {
+ $non_empty_values[] = $value;
+ }
+ $values[] = $value;
+ }
+
+ // Use the first non-empty value (highest priority)
+ if (!empty($non_empty_values)) {
+ $mapped[$target] = $non_empty_values[0];
+
+ // If multiple non-empty values exist, add alternatives to comment
+ if (count($non_empty_values) > 1) {
+ $mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1);
+ }
+ }
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * Detect available fields in CSV data for field mapping UI
+ */
+ public function detectFields(string $data, ?string $delimiter = null): array
+ {
+ if ($delimiter === null) {
+ // Detect delimiter by counting occurrences in the first row (header)
+ $delimiters = [',', ';', "\t"];
+ $lines = explode("\n", $data, 2);
+ $header_line = $lines[0] ?? '';
+ $delimiter_counts = [];
+ foreach ($delimiters as $delim) {
+ $delimiter_counts[$delim] = substr_count($header_line, $delim);
+ }
+ // Choose the delimiter with the highest count, default to comma if all are zero
+ $max_count = max($delimiter_counts);
+ $delimiter = array_search($max_count, $delimiter_counts, true);
+ if ($max_count === 0 || $delimiter === false) {
+ $delimiter = ',';
+ }
+ }
+ // Handle potential BOM (Byte Order Mark) at the beginning
+ $data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
+
+ // Get first line only for header detection
+ $lines = explode("\n", $data);
+ $header_line = trim($lines[0] ?? '');
+
+
+ // Simple manual parsing for header detection
+ // This handles quoted CSV fields better than the library for detection
+ $fields = [];
+ $current_field = '';
+ $in_quotes = false;
+ $quote_char = '"';
+
+ for ($i = 0; $i < strlen($header_line); $i++) {
+ $char = $header_line[$i];
+
+ if ($char === $quote_char && !$in_quotes) {
+ $in_quotes = true;
+ } elseif ($char === $quote_char && $in_quotes) {
+ // Check for escaped quote (double quote)
+ if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) {
+ $current_field .= $quote_char;
+ $i++; // Skip next quote
+ } else {
+ $in_quotes = false;
+ }
+ } elseif ($char === $delimiter && !$in_quotes) {
+ $fields[] = trim($current_field);
+ $current_field = '';
+ } else {
+ $current_field .= $char;
+ }
+ }
+
+ // Add the last field
+ if ($current_field !== '') {
+ $fields[] = trim($current_field);
+ }
+
+ // Clean up headers - remove quotes and trim whitespace
+ $headers = array_map(function ($header) {
+ return trim($header, '"\'');
+ }, $fields);
+
+
+ return array_values($headers);
+ }
}
diff --git a/src/Services/ImportExportSystem/BOMValidationService.php b/src/Services/ImportExportSystem/BOMValidationService.php
new file mode 100644
index 00000000..74f81fe3
--- /dev/null
+++ b/src/Services/ImportExportSystem/BOMValidationService.php
@@ -0,0 +1,476 @@
+.
+ */
+namespace App\Services\ImportExportSystem;
+
+use App\Entity\Parts\Part;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * Service for validating BOM import data with comprehensive validation rules
+ * and user-friendly error messages.
+ */
+class BOMValidationService
+{
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly TranslatorInterface $translator
+ ) {
+ }
+
+ /**
+ * Validation result structure
+ */
+ public static function createValidationResult(): array
+ {
+ return [
+ 'errors' => [],
+ 'warnings' => [],
+ 'info' => [],
+ 'is_valid' => true,
+ 'total_entries' => 0,
+ 'valid_entries' => 0,
+ 'invalid_entries' => 0,
+ ];
+ }
+
+ /**
+ * Validate a single BOM entry with comprehensive checks
+ */
+ public function validateBOMEntry(array $mapped_entry, int $line_number, array $options = []): array
+ {
+ $result = [
+ 'line_number' => $line_number,
+ 'errors' => [],
+ 'warnings' => [],
+ 'info' => [],
+ 'is_valid' => true,
+ ];
+
+ // Run all validation rules
+ $this->validateRequiredFields($mapped_entry, $result);
+ $this->validateDesignatorFormat($mapped_entry, $result);
+ $this->validateQuantityFormat($mapped_entry, $result);
+ $this->validateDesignatorQuantityMatch($mapped_entry, $result);
+ $this->validatePartDBLink($mapped_entry, $result);
+ $this->validateComponentName($mapped_entry, $result);
+ $this->validatePackageFormat($mapped_entry, $result);
+ $this->validateNumericFields($mapped_entry, $result);
+
+ $result['is_valid'] = empty($result['errors']);
+
+ return $result;
+ }
+
+ /**
+ * Validate multiple BOM entries and provide summary
+ */
+ public function validateBOMEntries(array $mapped_entries, array $options = []): array
+ {
+ $result = self::createValidationResult();
+ $result['total_entries'] = count($mapped_entries);
+
+ $line_results = [];
+ $all_errors = [];
+ $all_warnings = [];
+ $all_info = [];
+
+ foreach ($mapped_entries as $index => $entry) {
+ $line_number = $index + 1;
+ $line_result = $this->validateBOMEntry($entry, $line_number, $options);
+
+ $line_results[] = $line_result;
+
+ if ($line_result['is_valid']) {
+ $result['valid_entries']++;
+ } else {
+ $result['invalid_entries']++;
+ }
+
+ // Collect all messages
+ $all_errors = array_merge($all_errors, $line_result['errors']);
+ $all_warnings = array_merge($all_warnings, $line_result['warnings']);
+ $all_info = array_merge($all_info, $line_result['info']);
+ }
+
+ // Add summary messages
+ $this->addSummaryMessages($result, $all_errors, $all_warnings, $all_info);
+
+ $result['errors'] = $all_errors;
+ $result['warnings'] = $all_warnings;
+ $result['info'] = $all_info;
+ $result['line_results'] = $line_results;
+ $result['is_valid'] = empty($all_errors);
+
+ return $result;
+ }
+
+ /**
+ * Validate required fields are present
+ */
+ private function validateRequiredFields(array $entry, array &$result): void
+ {
+ $required_fields = ['Designator', 'Quantity'];
+
+ foreach ($required_fields as $field) {
+ if (!isset($entry[$field]) || trim($entry[$field]) === '') {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.required_field_missing', [
+ '%line%' => $result['line_number'],
+ '%field%' => $field
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Validate designator format and content
+ */
+ private function validateDesignatorFormat(array $entry, array &$result): void
+ {
+ if (!isset($entry['Designator']) || trim($entry['Designator']) === '') {
+ return; // Already handled by required fields validation
+ }
+
+ $designator = trim($entry['Designator']);
+ $mountnames = array_map('trim', explode(',', $designator));
+
+ // Remove empty entries
+ $mountnames = array_filter($mountnames, fn($name) => !empty($name));
+
+ if (empty($mountnames)) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.no_valid_designators', [
+ '%line%' => $result['line_number']
+ ]);
+ return;
+ }
+
+ // Validate each mountname format (allow 1-2 uppercase letters, followed by 1+ digits)
+ $invalid_mountnames = [];
+ foreach ($mountnames as $mountname) {
+ if (!preg_match('/^[A-Z]{1,2}[0-9]+$/', $mountname)) {
+ $invalid_mountnames[] = $mountname;
+ }
+ }
+
+ if (!empty($invalid_mountnames)) {
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.unusual_designator_format', [
+ '%line%' => $result['line_number'],
+ '%designators%' => implode(', ', $invalid_mountnames)
+ ]);
+ }
+
+ // Check for duplicate mountnames within the same line
+ $duplicates = array_diff_assoc($mountnames, array_unique($mountnames));
+ if (!empty($duplicates)) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.duplicate_designators', [
+ '%line%' => $result['line_number'],
+ '%designators%' => implode(', ', array_unique($duplicates))
+ ]);
+ }
+ }
+
+ /**
+ * Validate quantity format and value
+ */
+ private function validateQuantityFormat(array $entry, array &$result): void
+ {
+ if (!isset($entry['Quantity']) || trim($entry['Quantity']) === '') {
+ return; // Already handled by required fields validation
+ }
+
+ $quantity_str = trim($entry['Quantity']);
+
+ // Check if it's a valid number
+ if (!is_numeric($quantity_str)) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_quantity', [
+ '%line%' => $result['line_number'],
+ '%quantity%' => $quantity_str
+ ]);
+ return;
+ }
+
+ $quantity = (float) $quantity_str;
+
+ // Check for reasonable quantity values
+ if ($quantity <= 0) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_zero_or_negative', [
+ '%line%' => $result['line_number'],
+ '%quantity%' => $quantity_str
+ ]);
+ } elseif ($quantity > 10000) {
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_unusually_high', [
+ '%line%' => $result['line_number'],
+ '%quantity%' => $quantity_str
+ ]);
+ }
+
+ // Check if quantity is a whole number when it should be
+ if (isset($entry['Designator'])) {
+ $designator = trim($entry['Designator']);
+ $mountnames = array_map('trim', explode(',', $designator));
+ $mountnames = array_filter($mountnames, fn($name) => !empty($name));
+
+ if (count($mountnames) > 0 && $quantity != (int) $quantity) {
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_not_whole_number', [
+ '%line%' => $result['line_number'],
+ '%quantity%' => $quantity_str,
+ '%count%' => count($mountnames)
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Validate that designator count matches quantity
+ */
+ private function validateDesignatorQuantityMatch(array $entry, array &$result): void
+ {
+ if (!isset($entry['Designator']) || !isset($entry['Quantity'])) {
+ return; // Already handled by required fields validation
+ }
+
+ $designator = trim($entry['Designator']);
+ $quantity_str = trim($entry['Quantity']);
+
+ if (!is_numeric($quantity_str)) {
+ return; // Already handled by quantity validation
+ }
+
+ $mountnames = array_map('trim', explode(',', $designator));
+ $mountnames = array_filter($mountnames, fn($name) => !empty($name));
+ $mountnames_count = count($mountnames);
+ $quantity = (float) $quantity_str;
+
+ if ($mountnames_count !== (int) $quantity) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_designator_mismatch', [
+ '%line%' => $result['line_number'],
+ '%quantity%' => $quantity_str,
+ '%count%' => $mountnames_count,
+ '%designators%' => $designator
+ ]);
+ }
+ }
+
+ /**
+ * Validate Part-DB ID link
+ */
+ private function validatePartDBLink(array $entry, array &$result): void
+ {
+ if (!isset($entry['Part-DB ID']) || trim($entry['Part-DB ID']) === '') {
+ return;
+ }
+
+ $part_db_id = trim($entry['Part-DB ID']);
+
+ if (!is_numeric($part_db_id)) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_partdb_id', [
+ '%line%' => $result['line_number'],
+ '%id%' => $part_db_id
+ ]);
+ return;
+ }
+
+ $part_id = (int) $part_db_id;
+
+ if ($part_id <= 0) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.partdb_id_zero_or_negative', [
+ '%line%' => $result['line_number'],
+ '%id%' => $part_id
+ ]);
+ return;
+ }
+
+ // Check if part exists in database
+ $existing_part = $this->entityManager->getRepository(Part::class)->find($part_id);
+ if (!$existing_part) {
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.partdb_id_not_found', [
+ '%line%' => $result['line_number'],
+ '%id%' => $part_id
+ ]);
+ } else {
+ $result['info'][] = $this->translator->trans('project.bom_import.validation.info.partdb_link_success', [
+ '%line%' => $result['line_number'],
+ '%name%' => $existing_part->getName(),
+ '%id%' => $part_id
+ ]);
+ }
+ }
+
+ /**
+ * Validate component name/designation
+ */
+ private function validateComponentName(array $entry, array &$result): void
+ {
+ $name_fields = ['MPN', 'Designation', 'Value'];
+ $has_name = false;
+
+ foreach ($name_fields as $field) {
+ if (isset($entry[$field]) && trim($entry[$field]) !== '') {
+ $has_name = true;
+ break;
+ }
+ }
+
+ if (!$has_name) {
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.no_component_name', [
+ '%line%' => $result['line_number']
+ ]);
+ }
+ }
+
+ /**
+ * Validate package format
+ */
+ private function validatePackageFormat(array $entry, array &$result): void
+ {
+ if (!isset($entry['Package']) || trim($entry['Package']) === '') {
+ return;
+ }
+
+ $package = trim($entry['Package']);
+
+ // Check for common package format issues
+ if (strlen($package) > 100) {
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.package_name_too_long', [
+ '%line%' => $result['line_number'],
+ '%package%' => $package
+ ]);
+ }
+
+ // Check for library prefixes (KiCad format)
+ if (str_contains($package, ':')) {
+ $result['info'][] = $this->translator->trans('project.bom_import.validation.info.library_prefix_detected', [
+ '%line%' => $result['line_number'],
+ '%package%' => $package
+ ]);
+ }
+ }
+
+ /**
+ * Validate numeric fields
+ */
+ private function validateNumericFields(array $entry, array &$result): void
+ {
+ $numeric_fields = ['Quantity', 'Part-DB ID'];
+
+ foreach ($numeric_fields as $field) {
+ if (isset($entry[$field]) && trim($entry[$field]) !== '') {
+ $value = trim($entry[$field]);
+ if (!is_numeric($value)) {
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.non_numeric_field', [
+ '%line%' => $result['line_number'],
+ '%field%' => $field,
+ '%value%' => $value
+ ]);
+ }
+ }
+ }
+ }
+
+ /**
+ * Add summary messages to validation result
+ */
+ private function addSummaryMessages(array &$result, array $errors, array $warnings, array $info): void
+ {
+ $total_entries = $result['total_entries'];
+ $valid_entries = $result['valid_entries'];
+ $invalid_entries = $result['invalid_entries'];
+
+ // Add summary info
+ if ($total_entries > 0) {
+ $result['info'][] = $this->translator->trans('project.bom_import.validation.info.import_summary', [
+ '%total%' => $total_entries,
+ '%valid%' => $valid_entries,
+ '%invalid%' => $invalid_entries
+ ]);
+ }
+
+ // Add error summary
+ if (!empty($errors)) {
+ $error_count = count($errors);
+ $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.summary', [
+ '%count%' => $error_count
+ ]);
+ }
+
+ // Add warning summary
+ if (!empty($warnings)) {
+ $warning_count = count($warnings);
+ $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.summary', [
+ '%count%' => $warning_count
+ ]);
+ }
+
+ // Add success message if all entries are valid
+ if ($total_entries > 0 && $invalid_entries === 0) {
+ $result['info'][] = $this->translator->trans('project.bom_import.validation.info.all_valid');
+ }
+ }
+
+ /**
+ * Get user-friendly error message for a validation result
+ */
+ public function getErrorMessage(array $validation_result): string
+ {
+ if ($validation_result['is_valid']) {
+ return '';
+ }
+
+ $messages = [];
+
+ if (!empty($validation_result['errors'])) {
+ $messages[] = 'Errors:';
+ foreach ($validation_result['errors'] as $error) {
+ $messages[] = '• ' . $error;
+ }
+ }
+
+ if (!empty($validation_result['warnings'])) {
+ $messages[] = 'Warnings:';
+ foreach ($validation_result['warnings'] as $warning) {
+ $messages[] = '• ' . $warning;
+ }
+ }
+
+ return implode("\n", $messages);
+ }
+
+ /**
+ * Get validation statistics
+ */
+ public function getValidationStats(array $validation_result): array
+ {
+ return [
+ 'total_entries' => $validation_result['total_entries'] ?? 0,
+ 'valid_entries' => $validation_result['valid_entries'] ?? 0,
+ 'invalid_entries' => $validation_result['invalid_entries'] ?? 0,
+ 'error_count' => count($validation_result['errors'] ?? []),
+ 'warning_count' => count($validation_result['warnings'] ?? []),
+ 'info_count' => count($validation_result['info'] ?? []),
+ 'success_rate' => $validation_result['total_entries'] > 0
+ ? round(($validation_result['valid_entries'] / $validation_result['total_entries']) * 100, 1)
+ : 0,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
index 58df3b82..2d83fc7c 100755
--- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
@@ -123,11 +123,11 @@ class LCSCProvider implements InfoProviderInterface
*/
private function queryByTerm(string $term): array
{
- $response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
+ $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
- 'query' => [
+ 'json' => [
'keyword' => $term,
],
]);
@@ -165,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
if ($field === null) {
return null;
}
+ // Replace "range" indicators with mathematical tilde symbols
+ // so they don't get rendered as strikethrough by Markdown
+ $field = preg_replace("/~/", "\u{223c}", $field);
return strip_tags($field);
}
@@ -197,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
$category = $product['parentCatalogName'] ?? null;
if (isset($product['catalogName'])) {
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
-
- // Replace the / with a -> for better readability
- $category = str_replace('/', ' -> ', $category);
}
return new PartDetailDTO(
diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php
index 55fa335a..b74e0365 100644
--- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php
@@ -158,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
- manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
+ //TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
+ //manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),
diff --git a/src/Services/Tools/ExchangeRateUpdater.php b/src/Services/Tools/ExchangeRateUpdater.php
index 7c14b16f..6eb7ec13 100644
--- a/src/Services/Tools/ExchangeRateUpdater.php
+++ b/src/Services/Tools/ExchangeRateUpdater.php
@@ -26,6 +26,8 @@ use App\Entity\PriceInformations\Currency;
use App\Settings\SystemSettings\LocalizationSettings;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
+use Exchanger\Exception\UnsupportedCurrencyPairException;
+use Exchanger\Exception\UnsupportedExchangeQueryException;
use Swap\Swap;
class ExchangeRateUpdater
@@ -39,15 +41,21 @@ class ExchangeRateUpdater
*/
public function update(Currency $currency): Currency
{
- //Currency pairs are always in the format "BASE/QUOTE"
- $rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
- //The rate says how many quote units are worth one base unit
- //So we need to invert it to get the exchange rate
+ try {
+ //Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
+ $rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
+ $effective_rate = BigDecimal::of($rate->getValue());
+ } catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
+ //Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
+ $rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
+ //The rate says how many quote units are worth one base unit
+ //So we need to invert it to get the exchange rate
- $rate_bd = BigDecimal::of($rate->getValue());
- $rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
+ $rate_bd = BigDecimal::of($rate->getValue());
+ $effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
+ }
- $currency->setExchangeRate($rate_inverted);
+ $currency->setExchangeRate($effective_rate);
return $currency;
}
diff --git a/src/Services/UserSystem/VoterHelper.php b/src/Services/UserSystem/VoterHelper.php
index 644351f4..d3c5368c 100644
--- a/src/Services/UserSystem/VoterHelper.php
+++ b/src/Services/UserSystem/VoterHelper.php
@@ -28,6 +28,9 @@ use App\Repository\UserRepository;
use App\Security\ApiTokenAuthenticatedToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Tests\Services\UserSystem\VoterHelperTest
@@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class VoterHelper
{
private readonly UserRepository $userRepository;
+ private readonly array $permissionStructure;
- public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager)
+ public function __construct(private readonly PermissionManager $permissionManager,
+ private readonly TranslatorInterface $translator,
+ private readonly EntityManagerInterface $entityManager)
{
$this->userRepository = $this->entityManager->getRepository(User::class);
+ $this->permissionStructure = $this->permissionManager->getPermissionStructure();
}
/**
@@ -47,11 +54,16 @@ final class VoterHelper
* @param TokenInterface $token The token to check
* @param string $permission The permission to check
* @param string $operation The operation to check
+ * @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added.
* @return bool
*/
- public function isGranted(TokenInterface $token, string $permission, string $operation): bool
+ public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool
{
- return $this->isGrantedTrinary($token, $permission, $operation) ?? false;
+ $tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false;
+ if ($tmp === false) {
+ $this->addReason($vote, $permission, $operation);
+ }
+ return $tmp;
}
/**
@@ -124,4 +136,17 @@ final class VoterHelper
{
return $this->permissionManager->isValidOperation($permission, $operation);
}
-}
\ No newline at end of file
+
+ public function addReason(?Vote $voter, string $permission, $operation): void
+ {
+ if ($voter !== null) {
+ $voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).",
+ $this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ),
+ $this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission),
+ $this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation),
+ $permission,
+ $operation
+ ));
+ }
+ }
+}
diff --git a/symfony.lock b/symfony.lock
index d301c269..7c136b4b 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -133,15 +133,6 @@
"ekino/phpstan-banned-code": {
"version": "v0.3.1"
},
- "florianv/exchanger": {
- "version": "1.4.1"
- },
- "florianv/swap": {
- "version": "3.5.0"
- },
- "florianv/swap-bundle": {
- "version": "5.0.x-dev"
- },
"gregwar/captcha": {
"version": "v1.1.7"
},
@@ -254,6 +245,9 @@
"./config/packages/datatables.yaml"
]
},
+ "part-db/swap-bundle": {
+ "version": "v6.0.0"
+ },
"php-http/discovery": {
"version": "1.18",
"recipe": {
diff --git a/templates/bundles/TwigBundle/Exception/error403.html.twig b/templates/bundles/TwigBundle/Exception/error403.html.twig
index f5987179..334670fc 100644
--- a/templates/bundles/TwigBundle/Exception/error403.html.twig
+++ b/templates/bundles/TwigBundle/Exception/error403.html.twig
@@ -1,6 +1,9 @@
{% extends "bundles/TwigBundle/Exception/error.html.twig" %}
{% block status_comment %}
- Nice try! But you are not allowed to do this!
+ Nice try! But you are not allowed to do this!
+ {{ exception.message }}
If you think you should have access to this ressource, contact the adminstrator.
-{% endblock %}
\ No newline at end of file
+
+
+{% endblock %}
diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig
index 827a95fd..bf530ebd 100644
--- a/templates/info_providers/providers.macro.html.twig
+++ b/templates/info_providers/providers.macro.html.twig
@@ -23,7 +23,7 @@
{% trans %}project.bom_import.validation.errors.description{% endtrans %}
+{% trans %}project.bom_import.validation.warnings.description{% endtrans %}
+| {% trans %}project.bom_import.validation.details.line{% endtrans %} | +{% trans %}project.bom_import.validation.details.status{% endtrans %} | +{% trans %}project.bom_import.validation.details.messages{% endtrans %} | +
|---|---|---|
| + {{ line_result.line_number }} + | ++ {% if line_result.is_valid %} + + + {% trans %}project.bom_import.validation.details.valid{% endtrans %} + + {% else %} + + + {% trans %}project.bom_import.validation.details.invalid{% endtrans %} + + {% endif %} + | +
+ {% if line_result.errors is not empty %}
+
+ {% for error in line_result.errors %}
+
+ {% endif %}
+ {% if line_result.warnings is not empty %}
+ {{ error|raw }}
+ {% endfor %}
+
+ {% for warning in line_result.warnings %}
+
+ {% endif %}
+ {% if line_result.info is not empty %}
+ {{ warning|raw }}
+ {% endfor %}
+
+ {% for info in line_result.info %}
+
+ {% endif %}
+ {{ info|raw }}
+ {% endfor %}
+ |
+
| {% trans %}project.bom_import.field_mapping.csv_field{% endtrans %} | +{% trans %}project.bom_import.field_mapping.maps_to{% endtrans %} | +{% trans %}project.bom_import.field_mapping.suggestion{% endtrans %} | +{% trans %}project.bom_import.field_mapping.priority{% endtrans %} | +
|---|---|---|---|
+ {{ field }}
+ |
+ + {{ form_widget(form['mapping_' ~ field_name_mapping[field]], { + 'attr': { + 'class': 'form-select field-mapping-select', + 'data-field': field + } + }) }} + | ++ {% if suggested_mapping[field] is defined %} + + + {{ suggested_mapping[field] }} + + {% else %} + + + {% trans %}project.bom_import.field_mapping.no_suggestion{% endtrans %} + + {% endif %} + | ++ + | +