Merge branch 'master' into amazon_info_provider

This commit is contained in:
Jan Böhmer 2026-02-22 21:58:36 +01:00
commit c6cbc17c66
34 changed files with 1625 additions and 687 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
const menu = document.getElementById('locale-select-menu');
menu.innerHTML = this.element.innerHTML;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}
}

View file

@ -58,6 +58,12 @@
object-fit: contain;
}
@media (max-width: 768px) {
.part-info-image {
max-height: 100px;
}
}
.object-fit-cover {
object-fit: cover;
}

202
composer.lock generated
View file

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

View file

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

View file

@ -0,0 +1,48 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\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'] ?? '???');
}
}

View file

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

View file

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

View file

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

View file

@ -1,180 +0,0 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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();
}
}

View file

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

View file

@ -0,0 +1,315 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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;
}
}

View file

@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
* @return array<string, string|int|float|null>
*/
public function getDecodedForInfoMode(): array;
}
}

View file

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

View file

@ -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<string, string> $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;
}
}
}

View file

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Services\LabelSystem\BarcodeScanner;
use InvalidArgumentException;
/**
* This class represents the content of a lcsc.com barcode
* Its data structure is represented by {pbn:...,on:...,pc:...,pm:...,qty:...}
*/
readonly class LCSCBarcodeScanResult implements BarcodeScanResultInterface
{
/** @var string|null (pbn) */
public ?string $pickBatchNumber;
/** @var string|null (on) */
public ?string $orderNumber;
/** @var string|null LCSC Supplier part number (pc) */
public ?string $lcscCode;
/** @var string|null (pm) */
public ?string $mpn;
/** @var int|null (qty) */
public ?int $quantity;
/** @var string|null Country Channel as raw value (CC) */
public ?string $countryChannel;
/**
* @var string|null Warehouse code as raw value (WC)
*/
public ?string $warehouseCode;
/**
* @var string|null Unknown numeric code (pdi)
*/
public ?string $pdi;
/**
* @var string|null Unknown value (hp)
*/
public ?string $hp;
/**
* @param array<string, string> $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);
}
}

View file

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

View file

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

View file

@ -159,7 +159,7 @@
<li role="separator" class="dropdown-divider"></li>
<h6 class="dropdown-header">{% trans %}user.language_select{% endtrans %}</h6>
<div id="locale-select-menu">
{# 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 #}
</div>
</ul>
</li>

View file

@ -1,35 +1,39 @@
{# Insert flashes #}
<div class="toasts-global d-none">
{% for label, messages in app.flashes() %}
{% for message in messages %}
{{ include('_toast.html.twig', {
'label': label,
'message': message
}) }}
{% endfor %}
{% endfor %}
</div>
{% block flashes %}
{# Insert flashes #}
<turbo-stream action="replace" action="morph" target="toast-container">
<template>
<div class="toast-container" id="toast-container">
{% for label, messages in app.flashes() %}
{% for message in messages %}
{{ include('_toast.html.twig', {
'label': label,
'message': message
}) }}
{% endfor %}
{% endfor %}
</div>
</template>
</turbo-stream>
{% endblock %}
{# Allow pages to request a fully reload of everything #}
{% if global_reload_needed is defined and global_reload_needed %}
<div {{ stimulus_controller('turbo/global_reload') }}></div>
{% 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 #}
<span id="sidebar-last-time-updated" style="display: none;" data-last-update="{{ sidebar_tree_updater.lastTreeUpdate.format("Y-m-d\\TH:i:sP") }}"></span>
{# The title block is already escaped, therefore we dont require any additional escaping here #}
<div class="d-none" data-title="{{ current_page_title|trim|raw }}" {{ stimulus_controller('turbo/title') }}></div>
<turbo-stream action="update" target="locale-select-menu">
<template>
{% set locales = settings_instance('localization').languageMenuEntries %}
{% if locales is empty %}
{% set locales = locale_menu %}
{% endif %}
{% for locale in locales %}
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
{{ locale|language_name }} ({{ locale|upper }})</a>
{% endfor %}
</template>
</turbo-stream>
<div class="d-none" {{ stimulus_controller('turbo/locale_menu') }}>
{% set locales = settings_instance('localization').languageMenuEntries %}
{% if locales is empty %}
{% set locales = locale_menu %}
{% endif %}
{% for locale in locales %}
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
{{ locale|language_name }} ({{ locale|upper }})</a>
{% endfor %}
</div>

View file

@ -26,6 +26,11 @@
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
{# Allow pages to request a fully reload of everything #}
{% if global_reload_needed is defined and global_reload_needed %}
<meta name="turbo-visit-control" content="reload">
{% endif %}
<link rel="shortcut icon" type="image/x-icon" href="{{ asset('favicon.ico') }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('icon/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" href="{{ asset('icon/favicon-32x32.png') }}" sizes="32x32">

View file

@ -0,0 +1,154 @@
{% import "helper.twig" as helper %}
{% if decoded is not empty %}
<hr>
{% if part %} {# Show detailed info when it is a part #}
<div class="card border-success">
<h5 class="card-header text-bg-success">
<small>{% trans %}label_scanner.db_part_found{% endtrans %}</small>
{% if openUrl %}
<div class="btn-group float-end">
<a href="{{ openUrl }}" target="_blank" class="btn btn-sm btn-outline-light"
title="{% trans %}label_scanner.open{% endtrans %}">
<i class="fa-solid fa-eye"></i>
</a>
</div>
{% endif %}
</h5>
<div class="card-body row">
<div class="col-sm-2">
<img class="d-block w-100 img-fluid img-thumbnail bg-light part-info-image"
src="{{ entity_thumbnail(part) ?? asset('img/part_placeholder.svg') }}" alt="">
</div>
<div class="col-sm-10">
<h4 class="card-title mb-0">{{ part.name }}</h4>
<div class="card-text text-muted">{{ part.description | format_markdown(true) }}</div>
<div>
<dt class="d-inline-block">
<span class="visually-hidden">{% trans %}category.label{% endtrans %}</span>
<i class="fas fa-tag fa-fw" title="{% trans %}category.label{% endtrans %}"></i>
</dt>
<dd class="d-inline">
<span class="text-muted">{{ helper.structural_entity_link(part.category) }}</span>
</dd>
</div>
<div>
<dt class="d-inline-block">
<span class="visually-hidden">{% trans %}footprint.label{% endtrans %}</span>
<i class="fas fa-microchip fa-fw" title="{% trans %}footprint.label{% endtrans %}"></i>
</dt>
<dd class="d-inline">
<span class="text-muted">{{ helper.structural_entity_link(part.footprint) }}</span>
</dd>
</div>
{# Show part lots / locations #}
{% if part.partLots is not empty %}
<table class="table table-sm table-striped mb-2 w-auto">
<thead>
<tr>
<th scope="col">{% trans %}part_lots.storage_location{% endtrans %}</th>
<th scope="col" class="text-end" style="width: 6rem;">
{% trans %}part_lots.amount{% endtrans %}
</th>
</tr>
</thead>
<tbody>
{% for lot in part.partLots %}
<tr>
<td>
{% if lot.storageLocation %}
{{ helper.structural_entity_link(lot.storageLocation) }}
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
<td class="text-end" style="width: 6rem;">
{% if lot.instockUnknown %}
<span class="text-muted">?</span>
{% else %}
{{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-muted">{% trans %}label_scanner.no_locations{% endtrans %}</div>
{% endif %}
</div>
</div>
</div>
{% elseif entity %} {# If we have an entity but that is not an part #}
<div class="card border-success">
<h5 class="card-header text-bg-success">
<small>{% trans %}label_scanner.target_found{% endtrans %}: {{ type_label(entity) }}</small>
{% if openUrl %}
<div class="btn-group float-end">
<a href="{{ openUrl }}" target="_blank" class="btn btn-sm btn-outline-light"
title="{% trans %}label_scanner.open{% endtrans %}">
<i class="fa-solid fa-eye"></i>
</a>
</div>
{% endif %}
</h5>
<div class="card-body row">
<div class="col-sm-2">
<img class="d-block w-100 img-fluid img-thumbnail bg-light part-info-image"
src="{{ entity_thumbnail(entity) ?? asset('img/part_placeholder.svg') }}" alt="">
</div>
<div class="col-sm-10">
<h4 class="card-title mb-0">{{ entity.name }}</h4>
<p>{% trans %}id.label{% endtrans %}: {{ entity.id }} ({{ type_label(entity) }})</p>
{% if entity.fullPath is defined %}
{{ helper.breadcrumb_entity_link(entity)}}
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if createUrl %}
<div class="alert alert-info mb-2">
<h4 class="alert-heading mb-0">{% trans %}label_scanner.part_can_be_created{% endtrans %}</h4>
<p class="text-muted mb-0"><small >{% trans %}label_scanner.part_can_be_created.help{% endtrans %}</small></p>
<hr>
<a class="btn btn-outline-success" href="{{ createUrl }}" target="_blank"><i class="fas fa-plus-square"></i> {% trans %}label_scanner.part_create_btn{% endtrans %}</a>
</div>
{% endif %}
<h4 class="mt-2">
{% trans %}label_scanner.scan_result.title{% endtrans %}
</h4>
{# Decoded barcode fields #}
<table class="table table-striped table-hover table-bordered table-sm">
<tbody>
{% for key, value in decoded %}
<tr>
<th class="text-nowrap">{{ key }}</th>
<td><code>{{ value }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{# Whitespace under table and Input form fields #}
<hr>
{% endif %}

View file

@ -10,35 +10,28 @@
<div class="">
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<div class="img-thumbnail" style="max-width: 600px;">
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
</div>
</div>
</div>
<div id="scan-augmented-result" class="mt-3">
{% include "label_system/scanner/_info_mode.html.twig" %}
</div>
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
</div>
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
{{ form_end(form) }}
{% if infoModeData %}
<hr>
<h4>{% trans %}label_scanner.decoded_info.title{% endtrans %}</h4>
<table class="table table-striped table-hover table-bordered table-sm">
<tbody>
{% for key, value in infoModeData %}
<tr>
<td>{{ key }}</td>
<td><code>{{ value }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% block scan_results %}
<turbo-stream action="replace" action="morph" target="scan-augmented-result">
<template>
<div id="scan-augmented-result" class="mt-3">
{% include "label_system/scanner/_info_mode.html.twig" %}
</div>
</template>
</turbo-stream>
{% endblock %}

View file

@ -1,85 +0,0 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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));
}
}

View file

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

View file

@ -0,0 +1,183 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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']);
}
}

View file

@ -0,0 +1,86 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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)']);
}
}

View file

@ -9500,6 +9500,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders)</target>
</segment>
</unit>
<unit id="BnqcKWx" name="scan_dialog.mode.lcsc">
<segment>
<source>scan_dialog.mode.lcsc</source>
<target>LCSC.com barcode</target>
</segment>
</unit>
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
<segment state="translated">
<source>scan_dialog.info_mode</source>
@ -9512,6 +9518,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Decoded information</target>
</segment>
</unit>
<unit id="kQnodbA" name="label_scanner.target_found">
<segment>
<source>label_scanner.target_found</source>
<target>Item found in database</target>
</segment>
</unit>
<unit id="7Arfw2q" name="label_scanner.scan_result.title">
<segment>
<source>label_scanner.scan_result.title</source>
<target>Scan result</target>
</segment>
</unit>
<unit id="PTh4EK_" name="label_scanner.no_locations">
<segment>
<source>label_scanner.no_locations</source>
<target>Part is not stored at any location.</target>
</segment>
</unit>
<unit id="nmXQWcS" name="label_generator.edit_profiles">
<segment state="translated">
<source>label_generator.edit_profiles</source>
@ -12509,5 +12533,35 @@ Buerklin-API Authentication server:
<target>Last stocktake</target>
</segment>
</unit>
<unit id="aEgd0if" name="label_scanner.open">
<segment>
<source>label_scanner.open</source>
<target>View details</target>
</segment>
</unit>
<unit id="vw_0Qws" name="label_scanner.db_part_found">
<segment>
<source>label_scanner.db_part_found</source>
<target>Database [part] found for barcode</target>
</segment>
</unit>
<unit id="zntajcd" name="label_scanner.part_can_be_created">
<segment>
<source>label_scanner.part_can_be_created</source>
<target>[Part] can be created</target>
</segment>
</unit>
<unit id="cLTbd9w" name="label_scanner.part_can_be_created.help">
<segment>
<source>label_scanner.part_can_be_created.help</source>
<target>No matching [part] was found in the database, but you can create a new [part] based of this barcode.</target>
</segment>
</unit>
<unit id="FfHA3Yf" name="label_scanner.part_create_btn">
<segment>
<source>label_scanner.part_create_btn</source>
<target>Create [part] from barcode</target>
</segment>
</unit>
</file>
</xliff>

194
yarn.lock
View file

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