mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-20 18:31:33 +00:00
Merge branch 'master' into feature/kicad-enhancements
This commit is contained in:
commit
6ab75488b4
56 changed files with 7142 additions and 2997 deletions
4
.github/workflows/docker_build.yml
vendored
4
.github/workflows/docker_build.yml
vendored
|
|
@ -98,7 +98,7 @@ jobs:
|
||||||
-
|
-
|
||||||
name: Upload digest
|
name: Upload digest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.platform-slug }}
|
name: digests-${{ matrix.platform-slug }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
|
|
@ -113,7 +113,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Download digests
|
name: Download digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
|
||||||
4
.github/workflows/docker_frankenphp.yml
vendored
4
.github/workflows/docker_frankenphp.yml
vendored
|
|
@ -99,7 +99,7 @@ jobs:
|
||||||
-
|
-
|
||||||
name: Upload digest
|
name: Upload digest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.platform-slug }}
|
name: digests-${{ matrix.platform-slug }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
|
|
@ -114,7 +114,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Download digests
|
name: Download digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@
|
||||||
import { Controller } from '@hotwired/stimulus';
|
import { Controller } from '@hotwired/stimulus';
|
||||||
import { Toast } from 'bootstrap';
|
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 {
|
export default class extends Controller {
|
||||||
connect() {
|
connect() {
|
||||||
//Move all toasts from the page into our toast container and show them
|
//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);
|
const toast = new Toast(this.element);
|
||||||
toast.show();
|
toast.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Controller} from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose of this controller is to allow users to input non-printable characters like EOT, FS, etc. in a form field and submit them correctly with the form.
|
||||||
|
* The visible input field encodes non-printable characters via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉, so that they can be displayed in the input field without breaking the form submission.
|
||||||
|
* The actual value of the field, which is submitted with the form, is stored in a hidden input and contains the non-printable characters in their original form.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
|
||||||
|
_hiddenInput;
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.element.addEventListener("input", this._update.bind(this));
|
||||||
|
|
||||||
|
// We use a hidden input to store the actual value of the field, which is submitted with the form.
|
||||||
|
// The visible input is just for user interaction and can contain non-printable characters, which are not allowed in the hidden input.
|
||||||
|
this._hiddenInput = document.createElement("input");
|
||||||
|
this._hiddenInput.type = "hidden";
|
||||||
|
this._hiddenInput.name = this.element.name;
|
||||||
|
this.element.removeAttribute("name");
|
||||||
|
this.element.parentNode.insertBefore(this._hiddenInput, this.element.nextSibling);
|
||||||
|
|
||||||
|
this.element.addEventListener("keypress", this._onKeyPress.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that non-printable characters like EOT, FS, etc. gets added to the input value when the user types them
|
||||||
|
* @param event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_onKeyPress(event) {
|
||||||
|
const ALLOWED_INPUT_CODES = [4, 28, 29, 30, 31]; //EOT, FS, GS, RS, US
|
||||||
|
|
||||||
|
if (!ALLOWED_INPUT_CODES.includes(event.keyCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const char = String.fromCharCode(event.keyCode);
|
||||||
|
this.element.value += char;
|
||||||
|
|
||||||
|
this._update();
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
_update() {
|
||||||
|
//Chrome workaround: Remove a leading ∠ character (U+2220) that appears when the user types a non-printable character at the beginning of the input field.
|
||||||
|
if (this.element.value.startsWith("∠")) {
|
||||||
|
this.element.value = this.element.value.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove non-printable characters from the input value and store them in the hidden input
|
||||||
|
const normalizedValue = this.decodeNonPrintableChars(this.element.value);
|
||||||
|
this._hiddenInput.value = normalizedValue;
|
||||||
|
|
||||||
|
// Encode non-printable characters in the visible input to their Unicode Control picture representation
|
||||||
|
const encodedValue = this.encodeNonPrintableChars(normalizedValue);
|
||||||
|
if (encodedValue !== this.element.value) {
|
||||||
|
this.element.value = encodedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes non-printable characters in the given string via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉.
|
||||||
|
* This allows us to display non-printable characters in the input field without breaking the form submission.
|
||||||
|
* @param str
|
||||||
|
*/
|
||||||
|
encodeNonPrintableChars(str) {
|
||||||
|
return str.replace(/[\x00-\x1F\x7F]/g, (char) => {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
return String.fromCharCode(0x2400 + code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the Unicode Control picture representation of non-printable characters back to their original form, e.g. ␊ becomes \n and ␉ becomes \t.
|
||||||
|
* @param str
|
||||||
|
*/
|
||||||
|
decodeNonPrintableChars(str) {
|
||||||
|
return str.replace(/[\u2400-\u241F\u2421]/g, (char) => {
|
||||||
|
const code = char.charCodeAt(0) - 0x2400;
|
||||||
|
return String.fromCharCode(code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
136
assets/controllers/helpers/scan_special_char_controller.js
Normal file
136
assets/controllers/helpers/scan_special_char_controller.js
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This controller listens for a special non-printable character (SOH / ASCII 1) to be entered anywhere on the page,
|
||||||
|
* which is then used as a trigger to submit the following characters as a barcode / scan input.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
// Optional: Log to confirm global attachment
|
||||||
|
console.log("Scanner listener active")
|
||||||
|
|
||||||
|
this.isCapturing = false
|
||||||
|
this.buffer = ""
|
||||||
|
|
||||||
|
window.addEventListener("keypress", this.handleKeydown.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
this.isCapturing = false
|
||||||
|
this.buffer = ""
|
||||||
|
this.timeoutId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(event) {
|
||||||
|
|
||||||
|
// Ignore if the user is typing in a form field
|
||||||
|
const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) ||
|
||||||
|
event.target.isContentEditable;
|
||||||
|
if (isInput) return
|
||||||
|
|
||||||
|
// 1. Detect Start of Header (SOH / Ctrl+A)
|
||||||
|
if (event.key === "\x01" || event.keyCode === 1) {
|
||||||
|
this.startCapturing(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process characters if in capture mode
|
||||||
|
if (this.isCapturing) {
|
||||||
|
this.resetTimeout() // Push the expiration back with every keypress
|
||||||
|
|
||||||
|
if (event.key === "Enter" || event.keyCode === 13) {
|
||||||
|
|
||||||
|
this.finishCapturing(event)
|
||||||
|
} else if (event.key.length === 1) {
|
||||||
|
this.buffer += event.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCapturing(event) {
|
||||||
|
this.isCapturing = true
|
||||||
|
this.buffer = ""
|
||||||
|
this.resetTimeout()
|
||||||
|
event.preventDefault()
|
||||||
|
console.debug("Scan character detected. Capture started...")
|
||||||
|
}
|
||||||
|
|
||||||
|
finishCapturing(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const data = this.buffer;
|
||||||
|
this.stopCapturing()
|
||||||
|
this.processCapture(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCapturing() {
|
||||||
|
this.isCapturing = false
|
||||||
|
this.buffer = ""
|
||||||
|
if (this.timeoutId) clearTimeout(this.timeoutId)
|
||||||
|
console.debug("Capture cleared/finished.")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTimeout() {
|
||||||
|
if (this.timeoutId) clearTimeout(this.timeoutId)
|
||||||
|
|
||||||
|
this.timeoutId = setTimeout(() => {
|
||||||
|
if (this.isCapturing) {
|
||||||
|
console.warn("Capture timed out. Resetting buffer.")
|
||||||
|
this.stopCapturing()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
processCapture(data) {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
console.debug("Captured scan data: " + data)
|
||||||
|
|
||||||
|
const scanInput = document.getElementById("scan_dialog_input");
|
||||||
|
if (scanInput) { //When we are on the scan dialog page, submit the form there
|
||||||
|
this._submitScanForm(data);
|
||||||
|
} else { //Otherwise use our own form (e.g. on the part list page)
|
||||||
|
this.element.querySelector("input[name='input']").value = data;
|
||||||
|
this.element.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
_submitScanForm(data) {
|
||||||
|
const scanInput = document.getElementById("scan_dialog_input");
|
||||||
|
if (!scanInput) {
|
||||||
|
console.error("Scan input field not found!")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanInput.value = data;
|
||||||
|
scanInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const form = document.getElementById("scan_dialog_form");
|
||||||
|
if (!form) {
|
||||||
|
console.error("Scan form not found!")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus";
|
||||||
//import * as ZXing from "@zxing/library";
|
//import * as ZXing from "@zxing/library";
|
||||||
|
|
||||||
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
|
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
|
||||||
|
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
/* stimulusFetch: 'lazy' */
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
|
||||||
//codeReader = null;
|
|
||||||
|
|
||||||
_scanner = null;
|
_scanner = null;
|
||||||
|
_submitting = false;
|
||||||
|
_lastDecodedText = "";
|
||||||
|
_onInfoChange = null;
|
||||||
|
|
||||||
connect() {
|
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
|
//This function ensures, that the qrbox is 70% of the total viewport
|
||||||
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
|
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
|
||||||
|
|
@ -45,30 +59,66 @@ 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
|
//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) => {
|
Html5Qrcode.getCameras().catch(() => {
|
||||||
document.getElementById('scanner-warning').classList.remove('d-none');
|
document.getElementById("scanner-warning")?.classList.remove("d-none");
|
||||||
});
|
});
|
||||||
|
|
||||||
this._scanner = new Html5QrcodeScanner(this.element.id, {
|
this._scanner = new Html5QrcodeScanner(this.element.id, {
|
||||||
fps: 10,
|
fps: 10,
|
||||||
qrbox: qrboxFunction,
|
qrbox: qrboxFunction,
|
||||||
|
// Key change: shrink preview height on mobile
|
||||||
|
...(isMobile ? { aspectRatio: 1.0 } : {}),
|
||||||
experimentalFeatures: {
|
experimentalFeatures: {
|
||||||
//This option improves reading quality on android chrome
|
//This option improves reading quality on android chrome
|
||||||
useBarCodeDetectorIfSupported: true
|
useBarCodeDetectorIfSupported: true,
|
||||||
}
|
},
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
this._scanner.render(this.onScanSuccess.bind(this));
|
this._scanner.render(this.onScanSuccess.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
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) {
|
||||||
document.getElementById('scan_dialog_input').value = 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;
|
||||||
|
|
||||||
|
const input = document.getElementById('scan_dialog_input');
|
||||||
|
input.value = decodedText;
|
||||||
|
//Trigger nonprintable char input controller to update the hidden input value
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
//Submit form
|
//Submit form
|
||||||
document.getElementById('scan_dialog_form').requestSubmit();
|
document.getElementById('scan_dialog_form').requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,6 +58,12 @@
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.part-info-image {
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.object-fit-cover {
|
.object-fit-cover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,12 @@ class RegisterEventHelper {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.registerTooltips();
|
this.registerTooltips();
|
||||||
this.configureDropdowns();
|
this.configureDropdowns();
|
||||||
this.registerSpecialCharInput();
|
|
||||||
|
// Only register special character input if enabled in configuration
|
||||||
|
const keybindingsEnabled = document.body.dataset.keybindingsSpecialCharacters !== 'false';
|
||||||
|
if (keybindingsEnabled) {
|
||||||
|
this.registerSpecialCharInput();
|
||||||
|
}
|
||||||
|
|
||||||
//Initialize ClipboardJS
|
//Initialize ClipboardJS
|
||||||
this.registerLoadHandler(() => {
|
this.registerLoadHandler(() => {
|
||||||
|
|
|
||||||
818
composer.lock
generated
818
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -86,6 +86,9 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
||||||
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
|
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
|
||||||
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
|
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
|
||||||
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
|
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
|
||||||
|
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser.
|
||||||
|
Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled,
|
||||||
|
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.
|
||||||
* `USE_GRAVATAR`: Set to `1` to use [gravatar.com](https://gravatar.com/) images for user avatars (as long as they have
|
* `USE_GRAVATAR`: Set to `1` to use [gravatar.com](https://gravatar.com/) images for user avatars (as long as they have
|
||||||
not set their own picture). The users browsers have to download the pictures from a third-party (gravatar) server, so
|
not set their own picture). The users browsers have to download the pictures from a third-party (gravatar) server, so
|
||||||
this might be a privacy risk.
|
this might be a privacy risk.
|
||||||
|
|
@ -126,6 +129,8 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
||||||
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
|
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
|
||||||
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same
|
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same
|
||||||
description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list.
|
description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list.
|
||||||
|
* `KEYBINDINGS_SPECIAL_CHARS_ENABLED`: Set this to 0 to disable the special character keybindings (Alt + key) for inserting special characters. This can be useful if
|
||||||
|
they conflict with your keyboard layout or system shortcuts.
|
||||||
|
|
||||||
### E-Mail settings (all env only)
|
### E-Mail settings (all env only)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,17 @@ That method is not officially supported nor encouraged by Part-DB, and might bre
|
||||||
The following env configuration options are available:
|
The following env configuration options are available:
|
||||||
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
|
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
|
||||||
|
|
||||||
### Custom provider
|
### Canopy / Amazon
|
||||||
|
The Canopy provider uses the [Canopy API](https://www.canopyapi.co/) to search for parts and get shopping information from Amazon.
|
||||||
|
Canopy is a third-party service that provides access to Amazon product data through their API. Their trial plan offers 100 requests per month for free,
|
||||||
|
and they also offer paid plans with higher limits. To use the Canopy provider, you need to create an account on the Canopy website and obtain an API key.
|
||||||
|
Once you have the API key, you can configure the Canopy provider in Part-DB using the web UI or environment variables:
|
||||||
|
|
||||||
|
* `PROVIDER_CANOPY_API_KEY`: The API key you got from Canopy (mandatory)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Custom providers
|
||||||
|
|
||||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||||
as it is a valid Symfony service, it will be automatically loaded and can be used.
|
as it is a valid Symfony service, it will be automatically loaded and can be used.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,21 @@ parent: Usage
|
||||||
|
|
||||||
This page lists all the keybindings of Part-DB. Currently, there are only the special character keybindings.
|
This page lists all the keybindings of Part-DB. Currently, there are only the special character keybindings.
|
||||||
|
|
||||||
|
## Disabling keybindings
|
||||||
|
|
||||||
|
If you want to disable the special character keybindings (for example, because they conflict with your keyboard layout or system shortcuts), you can do so in two ways:
|
||||||
|
|
||||||
|
### Via the System Settings UI (recommended)
|
||||||
|
|
||||||
|
1. Navigate to **System Settings** (Tools → System Settings)
|
||||||
|
2. Go to **Behavior** → **Keybindings**
|
||||||
|
3. Uncheck **Enable special character keybindings**
|
||||||
|
4. Save the settings
|
||||||
|
|
||||||
|
### Via Environment Variable
|
||||||
|
|
||||||
|
Alternatively, you can set the environment variable `KEYBINDINGS_SPECIAL_CHARS_ENABLED=0` in your `.env.local` file or your server environment configuration.
|
||||||
|
|
||||||
## Special characters
|
## Special characters
|
||||||
|
|
||||||
Using the keybindings below (Alt + key) you can insert special characters into the text fields of Part-DB. This works on
|
Using the keybindings below (Alt + key) you can insert special characters into the text fields of Part-DB. This works on
|
||||||
|
|
|
||||||
51
docs/usage/scanner.md
Normal file
51
docs/usage/scanner.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
---
|
||||||
|
title: Barcode Scanner
|
||||||
|
layout: default
|
||||||
|
parent: Usage
|
||||||
|
---
|
||||||
|
|
||||||
|
# Barcode scanner
|
||||||
|
|
||||||
|
When the user has the correct permission there will be a barcode scanner button in the navbar.
|
||||||
|
On this page you can either input a barcode code by hand, use an external barcode scanner, or use your devices camera to
|
||||||
|
scan a barcode.
|
||||||
|
|
||||||
|
In info mode (when the "Info" toggle is enabled) you can scan a barcode and Part-DB will parse it and show information
|
||||||
|
about it.
|
||||||
|
|
||||||
|
Without info mode, the barcode will directly redirect you to the corresponding page.
|
||||||
|
|
||||||
|
### Barcode matching
|
||||||
|
|
||||||
|
When you scan a barcode, Part-DB will try to match it to an existing part, part lot or storage location first.
|
||||||
|
For Part-DB generated barcodes, it will use the internal ID of a part. Alternatively you can also scan a barcode that contains the part's IPN.
|
||||||
|
|
||||||
|
You can set a GTIN/EAN code in the part properties and Part-DB will open the part page when you scan the corresponding GTIN/EAN barcode.
|
||||||
|
|
||||||
|
On a part lot you can under "Advanced" set a user barcode, that will redirect you to the part lot page when scanned. This allows to reuse
|
||||||
|
arbitrary existing barcodes that already exist on the part lots (for example, from the manufacturer) and link them to the part lot in Part-DB.
|
||||||
|
|
||||||
|
Part-DB can also parse various distributor barcodes (for example from Digikey and Mouser) and will try to redirect you to the corresponding
|
||||||
|
part page based on the distributor part number in the barcode.
|
||||||
|
|
||||||
|
### Part creation from barcodes
|
||||||
|
For certain barcodes Part-DB can automatically create a new part, when it cannot find a matching part.
|
||||||
|
Part-DB will try to retrieve the part information from an information provider and redirects you to the part creation page
|
||||||
|
with the retrieved information pre-filled.
|
||||||
|
|
||||||
|
## Using an external barcode scanner
|
||||||
|
|
||||||
|
Part-DB supports the use of external barcode scanners that emulate keyboard input. To use a barcode scanner with Part-DB,
|
||||||
|
simply connect the scanner to your computer and scan a barcode while the cursor is in a text field in Part-DB.
|
||||||
|
The scanned barcode will be entered into the text field as if you had typed it on the keyboard.
|
||||||
|
|
||||||
|
In scanner fields, it will also try to insert special non-printable characters the scanner send via Alt + key combinations.
|
||||||
|
This is required for EIGP114 datamatrix codes.
|
||||||
|
|
||||||
|
### Automatically redirect on barcode scanning
|
||||||
|
|
||||||
|
If you configure your barcode scanner to send a <SOH> (Start of heading, 0x01) non-printable character at the beginning
|
||||||
|
of the scanned barcode, Part-DB will automatically scan the barcode that comes afterward (and is ended with an enter key)
|
||||||
|
and redirects you to the corresponding page.
|
||||||
|
This allows you to quickly scan a barcode from anywhere in Part-DB without the need to first open the scanner page.
|
||||||
|
If an input field is focused, the barcode will be entered into the field as usual and no redirection will happen.
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ use App\Form\Filters\AttachmentFilterType;
|
||||||
use App\Services\Attachments\AttachmentManager;
|
use App\Services\Attachments\AttachmentManager;
|
||||||
use App\Services\Trees\NodesListBuilder;
|
use App\Services\Trees\NodesListBuilder;
|
||||||
use App\Settings\BehaviorSettings\TableSettings;
|
use App\Settings\BehaviorSettings\TableSettings;
|
||||||
|
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||||
use Omines\DataTablesBundle\DataTableFactory;
|
use Omines\DataTablesBundle\DataTableFactory;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
|
@ -41,31 +42,56 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
class AttachmentFileController extends AbstractController
|
class AttachmentFileController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|
||||||
|
public function __construct(private readonly AttachmentManager $helper)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/attachment/{id}/sandbox', name: 'attachment_html_sandbox')]
|
||||||
|
public function htmlSandbox(Attachment $attachment, AttachmentsSettings $attachmentsSettings): Response
|
||||||
|
{
|
||||||
|
//Check if the sandbox is enabled in the settings, as it can be a security risk if used without proper precautions, so it should be opt-in
|
||||||
|
if (!$attachmentsSettings->showHTMLAttachments) {
|
||||||
|
throw $this->createAccessDeniedException('The HTML sandbox for attachments is disabled in the settings, as it can be a security risk if used without proper precautions. Please enable it in the settings if you want to use it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->checkPermissions($attachment);
|
||||||
|
|
||||||
|
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
|
||||||
|
|
||||||
|
$attachmentContent = file_get_contents($file_path);
|
||||||
|
|
||||||
|
$response = $this->render('attachments/html_sandbox.html.twig', [
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'content' => $attachmentContent,
|
||||||
|
]);
|
||||||
|
|
||||||
|
//Set an CSP that allows to run inline scripts, styles and images from external ressources, but does not allow any connections or others.
|
||||||
|
//Also set the sandbox CSP directive with only "allow-script" to run basic scripts
|
||||||
|
$response->headers->set('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' *; img-src data: *; sandbox allow-scripts allow-downloads allow-modals;");
|
||||||
|
|
||||||
|
//Forbid to embed the attachment render page in an iframe to prevent clickjacking, as it is not used anywhere else for now
|
||||||
|
$response->headers->set('X-Frame-Options', 'DENY');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download the selected attachment.
|
* Download the selected attachment.
|
||||||
*/
|
*/
|
||||||
#[Route(path: '/attachment/{id}/download', name: 'attachment_download')]
|
#[Route(path: '/attachment/{id}/download', name: 'attachment_download')]
|
||||||
public function download(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
|
public function download(Attachment $attachment): BinaryFileResponse
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('read', $attachment);
|
$this->checkPermissions($attachment);
|
||||||
|
|
||||||
if ($attachment->isSecure()) {
|
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
|
||||||
$this->denyAccessUnlessGranted('show_private', $attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$attachment->hasInternal()) {
|
|
||||||
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$helper->isInternalFileExisting($attachment)) {
|
|
||||||
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
|
||||||
}
|
|
||||||
|
|
||||||
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
|
|
||||||
$response = new BinaryFileResponse($file_path);
|
$response = new BinaryFileResponse($file_path);
|
||||||
|
|
||||||
|
$response = $this->forbidHTMLContentType($response);
|
||||||
|
|
||||||
//Set header content disposition, so that the file will be downloaded
|
//Set header content disposition, so that the file will be downloaded
|
||||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT);
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $attachment->getFilename());
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +100,35 @@ class AttachmentFileController extends AbstractController
|
||||||
* View the attachment.
|
* View the attachment.
|
||||||
*/
|
*/
|
||||||
#[Route(path: '/attachment/{id}/view', name: 'attachment_view')]
|
#[Route(path: '/attachment/{id}/view', name: 'attachment_view')]
|
||||||
public function view(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
|
public function view(Attachment $attachment): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$this->checkPermissions($attachment);
|
||||||
|
|
||||||
|
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
|
||||||
|
$response = new BinaryFileResponse($file_path);
|
||||||
|
|
||||||
|
$response = $this->forbidHTMLContentType($response);
|
||||||
|
|
||||||
|
//Set header content disposition, so that the file will be downloaded
|
||||||
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $attachment->getFilename());
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forbidHTMLContentType(BinaryFileResponse $response): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$mimeType = $response->getFile()->getMimeType();
|
||||||
|
|
||||||
|
if ($mimeType === 'text/html') {
|
||||||
|
$mimeType = 'text/plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->headers->set('Content-Type', $mimeType);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkPermissions(Attachment $attachment): void
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('read', $attachment);
|
$this->denyAccessUnlessGranted('read', $attachment);
|
||||||
|
|
||||||
|
|
@ -86,17 +140,9 @@ class AttachmentFileController extends AbstractController
|
||||||
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$helper->isInternalFileExisting($attachment)) {
|
if (!$this->helper->isInternalFileExisting($attachment)) {
|
||||||
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
||||||
}
|
}
|
||||||
|
|
||||||
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
|
|
||||||
$response = new BinaryFileResponse($file_path);
|
|
||||||
|
|
||||||
//Set header content disposition, so that the file will be downloaded
|
|
||||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route(path: '/attachment/list', name: 'attachment_list')]
|
#[Route(path: '/attachment/list', name: 'attachment_list')]
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,16 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Exceptions\InfoProviderNotActiveException;
|
||||||
use App\Form\LabelSystem\ScanDialogType;
|
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\BarcodeScanHelper;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
||||||
use Doctrine\ORM\EntityNotFoundException;
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
|
@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
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
|
* @see \App\Tests\Controller\ScanControllerTest
|
||||||
|
|
@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||||
#[Route(path: '/scan')]
|
#[Route(path: '/scan')]
|
||||||
class ScanController extends AbstractController
|
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')]
|
#[Route(path: '', name: 'scan_dialog')]
|
||||||
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
|
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
|
||||||
|
|
@ -72,35 +85,86 @@ class ScanController extends AbstractController
|
||||||
$form = $this->createForm(ScanDialogType::class);
|
$form = $this->createForm(ScanDialogType::class);
|
||||||
$form->handleRequest($request);
|
$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()) {
|
if ($input === null && $form->isSubmitted() && $form->isValid()) {
|
||||||
$input = $form['input']->getData();
|
$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 {
|
try {
|
||||||
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
$scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
||||||
//Perform a redirect if the info mode is not enabled
|
|
||||||
if (!$form['info_mode']->getData()) {
|
// If not in info mode, mimic “normal scan” behavior: redirect if possible.
|
||||||
try {
|
if (!$infoMode) {
|
||||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
|
||||||
} catch (EntityNotFoundException) {
|
// Try to get an Info URL if possible
|
||||||
$this->addFlash('success', 'scan.qr_not_found');
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
$this->addFlash('error', 'scan.format_unknown');
|
// 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', [
|
return $this->render('label_system/scanner/scanner.html.twig', [
|
||||||
'form' => $form,
|
'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
|
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) {
|
} catch (EntityNotFoundException) {
|
||||||
$this->addFlash('success', 'scan.qr_not_found');
|
$this->addFlash('success', 'scan.qr_not_found');
|
||||||
|
|
||||||
return $this->redirectToRoute('homepage');
|
return $this->redirectToRoute('homepage');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation.
|
||||||
|
* @param BarcodeScanResultInterface $scanResult
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->resultHandler->getCreationURL($scanResult);
|
||||||
|
} catch (InfoProviderNotActiveException $e) {
|
||||||
|
$this->addFlash('error', $e->getMessage());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Don’t break scanning UX if provider lookup fails
|
||||||
|
$this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,22 @@ abstract class Attachment extends AbstractNamedDBElement
|
||||||
return in_array(strtolower($extension), static::MODEL_EXTS, true);
|
return in_array(strtolower($extension), static::MODEL_EXTS, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this is a locally stored HTML file, which can be shown by the sandbox viewer.
|
||||||
|
* This is the case if we have an internal path with a html extension.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isLocalHTMLFile(): bool
|
||||||
|
{
|
||||||
|
if($this->hasInternal()){
|
||||||
|
|
||||||
|
$extension = pathinfo($this->getFilename(), PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
return in_array(strtolower($extension), ['html', 'htm'], true);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if this attachment has a path to an external file
|
* Checks if this attachment has a path to an external file
|
||||||
*
|
*
|
||||||
|
|
|
||||||
48
src/Exceptions/InfoProviderNotActiveException.php
Normal file
48
src/Exceptions/InfoProviderNotActiveException.php
Normal 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'] ?? '???');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,8 @@ class ScanDialogType extends AbstractType
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'autofocus' => true,
|
'autofocus' => true,
|
||||||
'id' => 'scan_dialog_input',
|
'id' => 'scan_dialog_input',
|
||||||
|
'style' => 'font-family: var(--bs-font-monospace)',
|
||||||
|
'data-controller' => 'elements--nonprintable-char-input',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -72,11 +74,7 @@ class ScanDialogType extends AbstractType
|
||||||
'placeholder' => 'scan_dialog.mode.auto',
|
'placeholder' => 'scan_dialog.mode.auto',
|
||||||
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
|
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
|
||||||
null => 'scan_dialog.mode.auto',
|
null => 'scan_dialog.mode.auto',
|
||||||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
default => 'scan_dialog.mode.' . $enum->value,
|
||||||
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
|
||||||
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
|
|
||||||
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
|
|
||||||
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -389,4 +389,93 @@ class PartRepository extends NamedDBElementRepository
|
||||||
return $baseIpn . '_' . ($maxSuffix + 1);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a part based on the provided SPN (Supplier Part Number), with an option for case sensitivity.
|
||||||
|
* If no part is found with the given SPN, null is returned.
|
||||||
|
* @param string $spn
|
||||||
|
* @param bool $caseInsensitive
|
||||||
|
* @return Part|null
|
||||||
|
*/
|
||||||
|
public function getPartBySPN(string $spn, bool $caseInsensitive = true): ?Part
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('part');
|
||||||
|
$qb->select('part');
|
||||||
|
|
||||||
|
$qb->leftJoin('part.orderdetails', 'o');
|
||||||
|
|
||||||
|
if ($caseInsensitive) {
|
||||||
|
$qb->where("LOWER(o.supplierpartnr) = LOWER(:spn)");
|
||||||
|
} else {
|
||||||
|
$qb->where("o.supplierpartnr = :spn");
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb->setParameter('spn', $spn);
|
||||||
|
|
||||||
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,10 @@ class AttachmentSubmitHandler
|
||||||
$attachment->getName()
|
$attachment->getName()
|
||||||
);
|
);
|
||||||
|
|
||||||
return $safeName.'-'.uniqid('', false).'.'.$extension;
|
// Generate a 12-character URL-safe random string, which should avoid collisions and prevent from guessing file paths.
|
||||||
|
$random = str_replace(['+', '/', '='], ['0', '1', '2'], base64_encode(random_bytes(9)));
|
||||||
|
|
||||||
|
return $safeName.'-'.$random.'.'.$extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services\Attachments;
|
namespace App\Services\Attachments;
|
||||||
|
|
||||||
|
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||||
use Imagine\Exception\RuntimeException;
|
use Imagine\Exception\RuntimeException;
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
@ -40,7 +41,7 @@ class AttachmentURLGenerator
|
||||||
|
|
||||||
public function __construct(protected Packages $assets, protected AttachmentPathResolver $pathResolver,
|
public function __construct(protected Packages $assets, protected AttachmentPathResolver $pathResolver,
|
||||||
protected UrlGeneratorInterface $urlGenerator, protected AttachmentManager $attachmentHelper,
|
protected UrlGeneratorInterface $urlGenerator, protected AttachmentManager $attachmentHelper,
|
||||||
protected CacheManager $thumbnailManager, protected LoggerInterface $logger)
|
protected CacheManager $thumbnailManager, protected LoggerInterface $logger, private readonly AttachmentsSettings $attachmentsSettings)
|
||||||
{
|
{
|
||||||
//Determine a normalized path to the public folder (assets are relative to this folder)
|
//Determine a normalized path to the public folder (assets are relative to this folder)
|
||||||
$this->public_path = $this->pathResolver->parameterToAbsolutePath('public');
|
$this->public_path = $this->pathResolver->parameterToAbsolutePath('public');
|
||||||
|
|
@ -99,6 +100,10 @@ class AttachmentURLGenerator
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->attachmentsSettings->showHTMLAttachments && $attachment->isLocalHTMLFile()) {
|
||||||
|
return $this->urlGenerator->generate('attachment_html_sandbox', ['id' => $attachment->getID()]);
|
||||||
|
}
|
||||||
|
|
||||||
$asset_path = $this->absolutePathToAssetPath($absolute_path);
|
$asset_path = $this->absolutePathToAssetPath($absolute_path);
|
||||||
//If path is not relative to public path or marked as secure, serve it via controller
|
//If path is not relative to public path or marked as secure, serve it via controller
|
||||||
if (null === $asset_path || $attachment->isSecure()) {
|
if (null === $asset_path || $attachment->isSecure()) {
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,15 @@ declare(strict_types=1);
|
||||||
namespace App\Services\InfoProviderSystem;
|
namespace App\Services\InfoProviderSystem;
|
||||||
|
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Exceptions\InfoProviderNotActiveException;
|
||||||
|
use App\Exceptions\OAuthReconnectRequiredException;
|
||||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||||
|
use Psr\Http\Client\ClientExceptionInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
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\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
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[]|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
|
* @param string $keyword The keyword to search for
|
||||||
* @return SearchResultDTO[] The search results
|
* @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
|
public function searchByKeyword(string $keyword, array $providers): array
|
||||||
{
|
{
|
||||||
|
|
@ -61,7 +71,7 @@ final class PartInfoRetriever
|
||||||
|
|
||||||
//Ensure that the provider is active
|
//Ensure that the provider is active
|
||||||
if (!$provider->isActive()) {
|
if (!$provider->isActive()) {
|
||||||
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
|
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$provider instanceof InfoProviderInterface) {
|
if (!$provider instanceof InfoProviderInterface) {
|
||||||
|
|
@ -85,7 +95,7 @@ final class PartInfoRetriever
|
||||||
$escaped_keyword = hash('xxh3', $keyword);
|
$escaped_keyword = hash('xxh3', $keyword);
|
||||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||||
//Set the expiration time
|
//Set the expiration time
|
||||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
|
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10);
|
||||||
|
|
||||||
return $provider->searchByKeyword($keyword);
|
return $provider->searchByKeyword($keyword);
|
||||||
});
|
});
|
||||||
|
|
@ -97,6 +107,7 @@ final class PartInfoRetriever
|
||||||
* @param string $provider_key
|
* @param string $provider_key
|
||||||
* @param string $part_id
|
* @param string $part_id
|
||||||
* @return PartDetailDTO
|
* @return PartDetailDTO
|
||||||
|
* @throws InfoProviderNotActiveException if the the given providers is not active
|
||||||
*/
|
*/
|
||||||
public function getDetails(string $provider_key, string $part_id): PartDetailDTO
|
public function getDetails(string $provider_key, string $part_id): PartDetailDTO
|
||||||
{
|
{
|
||||||
|
|
@ -104,14 +115,14 @@ final class PartInfoRetriever
|
||||||
|
|
||||||
//Ensure that the provider is active
|
//Ensure that the provider is active
|
||||||
if (!$provider->isActive()) {
|
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
|
//Generate key and escape reserved characters from the provider id
|
||||||
$escaped_part_id = hash('xxh3', $part_id);
|
$escaped_part_id = hash('xxh3', $part_id);
|
||||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||||
//Set the expiration time
|
//Set the expiration time
|
||||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
|
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10);
|
||||||
|
|
||||||
return $provider->getDetails($part_id);
|
return $provider->getDetails($part_id);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
231
src/Services/InfoProviderSystem/Providers/CanopyProvider.php
Normal file
231
src/Services/InfoProviderSystem/Providers/CanopyProvider.php
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
|
||||||
|
namespace App\Services\InfoProviderSystem\Providers;
|
||||||
|
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||||
|
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||||
|
use App\Settings\InfoProviderSystem\CanopySettings;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use canopy API to retrieve infos from amazon
|
||||||
|
*/
|
||||||
|
class CanopyProvider implements InfoProviderInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
public const BASE_URL = "https://rest.canopyapi.co/api";
|
||||||
|
public const SEARCH_API_URL = self::BASE_URL . "/amazon/search";
|
||||||
|
public const DETAIL_API_URL = self::BASE_URL . "/amazon/product";
|
||||||
|
|
||||||
|
public const DISTRIBUTOR_NAME = 'Amazon';
|
||||||
|
|
||||||
|
public function __construct(private readonly CanopySettings $settings,
|
||||||
|
private readonly HttpClientInterface $httpClient, private readonly CacheItemPoolInterface $partInfoCache)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderInfo(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Amazon (Canopy)',
|
||||||
|
'description' => 'Retrieves part infos from Amazon using the Canopy API',
|
||||||
|
'url' => 'https://canopyapi.co',
|
||||||
|
'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider',
|
||||||
|
'settings_class' => CanopySettings::class
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderKey(): string
|
||||||
|
{
|
||||||
|
return 'canopy';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->settings->apiKey !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productPageFromASIN(string $asin): string
|
||||||
|
{
|
||||||
|
return "https://www.{$this->settings->getRealDomain()}/dp/{$asin}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the given part to the cache.
|
||||||
|
* Everytime this function is called, the cache is overwritten.
|
||||||
|
* @param PartDetailDTO $part
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function saveToCache(PartDetailDTO $part): void
|
||||||
|
{
|
||||||
|
$key = 'canopy_part_'.$part->provider_id;
|
||||||
|
|
||||||
|
$item = $this->partInfoCache->getItem($key);
|
||||||
|
$item->set($part);
|
||||||
|
$item->expiresAfter(3600 * 24); //Cache for 1 day
|
||||||
|
$this->partInfoCache->save($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a from the cache, or null if it was not cached yet.
|
||||||
|
* @param string $id
|
||||||
|
* @return PartDetailDTO|null
|
||||||
|
*/
|
||||||
|
private function getFromCache(string $id): ?PartDetailDTO
|
||||||
|
{
|
||||||
|
$key = 'canopy_part_'.$id;
|
||||||
|
|
||||||
|
$item = $this->partInfoCache->getItem($key);
|
||||||
|
if ($item->isHit()) {
|
||||||
|
return $item->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchByKeyword(string $keyword): array
|
||||||
|
{
|
||||||
|
$response = $this->httpClient->request('GET', self::SEARCH_API_URL, [
|
||||||
|
'query' => [
|
||||||
|
'domain' => $this->settings->domain,
|
||||||
|
'searchTerm' => $keyword,
|
||||||
|
],
|
||||||
|
'headers' => [
|
||||||
|
'API-KEY' => $this->settings->apiKey,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
$results = $data['data']['amazonProductSearchResults']['productResults']['results'] ?? [];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($results as $result) {
|
||||||
|
|
||||||
|
|
||||||
|
$dto = new PartDetailDTO(
|
||||||
|
provider_key: $this->getProviderKey(),
|
||||||
|
provider_id: $result['asin'],
|
||||||
|
name: $result["title"],
|
||||||
|
description: "",
|
||||||
|
preview_image_url: $result["mainImageUrl"] ?? null,
|
||||||
|
provider_url: $this->productPageFromASIN($result['asin']),
|
||||||
|
vendor_infos: [$this->priceToPurchaseInfo($result['price'], $result['asin'])]
|
||||||
|
);
|
||||||
|
|
||||||
|
$out[] = $dto;
|
||||||
|
$this->saveToCache($dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function categoriesToCategory(array $categories): ?string
|
||||||
|
{
|
||||||
|
if (count($categories) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(" -> ", array_map(static fn($cat) => $cat['name'], $categories));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function feauturesBulletsToNotes(array $featureBullets): string
|
||||||
|
{
|
||||||
|
$notes = "<ul>";
|
||||||
|
foreach ($featureBullets as $bullet) {
|
||||||
|
$notes .= "<li>" . $bullet . "</li>";
|
||||||
|
}
|
||||||
|
$notes .= "</ul>";
|
||||||
|
return $notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO
|
||||||
|
{
|
||||||
|
$priceDtos = [];
|
||||||
|
if ($price !== null) {
|
||||||
|
$priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetails(string $id): PartDetailDTO
|
||||||
|
{
|
||||||
|
//Check that the id is a valid ASIN (10 characters, letters and numbers)
|
||||||
|
if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
|
||||||
|
throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
|
||||||
|
}
|
||||||
|
|
||||||
|
//Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details
|
||||||
|
if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->httpClient->request('GET', self::DETAIL_API_URL, [
|
||||||
|
'query' => [
|
||||||
|
'asin' => $id,
|
||||||
|
'domain' => $this->settings->domain,
|
||||||
|
],
|
||||||
|
'headers' => [
|
||||||
|
'API-KEY' => $this->settings->apiKey,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = $response->toArray()['data']['amazonProduct'];
|
||||||
|
|
||||||
|
|
||||||
|
if ($product === null) {
|
||||||
|
throw new \RuntimeException("Product with ASIN $id not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PartDetailDTO(
|
||||||
|
provider_key: $this->getProviderKey(),
|
||||||
|
provider_id: $product['asin'],
|
||||||
|
name: $product['title'],
|
||||||
|
description: '',
|
||||||
|
category: $this->categoriesToCategory($product['categories']),
|
||||||
|
manufacturer: $product['brand'] ?? null,
|
||||||
|
preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null,
|
||||||
|
provider_url: $this->productPageFromASIN($product['asin']),
|
||||||
|
notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []),
|
||||||
|
vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCapabilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ProviderCapabilities::BASIC,
|
||||||
|
ProviderCapabilities::PICTURE,
|
||||||
|
ProviderCapabilities::PRICE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
||||||
public function productMediaToDatasheets(array $productMedia): array
|
public function productMediaToDatasheets(array $productMedia): array
|
||||||
{
|
{
|
||||||
$files = [];
|
$files = [];
|
||||||
foreach ($productMedia['manuals'] as $manual) {
|
foreach ($productMedia['manuals'] ?? [] as $manual) {
|
||||||
//Filter out unwanted languages
|
//Filter out unwanted languages
|
||||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
<?php
|
||||||
/*
|
/*
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
* 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)
|
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
|
@ -17,15 +18,29 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Controller } from '@hotwired/stimulus';
|
declare(strict_types=1);
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
connect() {
|
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||||
//If we encounter an element with this, then change the title of our document according to data-title
|
|
||||||
this.changeTitle(this.element.dataset.title);
|
final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface
|
||||||
|
{
|
||||||
|
public function __construct(public string $asin) {
|
||||||
|
if (!self::isAmazonBarcode($asin)) {
|
||||||
|
throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changeTitle(title) {
|
public static function isAmazonBarcode(string $input): bool
|
||||||
document.title = title;
|
{
|
||||||
|
//Amazon barcodes are 10 alphanumeric characters
|
||||||
|
return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function getDecodedForInfoMode(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ASIN' => $this->asin,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -92,10 +92,19 @@ final class BarcodeScanHelper
|
||||||
if ($type === BarcodeSourceType::EIGP114) {
|
if ($type === BarcodeSourceType::EIGP114) {
|
||||||
return $this->parseEIGP114Barcode($input);
|
return $this->parseEIGP114Barcode($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === BarcodeSourceType::GTIN) {
|
if ($type === BarcodeSourceType::GTIN) {
|
||||||
return $this->parseGTINBarcode($input);
|
return $this->parseGTINBarcode($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($type === BarcodeSourceType::LCSC) {
|
||||||
|
return $this->parseLCSCBarcode($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === BarcodeSourceType::AMAZON) {
|
||||||
|
return new AmazonBarcodeScanResult($input);
|
||||||
|
}
|
||||||
|
|
||||||
//Null means auto and we try the different formats
|
//Null means auto and we try the different formats
|
||||||
$result = $this->parseInternalBarcode($input);
|
$result = $this->parseInternalBarcode($input);
|
||||||
|
|
||||||
|
|
@ -125,6 +134,16 @@ final class BarcodeScanHelper
|
||||||
return $this->parseGTINBarcode($input);
|
return $this->parseGTINBarcode($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try LCSC barcode
|
||||||
|
if (LCSCBarcodeScanResult::isLCSCBarcode($input)) {
|
||||||
|
return $this->parseLCSCBarcode($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Try amazon barcode
|
||||||
|
if (AmazonBarcodeScanResult::isAmazonBarcode($input)) {
|
||||||
|
return new AmazonBarcodeScanResult($input);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidArgumentException('Unknown barcode');
|
throw new InvalidArgumentException('Unknown barcode');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +157,11 @@ final class BarcodeScanHelper
|
||||||
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
|
||||||
|
{
|
||||||
|
return LCSCBarcodeScanResult::parse($input);
|
||||||
|
}
|
||||||
|
|
||||||
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
||||||
{
|
{
|
||||||
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
|
||||||
|
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
|
||||||
|
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scanResult instanceof AmazonBarcodeScanResult) {
|
||||||
|
return [
|
||||||
|
'providerKey' => 'canopy',
|
||||||
|
'providerId' => $scanResult->asin,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
|
||||||
* @return array<string, string|int|float|null>
|
* @return array<string, string|int|float|null>
|
||||||
*/
|
*/
|
||||||
public function getDecodedForInfoMode(): array;
|
public function getDecodedForInfoMode(): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,25 +26,30 @@ namespace App\Services\LabelSystem\BarcodeScanner;
|
||||||
/**
|
/**
|
||||||
* This enum represents the different types, where a barcode/QR-code can be generated from
|
* 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 */
|
/** 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) */
|
/** 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
|
* This barcode is a user defined barcode defined on a part lot
|
||||||
*/
|
*/
|
||||||
case USER_DEFINED;
|
case USER_DEFINED = 'user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
|
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
|
||||||
*/
|
*/
|
||||||
case EIGP114;
|
case EIGP114 = 'eigp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
|
* 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';
|
||||||
|
|
||||||
|
case AMAZON = 'amazon';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
* 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
|
* , 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
|
* @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
|
* @var string|null Customer assigned part number – Optional based on
|
||||||
* agreements between Distributor and Supplier
|
* agreements between Distributor and Supplier
|
||||||
*/
|
*/
|
||||||
public readonly ?string $customerPartNumber;
|
public ?string $customerPartNumber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string|null Supplier assigned part number
|
* @var string|null Supplier assigned part number
|
||||||
*/
|
*/
|
||||||
public readonly ?string $supplierPartNumber;
|
public ?string $supplierPartNumber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var int|null Quantity of product
|
* @var int|null Quantity of product
|
||||||
*/
|
*/
|
||||||
public readonly ?int $quantity;
|
public ?int $quantity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string|null Customer assigned purchase order number
|
* @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
|
* @var string|null Line item number from PO. Required on Logistic Label when
|
||||||
* used on back of Packing Slip. See Section 4.9
|
* 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
|
* 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.
|
* to indicate the product is Not Traceable by this data field.
|
||||||
* @var string|null
|
* @var string|null
|
||||||
*/
|
*/
|
||||||
public readonly ?string $dateCode;
|
public ?string $dateCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
|
* 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.
|
* to indicate the product is Not Traceable by this data field.
|
||||||
* @var string|null
|
* @var string|null
|
||||||
*/
|
*/
|
||||||
public readonly ?string $alternativeDateCode;
|
public ?string $alternativeDateCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Traceability number assigned to a batch or group of items. If
|
* Traceability number assigned to a batch or group of items. If
|
||||||
|
|
@ -86,14 +86,14 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||||
* by this data field.
|
* by this data field.
|
||||||
* @var string|null
|
* @var string|null
|
||||||
*/
|
*/
|
||||||
public readonly ?string $lotCode;
|
public ?string $lotCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Country where part was manufactured. Two-letter code from
|
* Country where part was manufactured. Two-letter code from
|
||||||
* ISO 3166 country code list
|
* ISO 3166 country code list
|
||||||
* @var string|null
|
* @var string|null
|
||||||
*/
|
*/
|
||||||
public readonly ?string $countryOfOrigin;
|
public ?string $countryOfOrigin;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string|null Unique alphanumeric number assigned by supplier
|
* @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
|
* Carton. Always used in conjunction with a mixed logistic label
|
||||||
* with a 5S data identifier for Package ID.
|
* with a 5S data identifier for Package ID.
|
||||||
*/
|
*/
|
||||||
public readonly ?string $packageId1;
|
public ?string $packageId1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string|null
|
* @var string|null
|
||||||
* 4S - Package ID for Logistic Carton with like items
|
* 4S - Package ID for Logistic Carton with like items
|
||||||
*/
|
*/
|
||||||
public readonly ?string $packageId2;
|
public ?string $packageId2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string|null
|
* @var string|null
|
||||||
* 5S - Package ID for Logistic Carton with mixed items
|
* 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.
|
* @var string|null Unique alphanumeric number assigned by supplier.
|
||||||
*/
|
*/
|
||||||
public readonly ?string $packingListNumber;
|
public ?string $packingListNumber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string|null Ship date in format YYYYMMDD
|
* @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
|
* @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 #”
|
* @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
|
* @var string|null Alphanumeric string assigned by the supplier to distinguish
|
||||||
* from one closely-related design variation to another. Use as
|
* from one closely-related design variation to another. Use as
|
||||||
* required or when applicable
|
* 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.
|
* @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.)
|
* @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
|
* @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.
|
* @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
|
* @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
|
* @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.
|
* @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
|
* @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
|
//IDs per EIGP 114.2018
|
||||||
$this->shipDate = $data['6D'] ?? null;
|
$this->shipDate = $data['6D'] ?? null;
|
||||||
|
|
@ -329,4 +329,4 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||||
|
|
||||||
return $tmp;
|
return $tmp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
* 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
|
* 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 function __construct(
|
||||||
public readonly LabelSupportedElement $target_type,
|
public LabelSupportedElement $target_type,
|
||||||
public readonly int $target_id,
|
public int $target_id,
|
||||||
public readonly BarcodeSourceType $source_type,
|
public BarcodeSourceType $source_type,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,4 +46,4 @@ class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||||
'Target ID' => $this->target_id,
|
'Target ID' => $this->target_id,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,7 @@ class BehaviorSettings
|
||||||
|
|
||||||
#[EmbeddedSettings]
|
#[EmbeddedSettings]
|
||||||
public ?PartInfoSettings $partInfo = null;
|
public ?PartInfoSettings $partInfo = null;
|
||||||
|
|
||||||
|
#[EmbeddedSettings]
|
||||||
|
public ?KeybindingsSettings $keybindings = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
src/Settings/BehaviorSettings/KeybindingsSettings.php
Normal file
47
src/Settings/BehaviorSettings/KeybindingsSettings.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2025 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\Settings\BehaviorSettings;
|
||||||
|
|
||||||
|
use App\Settings\SettingsIcon;
|
||||||
|
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||||
|
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||||
|
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||||
|
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||||
|
|
||||||
|
#[Settings(name: "keybindings", label: new TM("settings.behavior.keybindings"))]
|
||||||
|
#[SettingsIcon('fa-keyboard')]
|
||||||
|
class KeybindingsSettings
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Whether to enable special character keybindings (Alt+key) in text input fields
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
#[SettingsParameter(
|
||||||
|
label: new TM("settings.behavior.keybindings.enable_special_characters"),
|
||||||
|
description: new TM("settings.behavior.keybindings.enable_special_characters.help"),
|
||||||
|
envVar: "bool:KEYBINDINGS_SPECIAL_CHARS_ENABLED",
|
||||||
|
envVarMode: EnvVarMode::OVERWRITE
|
||||||
|
)]
|
||||||
|
public bool $enableSpecialCharacters = true;
|
||||||
|
}
|
||||||
96
src/Settings/InfoProviderSystem/CanopySettings.php
Normal file
96
src/Settings/InfoProviderSystem/CanopySettings.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?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\Settings\InfoProviderSystem;
|
||||||
|
|
||||||
|
use App\Form\Type\APIKeyType;
|
||||||
|
use App\Settings\SettingsIcon;
|
||||||
|
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||||
|
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||||
|
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||||
|
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||||
|
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[Settings(label: new TM("settings.ips.canopy"))]
|
||||||
|
#[SettingsIcon("fa-plug")]
|
||||||
|
class CanopySettings
|
||||||
|
{
|
||||||
|
public const ALLOWED_DOMAINS = [
|
||||||
|
"amazon.de" => "DE",
|
||||||
|
"amazon.com" => "US",
|
||||||
|
"amazon.co.uk" => "UK",
|
||||||
|
"amazon.fr" => "FR",
|
||||||
|
"amazon.it" => "IT",
|
||||||
|
"amazon.es" => "ES",
|
||||||
|
"amazon.ca" => "CA",
|
||||||
|
"amazon.com.au" => "AU",
|
||||||
|
"amazon.com.br" => "BR",
|
||||||
|
"amazon.com.mx" => "MX",
|
||||||
|
"amazon.in" => "IN",
|
||||||
|
"amazon.co.jp" => "JP",
|
||||||
|
"amazon.nl" => "NL",
|
||||||
|
"amazon.pl" => "PL",
|
||||||
|
"amazon.sa" => "SA",
|
||||||
|
"amazon.sg" => "SG",
|
||||||
|
"amazon.se" => "SE",
|
||||||
|
"amazon.com.tr" => "TR",
|
||||||
|
"amazon.ae" => "AE",
|
||||||
|
"amazon.com.be" => "BE",
|
||||||
|
"amazon.com.cn" => "CN",
|
||||||
|
];
|
||||||
|
|
||||||
|
use SettingsTrait;
|
||||||
|
|
||||||
|
#[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"),
|
||||||
|
formType: APIKeyType::class,
|
||||||
|
formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||||
|
public ?string $apiKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
|
||||||
|
*/
|
||||||
|
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
|
||||||
|
public string $domain = "DE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request
|
||||||
|
*/
|
||||||
|
#[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))]
|
||||||
|
public bool $alwaysGetDetails = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE)
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getRealDomain(): string
|
||||||
|
{
|
||||||
|
$domain = array_search($this->domain, self::ALLOWED_DOMAINS, true);
|
||||||
|
if ($domain === false) {
|
||||||
|
throw new \InvalidArgumentException("Invalid domain selected");
|
||||||
|
}
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,4 +72,7 @@ class InfoProviderSettings
|
||||||
|
|
||||||
#[EmbeddedSettings]
|
#[EmbeddedSettings]
|
||||||
public ?ConradSettings $conrad = null;
|
public ?ConradSettings $conrad = null;
|
||||||
|
|
||||||
|
#[EmbeddedSettings]
|
||||||
|
public ?CanopySettings $canopy = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,4 +58,11 @@ class AttachmentsSettings
|
||||||
envVar: "bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT", envVarMode: EnvVarMode::OVERWRITE
|
envVar: "bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT", envVarMode: EnvVarMode::OVERWRITE
|
||||||
)]
|
)]
|
||||||
public bool $downloadByDefault = false;
|
public bool $downloadByDefault = false;
|
||||||
}
|
|
||||||
|
#[SettingsParameter(
|
||||||
|
label: new TM("settings.system.attachments.showHTMLAttachments"),
|
||||||
|
description: new TM("settings.system.attachments.showHTMLAttachments.help"),
|
||||||
|
envVar: "bool:ATTACHMENT_SHOW_HTML_FILES", envVarMode: EnvVarMode::OVERWRITE
|
||||||
|
)]
|
||||||
|
public bool $showHTMLAttachments = false;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ declare(strict_types=1);
|
||||||
namespace App\Twig;
|
namespace App\Twig;
|
||||||
|
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
|
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
use App\Services\Attachments\AttachmentURLGenerator;
|
use App\Services\Attachments\AttachmentURLGenerator;
|
||||||
|
use App\Services\Attachments\PartPreviewGenerator;
|
||||||
use App\Services\Misc\FAIconGenerator;
|
use App\Services\Misc\FAIconGenerator;
|
||||||
use Twig\Attribute\AsTwigFunction;
|
use Twig\Attribute\AsTwigFunction;
|
||||||
use Twig\Extension\AbstractExtension;
|
use Twig\Extension\AbstractExtension;
|
||||||
|
|
@ -31,7 +34,7 @@ use Twig\TwigFunction;
|
||||||
|
|
||||||
final readonly class AttachmentExtension
|
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);
|
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.
|
* 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
|
* Null is allowed for files withot extension
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,15 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_granted("@tools.label_scanner") %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ path('scan_dialog') }}">
|
||||||
|
<i class="fa-fw fa-solid fa-camera-retro"></i>
|
||||||
|
{% trans %}parts.create_from_scan.title{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_granted('@parts.import') %}
|
{% if is_granted('@parts.import') %}
|
||||||
|
|
@ -159,7 +168,7 @@
|
||||||
<li role="separator" class="dropdown-divider"></li>
|
<li role="separator" class="dropdown-divider"></li>
|
||||||
<h6 class="dropdown-header">{% trans %}user.language_select{% endtrans %}</h6>
|
<h6 class="dropdown-header">{% trans %}user.language_select{% endtrans %}</h6>
|
||||||
<div id="locale-select-menu">
|
<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>
|
</div>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,39 @@
|
||||||
{# Insert flashes #}
|
{% block flashes %}
|
||||||
<div class="toasts-global d-none">
|
{# Insert flashes #}
|
||||||
{% for label, messages in app.flashes() %}
|
<turbo-stream action="replace" action="morph" target="toast-container">
|
||||||
{% for message in messages %}
|
<template>
|
||||||
{{ include('_toast.html.twig', {
|
<div class="toast-container" id="toast-container">
|
||||||
'label': label,
|
{% for label, messages in app.flashes() %}
|
||||||
'message': message
|
{% for message in messages %}
|
||||||
}) }}
|
{{ include('_toast.html.twig', {
|
||||||
{% endfor %}
|
'label': label,
|
||||||
{% endfor %}
|
'message': message
|
||||||
</div>
|
}) }}
|
||||||
|
{% 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 #}
|
{# 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>
|
<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 #}
|
<turbo-stream action="update" target="locale-select-menu">
|
||||||
<div class="d-none" data-title="{{ current_page_title|trim|raw }}" {{ stimulus_controller('turbo/title') }}></div>
|
<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>
|
|
||||||
|
|
|
||||||
78
templates/attachments/html_sandbox.html.twig
Normal file
78
templates/attachments/html_sandbox.html.twig
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>️⚠️️</text></svg>">
|
||||||
|
|
||||||
|
{# The content block is already escaped. so we must not escape it again. #}
|
||||||
|
<title>{% trans %}attachment.sandbox.title{% endtrans %}: {{ attachment.filename }}</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Reset margins and stop the page from scrolling */
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Flex Container */
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Warning Header */
|
||||||
|
.warning-bar {
|
||||||
|
background-color: #ff4d4d;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10; /* Keep it above the iframe */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Iframe: The 'flex: 1' makes it fill all remaining space */
|
||||||
|
.content-frame {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block body %}
|
||||||
|
{# We have a fullscreen iframe, with an warning on top #}
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<header class="warning-bar">
|
||||||
|
<b>⚠️ {% trans%}attachment.sandbox.warning{% endtrans %}</b>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<small>
|
||||||
|
{% trans%}[Attachment]{% endtrans%}: <b>{{ attachment.name }}</b> / <b>{{ attachment.filename ?? "" }}</b> ({% trans%}id.label{% endtrans %}: {{ attachment.id }})
|
||||||
|
<a href="{{ path("attachment_view", {id: attachment.id}) }}" style="color: white; margin-left: 15px;">{% trans%}attachment.sandbox.as_plain_text{% endtrans %}</a>
|
||||||
|
<a href="{{ path("homepage") }}" style="color: white; margin-left: 15px;">{% trans%}attachment.sandbox.back_to_partdb{% endtrans %}</a>
|
||||||
|
</small>
|
||||||
|
</header>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<iframe referrerpolicy="no-referrer" class="content-frame"
|
||||||
|
{# When changing this sandbox, also change the sandbox CSP in the controller #}
|
||||||
|
sandbox="allow-scripts allow-downloads allow-modals"
|
||||||
|
srcdoc="{{ content|e('html_attr') }}"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
|
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
|
||||||
{# For the UX translator, just use the language part (before the _. should be 2 chars), otherwise it finds no translations #}
|
{# For the UX translator, just use the language part (before the _. should be 2 chars), otherwise it finds no translations #}
|
||||||
data-symfony-ux-translator-locale="{{ app.request.locale|u.truncate(2) }}">
|
data-symfony-ux-translator-locale="{{ app.request.locale|u.truncate(2) }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|
@ -26,6 +26,11 @@
|
||||||
<meta name="turbo-refresh-method" content="morph">
|
<meta name="turbo-refresh-method" content="morph">
|
||||||
<meta name="turbo-refresh-scroll" content="preserve">
|
<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="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="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">
|
<link rel="icon" type="image/png" href="{{ asset('icon/favicon-32x32.png') }}" sizes="32x32">
|
||||||
|
|
@ -68,7 +73,17 @@
|
||||||
{{ encore_entry_script_tags('webauthn_tfa') }}
|
{{ encore_entry_script_tags('webauthn_tfa') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body data-base-url="{{ path('homepage', {'_locale': app.request.locale}) }}" data-locale="{{ app.request.locale|default("en")|slice(0,2) }}">
|
<body data-base-url="{{ path('homepage', {'_locale': app.request.locale}) }}"
|
||||||
|
data-locale="{{ app.request.locale|default("en")|slice(0,2) }}"
|
||||||
|
data-keybindings-special-characters="{{ settings_instance('keybindings').enableSpecialCharacters ? 'true' : 'false' }}">
|
||||||
|
|
||||||
|
{# Listen for the special #}
|
||||||
|
{% if is_granted("@tools.label_scanner") %}
|
||||||
|
<form class="d-none" {{ stimulus_controller('helpers/scan_special_char') }} action="{{ path("scan_dialog") }}" data-turbo-frame="content">
|
||||||
|
<input name="input" type="hidden">
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<header>
|
<header>
|
||||||
<turbo-frame id="navbar-frame" target="content" data-turbo-action="advance">
|
<turbo-frame id="navbar-frame" target="content" data-turbo-action="advance">
|
||||||
|
|
@ -114,13 +129,13 @@
|
||||||
|
|
||||||
<!-- Back to top button -->
|
<!-- Back to top button -->
|
||||||
<button id="back-to-top" class="btn btn-primary back-to-top btn-sm" role="button" title="{% trans %}back_to_top{% endtrans %}"
|
<button id="back-to-top" class="btn btn-primary back-to-top btn-sm" role="button" title="{% trans %}back_to_top{% endtrans %}"
|
||||||
{{ stimulus_controller('common/back_to_top') }} {{ stimulus_action('common/back_to_top', 'backToTop') }}>
|
{{ stimulus_controller('common/back_to_top') }} {{ stimulus_action('common/back_to_top', 'backToTop') }}>
|
||||||
<i class="fas fa-angle-up fa-fw"></i>
|
<i class="fas fa-angle-up fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{# Must be outside of the sidebar or it will be hidden too #}
|
{# Must be outside of the sidebar or it will be hidden too #}
|
||||||
<button class="btn btn-outline-secondary btn-sm p-0 d-md-block d-none" type="button" id="sidebar-toggle-button" title="{% trans %}sidebar.big.toggle{% endtrans %}"
|
<button class="btn btn-outline-secondary btn-sm p-0 d-md-block d-none" type="button" id="sidebar-toggle-button" title="{% trans %}sidebar.big.toggle{% endtrans %}"
|
||||||
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
|
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
|
||||||
<i class="fas fa-angle-left"></i>
|
<i class="fas fa-angle-left"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
||||||
154
templates/label_system/scanner/_info_mode.html.twig
Normal file
154
templates/label_system/scanner/_info_mode.html.twig
Normal 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 %}
|
||||||
|
|
@ -10,35 +10,28 @@
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="offset-sm-3 col-sm-9">
|
||||||
|
|
||||||
<div class="img-thumbnail" style="max-width: 600px;">
|
<div class="img-thumbnail" style="max-width: 600px;">
|
||||||
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
|
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
|
|
||||||
|
{% block scan_results %}
|
||||||
{{ form_end(form) }}
|
<turbo-stream action="replace" action="morph" target="scan-augmented-result">
|
||||||
|
<template>
|
||||||
|
<div id="scan-augmented-result" class="mt-3">
|
||||||
{% if infoModeData %}
|
{% include "label_system/scanner/_info_mode.html.twig" %}
|
||||||
<hr>
|
</div>
|
||||||
<h4>{% trans %}label_scanner.decoded_info.title{% endtrans %}</h4>
|
</template>
|
||||||
|
</turbo-stream>
|
||||||
<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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -279,4 +279,32 @@ final class AttachmentTest extends TestCase
|
||||||
$reflection_property->setAccessible(true);
|
$reflection_property->setAccessible(true);
|
||||||
$reflection_property->setValue($object, $value);
|
$reflection_property->setValue($object, $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testIsLocalHTMLFile(): void
|
||||||
|
{
|
||||||
|
$attachment = new PartAttachment();
|
||||||
|
|
||||||
|
$attachment->setExternalPath('https://google.de');
|
||||||
|
$this->assertFalse($attachment->isLocalHTMLFile());
|
||||||
|
|
||||||
|
$attachment->setExternalPath('https://google.de/test.html');
|
||||||
|
$this->assertFalse($attachment->isLocalHTMLFile());
|
||||||
|
|
||||||
|
$attachment->setInternalPath('%MEDIA%/test.html');
|
||||||
|
$this->assertTrue($attachment->isLocalHTMLFile());
|
||||||
|
|
||||||
|
$attachment->setInternalPath('%MEDIA%/test.htm');
|
||||||
|
$this->assertTrue($attachment->isLocalHTMLFile());
|
||||||
|
|
||||||
|
$attachment->setInternalPath('%MEDIA%/test.txt');
|
||||||
|
$this->assertFalse($attachment->isLocalHTMLFile());
|
||||||
|
|
||||||
|
//It works however, if the file is stored as txt, and the internal filename ends with .html
|
||||||
|
$attachment->setInternalPath('%MEDIA%/test.txt');
|
||||||
|
$this->setProtectedProperty($attachment, 'original_filename', 'test.html');
|
||||||
|
$this->assertTrue($attachment->isLocalHTMLFile());
|
||||||
|
|
||||||
|
$this->setProtectedProperty($attachment, 'original_filename', 'test.htm');
|
||||||
|
$this->assertTrue($attachment->isLocalHTMLFile());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,6 +49,7 @@ use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
||||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||||
|
|
||||||
final class BarcodeScanHelperTest extends WebTestCase
|
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"];
|
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
|
public static function invalidDataProvider(): \Iterator
|
||||||
|
|
@ -153,4 +162,33 @@ final class BarcodeScanHelperTest extends WebTestCase
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
$this->service->scanBarcodeContent($input);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?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 testGetRedirectURLReturnsNullOnUnknownScanType(): void
|
||||||
|
{
|
||||||
|
$unknown = new class implements BarcodeScanResultInterface {
|
||||||
|
public function getDecodedForInfoMode(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->assertNull($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 testResolveEntityReturnNullOnUnknownScanType(): void
|
||||||
|
{
|
||||||
|
$unknown = new class implements BarcodeScanResultInterface {
|
||||||
|
public function getDecodedForInfoMode(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->assertNull($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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9548,6 +9548,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>
|
<target>EIGP 114 barcode (e.g. the datamatrix codes on digikey and mouser orders)</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>scan_dialog.info_mode</source>
|
<source>scan_dialog.info_mode</source>
|
||||||
|
|
@ -9560,6 +9566,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||||
<target>Decoded information</target>
|
<target>Decoded information</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="nmXQWcS" name="label_generator.edit_profiles">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>label_generator.edit_profiles</source>
|
<source>label_generator.edit_profiles</source>
|
||||||
|
|
@ -10347,6 +10371,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||||
<target>Show the image overlay with attachment details on hovering over the part image gallery.</target>
|
<target>Show the image overlay with attachment details on hovering over the part image gallery.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="0iYdzdk" name="settings.behavior.keybindings">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.behavior.keybindings</source>
|
||||||
|
<target>Keybindings</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="_x13bMa" name="settings.behavior.keybindings.enable_special_characters">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.behavior.keybindings.enable_special_characters</source>
|
||||||
|
<target>Enable special character keybindings</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="Af8Zzqr" name="settings.behavior.keybindings.enable_special_characters.help">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.behavior.keybindings.enable_special_characters.help</source>
|
||||||
|
<target>Enable Alt+key shortcuts to insert special characters (Greek letters, mathematical symbols, etc.) in text input fields. Disable this if the shortcuts conflict with your keyboard layout or system shortcuts.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="ALfPkeR" name="perm.config.change_system_settings">
|
<unit id="ALfPkeR" name="perm.config.change_system_settings">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>perm.config.change_system_settings</source>
|
<source>perm.config.change_system_settings</source>
|
||||||
|
|
@ -12669,6 +12711,100 @@ Buerklin-API Authentication server:
|
||||||
<segment>
|
<segment>
|
||||||
<source>settings.misc.kicad_eda.default_orderdetails_visibility.help</source>
|
<source>settings.misc.kicad_eda.default_orderdetails_visibility.help</source>
|
||||||
<target>EDA visibility for all purchase infos who does not have an explicit visibility set. When enabled all purchase infos will be visible in the EDA software by default.</target>
|
<target>EDA visibility for all purchase infos who does not have an explicit visibility set. When enabled all purchase infos will be visible in the EDA software by default.</target>
|
||||||
|
<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>
|
||||||
|
<unit id="xH258F." name="parts.create_from_scan.title">
|
||||||
|
<segment>
|
||||||
|
<source>parts.create_from_scan.title</source>
|
||||||
|
<target>Create [part] from label scan</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="8WZYwRJ" name="scan_dialog.mode.amazon">
|
||||||
|
<segment>
|
||||||
|
<source>scan_dialog.mode.amazon</source>
|
||||||
|
<target>Amazon barcode</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="BQWuR_G" name="settings.ips.canopy">
|
||||||
|
<segment>
|
||||||
|
<source>settings.ips.canopy</source>
|
||||||
|
<target>Canopy</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="44BfYzy" name="settings.ips.canopy.alwaysGetDetails">
|
||||||
|
<segment>
|
||||||
|
<source>settings.ips.canopy.alwaysGetDetails</source>
|
||||||
|
<target>Always fetch details</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="so_ms3t" name="settings.ips.canopy.alwaysGetDetails.help">
|
||||||
|
<segment>
|
||||||
|
<source>settings.ips.canopy.alwaysGetDetails.help</source>
|
||||||
|
<target>When selected, more details will be fetched from canopy when creating a part. This causes an additional API request, but gives product bullet points and category info.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="D055xh8" name="attachment.sandbox.warning">
|
||||||
|
<segment>
|
||||||
|
<source>attachment.sandbox.warning</source>
|
||||||
|
<target>WARNING: You are viewing an user uploaded attachment. This is untrusted content. Proceed with care.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bRcdnJK" name="attachment.sandbox.back_to_partdb">
|
||||||
|
<segment>
|
||||||
|
<source>attachment.sandbox.back_to_partdb</source>
|
||||||
|
<target>Back to Part-DB</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="MzyA7N8" name="settings.system.attachments.showHTMLAttachments">
|
||||||
|
<segment>
|
||||||
|
<source>settings.system.attachments.showHTMLAttachments</source>
|
||||||
|
<target>Show uploaded HTML file attachments (sandboxed)</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="V_LJkRy" name="settings.system.attachments.showHTMLAttachments.help">
|
||||||
|
<segment>
|
||||||
|
<source>settings.system.attachments.showHTMLAttachments.help</source>
|
||||||
|
<target>⚠️ When enabled, user uploaded HTML attachments can be viewed directly in the browser. Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled, if you trust the users who can upload files.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="BQo2xWi" name="attachment.sandbox.title">
|
||||||
|
<segment>
|
||||||
|
<source>attachment.sandbox.title</source>
|
||||||
|
<target>HTML [Attachment]</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="sJ6v9uJ" name="attachment.sandbox.as_plain_text">
|
||||||
|
<segment>
|
||||||
|
<source>attachment.sandbox.as_plain_text</source>
|
||||||
|
<target>View as plain text</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
|
|
|
||||||
240
yarn.lock
240
yarn.lock
|
|
@ -2,58 +2,58 @@
|
||||||
# yarn lockfile v1
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
"@algolia/autocomplete-core@1.19.5":
|
"@algolia/autocomplete-core@1.19.6":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.5.tgz#52d99aafce19493161220e417071f0222eeea7d6"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.6.tgz#472ba8f84d3bd1d253d24759caeaac454db902e7"
|
||||||
integrity sha512-/kAE3mMBage/9m0OGnKQteSa7/eIfvhiKx28OWj857+dJ6qYepEBuw5L8its2oTX8ZNM/6TA3fo49kMwgcwjlg==
|
integrity sha512-6EoD7PeM2WBq5GY1jm0gGonDW2JVU4BaHT9tAwDcaPkc6gYIRZeY7X7aFuwdRvk9R/jwsh8sz4flDao0+Kua6g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@algolia/autocomplete-plugin-algolia-insights" "1.19.5"
|
"@algolia/autocomplete-plugin-algolia-insights" "1.19.6"
|
||||||
"@algolia/autocomplete-shared" "1.19.5"
|
"@algolia/autocomplete-shared" "1.19.6"
|
||||||
|
|
||||||
"@algolia/autocomplete-js@1.19.5", "@algolia/autocomplete-js@^1.17.0":
|
"@algolia/autocomplete-js@1.19.6", "@algolia/autocomplete-js@^1.17.0":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-js/-/autocomplete-js-1.19.5.tgz#2ec3efd9d5efd505ea677775d0199e1207e4624e"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-js/-/autocomplete-js-1.19.6.tgz#a81b3b40e7e6356f22af75bfc1c92116d4a86243"
|
||||||
integrity sha512-C2/bEQeqq4nZ4PH2rySRvU9B224KbiCXAPZIn3pmMII/7BiXkppPQyDd+Fdly3ubOmnGFDH6BTzGHamySeOYeg==
|
integrity sha512-rHYKT6P+2FZ1+7a1/JtWIuCmfioOt5eXsAcri6XTYsSutl3BIh8s2e98kbvjbhLfwEuuVDWtST1hdAY2pQdrKw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@algolia/autocomplete-core" "1.19.5"
|
"@algolia/autocomplete-core" "1.19.6"
|
||||||
"@algolia/autocomplete-preset-algolia" "1.19.5"
|
"@algolia/autocomplete-preset-algolia" "1.19.6"
|
||||||
"@algolia/autocomplete-shared" "1.19.5"
|
"@algolia/autocomplete-shared" "1.19.6"
|
||||||
htm "^3.1.1"
|
htm "^3.1.1"
|
||||||
preact "^10.13.2"
|
preact "^10.13.2"
|
||||||
|
|
||||||
"@algolia/autocomplete-plugin-algolia-insights@1.19.5":
|
"@algolia/autocomplete-plugin-algolia-insights@1.19.6":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.5.tgz#05246356fe9837475b08664ff4d6f55960127edc"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.6.tgz#7db79ca4a107059477b56e31e8f7760513f265a2"
|
||||||
integrity sha512-5zbetV9h2VxH+Mxx27I7BH2EIACVRUBE1FNykBK+2c2M+mhXYMY4npHbbGYj6QDEw3VVvH2UxAnghFpCtC6B/w==
|
integrity sha512-VD53DBixhEwDvOB00D03DtBVhh5crgb1N0oH3QTscfYk4TpBH+CKrwmN/XrN/VdJAdP+4K6SgwLii/3OwM9dHw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@algolia/autocomplete-shared" "1.19.5"
|
"@algolia/autocomplete-shared" "1.19.6"
|
||||||
|
|
||||||
"@algolia/autocomplete-plugin-recent-searches@^1.17.0":
|
"@algolia/autocomplete-plugin-recent-searches@^1.17.0":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-recent-searches/-/autocomplete-plugin-recent-searches-1.19.5.tgz#afd80f8abb281c4c01817a1edfde9a8aa95ed5db"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-recent-searches/-/autocomplete-plugin-recent-searches-1.19.6.tgz#13b6617f03bfc8257d947a0d6cf0435de5677847"
|
||||||
integrity sha512-lOEliMbohq0BsZJ7JXFHlfmGBNtuCsQW0PLq8m6X1SdMD4XAn8fFxiOO2Nk1A/IiymZcOoHQV71u6f14wiohDw==
|
integrity sha512-HQdSxHXFlxPUx6okxYWrrSbVD2o3OrDstU/E83Qvdl3Pwya3eZKrjhBb84i3Tqkm71wuABRYmCMNjc/qGFX4hw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@algolia/autocomplete-core" "1.19.5"
|
"@algolia/autocomplete-core" "1.19.6"
|
||||||
"@algolia/autocomplete-js" "1.19.5"
|
"@algolia/autocomplete-js" "1.19.6"
|
||||||
"@algolia/autocomplete-preset-algolia" "1.19.5"
|
"@algolia/autocomplete-preset-algolia" "1.19.6"
|
||||||
"@algolia/autocomplete-shared" "1.19.5"
|
"@algolia/autocomplete-shared" "1.19.6"
|
||||||
|
|
||||||
"@algolia/autocomplete-preset-algolia@1.19.5":
|
"@algolia/autocomplete-preset-algolia@1.19.6":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.19.5.tgz#a9d5756090314c16b8895fa0c74ffccca7f8a1e2"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.19.6.tgz#bf800e3e0e3f69f661476d9d1a3237b122e84aa5"
|
||||||
integrity sha512-afdgxUyBxgX1I34THLScCyC+ld2h8wnCTv7JndRxsRNIJjJpFtRNpnYDq0+HVcp+LYeNd1zksDu7CpltTSEsvA==
|
integrity sha512-/uQlHGK5Q2x5Nvrp3W7JMg4YNGG/ygkHtQLTltDbkpd45wnhV9jUiQA6aCnBed9cq0BXhOJZRxh1zGVZ3yRhBg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@algolia/autocomplete-shared" "1.19.5"
|
"@algolia/autocomplete-shared" "1.19.6"
|
||||||
|
|
||||||
"@algolia/autocomplete-shared@1.19.5":
|
"@algolia/autocomplete-shared@1.19.6":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.5.tgz#1a20f60fd400fd5641718358a2d5c3eb1893cf9c"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.6.tgz#5261f04a1cadf82138b6feb5a6df383106f50d60"
|
||||||
integrity sha512-yblBczNXtm2cCVzX4UAY3KkjdefmZPn1gWbIi8Q7qfBw7FjcKq2EjEl/65x4kU9nUc/ZkB5SeUf/bkqLEnA5gA==
|
integrity sha512-DG1n2B6XQw6DWB5veO4RuzQ/N2oGNpG+sSzGT7gUbi7WhF+jN57abcv2QhB5flXZ0NgddE1i6h7dZuQmYBEorQ==
|
||||||
|
|
||||||
"@algolia/autocomplete-theme-classic@^1.17.0":
|
"@algolia/autocomplete-theme-classic@^1.17.0":
|
||||||
version "1.19.5"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-theme-classic/-/autocomplete-theme-classic-1.19.5.tgz#7b0d3ac11f2dca33600fce9ac383056ab4202cdc"
|
resolved "https://registry.yarnpkg.com/@algolia/autocomplete-theme-classic/-/autocomplete-theme-classic-1.19.6.tgz#ba1c9760ac725283d086a9affd784823fdb72c71"
|
||||||
integrity sha512-LjjhOmDbEXmV2IqaA7Xe8jh6lSpG087yC79ffLpXMKJOib4xSHFvPavsXC8NW25pWVHJFoAfplAAmxmeM2/jhw==
|
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":
|
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
|
||||||
version "7.29.0"
|
version "7.29.0"
|
||||||
|
|
@ -1857,11 +1857,6 @@
|
||||||
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.23.tgz#a6eebc9ab4a5faadae265a4cbec8cfcb5731e77c"
|
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.23.tgz#a6eebc9ab4a5faadae265a4cbec8cfcb5731e77c"
|
||||||
integrity sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==
|
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":
|
"@jbtronics/bs-treeview@^1.0.1":
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/@jbtronics/bs-treeview/-/bs-treeview-1.0.6.tgz#7fe126a2ca4716c824d97ab6d1a5f2417750445a"
|
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==
|
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*":
|
||||||
version "25.2.3"
|
version "25.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.3.tgz#9c18245be768bdb4ce631566c7da303a5c99a7f8"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.3.tgz#605862544ee7ffd7a936bcbf0135a14012f1e549"
|
||||||
integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==
|
integrity sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types "~7.16.0"
|
undici-types "~7.18.0"
|
||||||
|
|
||||||
"@types/parse-json@^4.0.0":
|
"@types/parse-json@^4.0.0":
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
|
|
@ -2392,16 +2387,16 @@ acorn-import-phases@^1.0.3:
|
||||||
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
|
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
|
||||||
|
|
||||||
acorn-walk@^8.0.0:
|
acorn-walk@^8.0.0:
|
||||||
version "8.3.4"
|
version "8.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7"
|
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496"
|
||||||
integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==
|
integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn "^8.11.0"
|
acorn "^8.11.0"
|
||||||
|
|
||||||
acorn@^8.0.4, acorn@^8.11.0, acorn@^8.15.0:
|
acorn@^8.0.4, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.16.0:
|
||||||
version "8.15.0"
|
version "8.16.0"
|
||||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
||||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||||
|
|
||||||
adjust-sourcemap-loader@^4.0.0:
|
adjust-sourcemap-loader@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
|
|
@ -2439,9 +2434,9 @@ ajv-keywords@^5.1.0:
|
||||||
fast-deep-equal "^3.1.3"
|
fast-deep-equal "^3.1.3"
|
||||||
|
|
||||||
ajv@^6.12.5:
|
ajv@^6.12.5:
|
||||||
version "6.12.6"
|
version "6.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a"
|
||||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal "^3.1.1"
|
fast-deep-equal "^3.1.1"
|
||||||
fast-json-stable-stringify "^2.0.0"
|
fast-json-stable-stringify "^2.0.0"
|
||||||
|
|
@ -2606,18 +2601,16 @@ balanced-match@^1.0.0:
|
||||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
balanced-match@^4.0.2:
|
balanced-match@^4.0.2:
|
||||||
version "4.0.2"
|
version "4.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.2.tgz#241591ea634702bef9c482696f2469406e16d233"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||||
integrity sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==
|
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||||
dependencies:
|
|
||||||
jackspeak "^4.2.3"
|
|
||||||
|
|
||||||
barcode-detector@^3.0.0, barcode-detector@^3.0.5:
|
barcode-detector@^3.0.0, barcode-detector@^3.0.5:
|
||||||
version "3.0.8"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.0.8.tgz#09a3363cb24699d1d6389a291383113c44420324"
|
resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.0.tgz#ce340cead9f267951f4c53887ac24b64c21a79c4"
|
||||||
integrity sha512-Z9jzzE8ngEDyN9EU7lWdGgV07mcnEQnrX8W9WecXDqD2v+5CcVjt9+a134a5zb+kICvpsrDx6NYA6ay4LGFs8A==
|
integrity sha512-aQjGxrgsb/WTlw6pHZwFRO6NhFMhwHGEkd0pzV25fBn8dnRA1PA1G7bLeAzvSea646S/96nW5W3jD8wezQZ1vQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
zxing-wasm "2.2.4"
|
zxing-wasm "3.0.0"
|
||||||
|
|
||||||
base64-js@1.3.1:
|
base64-js@1.3.1:
|
||||||
version "1.3.1"
|
version "1.3.1"
|
||||||
|
|
@ -2630,9 +2623,9 @@ base64-js@^1.1.2, base64-js@^1.3.0:
|
||||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||||
|
|
||||||
baseline-browser-mapping@^2.9.0:
|
baseline-browser-mapping@^2.9.0:
|
||||||
version "2.9.19"
|
version "2.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488"
|
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9"
|
||||||
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
|
integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==
|
||||||
|
|
||||||
big.js@^5.2.2:
|
big.js@^5.2.2:
|
||||||
version "5.2.2"
|
version "5.2.2"
|
||||||
|
|
@ -2678,9 +2671,9 @@ brace-expansion@^1.1.7:
|
||||||
concat-map "0.0.1"
|
concat-map "0.0.1"
|
||||||
|
|
||||||
brace-expansion@^5.0.2:
|
brace-expansion@^5.0.2:
|
||||||
version "5.0.2"
|
version "5.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f"
|
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336"
|
||||||
integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==
|
integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match "^4.0.2"
|
balanced-match "^4.0.2"
|
||||||
|
|
||||||
|
|
@ -2800,9 +2793,9 @@ caniuse-api@^3.0.0:
|
||||||
lodash.uniq "^4.5.0"
|
lodash.uniq "^4.5.0"
|
||||||
|
|
||||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001759:
|
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001759:
|
||||||
version "1.0.30001770"
|
version "1.0.30001775"
|
||||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84"
|
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz#9572266e3f7f77efee5deac1efeb4795879d1b7f"
|
||||||
integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==
|
integrity sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==
|
||||||
|
|
||||||
ccount@^2.0.0:
|
ccount@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
|
|
@ -3175,9 +3168,9 @@ css-loader@^5.2.7:
|
||||||
semver "^7.3.5"
|
semver "^7.3.5"
|
||||||
|
|
||||||
css-loader@^7.1.0:
|
css-loader@^7.1.0:
|
||||||
version "7.1.3"
|
version "7.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.3.tgz#c0de715ceabe39b8531a85fcaf6734a430c4d99a"
|
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.4.tgz#8f6bf9f8fc8cbef7d2ef6e80acc6545eaefa90b1"
|
||||||
integrity sha512-frbERmjT0UC5lMheWpJmMilnt9GEhbZJN/heUb7/zaJYeIzj5St9HvDcfshzzOqbsS+rYpMk++2SD3vGETDSyA==
|
integrity sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==
|
||||||
dependencies:
|
dependencies:
|
||||||
icss-utils "^5.1.0"
|
icss-utils "^5.1.0"
|
||||||
postcss "^8.4.40"
|
postcss "^8.4.40"
|
||||||
|
|
@ -3681,9 +3674,9 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
|
||||||
gopd "^1.2.0"
|
gopd "^1.2.0"
|
||||||
|
|
||||||
electron-to-chromium@^1.5.263:
|
electron-to-chromium@^1.5.263:
|
||||||
version "1.5.286"
|
version "1.5.302"
|
||||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e"
|
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb"
|
||||||
integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==
|
integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==
|
||||||
|
|
||||||
emoji-regex@^7.0.1:
|
emoji-regex@^7.0.1:
|
||||||
version "7.0.3"
|
version "7.0.3"
|
||||||
|
|
@ -3701,9 +3694,9 @@ emojis-list@^3.0.0:
|
||||||
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
|
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
|
||||||
|
|
||||||
enhanced-resolve@^5.0.0, enhanced-resolve@^5.19.0:
|
enhanced-resolve@^5.0.0, enhanced-resolve@^5.19.0:
|
||||||
version "5.19.0"
|
version "5.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c"
|
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d"
|
||||||
integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==
|
integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs "^4.2.4"
|
graceful-fs "^4.2.4"
|
||||||
tapable "^2.3.0"
|
tapable "^2.3.0"
|
||||||
|
|
@ -4843,13 +4836,6 @@ isobject@^3.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
|
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
|
||||||
integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
|
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:
|
javascript-stringify@^1.6.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-1.6.0.tgz#142d111f3a6e3dae8f4a9afd77d45855b5a9cce3"
|
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"
|
setimmediate "^1.0.5"
|
||||||
|
|
||||||
katex@^0.16.0:
|
katex@^0.16.0:
|
||||||
version "0.16.28"
|
version "0.16.33"
|
||||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.28.tgz#64068425b5a29b41b136aae0d51cbb2c71d64c39"
|
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.33.tgz#5cd5af2ddc1132fe6a710ae6604ec1f19fca9e91"
|
||||||
integrity sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==
|
integrity sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==
|
||||||
dependencies:
|
dependencies:
|
||||||
commander "^8.3.0"
|
commander "^8.3.0"
|
||||||
|
|
||||||
|
|
@ -5119,9 +5105,9 @@ marked-mangle@^1.0.1:
|
||||||
integrity sha512-bRrqNcfU9v3iRECb7YPvA+/xKZMjHojd9R92YwHbFjdPQ+Wc7vozkbGKAv4U8AUl798mNUuY3DTBQkedsV3TeQ==
|
integrity sha512-bRrqNcfU9v3iRECb7YPvA+/xKZMjHojd9R92YwHbFjdPQ+Wc7vozkbGKAv4U8AUl798mNUuY3DTBQkedsV3TeQ==
|
||||||
|
|
||||||
marked@^17.0.1:
|
marked@^17.0.1:
|
||||||
version "17.0.2"
|
version "17.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.2.tgz#a103f82bed9653dd1d74c15f74107c84ddbe749d"
|
resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.3.tgz#0defa25b1ba288433aa847848475d11109e1b3fd"
|
||||||
integrity sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==
|
integrity sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==
|
||||||
|
|
||||||
math-intrinsics@^1.1.0:
|
math-intrinsics@^1.1.0:
|
||||||
version "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"
|
unist-util-visit-parents "^6.0.0"
|
||||||
|
|
||||||
mdast-util-from-markdown@^2.0.0:
|
mdast-util-from-markdown@^2.0.0:
|
||||||
version "2.0.2"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a"
|
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7"
|
||||||
integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==
|
integrity sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/mdast" "^4.0.0"
|
"@types/mdast" "^4.0.0"
|
||||||
"@types/unist" "^3.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"
|
tapable "^2.2.1"
|
||||||
|
|
||||||
minimatch@*:
|
minimatch@*:
|
||||||
version "10.2.0"
|
version "10.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.0.tgz#e710473e66e3e1aaf376d0aa82438375cac86e9e"
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
|
||||||
integrity sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==
|
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^5.0.2"
|
brace-expansion "^5.0.2"
|
||||||
|
|
||||||
|
|
@ -5620,9 +5606,9 @@ minimatch@3.0.4:
|
||||||
brace-expansion "^1.1.7"
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
minimatch@^3.0.4, minimatch@^3.1.1:
|
minimatch@^3.0.4, minimatch@^3.1.1:
|
||||||
version "3.1.2"
|
version "3.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
|
||||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^1.1.7"
|
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"
|
source-map-js "^1.2.1"
|
||||||
|
|
||||||
preact@^10.13.2:
|
preact@^10.13.2:
|
||||||
version "10.28.3"
|
version "10.28.4"
|
||||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.3.tgz#3c2171526b3e29628ad1a6c56a9e3ca867bbdee8"
|
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a"
|
||||||
integrity sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==
|
integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==
|
||||||
|
|
||||||
pretty-error@^4.0.0:
|
pretty-error@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
|
|
@ -7472,9 +7458,9 @@ to-regex-range@^5.0.1:
|
||||||
is-number "^7.0.0"
|
is-number "^7.0.0"
|
||||||
|
|
||||||
tom-select@^2.1.0:
|
tom-select@^2.1.0:
|
||||||
version "2.5.1"
|
version "2.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.5.1.tgz#8c8d3f11e5c1780b5f26c9e90f4e650842ff9596"
|
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.5.2.tgz#77dd4bc780b1ea72905337b24f04ce19dc6d2ca1"
|
||||||
integrity sha512-63D5/Qf6bb6kLSgksEuas/60oawDcuUHrD90jZofeOpF6bkQFYriKrvtpJBQQ4xIA5dUGcjhBbk/yrlfOQsy3g==
|
integrity sha512-VAlGj5MBWVLMJje2NwA3XSmxa7CUFpp1tdzFZ8wymCkcLeP0NwF4ARmSuUK4BWbmSN1fETlSazWkMIxEpP4GdQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@orchidjs/sifter" "^1.1.0"
|
"@orchidjs/sifter" "^1.1.0"
|
||||||
"@orchidjs/unicode-variants" "^1.1.2"
|
"@orchidjs/unicode-variants" "^1.1.2"
|
||||||
|
|
@ -7515,7 +7501,7 @@ tslib@^2.8.0:
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
||||||
type-fest@^5.2.0:
|
type-fest@^5.4.4:
|
||||||
version "5.4.4"
|
version "5.4.4"
|
||||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.4.4.tgz#577f165b5ecb44cfc686559cc54ca77f62aa374d"
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.4.4.tgz#577f165b5ecb44cfc686559cc54ca77f62aa374d"
|
||||||
integrity sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==
|
integrity sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==
|
||||||
|
|
@ -7582,10 +7568,10 @@ unbox-primitive@^1.1.0:
|
||||||
has-symbols "^1.1.0"
|
has-symbols "^1.1.0"
|
||||||
which-boxed-primitive "^1.1.1"
|
which-boxed-primitive "^1.1.1"
|
||||||
|
|
||||||
undici-types@~7.16.0:
|
undici-types@~7.18.0:
|
||||||
version "7.16.0"
|
version "7.18.2"
|
||||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
|
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9"
|
||||||
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
|
integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@^2.0.0:
|
unicode-canonical-property-names-ecmascript@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
|
|
@ -7854,15 +7840,15 @@ webpack-sources@^2.0.1, webpack-sources@^2.2.0:
|
||||||
source-list-map "^2.0.1"
|
source-list-map "^2.0.1"
|
||||||
source-map "^0.6.1"
|
source-map "^0.6.1"
|
||||||
|
|
||||||
webpack-sources@^3.3.3:
|
webpack-sources@^3.3.4:
|
||||||
version "3.3.4"
|
version "3.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
|
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
|
||||||
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
|
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
|
||||||
|
|
||||||
webpack@^5.74.0:
|
webpack@^5.74.0:
|
||||||
version "5.105.2"
|
version "5.105.3"
|
||||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.2.tgz#f3b76f9fc36f1152e156e63ffda3bbb82e6739ea"
|
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.3.tgz#307ad95bafffd08bc81049d6519477b16e42e7ba"
|
||||||
integrity sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==
|
integrity sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/eslint-scope" "^3.7.7"
|
"@types/eslint-scope" "^3.7.7"
|
||||||
"@types/estree" "^1.0.8"
|
"@types/estree" "^1.0.8"
|
||||||
|
|
@ -7870,7 +7856,7 @@ webpack@^5.74.0:
|
||||||
"@webassemblyjs/ast" "^1.14.1"
|
"@webassemblyjs/ast" "^1.14.1"
|
||||||
"@webassemblyjs/wasm-edit" "^1.14.1"
|
"@webassemblyjs/wasm-edit" "^1.14.1"
|
||||||
"@webassemblyjs/wasm-parser" "^1.14.1"
|
"@webassemblyjs/wasm-parser" "^1.14.1"
|
||||||
acorn "^8.15.0"
|
acorn "^8.16.0"
|
||||||
acorn-import-phases "^1.0.3"
|
acorn-import-phases "^1.0.3"
|
||||||
browserslist "^4.28.1"
|
browserslist "^4.28.1"
|
||||||
chrome-trace-event "^1.0.2"
|
chrome-trace-event "^1.0.2"
|
||||||
|
|
@ -7888,7 +7874,7 @@ webpack@^5.74.0:
|
||||||
tapable "^2.3.0"
|
tapable "^2.3.0"
|
||||||
terser-webpack-plugin "^5.3.16"
|
terser-webpack-plugin "^5.3.16"
|
||||||
watchpack "^2.5.1"
|
watchpack "^2.5.1"
|
||||||
webpack-sources "^3.3.3"
|
webpack-sources "^3.3.4"
|
||||||
|
|
||||||
which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
|
which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
|
|
@ -8068,10 +8054,10 @@ zwitch@^2.0.0, zwitch@^2.0.4:
|
||||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
|
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
|
||||||
integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
|
integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
|
||||||
|
|
||||||
zxing-wasm@2.2.4:
|
zxing-wasm@3.0.0:
|
||||||
version "2.2.4"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-2.2.4.tgz#06b73db93c5a980d4441f357c0a1f8483c7af691"
|
resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-3.0.0.tgz#184feade580ef7763cac4f1231eae1aa6fe28a39"
|
||||||
integrity sha512-1gq5zs4wuNTs5umWLypzNNeuJoluFvwmvjiiT3L9z/TMlVveeJRWy7h90xyUqCe+Qq0zL0w7o5zkdDMWDr9aZA==
|
integrity sha512-s7ASCPKX+QnH7Y83f4Byxmq/vDzYW7B9m6jMP5S30JGfN2A6WAUn6P3vcBmNguDhPLE6ny2fjTooQVyKBXI1qA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/emscripten" "^1.41.5"
|
"@types/emscripten" "^1.41.5"
|
||||||
type-fest "^5.2.0"
|
type-fest "^5.4.4"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue