diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 2e0d533c..1eff846e 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -98,7 +98,7 @@ jobs: - name: Upload digest if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: digests-${{ matrix.platform-slug }} path: /tmp/digests/* @@ -113,7 +113,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: /tmp/digests pattern: digests-* diff --git a/.github/workflows/docker_frankenphp.yml b/.github/workflows/docker_frankenphp.yml index 10e62dfc..8acb5c22 100644 --- a/.github/workflows/docker_frankenphp.yml +++ b/.github/workflows/docker_frankenphp.yml @@ -99,7 +99,7 @@ jobs: - name: Upload digest if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: digests-${{ matrix.platform-slug }} path: /tmp/digests/* @@ -114,7 +114,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: /tmp/digests pattern: digests-* diff --git a/assets/controllers/common/toast_controller.js b/assets/controllers/common/toast_controller.js index 36b7f3cc..196692fb 100644 --- a/assets/controllers/common/toast_controller.js +++ b/assets/controllers/common/toast_controller.js @@ -20,6 +20,10 @@ import { Controller } from '@hotwired/stimulus'; import { Toast } from 'bootstrap'; +/** + * The purpose of this controller, is to show all containers. + * They should already be added via turbo-streams, but have to be called for to show them. + */ export default class extends Controller { connect() { //Move all toasts from the page into our toast container and show them @@ -33,4 +37,4 @@ export default class extends Controller { const toast = new Toast(this.element); toast.show(); } -} \ No newline at end of file +} diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index 200dd2a7..ae51e951 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus"; //import * as ZXing from "@zxing/library"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode"; +import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller"; /* stimulusFetch: 'lazy' */ + export default class extends Controller { - - //codeReader = null; - _scanner = null; - + _submitting = false; + _lastDecodedText = ""; + _onInfoChange = null; connect() { - console.log('Init Scanner'); + + // Prevent double init if connect fires twice + if (this._scanner) return; + + // clear last decoded barcode when state changes on info box + const info = document.getElementById("scan_dialog_info_mode"); + if (info) { + this._onInfoChange = () => { + this._lastDecodedText = ""; + }; + info.addEventListener("change", this._onInfoChange); + } + + const isMobile = window.matchMedia("(max-width: 768px)").matches; //This function ensures, that the qrbox is 70% of the total viewport let qrboxFunction = function(viewfinderWidth, viewfinderHeight) { @@ -45,29 +59,61 @@ export default class extends Controller { } //Try to get the number of cameras. If the number is 0, then the promise will fail, and we show the warning dialog - Html5Qrcode.getCameras().catch((devices) => { - document.getElementById('scanner-warning').classList.remove('d-none'); + Html5Qrcode.getCameras().catch(() => { + document.getElementById("scanner-warning")?.classList.remove("d-none"); }); this._scanner = new Html5QrcodeScanner(this.element.id, { fps: 10, qrbox: qrboxFunction, + // Key change: shrink preview height on mobile + ...(isMobile ? { aspectRatio: 1.0 } : {}), experimentalFeatures: { //This option improves reading quality on android chrome - useBarCodeDetectorIfSupported: true - } + useBarCodeDetectorIfSupported: true, + }, }, false); this._scanner.render(this.onScanSuccess.bind(this)); } disconnect() { - this._scanner.pause(); - this._scanner.clear(); + + // If we already stopped/cleared before submit, nothing to do. + const scanner = this._scanner; + this._scanner = null; + this._lastDecodedText = ""; + + // Unbind info-mode change handler (always do this, even if scanner is null) + const info = document.getElementById("scan_dialog_info_mode"); + if (info && this._onInfoChange) { + info.removeEventListener("change", this._onInfoChange); + } + this._onInfoChange = null; + + if (!scanner) return; + + try { + const p = scanner.clear?.(); + if (p && typeof p.then === "function") p.catch(() => {}); + } catch (_) { + // ignore + } } - onScanSuccess(decodedText, decodedResult) { - //Put our decoded Text into the input box + + onScanSuccess(decodedText) { + if (!decodedText) return; + + const normalized = String(decodedText).trim(); + if (!normalized) return; + + // scan once per barcode + if (normalized === this._lastDecodedText) return; + + // Mark as handled immediately (prevents spam even if callback fires repeatedly) + this._lastDecodedText = normalized; + document.getElementById('scan_dialog_input').value = decodedText; //Submit form document.getElementById('scan_dialog_form').requestSubmit(); diff --git a/assets/controllers/turbo/global_reload_controller.js b/assets/controllers/turbo/global_reload_controller.js deleted file mode 100644 index ce8a6c72..00000000 --- a/assets/controllers/turbo/global_reload_controller.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - connect() { - //If we encounter an element with global reload controller, then reload the whole page - window.location.reload(); - } -} \ No newline at end of file diff --git a/assets/controllers/turbo/locale_menu_controller.js b/assets/controllers/turbo/locale_menu_controller.js deleted file mode 100644 index d55ff8da..00000000 --- a/assets/controllers/turbo/locale_menu_controller.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - connect() { - const menu = document.getElementById('locale-select-menu'); - menu.innerHTML = this.element.innerHTML; - } -} \ No newline at end of file diff --git a/assets/controllers/turbo/title_controller.js b/assets/controllers/turbo/title_controller.js deleted file mode 100644 index 6bbebdf7..00000000 --- a/assets/controllers/turbo/title_controller.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - connect() { - //If we encounter an element with this, then change the title of our document according to data-title - this.changeTitle(this.element.dataset.title); - } - - changeTitle(title) { - document.title = title; - } -} \ No newline at end of file diff --git a/assets/css/app/images.css b/assets/css/app/images.css index 0212a85b..7fa23a9e 100644 --- a/assets/css/app/images.css +++ b/assets/css/app/images.css @@ -58,6 +58,12 @@ object-fit: contain; } +@media (max-width: 768px) { + .part-info-image { + max-height: 100px; + } +} + .object-fit-cover { object-fit: cover; } diff --git a/composer.lock b/composer.lock index 2db828a5..1daf67b5 100644 --- a/composer.lock +++ b/composer.lock @@ -968,7 +968,7 @@ }, { "name": "api-platform/doctrine-common", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-common.git", @@ -1052,13 +1052,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-common/tree/v4.2.16" + "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.0-alpha.1" }, "time": "2026-02-13T15:07:33+00:00" }, { "name": "api-platform/doctrine-orm", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-orm.git", @@ -1139,13 +1139,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-orm/tree/v4.2.16" + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.2.17" }, "time": "2026-02-13T17:30:49+00:00" }, { "name": "api-platform/documentation", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/documentation.git", @@ -1202,13 +1202,13 @@ ], "description": "API Platform documentation controller.", "support": { - "source": "https://github.com/api-platform/documentation/tree/v4.2.16" + "source": "https://github.com/api-platform/documentation/tree/v4.3.0-alpha.1" }, "time": "2025-12-27T22:15:57+00:00" }, { "name": "api-platform/http-cache", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/http-cache.git", @@ -1282,22 +1282,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/http-cache/tree/v4.2.16" + "source": "https://github.com/api-platform/http-cache/tree/v4.3.0-alpha.1" }, "time": "2026-02-13T15:07:33+00:00" }, { "name": "api-platform/hydra", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/hydra.git", - "reference": "ddba613f615caa8372df3d478a36a910b77f6d28" + "reference": "392c44574a7746de03564d709c4eb5c652509440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/hydra/zipball/ddba613f615caa8372df3d478a36a910b77f6d28", - "reference": "ddba613f615caa8372df3d478a36a910b77f6d28", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/392c44574a7746de03564d709c4eb5c652509440", + "reference": "392c44574a7746de03564d709c4eb5c652509440", "shasum": "" }, "require": { @@ -1369,13 +1369,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/hydra/tree/v4.2.16" + "source": "https://github.com/api-platform/hydra/tree/v4.2.17" }, - "time": "2026-02-13T15:07:33+00:00" + "time": "2026-02-17T13:24:02+00:00" }, { "name": "api-platform/json-api", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/json-api.git", @@ -1451,22 +1451,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/json-api/tree/v4.2.16" + "source": "https://github.com/api-platform/json-api/tree/v4.2.17" }, "time": "2026-02-13T17:30:49+00:00" }, { "name": "api-platform/json-schema", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/json-schema.git", - "reference": "3569ab8e3e5c01d77f00964683254809571fa078" + "reference": "de96f482b6dcf913a2849a71ae102d2e45a61b52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-schema/zipball/3569ab8e3e5c01d77f00964683254809571fa078", - "reference": "3569ab8e3e5c01d77f00964683254809571fa078", + "url": "https://api.github.com/repos/api-platform/json-schema/zipball/de96f482b6dcf913a2849a71ae102d2e45a61b52", + "reference": "de96f482b6dcf913a2849a71ae102d2e45a61b52", "shasum": "" }, "require": { @@ -1532,13 +1532,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/json-schema/tree/v4.2.16" + "source": "https://github.com/api-platform/json-schema/tree/v4.2.17" }, - "time": "2026-02-13T15:07:33+00:00" + "time": "2026-02-20T20:33:01+00:00" }, { "name": "api-platform/jsonld", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/jsonld.git", @@ -1612,13 +1612,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/jsonld/tree/v4.2.16" + "source": "https://github.com/api-platform/jsonld/tree/v4.2.17" }, "time": "2026-02-13T17:30:49+00:00" }, { "name": "api-platform/metadata", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", @@ -1710,13 +1710,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/metadata/tree/v4.2.16" + "source": "https://github.com/api-platform/metadata/tree/v4.2.17" }, "time": "2026-02-13T15:07:33+00:00" }, { "name": "api-platform/openapi", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/openapi.git", @@ -1800,22 +1800,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/openapi/tree/v4.2.16" + "source": "https://github.com/api-platform/openapi/tree/v4.2.17" }, "time": "2026-01-26T15:38:30+00:00" }, { "name": "api-platform/serializer", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/serializer.git", - "reference": "e01024d458c26d230eafbe8ac79dc8e28c3dc379" + "reference": "f021a31e6a409e8c3fd24bee1e5e183b995f9137" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/serializer/zipball/e01024d458c26d230eafbe8ac79dc8e28c3dc379", - "reference": "e01024d458c26d230eafbe8ac79dc8e28c3dc379", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/f021a31e6a409e8c3fd24bee1e5e183b995f9137", + "reference": "f021a31e6a409e8c3fd24bee1e5e183b995f9137", "shasum": "" }, "require": { @@ -1893,22 +1893,22 @@ "serializer" ], "support": { - "source": "https://github.com/api-platform/serializer/tree/v4.2.16" + "source": "https://github.com/api-platform/serializer/tree/v4.2.17" }, - "time": "2026-02-13T17:30:49+00:00" + "time": "2026-02-20T09:35:37+00:00" }, { "name": "api-platform/state", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/state.git", - "reference": "0fcd612696acac4632a626bb5dfc6bd99ec3b44a" + "reference": "1b6f69c75579ab0f132cd45e45d5f43ed19a15a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/state/zipball/0fcd612696acac4632a626bb5dfc6bd99ec3b44a", - "reference": "0fcd612696acac4632a626bb5dfc6bd99ec3b44a", + "url": "https://api.github.com/repos/api-platform/state/zipball/1b6f69c75579ab0f132cd45e45d5f43ed19a15a5", + "reference": "1b6f69c75579ab0f132cd45e45d5f43ed19a15a5", "shasum": "" }, "require": { @@ -1990,22 +1990,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/state/tree/v4.2.16" + "source": "https://github.com/api-platform/state/tree/v4.2.17" }, - "time": "2026-02-13T15:07:33+00:00" + "time": "2026-02-17T09:18:17+00:00" }, { "name": "api-platform/symfony", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/symfony.git", - "reference": "769f5bc29ce59a5c68006ca5876c409072340e92" + "reference": "04052b61e26a1c059bb595b78670302ad4517b7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/symfony/zipball/769f5bc29ce59a5c68006ca5876c409072340e92", - "reference": "769f5bc29ce59a5c68006ca5876c409072340e92", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/04052b61e26a1c059bb595b78670302ad4517b7f", + "reference": "04052b61e26a1c059bb595b78670302ad4517b7f", "shasum": "" }, "require": { @@ -2118,13 +2118,13 @@ "symfony" ], "support": { - "source": "https://github.com/api-platform/symfony/tree/v4.2.16" + "source": "https://github.com/api-platform/symfony/tree/v4.2.17" }, - "time": "2026-02-13T17:30:49+00:00" + "time": "2026-02-18T14:55:07+00:00" }, { "name": "api-platform/validator", - "version": "v4.2.16", + "version": "v4.2.17", "source": { "type": "git", "url": "https://github.com/api-platform/validator.git", @@ -2194,7 +2194,7 @@ "validator" ], "support": { - "source": "https://github.com/api-platform/validator/tree/v4.2.16" + "source": "https://github.com/api-platform/validator/tree/v4.2.17" }, "time": "2026-01-26T15:45:40+00:00" }, @@ -9500,33 +9500,35 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.1.0", + "version": "v9.2.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" + "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", - "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/59373045e11ad47b5c18fc615feee0219e42f6d3", + "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3", "shasum": "" }, "require": { "ext-iconv": "*", "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.28 || 2.1.25", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", - "phpunit/phpunit": "8.5.46", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.1.7", - "rector/type-perfect": "1.0.0 || 2.1.0" + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -9534,10 +9536,14 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "9.3.x-dev" } }, "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], "psr-4": { "Sabberworm\\CSS\\": "src/" } @@ -9568,9 +9574,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.2.0" }, - "time": "2025-09-14T07:37:21+00:00" + "time": "2026-02-21T17:12:03+00:00" }, { "name": "sabre/uri", @@ -17669,16 +17675,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.3", + "version": "2.1.5", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46" + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", - "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", "shasum": "" }, "require": { @@ -17725,9 +17731,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.3" + "source": "https://github.com/webmozarts/assert/tree/2.1.5" }, - "time": "2026-02-13T21:01:40+00:00" + "time": "2026-02-18T14:09:36+00:00" }, { "name": "willdurand/negotiation", @@ -18417,16 +18423,16 @@ }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.16", + "version": "2.0.17", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "f4ff6084a26d91174b3f0b047589af293a893104" + "reference": "734ef36c2709b51943f04aacadddb76f239562d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/f4ff6084a26d91174b3f0b047589af293a893104", - "reference": "f4ff6084a26d91174b3f0b047589af293a893104", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/734ef36c2709b51943f04aacadddb76f239562d3", + "reference": "734ef36c2709b51943f04aacadddb76f239562d3", "shasum": "" }, "require": { @@ -18487,9 +18493,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.16" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.17" }, - "time": "2026-02-11T08:54:45+00:00" + "time": "2026-02-18T10:21:23+00:00" }, { "name": "phpstan/phpstan-strict-rules", @@ -18965,16 +18971,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.53", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607", - "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -19047,7 +19053,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.53" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -19071,20 +19077,20 @@ "type": "tidelift" } ], - "time": "2026-02-10T12:28:25+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "rector/rector", - "version": "2.3.6", + "version": "2.3.8", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b" + "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/ca9ebb81d280cd362ea39474dabd42679e32ca6b", - "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", "shasum": "" }, "require": { @@ -19123,7 +19129,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.6" + "source": "https://github.com/rectorphp/rector/tree/2.3.8" }, "funding": [ { @@ -19131,7 +19137,7 @@ "type": "github" } ], - "time": "2026-02-06T14:25:06+00:00" + "time": "2026-02-22T09:45:50+00:00" }, { "name": "roave/security-advisories", @@ -19139,12 +19145,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "7f3e95c9ebf1b16e002dd2c913d30d962c2a6a16" + "reference": "92c5ec5685cfbcd7ef721a502e6622516728011c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/7f3e95c9ebf1b16e002dd2c913d30d962c2a6a16", - "reference": "7f3e95c9ebf1b16e002dd2c913d30d962c2a6a16", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/92c5ec5685cfbcd7ef721a502e6622516728011c", + "reference": "92c5ec5685cfbcd7ef721a502e6622516728011c", "shasum": "" }, "conflict": { @@ -19312,6 +19318,7 @@ "devgroup/dotplant": "<2020.09.14-dev", "digimix/wp-svg-upload": "<=1", "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", + "directorytree/imapengine": "<1.22.3", "dl/yag": "<3.0.1", "dmk/webkitpdf": "<1.1.4", "dnadesign/silverstripe-elemental": "<5.3.12", @@ -19414,7 +19421,7 @@ "filegator/filegator": "<7.8", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", - "firebase/php-jwt": "<6", + "firebase/php-jwt": "<7", "fisharebest/webtrees": "<=2.1.18", "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", "fixpunkt/fp-newsletter": "<1.1.1|>=1.2,<2.1.2|>=2.2,<3.2.6", @@ -19452,7 +19459,7 @@ "genix/cms": "<=1.1.11", "georgringer/news": "<1.3.3", "geshi/geshi": "<=1.0.9.1", - "getformwork/formwork": "<2.2", + "getformwork/formwork": "<=2.3.3", "getgrav/grav": "<1.11.0.0-beta1", "getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<=5.2.1", "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", @@ -19575,7 +19582,7 @@ "leantime/leantime": "<3.3", "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", "libreform/libreform": ">=2,<=2.0.8", - "librenms/librenms": "<25.12", + "librenms/librenms": "<26.2", "liftkit/database": "<2.13.2", "lightsaml/lightsaml": "<1.3.5", "limesurvey/limesurvey": "<6.5.12", @@ -19783,7 +19790,7 @@ "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", "propel/propel1": ">=1,<=1.7.1", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", - "pterodactyl/panel": "<1.12", + "pterodactyl/panel": "<1.12.1", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", @@ -19886,7 +19893,7 @@ "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.73.6|>=6,<6.2.5", + "statamic/cms": "<5.73.9|>=6,<6.3.2", "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.64", "studiomitte/friendlycaptcha": "<0.1.4", @@ -20058,7 +20065,7 @@ "wpanel/wpanel4-cms": "<=4.3.1", "wpcloud/wp-stateless": "<3.2", "wpglobus/wpglobus": "<=1.9.6", - "wwbn/avideo": "<14.3", + "wwbn/avideo": "<21", "xataface/xataface": "<3", "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", @@ -20117,7 +20124,8 @@ "zf-commons/zfc-user": "<1.2.2", "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", "zfr/zfr-oauth2-server-module": "<0.1.2", - "zoujingli/thinkadmin": "<=6.1.53" + "zoujingli/thinkadmin": "<=6.1.53", + "zumba/json-serializer": "<3.2.3" }, "default-branch": true, "type": "metapackage", @@ -20155,7 +20163,7 @@ "type": "tidelift" } ], - "time": "2026-02-13T23:11:21+00:00" + "time": "2026-02-20T22:06:39+00:00" }, { "name": "sebastian/cli-parser", diff --git a/src/Controller/ScanController.php b/src/Controller/ScanController.php index aebadd89..65eccf27 100644 --- a/src/Controller/ScanController.php +++ b/src/Controller/ScanController.php @@ -41,11 +41,16 @@ declare(strict_types=1); namespace App\Controller; +use App\Exceptions\InfoProviderNotActiveException; use App\Form\LabelSystem\ScanDialogType; -use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; +use App\Services\InfoProviderSystem\Providers\LCSCProvider; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use Doctrine\ORM\EntityNotFoundException; use InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\Routing\Attribute\Route; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use App\Entity\Parts\Part; +use \App\Entity\Parts\StorageLocation; +use Symfony\UX\Turbo\TurboBundle; /** * @see \App\Tests\Controller\ScanControllerTest @@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route; #[Route(path: '/scan')] class ScanController extends AbstractController { - public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer) - { - } + public function __construct( + protected BarcodeScanResultHandler $resultHandler, + protected BarcodeScanHelper $barcodeNormalizer, + ) {} #[Route(path: '', name: 'scan_dialog')] public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response @@ -72,35 +85,86 @@ class ScanController extends AbstractController $form = $this->createForm(ScanDialogType::class); $form->handleRequest($request); + // If JS is working, scanning uses /scan/lookup and this action just renders the page. + // This fallback only runs if user submits the form manually or uses ?input=... if ($input === null && $form->isSubmitted() && $form->isValid()) { $input = $form['input']->getData(); - $mode = $form['mode']->getData(); } - $infoModeData = null; - if ($input !== null) { + if ($input !== null && $input !== '') { + $mode = $form->isSubmitted() ? $form['mode']->getData() : null; + $infoMode = $form->isSubmitted() && $form['info_mode']->getData(); + try { - $scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); - //Perform a redirect if the info mode is not enabled - if (!$form['info_mode']->getData()) { - try { - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); - } catch (EntityNotFoundException) { - $this->addFlash('success', 'scan.qr_not_found'); + $scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); + + // If not in info mode, mimic “normal scan” behavior: redirect if possible. + if (!$infoMode) { + + // Try to get an Info URL if possible + $url = $this->resultHandler->getInfoURL($scan); + if ($url !== null) { + return $this->redirect($url); + } + + //Try to get an creation URL if possible (only for vendor codes) + $createUrl = $this->buildCreateUrlForScanResult($scan); + if ($createUrl !== null) { + return $this->redirect($createUrl); + } + + //// Otherwise: show “not found” (not “format unknown”) + $this->addFlash('warning', 'scan.qr_not_found'); + } else { // Info mode + // Info mode fallback: render page with prefilled result + $decoded = $scan->getDecodedForInfoMode(); + + //Try to resolve to an entity, to enhance info mode with entity-specific data + $dbEntity = $this->resultHandler->resolveEntity($scan); + $resolvedPart = $this->resultHandler->resolvePart($scan); + $openUrl = $this->resultHandler->getInfoURL($scan); + + //If no entity is found, try to create an URL for creating a new part (only for vendor codes) + $createUrl = null; + if ($dbEntity === null) { + $createUrl = $this->buildCreateUrlForScanResult($scan); + } + + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [ + 'decoded' => $decoded, + 'entity' => $dbEntity, + 'part' => $resolvedPart, + 'openUrl' => $openUrl, + 'createUrl' => $createUrl, + ]); } - } else { //Otherwise retrieve infoModeData - $infoModeData = $scan_result->getDecodedForInfoMode(); } - } catch (InvalidArgumentException) { - $this->addFlash('error', 'scan.format_unknown'); + } catch (\Throwable $e) { + // Keep fallback user-friendly; avoid 500 + $this->addFlash('warning', 'scan.format_unknown'); } } + //When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload + if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + //Only send our flash message, so the client can show it without a full page reload + return $this->renderBlock('_turbo_control.html.twig', 'flashes'); + } + return $this->render('label_system/scanner/scanner.html.twig', [ 'form' => $form, - 'infoModeData' => $infoModeData, + + //Info mode + 'decoded' => $decoded ?? null, + 'entity' => $dbEntity ?? null, + 'part' => $resolvedPart ?? null, + 'openUrl' => $openUrl ?? null, + 'createUrl' => $createUrl ?? null, ]); } @@ -125,11 +189,30 @@ class ScanController extends AbstractController source_type: BarcodeSourceType::INTERNAL ); - return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); + return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found")); } catch (EntityNotFoundException) { $this->addFlash('success', 'scan.qr_not_found'); return $this->redirectToRoute('homepage'); } } + + /** + * Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation. + * @param BarcodeScanResultInterface $scanResult + * @return string|null + */ + private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string + { + try { + return $this->resultHandler->getCreationURL($scanResult); + } catch (InfoProviderNotActiveException $e) { + $this->addFlash('error', $e->getMessage()); + } catch (\Throwable) { + // Don’t break scanning UX if provider lookup fails + $this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.'); + } + + return null; + } } diff --git a/src/Exceptions/InfoProviderNotActiveException.php b/src/Exceptions/InfoProviderNotActiveException.php new file mode 100644 index 00000000..02f7cfb7 --- /dev/null +++ b/src/Exceptions/InfoProviderNotActiveException.php @@ -0,0 +1,48 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Exceptions; + +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; + +/** + * An exception denoting that a required info provider is not active. This can be used to display a user-friendly error message, + * when a user tries to use an info provider that is not active. + */ +class InfoProviderNotActiveException extends \RuntimeException +{ + public function __construct(public readonly string $providerKey, public readonly string $friendlyName) + { + parent::__construct(sprintf('The info provider "%s" (%s) is not active.', $this->friendlyName, $this->providerKey)); + } + + /** + * Creates an instance of this exception from an info provider instance + * @param InfoProviderInterface $provider + * @return self + */ + public static function fromProvider(InfoProviderInterface $provider): self + { + return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???'); + } +} diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index 9199c31d..d9c1de0e 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -77,6 +77,7 @@ class ScanDialogType extends AbstractType BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', + BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc', }, ]); diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 9d5fee5e..49342301 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -389,4 +389,69 @@ class PartRepository extends NamedDBElementRepository return $baseIpn . '_' . ($maxSuffix + 1); } + /** + * Finds a part based on the provided info provider key and ID, with an option for case sensitivity. + * If no part is found with the given provider key and ID, null is returned. + * @param string $providerID + * @param string|null $providerKey If null, the provider key will not be included in the search criteria, and only the provider ID will be used for matching. + * @param bool $caseInsensitive If true, the provider ID comparison will be case-insensitive. Default is true. + * @return Part|null + */ + public function getPartByProviderInfo(string $providerID, ?string $providerKey = null, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + if ($providerKey) { + $qb->where("part.providerReference.provider_key = :providerKey"); + $qb->setParameter('providerKey', $providerKey); + } + + + if ($caseInsensitive) { + $qb->andWhere("LOWER(part.providerReference.provider_id) = LOWER(:providerID)"); + } else { + $qb->andWhere("part.providerReference.provider_id = :providerID"); + } + + $qb->setParameter('providerID', $providerID); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Finds a part based on the provided MPN (Manufacturer Part Number), with an option for case sensitivity. + * If no part is found with the given MPN, null is returned. + * @param string $mpn + * @param string|null $manufacturerName If provided, the search will also include a match for the manufacturer's name. If null, the manufacturer name will not be included in the search criteria. + * @param bool $caseInsensitive If true, the MPN comparison will be case-insensitive. Default is true (case-insensitive). + * @return Part|null + */ + public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + if ($caseInsensitive) { + $qb->where("LOWER(part.manufacturer_product_number) = LOWER(:mpn)"); + } else { + $qb->where("part.manufacturer_product_number = :mpn"); + } + + if ($manufacturerName !== null) { + $qb->leftJoin('part.manufacturer', 'manufacturer'); + + if ($caseInsensitive) { + $qb->andWhere("LOWER(manufacturer.name) = LOWER(:manufacturerName)"); + } else { + $qb->andWhere("manufacturer.name = :manufacturerName"); + } + $qb->setParameter('manufacturerName', $manufacturerName); + } + + $qb->setParameter('mpn', $mpn); + + return $qb->getQuery()->getOneOrNullResult(); + } + } diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 9a24f3ae..5cc23f05 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -24,10 +24,15 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem; use App\Entity\Parts\Part; +use App\Exceptions\InfoProviderNotActiveException; +use App\Exceptions\OAuthReconnectRequiredException; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -49,6 +54,11 @@ final class PartInfoRetriever * @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances * @param string $keyword The keyword to search for * @return SearchResultDTO[] The search results + * @throws InfoProviderNotActiveException if any of the given providers is not active + * @throws ClientException if any of the providers throws an exception during the search + * @throws \InvalidArgumentException if any of the given providers is not a valid provider key or instance + * @throws TransportException if any of the providers throws an exception during the search + * @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed */ public function searchByKeyword(string $keyword, array $providers): array { @@ -61,7 +71,7 @@ final class PartInfoRetriever //Ensure that the provider is active if (!$provider->isActive()) { - throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!"); + throw InfoProviderNotActiveException::fromProvider($provider); } if (!$provider instanceof InfoProviderInterface) { @@ -97,6 +107,7 @@ final class PartInfoRetriever * @param string $provider_key * @param string $part_id * @return PartDetailDTO + * @throws InfoProviderNotActiveException if the the given providers is not active */ public function getDetails(string $provider_key, string $part_id): PartDetailDTO { @@ -104,7 +115,7 @@ final class PartInfoRetriever //Ensure that the provider is active if (!$provider->isActive()) { - throw new \RuntimeException("The provider with key $provider_key is not active!"); + throw InfoProviderNotActiveException::fromProvider($provider); } //Generate key and escape reserved characters from the provider id diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php deleted file mode 100644 index 1a3c29c2..00000000 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php +++ /dev/null @@ -1,180 +0,0 @@ -. - */ - -declare(strict_types=1); - -/** - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace App\Services\LabelSystem\BarcodeScanner; - -use App\Entity\LabelSystem\LabelSupportedElement; -use App\Entity\Parts\Manufacturer; -use App\Entity\Parts\Part; -use App\Entity\Parts\PartLot; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityNotFoundException; -use InvalidArgumentException; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; - -/** - * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest - */ -final class BarcodeRedirector -{ - public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em) - { - } - - /** - * Determines the URL to which the user should be redirected, when scanning a QR code. - * - * @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan - * @return string the URL to which should be redirected - * - * @throws EntityNotFoundException - */ - public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string - { - if($barcodeScan instanceof LocalBarcodeScanResult) { - return $this->getURLLocalBarcode($barcodeScan); - } - - if ($barcodeScan instanceof EIGP114BarcodeScanResult) { - return $this->getURLVendorBarcode($barcodeScan); - } - - if ($barcodeScan instanceof GTINBarcodeScanResult) { - return $this->getURLGTINBarcode($barcodeScan); - } - - throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan)); - } - - private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string - { - switch ($barcodeScan->target_type) { - case LabelSupportedElement::PART: - return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]); - case LabelSupportedElement::PART_LOT: - //Try to determine the part to the given lot - $lot = $this->em->find(PartLot::class, $barcodeScan->target_id); - if (!$lot instanceof PartLot) { - throw new EntityNotFoundException(); - } - - return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]); - - case LabelSupportedElement::STORELOCATION: - return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]); - - default: - throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name); - } - } - - /** - * Gets the URL to a part from a scan of a Vendor Barcode - */ - private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string - { - $part = $this->getPartFromVendor($barcodeScan); - return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); - } - - private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string - { - $part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); - if (!$part instanceof Part) { - throw new EntityNotFoundException(); - } - - return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]); - } - - /** - * Gets a part from a scan of a Vendor Barcode by filtering for parts - * with the same Info Provider Id or, if that fails, by looking for parts with a - * matching manufacturer product number. Only returns the first matching part. - */ - private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part - { - // first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via - // the info provider system or if the part was bought from a different vendor than the data was retrieved - // from. - if($barcodeScan->digikeyPartNumber) { - $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part'); - //Lower() to be case insensitive - $qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)')); - $qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber); - $results = $qb->getQuery()->getResult(); - if ($results) { - return $results[0]; - } - } - - if(!$barcodeScan->supplierPartNumber){ - throw new EntityNotFoundException(); - } - - //Fallback to the manufacturer part number. This may return false positives, since it is common for - //multiple manufacturers to use the same part number for their version of a common product - //We assume the user is able to realize when this returns the wrong part - //If the barcode specifies the manufacturer we try to use that as well - $mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part'); - $mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)')); - $mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber); - - if($barcodeScan->mouserManufacturer){ - $manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer"); - $manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)")); - $manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer); - $manufacturers = $manufacturerQb->getQuery()->getResult(); - - if($manufacturers) { - $mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer")); - $mpnQb->setParameter("manufacturer", $manufacturers); - } - - } - - $results = $mpnQb->getQuery()->getResult(); - if($results){ - return $results[0]; - } - throw new EntityNotFoundException(); - } -} diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index 520c9f3b..b2363ec8 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -92,10 +92,15 @@ final class BarcodeScanHelper if ($type === BarcodeSourceType::EIGP114) { return $this->parseEIGP114Barcode($input); } + if ($type === BarcodeSourceType::GTIN) { return $this->parseGTINBarcode($input); } + if ($type === BarcodeSourceType::LCSC) { + return $this->parseLCSCBarcode($input); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -125,6 +130,11 @@ final class BarcodeScanHelper return $this->parseGTINBarcode($input); } + // Try LCSC barcode + if (LCSCBarcodeScanResult::isLCSCBarcode($input)) { + return $this->parseLCSCBarcode($input); + } + throw new InvalidArgumentException('Unknown barcode'); } @@ -138,6 +148,11 @@ final class BarcodeScanHelper return EIGP114BarcodeScanResult::parseFormat06Code($input); } + private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult + { + return LCSCBarcodeScanResult::parse($input); + } + private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult { $lot_repo = $this->entityManager->getRepository(PartLot::class); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php new file mode 100644 index 00000000..372e976e --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -0,0 +1,315 @@ +. + */ + +declare(strict_types=1); + +/** + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace App\Services\LabelSystem\BarcodeScanner; + +use App\Entity\LabelSystem\LabelSupportedElement; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; +use App\Exceptions\InfoProviderNotActiveException; +use App\Repository\Parts\PartRepository; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityNotFoundException; +use InvalidArgumentException; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * This class handles the result of a barcode scan and determines further actions, like which URL the user should be redirected to. + * + * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest + */ +final readonly class BarcodeScanResultHandler +{ + public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em, private PartInfoRetriever $infoRetriever, + private ProviderRegistry $providerRegistry) + { + } + + /** + * Determines the URL to which the user should be redirected, when scanning a QR code. + * + * @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan + * @return string|null the URL to which should be redirected, or null if no suitable URL could be determined for the given barcode scan result + */ + public function getInfoURL(BarcodeScanResultInterface $barcodeScan): ?string + { + //For other barcodes try to resolve the part first and then redirect to the part page + $entity = $this->resolveEntity($barcodeScan); + + if ($entity === null) { + return null; + } + + if ($entity instanceof Part) { + return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getID()]); + } + + if ($entity instanceof PartLot) { + return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getPart()->getID(), 'highlightLot' => $entity->getID()]); + } + + if ($entity instanceof StorageLocation) { + return $this->urlGenerator->generate('part_list_store_location', ['id' => $entity->getID()]); + } + + //@phpstan-ignore-next-line This should never happen, since resolveEntity should only return Part, PartLot or StorageLocation + throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity)); + } + + /** + * Returns a URL to create a new part based on this barcode scan result, if possible. + * @param BarcodeScanResultInterface $scanResult + * @return string|null + * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system + */ + public function getCreationURL(BarcodeScanResultInterface $scanResult): ?string + { + $infos = $this->getCreateInfos($scanResult); + if ($infos === null) { + return null; + } + + //Ensure that the provider is active, otherwise we should not generate a creation URL for it + $provider = $this->providerRegistry->getProviderByKey($infos['providerKey']); + if (!$provider->isActive()) { + throw InfoProviderNotActiveException::fromProvider($provider); + } + + return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]); + } + + /** + * Tries to resolve the given barcode scan result to a local entity. This can be a Part, a PartLot or a StorageLocation, depending on the type of the barcode and the information contained in it. + * Returns null if no matching entity could be found. + * @param BarcodeScanResultInterface $barcodeScan + * @return Part|PartLot|StorageLocation|null + */ + public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null + { + if ($barcodeScan instanceof LocalBarcodeScanResult) { + return $this->resolvePartFromLocal($barcodeScan); + } + + if ($barcodeScan instanceof EIGP114BarcodeScanResult) { + return $this->resolvePartFromVendor($barcodeScan); + } + + if ($barcodeScan instanceof GTINBarcodeScanResult) { + return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]); + } + + if ($barcodeScan instanceof LCSCBarcodeScanResult) { + return $this->resolvePartFromLCSC($barcodeScan); + } + + throw new \InvalidArgumentException("Barcode does not support resolving to a local entity: ".get_class($barcodeScan)); + } + + /** + * Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode, + * or the barcode doesn't contain information allowing to resolve to a local part. + * @param BarcodeScanResultInterface $barcodeScan + * @return Part|null + * @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function + */ + public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part + { + $entity = $this->resolveEntity($barcodeScan); + if ($entity instanceof Part) { + return $entity; + } + if ($entity instanceof PartLot) { + return $entity->getPart(); + } + //Storage locations are not associated with a specific part, so we cannot resolve a part for + //a storage location barcode + return null; + } + + private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): Part|PartLot|StorageLocation|null + { + return match ($barcodeScan->target_type) { + LabelSupportedElement::PART => $this->em->find(Part::class, $barcodeScan->target_id), + LabelSupportedElement::PART_LOT => $this->em->find(PartLot::class, $barcodeScan->target_id), + LabelSupportedElement::STORELOCATION => $this->em->find(StorageLocation::class, $barcodeScan->target_id), + }; + } + + /** + * Gets a part from a scan of a Vendor Barcode by filtering for parts + * with the same Info Provider Id or, if that fails, by looking for parts with a + * matching manufacturer product number. Only returns the first matching part. + */ + private function resolvePartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : ?Part + { + // first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via + // the info provider system or if the part was bought from a different vendor than the data was retrieved + // from. + if($barcodeScan->digikeyPartNumber) { + + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->digikeyPartNumber); + if ($part !== null) { + return $part; + } + } + + if (!$barcodeScan->supplierPartNumber){ + return null; + } + + //Fallback to the manufacturer part number. This may return false positives, since it is common for + //multiple manufacturers to use the same part number for their version of a common product + //We assume the user is able to realize when this returns the wrong part + //If the barcode specifies the manufacturer we try to use that as well + + return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer); + } + + /** + * Resolve LCSC barcode -> Part. + * Strategy: + * 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there + * 2) Fallback to manufacturer_product_number == pm (MPN) + * Returns first match (consistent with EIGP114 logic) + */ + private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part + { + // Try LCSC code (pc) as provider id if available + $pc = $barcodeScan->lcscCode; // e.g. C138033 + if ($pc) { + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc); + if ($part !== null) { + return $part; + } + } + + // Fallback to MPN (pm) + $pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML + if (!$pm) { + return null; + } + + return $this->em->getRepository(Part::class)->getPartByMPN($pm); + } + + + /** + * Tries to extract creation information for a part from the given barcode scan result. This can be used to + * automatically fill in the info provider reference of a part, when creating a new part based on the scan result. + * Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function. + * It is not necessarily checked that the provider is active, or that the result actually exists on the provider side. + * @param BarcodeScanResultInterface $scanResult + * @return array{providerKey: string, providerId: string}|null + * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system + */ + public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array + { + // LCSC + if ($scanResult instanceof LCSCBarcodeScanResult) { + return [ + 'providerKey' => 'lcsc', + 'providerId' => $scanResult->lcscCode, + ]; + } + + if ($scanResult instanceof EIGP114BarcodeScanResult) { + return $this->getCreationInfoForEIGP114($scanResult); + } + + return null; + + } + + /** + * @param EIGP114BarcodeScanResult $scanResult + * @return array{providerKey: string, providerId: string}|null + */ + private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array + { + $vendor = $scanResult->guessBarcodeVendor(); + + // Mouser: use supplierPartNumber -> search provider -> provider_id + if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null + ) { + // Search Mouser using the MPN + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $scanResult->supplierPartNumber, + providers: ["mouser"] + ); + + // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) + $best = $dtos[0] ?? null; + + if ($best !== null) { + return [ + 'providerKey' => 'mouser', + 'providerId' => $best->provider_id, + ]; + } + + return null; + } + + // Digi-Key: can use customerPartNumber or supplierPartNumber directly + if ($vendor === 'digikey') { + return [ + 'providerKey' => 'digikey', + 'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber, + ]; + } + + // Element14: can use supplierPartNumber directly + if ($vendor === 'element14') { + return [ + 'providerKey' => 'element14', + 'providerId' => $scanResult->supplierPartNumber, + ]; + } + + return null; + } + + +} diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php index 88130351..befa91b6 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php @@ -33,4 +33,4 @@ interface BarcodeScanResultInterface * @return array */ public function getDecodedForInfoMode(): array; -} \ No newline at end of file +} diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index 43643d12..13ab4bf3 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -26,25 +26,28 @@ namespace App\Services\LabelSystem\BarcodeScanner; /** * This enum represents the different types, where a barcode/QR-code can be generated from */ -enum BarcodeSourceType +enum BarcodeSourceType: string { /** This Barcode was generated using Part-DB internal recommended barcode generator */ - case INTERNAL; + case INTERNAL = 'internal'; /** This barcode is containing an internal part number (IPN) */ - case IPN; + case IPN = 'ipn'; /** * This barcode is a user defined barcode defined on a part lot */ - case USER_DEFINED; + case USER_DEFINED = 'user_defined'; /** * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ - case EIGP114; + case EIGP114 = 'eigp114'; /** * GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part. */ - case GTIN; + case GTIN = 'gtin'; + + /** For LCSC.com formatted QR codes */ + case LCSC = 'lcsc'; } diff --git a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php index 0b4f4b56..37c03f55 100644 --- a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php @@ -28,40 +28,40 @@ namespace App\Services\LabelSystem\BarcodeScanner; * Based on PR 811, EIGP 114.2018 (https://www.ecianow.org/assets/docs/GIPC/EIGP-114.2018%20ECIA%20Labeling%20Specification%20for%20Product%20and%20Shipment%20Identification%20in%20the%20Electronics%20Industry%20-%202D%20Barcode.pdf), * , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097 */ -class EIGP114BarcodeScanResult implements BarcodeScanResultInterface +readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface { /** * @var string|null Ship date in format YYYYMMDD */ - public readonly ?string $shipDate; + public ?string $shipDate; /** * @var string|null Customer assigned part number – Optional based on * agreements between Distributor and Supplier */ - public readonly ?string $customerPartNumber; + public ?string $customerPartNumber; /** * @var string|null Supplier assigned part number */ - public readonly ?string $supplierPartNumber; + public ?string $supplierPartNumber; /** * @var int|null Quantity of product */ - public readonly ?int $quantity; + public ?int $quantity; /** * @var string|null Customer assigned purchase order number */ - public readonly ?string $customerPO; + public ?string $customerPO; /** * @var string|null Line item number from PO. Required on Logistic Label when * used on back of Packing Slip. See Section 4.9 */ - public readonly ?string $customerPOLine; + public ?string $customerPOLine; /** * 9D - YYWW (Year and Week of Manufacture). ) If no date code is used @@ -69,7 +69,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * to indicate the product is Not Traceable by this data field. * @var string|null */ - public readonly ?string $dateCode; + public ?string $dateCode; /** * 10D - YYWW (Year and Week of Manufacture). ) If no date code is used @@ -77,7 +77,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * to indicate the product is Not Traceable by this data field. * @var string|null */ - public readonly ?string $alternativeDateCode; + public ?string $alternativeDateCode; /** * Traceability number assigned to a batch or group of items. If @@ -86,14 +86,14 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * by this data field. * @var string|null */ - public readonly ?string $lotCode; + public ?string $lotCode; /** * Country where part was manufactured. Two-letter code from * ISO 3166 country code list * @var string|null */ - public readonly ?string $countryOfOrigin; + public ?string $countryOfOrigin; /** * @var string|null Unique alphanumeric number assigned by supplier @@ -101,85 +101,85 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * Carton. Always used in conjunction with a mixed logistic label * with a 5S data identifier for Package ID. */ - public readonly ?string $packageId1; + public ?string $packageId1; /** * @var string|null * 4S - Package ID for Logistic Carton with like items */ - public readonly ?string $packageId2; + public ?string $packageId2; /** * @var string|null * 5S - Package ID for Logistic Carton with mixed items */ - public readonly ?string $packageId3; + public ?string $packageId3; /** * @var string|null Unique alphanumeric number assigned by supplier. */ - public readonly ?string $packingListNumber; + public ?string $packingListNumber; /** * @var string|null Ship date in format YYYYMMDD */ - public readonly ?string $serialNumber; + public ?string $serialNumber; /** * @var string|null Code for sorting and classifying LEDs. Use when applicable */ - public readonly ?string $binCode; + public ?string $binCode; /** * @var int|null Sequential carton count in format “#/#” or “# of #” */ - public readonly ?int $packageCount; + public ?int $packageCount; /** * @var string|null Alphanumeric string assigned by the supplier to distinguish * from one closely-related design variation to another. Use as * required or when applicable */ - public readonly ?string $revisionNumber; + public ?string $revisionNumber; /** * @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number. */ - public readonly ?string $digikeyPartNumber; + public ?string $digikeyPartNumber; /** * @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.) */ - public readonly ?string $digikeySalesOrderNumber; + public ?string $digikeySalesOrderNumber; /** * @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers */ - public readonly ?string $digikeyInvoiceNumber; + public ?string $digikeyInvoiceNumber; /** * @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type. */ - public readonly ?string $digikeyLabelType; + public ?string $digikeyLabelType; /** * @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907 */ - public readonly ?string $digikeyPartID; + public ?string $digikeyPartID; /** * @var string|null Digikey Extension: For internal use of Digikey. Probably not needed */ - public readonly ?string $digikeyNA; + public ?string $digikeyNA; /** * @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore. */ - public readonly ?string $digikeyPadding; + public ?string $digikeyPadding; - public readonly ?string $mouserPositionInOrder; + public ?string $mouserPositionInOrder; - public readonly ?string $mouserManufacturer; + public ?string $mouserManufacturer; @@ -187,7 +187,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * * @param array $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content */ - public function __construct(public readonly array $data) + public function __construct(public array $data) { //IDs per EIGP 114.2018 $this->shipDate = $data['6D'] ?? null; @@ -329,4 +329,4 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface return $tmp; } -} \ No newline at end of file +} diff --git a/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php new file mode 100644 index 00000000..0151cffa --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResult.php @@ -0,0 +1,157 @@ + $fields + */ + public function __construct( + public array $fields, + public string $rawInput, + ) { + + $this->pickBatchNumber = $this->fields['pbn'] ?? null; + $this->orderNumber = $this->fields['on'] ?? null; + $this->lcscCode = $this->fields['pc'] ?? null; + $this->mpn = $this->fields['pm'] ?? null; + $this->quantity = isset($this->fields['qty']) ? (int)$this->fields['qty'] : null; + $this->countryChannel = $this->fields['cc'] ?? null; + $this->warehouseCode = $this->fields['wc'] ?? null; + $this->pdi = $this->fields['pdi'] ?? null; + $this->hp = $this->fields['hp'] ?? null; + + } + + public function getSourceType(): BarcodeSourceType + { + return BarcodeSourceType::LCSC; + } + + /** + * @return array|float[]|int[]|null[]|string[] An array of fields decoded from the barcode + */ + public function getDecodedForInfoMode(): array + { + // Keep it human-friendly + return [ + 'Barcode type' => 'LCSC', + 'MPN (pm)' => $this->mpn ?? '', + 'LCSC code (pc)' => $this->lcscCode ?? '', + 'Qty' => $this->quantity !== null ? (string) $this->quantity : '', + 'Order No (on)' => $this->orderNumber ?? '', + 'Pick Batch (pbn)' => $this->pickBatchNumber ?? '', + 'Warehouse (wc)' => $this->warehouseCode ?? '', + 'Country/Channel (cc)' => $this->countryChannel ?? '', + 'PDI (unknown meaning)' => $this->pdi ?? '', + 'HP (unknown meaning)' => $this->hp ?? '', + ]; + } + + /** + * Parses the barcode data to see if the input matches the expected format used by lcsc.com + * @param string $input + * @return bool + */ + public static function isLCSCBarcode(string $input): bool + { + $s = trim($input); + + // Your example: {pbn:...,on:...,pc:...,pm:...,qty:...} + if (!str_starts_with($s, '{') || !str_ends_with($s, '}')) { + return false; + } + + // Must contain at least pm: and pc: (common for LCSC labels) + return (stripos($s, 'pm:') !== false) && (stripos($s, 'pc:') !== false); + } + + /** + * Parse the barcode input string into the fields used by lcsc.com + * @param string $input + * @return self + */ + public static function parse(string $input): self + { + $raw = trim($input); + + if (!self::isLCSCBarcode($raw)) { + throw new InvalidArgumentException('Not an LCSC barcode'); + } + + $inner = substr($raw, 1, -1); // remove { } + + $fields = []; + + // This format is comma-separated pairs, values do not contain commas in your sample. + $pairs = array_filter( + array_map(trim(...), explode(',', $inner)), + static fn(string $s): bool => $s !== '' + ); + + foreach ($pairs as $pair) { + $pos = strpos($pair, ':'); + if ($pos === false) { + continue; + } + + $k = trim(substr($pair, 0, $pos)); + $v = trim(substr($pair, $pos + 1)); + + if ($k === '') { + continue; + } + + $fields[$k] = $v; + } + + if (!isset($fields['pm']) || trim($fields['pm']) === '') { + throw new InvalidArgumentException('LCSC barcode missing pm field'); + } + + return new self($fields, $raw); + } +} diff --git a/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php index 050aff6f..25fb4710 100644 --- a/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php @@ -29,12 +29,12 @@ use App\Entity\LabelSystem\LabelSupportedElement; * This class represents the result of a barcode scan of a barcode that uniquely identifies a local entity, * like an internally generated barcode or a barcode that was added manually to the system by a user */ -class LocalBarcodeScanResult implements BarcodeScanResultInterface +readonly class LocalBarcodeScanResult implements BarcodeScanResultInterface { public function __construct( - public readonly LabelSupportedElement $target_type, - public readonly int $target_id, - public readonly BarcodeSourceType $source_type, + public LabelSupportedElement $target_type, + public int $target_id, + public BarcodeSourceType $source_type, ) { } @@ -46,4 +46,4 @@ class LocalBarcodeScanResult implements BarcodeScanResultInterface 'Target ID' => $this->target_id, ]; } -} \ No newline at end of file +} diff --git a/src/Twig/AttachmentExtension.php b/src/Twig/AttachmentExtension.php index 3d5ec611..23ab7d6e 100644 --- a/src/Twig/AttachmentExtension.php +++ b/src/Twig/AttachmentExtension.php @@ -23,7 +23,10 @@ declare(strict_types=1); namespace App\Twig; use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\AttachmentContainingDBElement; +use App\Entity\Parts\Part; use App\Services\Attachments\AttachmentURLGenerator; +use App\Services\Attachments\PartPreviewGenerator; use App\Services\Misc\FAIconGenerator; use Twig\Attribute\AsTwigFunction; use Twig\Extension\AbstractExtension; @@ -31,7 +34,7 @@ use Twig\TwigFunction; final readonly class AttachmentExtension { - public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator) + public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator, private PartPreviewGenerator $partPreviewGenerator) { } @@ -44,6 +47,26 @@ final readonly class AttachmentExtension return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name); } + /** + * Returns the URL of the thumbnail of the given element. Returns null if no thumbnail is available. + * For parts, a special preview image is generated, for other entities, the master picture is used as preview (if available). + */ + #[AsTwigFunction("entity_thumbnail")] + public function entityThumbnail(AttachmentContainingDBElement $element, string $filter_name = 'thumbnail_sm'): ?string + { + if ($element instanceof Part) { + $preview_attachment = $this->partPreviewGenerator->getTablePreviewAttachment($element); + } else { // For other entities, we just use the master picture as preview, if available + $preview_attachment = $element->getMasterPictureAttachment(); + } + + if ($preview_attachment === null) { + return null; + } + + return $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, $filter_name); + } + /** * Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available. * Null is allowed for files withot extension diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index d327a4f6..54be3fd0 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -159,7 +159,7 @@
- {# This menu is filled by 'turbo/locale_menu' controller from the _turbo_control.html.twig template, to always have the correct path #} + {# This menu is filled by a turbo-stream in _turbo_contro.html.twig #}
diff --git a/templates/_turbo_control.html.twig b/templates/_turbo_control.html.twig index 90ae8d9a..281b21f2 100644 --- a/templates/_turbo_control.html.twig +++ b/templates/_turbo_control.html.twig @@ -1,35 +1,39 @@ -{# Insert flashes #} -
- {% for label, messages in app.flashes() %} - {% for message in messages %} - {{ include('_toast.html.twig', { - 'label': label, - 'message': message - }) }} - {% endfor %} - {% endfor %} -
+{% block flashes %} + {# Insert flashes #} + + + +{% endblock %} + -{# Allow pages to request a fully reload of everything #} -{% if global_reload_needed is defined and global_reload_needed %} -
-{% endif %} {# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #} -{# The title block is already escaped, therefore we dont require any additional escaping here #} -
+ + + -
- {% set locales = settings_instance('localization').languageMenuEntries %} - {% if locales is empty %} - {% set locales = locale_menu %} - {% endif %} - {% for locale in locales %} - - {{ locale|language_name }} ({{ locale|upper }}) - {% endfor %} -
diff --git a/templates/base.html.twig b/templates/base.html.twig index 2db726ee..8dc87239 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -26,6 +26,11 @@ + {# Allow pages to request a fully reload of everything #} + {% if global_reload_needed is defined and global_reload_needed %} + + {% endif %} + diff --git a/templates/label_system/scanner/_info_mode.html.twig b/templates/label_system/scanner/_info_mode.html.twig new file mode 100644 index 00000000..23deb6d3 --- /dev/null +++ b/templates/label_system/scanner/_info_mode.html.twig @@ -0,0 +1,154 @@ +{% import "helper.twig" as helper %} + +{% if decoded is not empty %} +
+ + {% if part %} {# Show detailed info when it is a part #} +
+
+ {% trans %}label_scanner.db_part_found{% endtrans %} + {% if openUrl %} +
+ + + +
+ {% endif %} + +
+
+
+ +
+ + +
+

{{ part.name }}

+
{{ part.description | format_markdown(true) }}
+
+
+ {% trans %}category.label{% endtrans %} + +
+
+ {{ helper.structural_entity_link(part.category) }} +
+
+ +
+
+ {% trans %}footprint.label{% endtrans %} + +
+
+ {{ helper.structural_entity_link(part.footprint) }} +
+
+ + {# Show part lots / locations #} + {% if part.partLots is not empty %} + + + + + + + + + {% for lot in part.partLots %} + + + + + {% endfor %} + +
{% trans %}part_lots.storage_location{% endtrans %} + {% trans %}part_lots.amount{% endtrans %} +
+ {% if lot.storageLocation %} + {{ helper.structural_entity_link(lot.storageLocation) }} + {% else %} + + {% endif %} + + {% if lot.instockUnknown %} + ? + {% else %} + {{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }} + {% endif %} +
+ {% else %} +
{% trans %}label_scanner.no_locations{% endtrans %}
+ {% endif %} + +
+
+
+ + {% elseif entity %} {# If we have an entity but that is not an part #} + +
+
+ {% trans %}label_scanner.target_found{% endtrans %}: {{ type_label(entity) }} + {% if openUrl %} +
+ + + +
+ {% endif %} + +
+
+
+ +
+ + +
+

{{ entity.name }}

+

{% trans %}id.label{% endtrans %}: {{ entity.id }} ({{ type_label(entity) }})

+ + {% if entity.fullPath is defined %} + {{ helper.breadcrumb_entity_link(entity)}} + {% endif %} +
+
+
+ + {% endif %} + + + {% if createUrl %} +
+

{% trans %}label_scanner.part_can_be_created{% endtrans %}

+

{% trans %}label_scanner.part_can_be_created.help{% endtrans %}

+
+ {% trans %}label_scanner.part_create_btn{% endtrans %} +
+ {% endif %} + +

+ {% trans %}label_scanner.scan_result.title{% endtrans %} +

+ + {# Decoded barcode fields #} + + + {% for key, value in decoded %} + + + + + {% endfor %} + +
{{ key }}{{ value }}
+ + {# Whitespace under table and Input form fields #} +
+ +{% endif %} diff --git a/templates/label_system/scanner/scanner.html.twig b/templates/label_system/scanner/scanner.html.twig index 1f978a9b..f9b51388 100644 --- a/templates/label_system/scanner/scanner.html.twig +++ b/templates/label_system/scanner/scanner.html.twig @@ -10,35 +10,28 @@
-
+
+ {% include "label_system/scanner/_info_mode.html.twig" %} +
+ + {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} + + {{ form_end(form) }}
- - {{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }} - - {{ form_end(form) }} - - - {% if infoModeData %} -
-

{% trans %}label_scanner.decoded_info.title{% endtrans %}

- - - - {% for key, value in infoModeData %} - - - - - {% endfor %} - -
{{ key }}{{ value }}
- - {% endif %} - +{% endblock %} + +{% block scan_results %} + + + {% endblock %} diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php deleted file mode 100644 index c5bdb02d..00000000 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ /dev/null @@ -1,85 +0,0 @@ -. - */ - -declare(strict_types=1); - -/** - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace App\Tests\Services\LabelSystem\BarcodeScanner; - -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use App\Entity\LabelSystem\LabelSupportedElement; -use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector; -use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; -use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; -use Doctrine\ORM\EntityNotFoundException; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; - -final class BarcodeRedirectorTest extends KernelTestCase -{ - private ?BarcodeRedirector $service = null; - - protected function setUp(): void - { - self::bootKernel(); - $this->service = self::getContainer()->get(BarcodeRedirector::class); - } - - public static function urlDataProvider(): \Iterator - { - yield [new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL), '/en/part/1']; - //Part lot redirects to Part info page (Part lot 1 is associated with part 3) - yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3?highlightLot=1']; - yield [new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL), '/en/store_location/1/parts']; - } - - #[DataProvider('urlDataProvider')] - #[Group('DB')] - public function testGetRedirectURL(LocalBarcodeScanResult $scanResult, string $url): void - { - $this->assertSame($url, $this->service->getRedirectURL($scanResult)); - } - - public function testGetRedirectEntityNotFount(): void - { - $this->expectException(EntityNotFoundException::class); - //If we encounter an invalid lot, we must throw an exception - $this->service->getRedirectURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, - 12_345_678, BarcodeSourceType::INTERNAL)); - } -} diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php index 248f1ae9..8f8c7a18 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php @@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; final class BarcodeScanHelperTest extends WebTestCase { @@ -124,6 +125,14 @@ final class BarcodeScanHelperTest extends WebTestCase ]); yield [$eigp114Result, "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"]; + + $lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}'; + $lcscResult = new LCSCBarcodeScanResult( + ['pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10'], + $lcscInput + ); + + yield [$lcscResult, $lcscInput]; } public static function invalidDataProvider(): \Iterator @@ -153,4 +162,33 @@ final class BarcodeScanHelperTest extends WebTestCase $this->expectException(\InvalidArgumentException::class); $this->service->scanBarcodeContent($input); } + + public function testAutoDetectLcscBarcode(): void + { + $input = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'; + + $result = $this->service->scanBarcodeContent($input); + + $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); + $this->assertSame('C138033', $result->lcscCode); + $this->assertSame('RC0402FR-071ML', $result->mpn); + } + + public function testLcscExplicitTypeParses(): void + { + $input = '{pc:C138033,pm:RC0402FR-071ML,qty:10}'; + + $result = $this->service->scanBarcodeContent($input, BarcodeSourceType::LCSC); + + $this->assertInstanceOf(LCSCBarcodeScanResult::class, $result); + $this->assertSame('C138033', $result->lcscCode); + $this->assertSame('RC0402FR-071ML', $result->mpn); + } + + public function testLcscExplicitTypeRejectsNonLcsc(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->service->scanBarcodeContent('not-an-lcsc', BarcodeSourceType::LCSC); + } } diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php new file mode 100644 index 00000000..840e84c0 --- /dev/null +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandlerTest.php @@ -0,0 +1,183 @@ +. + */ + +declare(strict_types=1); + +/** + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace App\Tests\Services\LabelSystem\BarcodeScanner; + +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use App\Entity\LabelSystem\LabelSupportedElement; +use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType; +use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult; +use Doctrine\ORM\EntityNotFoundException; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface; +use InvalidArgumentException; + + +final class BarcodeScanResultHandlerTest extends KernelTestCase +{ + private ?BarcodeScanResultHandler $service = null; + + protected function setUp(): void + { + self::bootKernel(); + $this->service = self::getContainer()->get(BarcodeScanResultHandler::class); + } + + public static function urlDataProvider(): \Iterator + { + yield [new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL), '/en/part/1']; + //Part lot redirects to Part info page (Part lot 1 is associated with part 3) + yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3?highlightLot=1']; + yield [new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL), '/en/store_location/1/parts']; + } + + #[DataProvider('urlDataProvider')] + #[Group('DB')] + public function testGetRedirectURL(LocalBarcodeScanResult $scanResult, string $url): void + { + $this->assertSame($url, $this->service->getInfoURL($scanResult)); + } + + public function testGetRedirectEntityNotFound(): void + { + //If we encounter an invalid lot, we must get an null result + $url = $this->service->getInfoURL(new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, + 12_345_678, BarcodeSourceType::INTERNAL)); + + $this->assertNull($url); + } + + public function testGetRedirectURLThrowsOnUnknownScanType(): void + { + $unknown = new class implements BarcodeScanResultInterface { + public function getDecodedForInfoMode(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->service->getInfoURL($unknown); + } + + public function testEIGPBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void + { + $scan = new EIGP114BarcodeScanResult([]); + + $this->assertNull($this->service->resolvePart($scan)); + $this->assertNull($this->service->getInfoURL($scan)); + } + + public function testLCSCBarcodeResolvePartOrNullReturnsNullWhenNotFound(): void + { + $scan = new LCSCBarcodeScanResult( + fields: ['pc' => 'C0000000', 'pm' => ''], + rawInput: '{pc:C0000000,pm:}' + ); + + $this->assertNull($this->service->resolvePart($scan)); + $this->assertNull($this->service->getInfoURL($scan)); + } + + public function testResolveEntityThrowsOnUnknownScanType(): void + { + $unknown = new class implements BarcodeScanResultInterface { + public function getDecodedForInfoMode(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->service->resolvePart($unknown); + } + + public function testResolveEntity(): void + { + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolveEntity($scan); + + $this->assertSame(1, $part->getId()); + $this->assertInstanceOf(Part::class, $part); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL); + $entity = $this->service->resolveEntity($scan); + $this->assertSame(1, $entity->getId()); + $this->assertInstanceOf(PartLot::class, $entity); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); + $entity = $this->service->resolveEntity($scan); + $this->assertSame(1, $entity->getId()); + $this->assertInstanceOf(StorageLocation::class, $entity); + } + + public function testResolvePart(): void + { + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + + $this->assertSame(1, $part->getId()); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + $this->assertSame(3, $part->getId()); + + $scan = new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL); + $part = $this->service->resolvePart($scan); + $this->assertNull($part); //Store location does not resolve to a part + } + + public function testGetCreateInfos(): void + { + $lcscScan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + $infos = $this->service->getCreateInfos($lcscScan); + + $this->assertSame('lcsc', $infos['providerKey']); + $this->assertSame('C138033', $infos['providerId']); + } +} diff --git a/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php b/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php new file mode 100644 index 00000000..2128f113 --- /dev/null +++ b/tests/Services/LabelSystem/BarcodeScanner/LCSCBarcodeScanResultTest.php @@ -0,0 +1,86 @@ +. + */ + +namespace App\Tests\Services\LabelSystem\BarcodeScanner; + +use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +class LCSCBarcodeScanResultTest extends TestCase +{ + public function testIsLCSCBarcode(): void + { + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('invalid')); + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('LCSC-12345')); + $this->assertFalse(LCSCBarcodeScanResult::isLCSCBarcode('')); + + $this->assertTrue(LCSCBarcodeScanResult::isLCSCBarcode('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}')); + $this->assertTrue(LCSCBarcodeScanResult::isLCSCBarcode('{pbn:PICK2506270148,on:GB2506270877,pc:C22437266,pm:IA0509S-2W,qty:3,mc:,cc:1,pdi:164234874,hp:null,wc:ZH}')); + } + + public function testConstruct(): void + { + $raw = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'; + $fields = ['pbn' => 'PB1', 'on' => 'ON1', 'pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10']; + $scan = new LCSCBarcodeScanResult($fields, $raw); + //Splitting up should work and assign the correct values to the properties: + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); + + //Fields and raw input should be preserved + $this->assertSame($fields, $scan->fields); + $this->assertSame($raw, $scan->rawInput); + } + + public function testLCSCParseInvalidFormatThrows(): void + { + $this->expectException(InvalidArgumentException::class); + LCSCBarcodeScanResult::parse('not-an-lcsc-barcode'); + } + + public function testParse(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PICK2506270148,on:GB2506270877,pc:C22437266,pm:IA0509S-2W,qty:3,mc:,cc:1,pdi:164234874,hp:null,wc:ZH}'); + + $this->assertSame('IA0509S-2W', $scan->mpn); + $this->assertSame('C22437266', $scan->lcscCode); + $this->assertSame('PICK2506270148', $scan->pickBatchNumber); + $this->assertSame('GB2506270877', $scan->orderNumber); + $this->assertSame(3, $scan->quantity); + $this->assertSame('1', $scan->countryChannel); + $this->assertSame('164234874', $scan->pdi); + $this->assertSame('null', $scan->hp); + $this->assertSame('ZH', $scan->warehouseCode); + } + + public function testLCSCParseExtractsFields(): void + { + $scan = LCSCBarcodeScanResult::parse('{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}'); + + $this->assertSame('RC0402FR-071ML', $scan->mpn); + $this->assertSame('C138033', $scan->lcscCode); + + $decoded = $scan->getDecodedForInfoMode(); + $this->assertSame('LCSC', $decoded['Barcode type']); + $this->assertSame('RC0402FR-071ML', $decoded['MPN (pm)']); + $this->assertSame('C138033', $decoded['LCSC code (pc)']); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index d9418563..31bc3884 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9500,6 +9500,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders) + + + scan_dialog.mode.lcsc + LCSC.com barcode + + scan_dialog.info_mode @@ -9512,6 +9518,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g Decoded information + + + label_scanner.target_found + Item found in database + + + + + label_scanner.scan_result.title + Scan result + + + + + label_scanner.no_locations + Part is not stored at any location. + + label_generator.edit_profiles @@ -12509,5 +12533,35 @@ Buerklin-API Authentication server: Last stocktake + + + label_scanner.open + View details + + + + + label_scanner.db_part_found + Database [part] found for barcode + + + + + label_scanner.part_can_be_created + [Part] can be created + + + + + label_scanner.part_can_be_created.help + No matching [part] was found in the database, but you can create a new [part] based of this barcode. + + + + + label_scanner.part_create_btn + Create [part] from barcode + + diff --git a/yarn.lock b/yarn.lock index e3d72ad7..5ab2d6b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,58 +2,58 @@ # yarn lockfile v1 -"@algolia/autocomplete-core@1.19.5": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.5.tgz#52d99aafce19493161220e417071f0222eeea7d6" - integrity sha512-/kAE3mMBage/9m0OGnKQteSa7/eIfvhiKx28OWj857+dJ6qYepEBuw5L8its2oTX8ZNM/6TA3fo49kMwgcwjlg== +"@algolia/autocomplete-core@1.19.6": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.6.tgz#472ba8f84d3bd1d253d24759caeaac454db902e7" + integrity sha512-6EoD7PeM2WBq5GY1jm0gGonDW2JVU4BaHT9tAwDcaPkc6gYIRZeY7X7aFuwdRvk9R/jwsh8sz4flDao0+Kua6g== dependencies: - "@algolia/autocomplete-plugin-algolia-insights" "1.19.5" - "@algolia/autocomplete-shared" "1.19.5" + "@algolia/autocomplete-plugin-algolia-insights" "1.19.6" + "@algolia/autocomplete-shared" "1.19.6" -"@algolia/autocomplete-js@1.19.5", "@algolia/autocomplete-js@^1.17.0": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-js/-/autocomplete-js-1.19.5.tgz#2ec3efd9d5efd505ea677775d0199e1207e4624e" - integrity sha512-C2/bEQeqq4nZ4PH2rySRvU9B224KbiCXAPZIn3pmMII/7BiXkppPQyDd+Fdly3ubOmnGFDH6BTzGHamySeOYeg== +"@algolia/autocomplete-js@1.19.6", "@algolia/autocomplete-js@^1.17.0": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-js/-/autocomplete-js-1.19.6.tgz#a81b3b40e7e6356f22af75bfc1c92116d4a86243" + integrity sha512-rHYKT6P+2FZ1+7a1/JtWIuCmfioOt5eXsAcri6XTYsSutl3BIh8s2e98kbvjbhLfwEuuVDWtST1hdAY2pQdrKw== dependencies: - "@algolia/autocomplete-core" "1.19.5" - "@algolia/autocomplete-preset-algolia" "1.19.5" - "@algolia/autocomplete-shared" "1.19.5" + "@algolia/autocomplete-core" "1.19.6" + "@algolia/autocomplete-preset-algolia" "1.19.6" + "@algolia/autocomplete-shared" "1.19.6" htm "^3.1.1" preact "^10.13.2" -"@algolia/autocomplete-plugin-algolia-insights@1.19.5": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.5.tgz#05246356fe9837475b08664ff4d6f55960127edc" - integrity sha512-5zbetV9h2VxH+Mxx27I7BH2EIACVRUBE1FNykBK+2c2M+mhXYMY4npHbbGYj6QDEw3VVvH2UxAnghFpCtC6B/w== +"@algolia/autocomplete-plugin-algolia-insights@1.19.6": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.6.tgz#7db79ca4a107059477b56e31e8f7760513f265a2" + integrity sha512-VD53DBixhEwDvOB00D03DtBVhh5crgb1N0oH3QTscfYk4TpBH+CKrwmN/XrN/VdJAdP+4K6SgwLii/3OwM9dHw== dependencies: - "@algolia/autocomplete-shared" "1.19.5" + "@algolia/autocomplete-shared" "1.19.6" "@algolia/autocomplete-plugin-recent-searches@^1.17.0": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-recent-searches/-/autocomplete-plugin-recent-searches-1.19.5.tgz#afd80f8abb281c4c01817a1edfde9a8aa95ed5db" - integrity sha512-lOEliMbohq0BsZJ7JXFHlfmGBNtuCsQW0PLq8m6X1SdMD4XAn8fFxiOO2Nk1A/IiymZcOoHQV71u6f14wiohDw== + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-recent-searches/-/autocomplete-plugin-recent-searches-1.19.6.tgz#13b6617f03bfc8257d947a0d6cf0435de5677847" + integrity sha512-HQdSxHXFlxPUx6okxYWrrSbVD2o3OrDstU/E83Qvdl3Pwya3eZKrjhBb84i3Tqkm71wuABRYmCMNjc/qGFX4hw== dependencies: - "@algolia/autocomplete-core" "1.19.5" - "@algolia/autocomplete-js" "1.19.5" - "@algolia/autocomplete-preset-algolia" "1.19.5" - "@algolia/autocomplete-shared" "1.19.5" + "@algolia/autocomplete-core" "1.19.6" + "@algolia/autocomplete-js" "1.19.6" + "@algolia/autocomplete-preset-algolia" "1.19.6" + "@algolia/autocomplete-shared" "1.19.6" -"@algolia/autocomplete-preset-algolia@1.19.5": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.19.5.tgz#a9d5756090314c16b8895fa0c74ffccca7f8a1e2" - integrity sha512-afdgxUyBxgX1I34THLScCyC+ld2h8wnCTv7JndRxsRNIJjJpFtRNpnYDq0+HVcp+LYeNd1zksDu7CpltTSEsvA== +"@algolia/autocomplete-preset-algolia@1.19.6": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.19.6.tgz#bf800e3e0e3f69f661476d9d1a3237b122e84aa5" + integrity sha512-/uQlHGK5Q2x5Nvrp3W7JMg4YNGG/ygkHtQLTltDbkpd45wnhV9jUiQA6aCnBed9cq0BXhOJZRxh1zGVZ3yRhBg== dependencies: - "@algolia/autocomplete-shared" "1.19.5" + "@algolia/autocomplete-shared" "1.19.6" -"@algolia/autocomplete-shared@1.19.5": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.5.tgz#1a20f60fd400fd5641718358a2d5c3eb1893cf9c" - integrity sha512-yblBczNXtm2cCVzX4UAY3KkjdefmZPn1gWbIi8Q7qfBw7FjcKq2EjEl/65x4kU9nUc/ZkB5SeUf/bkqLEnA5gA== +"@algolia/autocomplete-shared@1.19.6": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.6.tgz#5261f04a1cadf82138b6feb5a6df383106f50d60" + integrity sha512-DG1n2B6XQw6DWB5veO4RuzQ/N2oGNpG+sSzGT7gUbi7WhF+jN57abcv2QhB5flXZ0NgddE1i6h7dZuQmYBEorQ== "@algolia/autocomplete-theme-classic@^1.17.0": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-theme-classic/-/autocomplete-theme-classic-1.19.5.tgz#7b0d3ac11f2dca33600fce9ac383056ab4202cdc" - integrity sha512-LjjhOmDbEXmV2IqaA7Xe8jh6lSpG087yC79ffLpXMKJOib4xSHFvPavsXC8NW25pWVHJFoAfplAAmxmeM2/jhw== + version "1.19.6" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-theme-classic/-/autocomplete-theme-classic-1.19.6.tgz#ba1c9760ac725283d086a9affd784823fdb72c71" + integrity sha512-lJg8fGK7ucuapoCwFqciTAvAOb7lI/BgWXN0VP+nW/oG0xtig6FvJz/XXxHxfvfVWLCfDvmW5Dw+vEAnbxXiFA== "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" @@ -1857,11 +1857,6 @@ resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.23.tgz#a6eebc9ab4a5faadae265a4cbec8cfcb5731e77c" integrity sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ== -"@isaacs/cliui@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3" - integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg== - "@jbtronics/bs-treeview@^1.0.1": version "1.0.6" resolved "https://registry.yarnpkg.com/@jbtronics/bs-treeview/-/bs-treeview-1.0.6.tgz#7fe126a2ca4716c824d97ab6d1a5f2417750445a" @@ -2165,11 +2160,11 @@ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== "@types/node@*": - version "25.2.3" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.3.tgz#9c18245be768bdb4ce631566c7da303a5c99a7f8" - integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ== + version "25.3.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.0.tgz#749b1bd4058e51b72e22bd41e9eab6ebd0180470" + integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A== dependencies: - undici-types "~7.16.0" + undici-types "~7.18.0" "@types/parse-json@^4.0.0": version "4.0.2" @@ -2392,16 +2387,16 @@ acorn-import-phases@^1.0.3: integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== acorn-walk@^8.0.0: - version "8.3.4" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" - integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + version "8.3.5" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496" + integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw== dependencies: acorn "^8.11.0" acorn@^8.0.4, acorn@^8.11.0, acorn@^8.15.0: - version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" - integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== adjust-sourcemap-loader@^4.0.0: version "4.0.0" @@ -2439,9 +2434,9 @@ ajv-keywords@^5.1.0: fast-deep-equal "^3.1.3" ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -2606,11 +2601,9 @@ balanced-match@^1.0.0: integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== balanced-match@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.2.tgz#241591ea634702bef9c482696f2469406e16d233" - integrity sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg== - dependencies: - jackspeak "^4.2.3" + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== barcode-detector@^3.0.0, barcode-detector@^3.0.5: version "3.0.8" @@ -2630,9 +2623,9 @@ base64-js@^1.1.2, base64-js@^1.3.0: integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== baseline-browser-mapping@^2.9.0: - version "2.9.19" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" - integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== + version "2.10.0" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" + integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== big.js@^5.2.2: version "5.2.2" @@ -2678,9 +2671,9 @@ brace-expansion@^1.1.7: concat-map "0.0.1" brace-expansion@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" - integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== + version "5.0.3" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.3.tgz#6a9c6c268f85b53959ec527aeafe0f7300258eef" + integrity sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA== dependencies: balanced-match "^4.0.2" @@ -2800,9 +2793,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001759: - version "1.0.30001770" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84" - integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw== + version "1.0.30001772" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz#aa8a176eba0006e78c965a8215c7a1ceb030122d" + integrity sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg== ccount@^2.0.0: version "2.0.1" @@ -3175,9 +3168,9 @@ css-loader@^5.2.7: semver "^7.3.5" css-loader@^7.1.0: - version "7.1.3" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.3.tgz#c0de715ceabe39b8531a85fcaf6734a430c4d99a" - integrity sha512-frbERmjT0UC5lMheWpJmMilnt9GEhbZJN/heUb7/zaJYeIzj5St9HvDcfshzzOqbsS+rYpMk++2SD3vGETDSyA== + version "7.1.4" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.4.tgz#8f6bf9f8fc8cbef7d2ef6e80acc6545eaefa90b1" + integrity sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw== dependencies: icss-utils "^5.1.0" postcss "^8.4.40" @@ -3681,9 +3674,9 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: gopd "^1.2.0" electron-to-chromium@^1.5.263: - version "1.5.286" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" - integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== + version "1.5.302" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" + integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== emoji-regex@^7.0.1: version "7.0.3" @@ -4843,13 +4836,6 @@ isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== -jackspeak@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483" - integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg== - dependencies: - "@isaacs/cliui" "^9.0.0" - javascript-stringify@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-1.6.0.tgz#142d111f3a6e3dae8f4a9afd77d45855b5a9cce3" @@ -4974,9 +4960,9 @@ jszip@^3.2.0: setimmediate "^1.0.5" katex@^0.16.0: - version "0.16.28" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.28.tgz#64068425b5a29b41b136aae0d51cbb2c71d64c39" - integrity sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg== + version "0.16.29" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.29.tgz#d6d2cc2e1840663c2ceb6fc764d4f0d9ca04fa4c" + integrity sha512-ef+wYUDehNgScWoA0ZhEngsNqUv9uIj4ftd/PapQmT+E85lXI6Wx6BvJO48v80Vhj3t/IjEoZWw9/ZPe8kHwHg== dependencies: commander "^8.3.0" @@ -5119,9 +5105,9 @@ marked-mangle@^1.0.1: integrity sha512-bRrqNcfU9v3iRECb7YPvA+/xKZMjHojd9R92YwHbFjdPQ+Wc7vozkbGKAv4U8AUl798mNUuY3DTBQkedsV3TeQ== marked@^17.0.1: - version "17.0.2" - resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.2.tgz#a103f82bed9653dd1d74c15f74107c84ddbe749d" - integrity sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA== + version "17.0.3" + resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.3.tgz#0defa25b1ba288433aa847848475d11109e1b3fd" + integrity sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A== math-intrinsics@^1.1.0: version "1.1.0" @@ -5139,9 +5125,9 @@ mdast-util-find-and-replace@^3.0.0: unist-util-visit-parents "^6.0.0" mdast-util-from-markdown@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a" - integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA== + version "2.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7" + integrity sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q== dependencies: "@types/mdast" "^4.0.0" "@types/unist" "^3.0.0" @@ -5606,9 +5592,9 @@ mini-css-extract-plugin@^2.4.2, mini-css-extract-plugin@^2.6.0: tapable "^2.2.1" minimatch@*: - version "10.2.0" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.0.tgz#e710473e66e3e1aaf376d0aa82438375cac86e9e" - integrity sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w== + version "10.2.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.2.tgz#361603ee323cfb83496fea2ae17cc44ea4e1f99f" + integrity sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw== dependencies: brace-expansion "^5.0.2" @@ -5620,9 +5606,9 @@ minimatch@3.0.4: brace-expansion "^1.1.7" minimatch@^3.0.4, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + version "3.1.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624" + integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA== dependencies: brace-expansion "^1.1.7" @@ -6530,9 +6516,9 @@ postcss@^8.2.14, postcss@^8.2.15, postcss@^8.4.12, postcss@^8.4.40: source-map-js "^1.2.1" preact@^10.13.2: - version "10.28.3" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.3.tgz#3c2171526b3e29628ad1a6c56a9e3ca867bbdee8" - integrity sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA== + version "10.28.4" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a" + integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ== pretty-error@^4.0.0: version "4.0.0" @@ -7582,10 +7568,10 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -undici-types@~7.16.0: - version "7.16.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" - integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1"