diff --git a/README.md b/README.md
index c9d25016..291b574a 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@

[](https://codecov.io/gh/Part-DB/Part-DB-server)

-
+


diff --git a/VERSION b/VERSION
index e9307ca5..eca07e4c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.0.2
+2.1.2
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/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js
index 1fe11a20..c43fa276 100644
--- a/assets/controllers/elements/datatables/parts_controller.js
+++ b/assets/controllers/elements/datatables/parts_controller.js
@@ -45,8 +45,10 @@ export default class extends DatatablesController {
//Hide/Unhide panel with the selection tools
if (count > 0) {
selectPanel.classList.remove('d-none');
+ selectPanel.classList.add('sticky-select-bar');
} else {
selectPanel.classList.add('d-none');
+ selectPanel.classList.remove('sticky-select-bar');
}
//Update selection count text
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/app/images.css b/assets/css/app/images.css
index 214776e7..0212a85b 100644
--- a/assets/css/app/images.css
+++ b/assets/css/app/images.css
@@ -18,8 +18,8 @@
*/
.hoverpic {
- min-width: 10px;
- max-width: 30px;
+ min-width: var(--table-image-preview-min-size, 20px);
+ max-width: var(--table-image-preview-max-size, 35px);
display: block;
margin-left: auto;
margin-right: auto;
@@ -49,7 +49,7 @@
}
.part-table-image {
- max-height: 40px;
+ max-height: calc(1.2*var(--table-image-preview-max-size, 35px)); /** Aspect ratio of maximum 1.2 */
object-fit: contain;
}
diff --git a/assets/css/app/tables.css b/assets/css/app/tables.css
index ae892f50..8d4b200c 100644
--- a/assets/css/app/tables.css
+++ b/assets/css/app/tables.css
@@ -17,6 +17,16 @@
* along with this program. If not, see .
*/
+/****************************************
+ * Action bar
+ ****************************************/
+
+.sticky-select-bar {
+ position: sticky;
+ top: 120px;
+ z-index: 1000; /* Ensure the bar is above other content */
+}
+
/****************************************
* Tables
****************************************/
@@ -109,4 +119,4 @@ Classes for Datatables export
#export-messageTop,
.export-helper{
display: none;
-}
\ 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..1f67b80f 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",
@@ -7634,17 +7435,94 @@
"time": "2024-04-22T22:05:04+00:00"
},
{
- "name": "part-db/label-fonts",
- "version": "v1.1.0",
+ "name": "part-db/exchanger",
+ "version": "v3.1.0",
"source": {
"type": "git",
- "url": "https://github.com/Part-DB/label-fonts.git",
- "reference": "77c84b70ed3bb005df15f30ff835ddec490394b9"
+ "url": "https://github.com/Part-DB/exchanger.git",
+ "reference": "a43fe79a082e331ec2b24f3579e4fba153743757"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Part-DB/label-fonts/zipball/77c84b70ed3bb005df15f30ff835ddec490394b9",
- "reference": "77c84b70ed3bb005df15f30ff835ddec490394b9",
+ "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.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Part-DB/label-fonts.git",
+ "reference": "c85aeb051d6492961a2c59bc291979f15ce60e88"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Part-DB/label-fonts/zipball/c85aeb051d6492961a2c59bc291979f15ce60e88",
+ "reference": "c85aeb051d6492961a2c59bc291979f15ce60e88",
"shasum": ""
},
"type": "library",
@@ -7667,9 +7545,152 @@
],
"support": {
"issues": "https://github.com/Part-DB/label-fonts/issues",
- "source": "https://github.com/Part-DB/label-fonts/tree/v1.1.0"
+ "source": "https://github.com/Part-DB/label-fonts/tree/v1.2.0"
},
- "time": "2024-02-08T21:44:38+00:00"
+ "time": "2025-09-07T15:42:51+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",
@@ -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",
@@ -17807,16 +17883,16 @@
},
{
"name": "phpstan/phpstan-doctrine",
- "version": "2.0.4",
+ "version": "2.0.5",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
- "reference": "6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8"
+ "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8",
- "reference": "6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8",
+ "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/eeff19808f8ae3a6f7c4e43e388a2848eb2b0865",
+ "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865",
"shasum": ""
},
"require": {
@@ -17873,9 +17949,9 @@
"description": "Doctrine extensions for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
- "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.4"
+ "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.5"
},
- "time": "2025-07-17T11:57:55+00:00"
+ "time": "2025-09-07T11:52:30+00:00"
},
{
"name": "phpstan/phpstan-strict-rules",
@@ -17927,16 +18003,16 @@
},
{
"name": "phpstan/phpstan-symfony",
- "version": "2.0.7",
+ "version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
- "reference": "392f7ab8f52a0a776977be4e62535358c28e1b15"
+ "reference": "8820c22d785c235f69bb48da3d41e688bc8a1796"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/392f7ab8f52a0a776977be4e62535358c28e1b15",
- "reference": "392f7ab8f52a0a776977be4e62535358c28e1b15",
+ "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/8820c22d785c235f69bb48da3d41e688bc8a1796",
+ "reference": "8820c22d785c235f69bb48da3d41e688bc8a1796",
"shasum": ""
},
"require": {
@@ -17992,9 +18068,9 @@
"description": "Symfony Framework extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
- "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.7"
+ "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.8"
},
- "time": "2025-07-22T09:40:57+00:00"
+ "time": "2025-09-07T06:55:50+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -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/settings.yaml b/config/packages/settings.yaml
index 05e21636..c16d1804 100644
--- a/config/packages/settings.yaml
+++ b/config/packages/settings.yaml
@@ -5,4 +5,11 @@ jbtronics_settings:
default_cacheable: true
orm_storage:
- default_entity_class: App\Entity\SettingsEntry
\ No newline at end of file
+ default_entity_class: App\Entity\SettingsEntry
+
+
+# Disable caching for development environment
+when@dev:
+ jbtronics_settings:
+ cache:
+ default_cacheable: false
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/AttachmentFileController.php b/src/Controller/AttachmentFileController.php
index 7917e97f..81369e12 100644
--- a/src/Controller/AttachmentFileController.php
+++ b/src/Controller/AttachmentFileController.php
@@ -24,6 +24,7 @@ namespace App\Controller;
use App\DataTables\AttachmentDataTable;
use App\DataTables\Filters\AttachmentFilter;
+use App\DataTables\PartsDataTable;
use App\Entity\Attachments\Attachment;
use App\Form\Filters\AttachmentFilterType;
use App\Services\Attachments\AttachmentManager;
@@ -112,7 +113,7 @@ class AttachmentFileController extends AbstractController
$filterForm->handleRequest($formRequest);
- $table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize])
+ $table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request);
if ($table->isCallback()) {
diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php
index a6e886e6..dae8213e 100644
--- a/src/Controller/InfoProviderController.php
+++ b/src/Controller/InfoProviderController.php
@@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Settings\AppSettings;
+use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
@@ -113,7 +114,7 @@ class InfoProviderController extends AbstractController
#[Route('/search', name: 'info_providers_search')]
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
- public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response
+ public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@@ -144,6 +145,23 @@ class InfoProviderController extends AbstractController
}
}
+ //If the providers form is still empty, use our default value from the settings
+ if (count($form->get('providers')->getData() ?? []) === 0) {
+ $default_providers = $infoProviderSettings->defaultSearchProviders;
+ $provider_objects = [];
+ foreach ($default_providers as $provider_key) {
+ try {
+ $tmp = $this->providerRegistry->getProviderByKey($provider_key);
+ if ($tmp->isActive()) {
+ $provider_objects[] = $tmp;
+ }
+ } catch (\InvalidArgumentException $e) {
+ //If the provider is not found, just ignore it
+ }
+ }
+ $form->get('providers')->setData($provider_objects);
+ }
+
if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();
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/PartController.php b/src/Controller/PartController.php
index b11a5c90..6708ed4c 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
+use App\Settings\BehaviorSettings\PartInfoSettings;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -69,7 +70,7 @@ class PartController extends AbstractController
protected PartPreviewGenerator $partPreviewGenerator,
private readonly TranslatorInterface $translator,
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
- protected EventCommentHelper $commentHelper)
+ protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
{
}
@@ -119,8 +120,8 @@ class PartController extends AbstractController
'pricedetail_helper' => $this->pricedetailHelper,
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
'timeTravel' => $timeTravel_timestamp,
- 'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
- 'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
+ 'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
+ 'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper,
]
);
diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php
index f6836ddc..b2df18c1 100644
--- a/src/Controller/PartListsController.php
+++ b/src/Controller/PartListsController.php
@@ -161,7 +161,9 @@ class PartListsController extends AbstractController
$filterForm->handleRequest($formRequest);
- $table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(['filter' => $filter], $additional_table_vars), ['pageLength' => $this->tableSettings->fullDefaultPageSize])
+ $table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
+ ['filter' => $filter], $additional_table_vars),
+ ['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request);
if ($table->isCallback()) {
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/DataTables/Filters/AttachmentFilter.php b/src/DataTables/Filters/AttachmentFilter.php
index d41bbe39..69d2aeac 100644
--- a/src/DataTables/Filters/AttachmentFilter.php
+++ b/src/DataTables/Filters/AttachmentFilter.php
@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
+use Omines\DataTablesBundle\Filter\AbstractFilter;
class AttachmentFilter implements FilterInterface
{
@@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
public function __construct(NodesListBuilder $nodesListBuilder)
{
+ //Must be done for every new set of attachment filters, to ensure deterministic parameter names.
+ AbstractConstraint::resetParameterCounter();
+
$this->dbId = new IntConstraint('attachment.id');
$this->name = new TextConstraint('attachment.name');
$this->targetType = new InstanceOfConstraint('attachment');
diff --git a/src/DataTables/Filters/Constraints/AbstractConstraint.php b/src/DataTables/Filters/Constraints/AbstractConstraint.php
index 7f16511e..c632b2a4 100644
--- a/src/DataTables/Filters/Constraints/AbstractConstraint.php
+++ b/src/DataTables/Filters/Constraints/AbstractConstraint.php
@@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
- /**
- * @var string
- */
- protected string $identifier;
+ protected ?string $identifier;
/**
diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php
index 3260e4e3..2932914a 100644
--- a/src/DataTables/Filters/Constraints/FilterTrait.php
+++ b/src/DataTables/Filters/Constraints/FilterTrait.php
@@ -28,6 +28,7 @@ trait FilterTrait
{
protected bool $useHaving = false;
+ protected static int $parameterCounter = 0;
public function useHaving($value = true): static
{
@@ -50,8 +51,18 @@ trait FilterTrait
{
//Replace all special characters with underscores
$property = preg_replace('/\W/', '_', $property);
- //Add a random number to the end of the property name for uniqueness
- return $property . '_' . uniqid("", false);
+ return $property . '_' . (self::$parameterCounter++) . '_';
+ }
+
+ /**
+ * Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again.
+ * This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter
+ * identifiers are deterministic so that they are cacheable.
+ * @return void
+ */
+ public static function resetParameterCounter(): void
+ {
+ self::$parameterCounter = 0;
}
/**
diff --git a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
index 02eab7a1..2b28e6b4 100644
--- a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
+++ b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
@@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
//Escape any %, _ or \ in the tag
$tag = addcslashes($tag, '%_\\');
- $tag_identifier_prefix = uniqid($this->identifier . '_', false);
+ $tag_identifier_prefix = $this->generateParameterIdentifier('tag');
$expr = $queryBuilder->expr();
diff --git a/src/DataTables/Filters/LogFilter.php b/src/DataTables/Filters/LogFilter.php
index 35d32e74..38dc2191 100644
--- a/src/DataTables/Filters/LogFilter.php
+++ b/src/DataTables/Filters/LogFilter.php
@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
public function __construct()
{
+ //Must be done for every new set of attachment filters, to ensure deterministic parameter names.
+ AbstractConstraint::resetParameterCounter();
+
$this->timestamp = new DateTimeConstraint('log.timestamp');
$this->dbId = new IntConstraint('log.id');
$this->level = new ChoiceConstraint('log.level');
diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php
index ff98c76f..8dcbd6b3 100644
--- a/src/DataTables/Filters/PartFilter.php
+++ b/src/DataTables/Filters/PartFilter.php
@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
@@ -103,6 +104,9 @@ class PartFilter implements FilterInterface
public function __construct(NodesListBuilder $nodesListBuilder)
{
+ //Must be done for every new set of attachment filters, to ensure deterministic parameter names.
+ AbstractConstraint::resetParameterCounter();
+
$this->name = new TextConstraint('part.name');
$this->description = new TextConstraint('part.description');
$this->comment = new TextConstraint('part.comment');
diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php
index 6e2e5894..60832b26 100644
--- a/src/DataTables/Filters/PartSearchFilter.php
+++ b/src/DataTables/Filters/PartSearchFilter.php
@@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see .
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface
diff --git a/src/Form/InfoProviderSystem/ProviderSelectType.php b/src/Form/InfoProviderSystem/ProviderSelectType.php
index a9373390..95e10791 100644
--- a/src/Form/InfoProviderSystem/ProviderSelectType.php
+++ b/src/Form/InfoProviderSystem/ProviderSelectType.php
@@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProviderSelectType extends AbstractType
@@ -44,13 +45,43 @@ class ProviderSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
- $resolver->setDefaults([
- 'choices' => $this->providerRegistry->getActiveProviders(),
- 'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
- 'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
+ $providers = $this->providerRegistry->getActiveProviders();
- 'multiple' => true,
- ]);
+ $resolver->setDefault('input', 'object');
+ $resolver->setAllowedTypes('input', 'string');
+ //Either the form returns the provider objects or their keys
+ $resolver->setAllowedValues('input', ['object', 'string']);
+ $resolver->setDefault('multiple', true);
+
+ $resolver->setDefault('choices', function (Options $options) use ($providers) {
+ if ('object' === $options['input']) {
+ return $this->providerRegistry->getActiveProviders();
+ }
+
+ $tmp = [];
+ foreach ($providers as $provider) {
+ $name = $provider->getProviderInfo()['name'];
+ $tmp[$name] = $provider->getProviderKey();
+ }
+
+ return $tmp;
+ });
+
+ //The choice_label and choice_value only needs to be set if we want the objects
+ $resolver->setDefault('choice_label', function (Options $options){
+ if ('object' === $options['input']) {
+ return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
+ }
+
+ return null;
+ });
+ $resolver->setDefault('choice_value', function (Options $options) {
+ if ('object' === $options['input']) {
+ return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
+ }
+
+ return null;
+ });
}
-}
\ No newline at end of file
+}
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/src/Settings/BehaviorSettings/PartInfoSettings.php b/src/Settings/BehaviorSettings/PartInfoSettings.php
index 4c44b9bb..f017c846 100644
--- a/src/Settings/BehaviorSettings/PartInfoSettings.php
+++ b/src/Settings/BehaviorSettings/PartInfoSettings.php
@@ -40,4 +40,10 @@ class PartInfoSettings
#[SettingsParameter(label: new TM("settings.behavior.part_info.show_part_image_overlay"), description: new TM("settings.behavior.part_info.show_part_image_overlay.help"),
envVar: "bool:SHOW_PART_IMAGE_OVERLAY", envVarMode: EnvVarMode::OVERWRITE)]
public bool $showPartImageOverlay = true;
-}
\ No newline at end of file
+
+ #[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_description"))]
+ public bool $extractParamsFromDescription = true;
+
+ #[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_notes"))]
+ public bool $extractParamsFromNotes = true;
+}
diff --git a/src/Settings/BehaviorSettings/TableSettings.php b/src/Settings/BehaviorSettings/TableSettings.php
index 7b4e7912..b6964876 100644
--- a/src/Settings/BehaviorSettings/TableSettings.php
+++ b/src/Settings/BehaviorSettings/TableSettings.php
@@ -70,6 +70,20 @@ class TableSettings
PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER,
PartTableColumns::LOCATION, PartTableColumns::AMOUNT];
+ #[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"),
+ formOptions: ['attr' => ['min' => 1, 'max' => 100]],
+ envVar: "int:TABLE_IMAGE_PREVIEW_MIN_SIZE", envVarMode: EnvVarMode::OVERWRITE
+ )]
+ #[Assert\Range(min: 1, max: 100)]
+ public int $previewImageMinWidth = 20;
+
+ #[SettingsParameter(label: new TM("settings.behavior.table.preview_image_max_width"),
+ formOptions: ['attr' => ['min' => 1, 'max' => 100]],
+ envVar: "int:TABLE_IMAGE_PREVIEW_MAX_SIZE", envVarMode: EnvVarMode::OVERWRITE
+ )]
+ #[Assert\Range(min: 1, max: 100)]
+ #[Assert\GreaterThanOrEqual(propertyPath: 'previewImageMinWidth')]
+ public int $previewImageMaxWidth = 35;
public static function mapPartsDefaultColumnsEnv(string $columns): array
{
@@ -87,4 +101,4 @@ class TableSettings
return $ret;
}
-}
\ No newline at end of file
+}
diff --git a/src/Settings/InfoProviderSystem/InfoProviderGeneralSettings.php b/src/Settings/InfoProviderSystem/InfoProviderGeneralSettings.php
new file mode 100644
index 00000000..fac6aae9
--- /dev/null
+++ b/src/Settings/InfoProviderSystem/InfoProviderGeneralSettings.php
@@ -0,0 +1,45 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Settings\InfoProviderSystem;
+
+use App\Form\InfoProviderSystem\ProviderSelectType;
+use App\Settings\SettingsIcon;
+use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
+use Jbtronics\SettingsBundle\ParameterTypes\StringType;
+use Jbtronics\SettingsBundle\Settings\Settings;
+use Jbtronics\SettingsBundle\Settings\SettingsParameter;
+use Symfony\Component\Translation\TranslatableMessage as TM;
+
+#[Settings(label: new TM("settings.ips.general"))]
+#[SettingsIcon("fa-magnifying-glass")]
+class InfoProviderGeneralSettings
+{
+ /**
+ * @var string[]
+ */
+ #[SettingsParameter(type: ArrayType::class, label: new TM("settings.ips.default_providers"),
+ description: new TM("settings.ips.default_providers.help"), options: ['type' => StringType::class],
+ formType: ProviderSelectType::class, formOptions: ['input' => 'string', 'required' => false, 'empty_data' => []])]
+ public array $defaultSearchProviders = [];
+}
diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php
index 3c7159cb..c223bd88 100644
--- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php
+++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php
@@ -25,6 +25,7 @@ namespace App\Settings\InfoProviderSystem;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
+use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
#[Settings()]
@@ -32,6 +33,9 @@ class InfoProviderSettings
{
use SettingsTrait;
+ #[EmbeddedSettings]
+ public ?InfoProviderGeneralSettings $general = null;
+
#[EmbeddedSettings]
public ?DigikeySettings $digikey = null;
@@ -58,4 +62,4 @@ class InfoProviderSettings
#[EmbeddedSettings]
public ?PollinSettings $pollin = null;
-}
\ No newline at end of file
+}
diff --git a/src/Settings/SystemSettings/CustomizationSettings.php b/src/Settings/SystemSettings/CustomizationSettings.php
index d7e92a51..623e6187 100644
--- a/src/Settings/SystemSettings/CustomizationSettings.php
+++ b/src/Settings/SystemSettings/CustomizationSettings.php
@@ -28,10 +28,13 @@ use App\Form\Type\ThemeChoiceType;
use App\Settings\SettingsIcon;
use App\Validator\Constraints\ValidTheme;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
+use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
+use Jbtronics\SettingsBundle\ParameterTypes\EnumType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Translation\TranslatableMessage as TM;
+use Symfony\Component\Validator\Constraints as Assert;
#[Settings(name: "customization", label: new TM("settings.system.customization"))]
#[SettingsIcon("fa-paint-roller")]
@@ -46,6 +49,13 @@ class CustomizationSettings
)]
public string $instanceName = "Part-DB";
+ #[SettingsParameter(
+ label: new TM("settings.system.customization.theme"),
+ formType: ThemeChoiceType::class, formOptions: ['placeholder' => false]
+ )]
+ #[ValidTheme]
+ public string $theme = 'bootstrap';
+
#[SettingsParameter(
label: new TM("settings.system.customization.banner"),
formType: RichTextEditorType::class, formOptions: ['mode' => 'markdown-full'],
@@ -53,10 +63,22 @@ class CustomizationSettings
)]
public ?string $banner = null;
- #[SettingsParameter(
- label: new TM("settings.system.customization.theme"),
- formType: ThemeChoiceType::class, formOptions: ['placeholder' => false]
+ /**
+ * @var HomepageItems[] The items to show in the sidebar.
+ */
+ #[SettingsParameter(ArrayType::class,
+ label: new TM("settings.behavior.hompepage.items"),
+ description: new TM("settings.behavior.homepage.items.help"),
+ options: ['type' => EnumType::class, 'options' => ['class' => HomepageItems::class]],
+ formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
+ formOptions: ['class' => HomepageItems::class, 'multiple' => true, 'ordered' => true]
)]
- #[ValidTheme]
- public string $theme = 'bootstrap';
+ #[Assert\NotBlank()]
+ #[Assert\Unique()]
+ public array $homepageitems = [HomepageItems::SEARCH, HomepageItems::BANNER, HomepageItems::FIRST_STEPS, HomepageItems::LICENSE, HomepageItems::LAST_ACTIVITY];
+
+ #[SettingsParameter(
+ label: new TM("settings.system.customization.showVersionOnHomepage")
+ )]
+ public bool $showVersionOnHomepage = true;
}
diff --git a/src/Settings/SystemSettings/HomepageItems.php b/src/Settings/SystemSettings/HomepageItems.php
new file mode 100644
index 00000000..7366dfa2
--- /dev/null
+++ b/src/Settings/SystemSettings/HomepageItems.php
@@ -0,0 +1,51 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Settings\SystemSettings;
+
+use Symfony\Contracts\Translation\TranslatableInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+use function Symfony\Component\Translation\t;
+
+enum HomepageItems: string implements TranslatableInterface
+{
+ case SEARCH = 'search';
+ case BANNER = 'banner';
+ case LICENSE = 'license';
+ case FIRST_STEPS = 'first_steps';
+ case LAST_ACTIVITY = 'last_activity';
+
+ public function trans(TranslatorInterface $translator, ?string $locale = null): string
+ {
+ $key = match($this) {
+ self::SEARCH => 'search.placeholder',
+ self::BANNER => 'settings.system.customization.banner',
+ self::LICENSE => 'homepage.license',
+ self::FIRST_STEPS => 'homepage.first_steps.title',
+ self::LAST_ACTIVITY => 'homepage.last_activity',
+ };
+
+ return $translator->trans($key, locale: $locale);
+ }
+}
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/base.html.twig b/templates/base.html.twig
index 48e45ab0..ee79549b 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -53,6 +53,14 @@
{% endif %}
{{ encore_entry_link_tags('app') }}
+
+ {% set table_settings = settings_instance('table') %}
+
{% endblock %}
{% block javascripts %}
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/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig
index 5ce0f23f..009f815e 100644
--- a/templates/components/datatables.macro.html.twig
+++ b/templates/components/datatables.macro.html.twig
@@ -29,7 +29,7 @@
-
+
{#
#}
@@ -95,4 +95,4 @@
-{% endmacro %}
\ No newline at end of file
+{% endmacro %}
diff --git a/templates/homepage.html.twig b/templates/homepage.html.twig
index 3f820a53..6e7aa360 100644
--- a/templates/homepage.html.twig
+++ b/templates/homepage.html.twig
@@ -4,26 +4,23 @@
{% import "components/search.macro.html.twig" as search %}
{% import "vars.macro.twig" as vars %}
-{% block content %}
-
- {% if is_granted('@system.show_updates') %}
- {{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
- {% endif %}
-
+{% block item_search %}
{% if is_granted('@parts.read') %}
{{ search.search_form("standalone") }}
-
{% endif %}
+{% endblock %}
-
+{% block item_banner %}
{{ vars.partdb_title() }}
-
- {% trans %}version.caption{% endtrans %}: {{ shivas_app_version }}
- {% if git_branch is not empty or git_commit is not empty %}
- ({{ git_branch ?? '' }}/{{ git_commit ?? '' }})
- {% endif %}
-
+ {% if settings_instance('customization').showVersionOnHomepage %}
+
+ {% trans %}version.caption{% endtrans %}: {{ shivas_app_version }}
+ {% if git_branch is not empty or git_commit is not empty %}
+ ({{ git_branch ?? '' }}/{{ git_commit ?? '' }})
+ {% endif %}
+
+ {% endif %}
{% if banner is not empty %}
@@ -31,9 +28,11 @@
{% endif %}
+{% endblock %}
+{% block item_first_steps %}
{% if show_first_steps %}
-
{% endif %}
+{% endblock %}
-
+{% block item_license %}
+
@@ -68,9 +69,11 @@
{% trans %}homepage.forum.caption{% endtrans %}: {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server/discussions'}%}homepage.forum.text{% endtrans %}
+{% endblock %}
+{% block item_last_activity %}
{% if datatable is not null %}
-
+
{% import "components/history_log_macros.html.twig" as log %}
@@ -78,4 +81,23 @@
{% endif %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
+
+{% block content %}
+
+ {% if is_granted('@system.show_updates') %}
+ {{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
+ {% endif %}
+
+ {% for item in settings_instance('customization').homepageitems %}
+ {% if block('item_' ~ item.value) is defined %}
+ {{ block('item_' ~ item.value) }}
+
+ {% else %}
+
+ Alert: The homepage item "{{ item.value }}" is not defined!
+
+ {% endif %}
+ {% endfor %}
+
+{% 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 @@
{% if provider.providerInfo.settings_class is defined %}
-
{% endif %}
diff --git a/templates/label_system/dialog.html.twig b/templates/label_system/dialog.html.twig
index 50db99e7..037b549e 100644
--- a/templates/label_system/dialog.html.twig
+++ b/templates/label_system/dialog.html.twig
@@ -100,6 +100,10 @@
{% endif %}
+ {% if form.update_profile is defined %}
+ {{ form_row(form.update_profile) }}
+ {% endif %}
+