mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-18 13:18:31 +01:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78b1d41cf8 | ||
|
|
616c3a6742 | ||
|
|
d24a50a696 | ||
|
|
3480dd146e | ||
|
|
dbe49b5f00 | ||
|
|
1c28efb12e | ||
|
|
a6ee68d75a | ||
|
|
30ece64423 | ||
|
|
77ef77961d | ||
|
|
a629949479 | ||
|
|
af6ddffa1d | ||
|
|
f15979ed11 | ||
|
|
df3262a3f7 | ||
|
|
a071701870 | ||
|
|
c549665578 | ||
|
|
2137eecddf | ||
|
|
32a666f6c3 | ||
|
|
1ee998853f | ||
|
|
9ae585d2b7 | ||
|
|
8f92615491 | ||
|
|
e5dcfad3ff | ||
|
|
b7cc14fa14 | ||
|
|
b9d940ae33 | ||
|
|
46617f01a4 | ||
|
|
f097b79103 | ||
|
|
a8f9f9832e | ||
|
|
f3dab36bbe | ||
|
|
bebd603117 | ||
|
|
2660f4ee82 | ||
|
|
eb2bbdd633 | ||
|
|
24966230ea | ||
|
|
477cc1c0bb | ||
|
|
1eee2b30fa | ||
|
|
1650ade338 | ||
|
|
0c83fd4799 | ||
|
|
4004cf9c88 | ||
|
|
419b46e806 | ||
|
|
dcafc8a1a1 | ||
|
|
628f794b37 | ||
|
|
a1fd3199d6 | ||
|
|
4a5cc454ce | ||
|
|
63dd344c02 | ||
|
|
a7a1026f9b | ||
|
|
a67f106bc6 | ||
|
|
430a564592 | ||
|
|
e283d9ced6 | ||
|
|
300382f6e3 | ||
|
|
0b9b2cbf58 | ||
|
|
87919eb445 | ||
|
|
258289482b | ||
|
|
aa9436a19b | ||
|
|
cee6c0ef11 | ||
|
|
c6cbc17c66 | ||
|
|
2ba0f2a95d | ||
|
|
e2b43ba01f | ||
|
|
b6d77af91b | ||
|
|
36e6c9a402 | ||
|
|
f124fa0023 | ||
|
|
c29605ef23 | ||
|
|
8ef9dd432f | ||
|
|
d4d1964aea | ||
|
|
3ffb5e8278 |
16
.github/workflows/assets_artifact_build.yml
vendored
16
.github/workflows/assets_artifact_build.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- '*'
|
||||
- "!l10n_*" # Dont test localization branches
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- 'v*.*.*-**'
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
@@ -17,6 +20,8 @@ jobs:
|
||||
assets_artifact_build:
|
||||
name: Build assets artifact
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
APP_ENV: prod
|
||||
@@ -80,13 +85,20 @@ jobs:
|
||||
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
||||
|
||||
- name: Upload assets artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Only dependencies and built assets
|
||||
path: /tmp/partdb_assets.zip
|
||||
|
||||
- name: Upload full artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Full Part-DB including dependencies and built assets
|
||||
path: /tmp/partdb_with_assets.zip
|
||||
|
||||
- name: Upload assets as release attachment
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
4
.github/workflows/docker_build.yml
vendored
4
.github/workflows/docker_build.yml
vendored
@@ -98,7 +98,7 @@ jobs:
|
||||
-
|
||||
name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform-slug }}
|
||||
path: /tmp/digests/*
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/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
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform-slug }}
|
||||
path: /tmp/digests/*
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
206
assets/commands/kicad_populate_default_mappings.json
Normal file
206
assets/commands/kicad_populate_default_mappings.json
Normal file
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"_comment": "Default KiCad footprint/symbol mappings for partdb:kicad:populate command. Based on KiCad 9.x standard libraries. Use --mapping-file to override or extend these mappings.",
|
||||
"footprints": {
|
||||
"SOT-23": "Package_TO_SOT_SMD:SOT-23",
|
||||
"SOT-23-3": "Package_TO_SOT_SMD:SOT-23",
|
||||
"SOT-23-5": "Package_TO_SOT_SMD:SOT-23-5",
|
||||
"SOT-23-6": "Package_TO_SOT_SMD:SOT-23-6",
|
||||
"SOT-223": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
|
||||
"SOT-223-3": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
|
||||
"SOT-89": "Package_TO_SOT_SMD:SOT-89-3",
|
||||
"SOT-89-3": "Package_TO_SOT_SMD:SOT-89-3",
|
||||
"SOT-323": "Package_TO_SOT_SMD:SOT-323_SC-70",
|
||||
"SOT-363": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
|
||||
"TSOT-25": "Package_TO_SOT_SMD:SOT-23-5",
|
||||
"SC-70-5": "Package_TO_SOT_SMD:SOT-353_SC-70-5",
|
||||
"SC-70-6": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
|
||||
"TO-220": "Package_TO_SOT_THT:TO-220-3_Vertical",
|
||||
"TO-220AB": "Package_TO_SOT_THT:TO-220-3_Vertical",
|
||||
"TO-220AB-3": "Package_TO_SOT_THT:TO-220-3_Vertical",
|
||||
"TO-220FP": "Package_TO_SOT_THT:TO-220F-3_Vertical",
|
||||
"TO-247-3": "Package_TO_SOT_THT:TO-247-3_Vertical",
|
||||
"TO-92": "Package_TO_SOT_THT:TO-92_Inline",
|
||||
"TO-92-3": "Package_TO_SOT_THT:TO-92_Inline",
|
||||
"TO-252": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"TO-252-2L": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"TO-252-3L": "Package_TO_SOT_SMD:TO-252-3",
|
||||
"TO-263": "Package_TO_SOT_SMD:TO-263-2",
|
||||
"TO-263-2": "Package_TO_SOT_SMD:TO-263-2",
|
||||
"D2PAK": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"DPAK": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"SOIC-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
|
||||
"ESOP-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
|
||||
"SOIC-14": "Package_SO:SOIC-14_3.9x8.7mm_P1.27mm",
|
||||
"SOIC-16": "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm",
|
||||
"TSSOP-8": "Package_SO:TSSOP-8_3x3mm_P0.65mm",
|
||||
"TSSOP-14": "Package_SO:TSSOP-14_4.4x5mm_P0.65mm",
|
||||
"TSSOP-16": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
|
||||
"TSSOP-16L": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
|
||||
"TSSOP-20": "Package_SO:TSSOP-20_4.4x6.5mm_P0.65mm",
|
||||
"MSOP-8": "Package_SO:MSOP-8_3x3mm_P0.65mm",
|
||||
"MSOP-10": "Package_SO:MSOP-10_3x3mm_P0.5mm",
|
||||
"MSOP-16": "Package_SO:MSOP-16_3x4mm_P0.5mm",
|
||||
"SO-5": "Package_TO_SOT_SMD:SOT-23-5",
|
||||
"DIP-4": "Package_DIP:DIP-4_W7.62mm",
|
||||
"DIP-6": "Package_DIP:DIP-6_W7.62mm",
|
||||
"DIP-8": "Package_DIP:DIP-8_W7.62mm",
|
||||
"DIP-14": "Package_DIP:DIP-14_W7.62mm",
|
||||
"DIP-16": "Package_DIP:DIP-16_W7.62mm",
|
||||
"DIP-18": "Package_DIP:DIP-18_W7.62mm",
|
||||
"DIP-20": "Package_DIP:DIP-20_W7.62mm",
|
||||
"DIP-24": "Package_DIP:DIP-24_W7.62mm",
|
||||
"DIP-28": "Package_DIP:DIP-28_W7.62mm",
|
||||
"DIP-40": "Package_DIP:DIP-40_W15.24mm",
|
||||
"QFN-8": "Package_DFN_QFN:QFN-8-1EP_3x3mm_P0.65mm_EP1.55x1.55mm",
|
||||
"QFN-12(3x3)": "Package_DFN_QFN:QFN-12-1EP_3x3mm_P0.5mm_EP1.65x1.65mm",
|
||||
"QFN-16": "Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.45x1.45mm",
|
||||
"QFN-20": "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm",
|
||||
"QFN-24": "Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm",
|
||||
"QFN-32": "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm",
|
||||
"QFN-48": "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm",
|
||||
"TQFP-32": "Package_QFP:TQFP-32_7x7mm_P0.8mm",
|
||||
"TQFP-44": "Package_QFP:TQFP-44_10x10mm_P0.8mm",
|
||||
"TQFP-48": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
|
||||
"TQFP-48(7x7)": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
|
||||
"TQFP-64": "Package_QFP:TQFP-64_10x10mm_P0.5mm",
|
||||
"TQFP-100": "Package_QFP:TQFP-100_14x14mm_P0.5mm",
|
||||
"LQFP-32": "Package_QFP:LQFP-32_7x7mm_P0.8mm",
|
||||
"LQFP-48": "Package_QFP:LQFP-48_7x7mm_P0.5mm",
|
||||
"LQFP-64": "Package_QFP:LQFP-64_10x10mm_P0.5mm",
|
||||
"LQFP-100": "Package_QFP:LQFP-100_14x14mm_P0.5mm",
|
||||
|
||||
"SOD-123": "Diode_SMD:D_SOD-123",
|
||||
"SOD-123F": "Diode_SMD:D_SOD-123F",
|
||||
"SOD-123FL": "Diode_SMD:D_SOD-123F",
|
||||
"SOD-323": "Diode_SMD:D_SOD-323",
|
||||
"SOD-523": "Diode_SMD:D_SOD-523",
|
||||
"SOD-882": "Diode_SMD:D_SOD-882",
|
||||
"SOD-882D": "Diode_SMD:D_SOD-882",
|
||||
"SMA(DO-214AC)": "Diode_SMD:D_SMA",
|
||||
"SMA": "Diode_SMD:D_SMA",
|
||||
"SMB": "Diode_SMD:D_SMB",
|
||||
"SMC": "Diode_SMD:D_SMC",
|
||||
|
||||
"DO-35": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
|
||||
"DO-35(DO-204AH)": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
|
||||
"DO-41": "Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal",
|
||||
"DO-201": "Diode_THT:D_DO-201_P15.24mm_Horizontal",
|
||||
|
||||
"DFN-2(0.6x1)": "Package_DFN_QFN:DFN-2-1EP_0.6x1.0mm_P0.65mm_EP0.2x0.55mm",
|
||||
"DFN1006-2": "Package_DFN_QFN:DFN-2_1.0x0.6mm",
|
||||
"DFN-6": "Package_DFN_QFN:DFN-6-1EP_2x2mm_P0.65mm_EP1x1.6mm",
|
||||
"DFN-8": "Package_DFN_QFN:DFN-8-1EP_3x2mm_P0.5mm_EP1.3x1.5mm",
|
||||
|
||||
"0201": "Resistor_SMD:R_0201_0603Metric",
|
||||
"0402": "Resistor_SMD:R_0402_1005Metric",
|
||||
"0603": "Resistor_SMD:R_0603_1608Metric",
|
||||
"0805": "Resistor_SMD:R_0805_2012Metric",
|
||||
"1206": "Resistor_SMD:R_1206_3216Metric",
|
||||
"1210": "Resistor_SMD:R_1210_3225Metric",
|
||||
"1812": "Resistor_SMD:R_1812_4532Metric",
|
||||
"2010": "Resistor_SMD:R_2010_5025Metric",
|
||||
"2512": "Resistor_SMD:R_2512_6332Metric",
|
||||
"2917": "Resistor_SMD:R_2917_7343Metric",
|
||||
"2920": "Resistor_SMD:R_2920_7350Metric",
|
||||
|
||||
"CASE-A-3216-18(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A",
|
||||
"CASE-B-3528-21(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3528-21_Kemet-B",
|
||||
"CASE-C-6032-28(mm)": "Capacitor_Tantalum_SMD:CP_EIA-6032-28_Kemet-C",
|
||||
"CASE-D-7343-31(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-31_Kemet-D",
|
||||
"CASE-E-7343-43(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-43_Kemet-E",
|
||||
|
||||
"SMD,D4xL5.4mm": "Capacitor_SMD:CP_Elec_4x5.4",
|
||||
"SMD,D5xL5.4mm": "Capacitor_SMD:CP_Elec_5x5.4",
|
||||
"SMD,D6.3xL5.4mm": "Capacitor_SMD:CP_Elec_6.3x5.4",
|
||||
"SMD,D6.3xL7.7mm": "Capacitor_SMD:CP_Elec_6.3x7.7",
|
||||
"SMD,D8xL6.5mm": "Capacitor_SMD:CP_Elec_8x6.5",
|
||||
"SMD,D8xL10mm": "Capacitor_SMD:CP_Elec_8x10",
|
||||
"SMD,D10xL10mm": "Capacitor_SMD:CP_Elec_10x10",
|
||||
"SMD,D10xL10.5mm": "Capacitor_SMD:CP_Elec_10x10.5",
|
||||
|
||||
"Through Hole,D5xL11mm": "Capacitor_THT:CP_Radial_D5.0mm_P2.00mm",
|
||||
"Through Hole,D6.3xL11mm": "Capacitor_THT:CP_Radial_D6.3mm_P2.50mm",
|
||||
"Through Hole,D8xL11mm": "Capacitor_THT:CP_Radial_D8.0mm_P3.50mm",
|
||||
"Through Hole,D10xL16mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
|
||||
"Through Hole,D10xL20mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
|
||||
"Through Hole,D12.5xL20mm": "Capacitor_THT:CP_Radial_D12.5mm_P5.00mm",
|
||||
|
||||
"LED 3mm": "LED_THT:LED_D3.0mm",
|
||||
"LED 5mm": "LED_THT:LED_D5.0mm",
|
||||
"LED 0603": "LED_SMD:LED_0603_1608Metric",
|
||||
"LED 0805": "LED_SMD:LED_0805_2012Metric",
|
||||
"SMD5050-4P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
|
||||
"SMD5050-6P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
|
||||
|
||||
"HC-49": "Crystal:Crystal_HC49-4H_Vertical",
|
||||
"HC-49/U": "Crystal:Crystal_HC49-4H_Vertical",
|
||||
"HC-49/S": "Crystal:Crystal_HC49-U_Vertical",
|
||||
"HC-49/US": "Crystal:Crystal_HC49-U_Vertical",
|
||||
|
||||
"USB-A": "Connector_USB:USB_A_Stewart_SS-52100-001_Horizontal",
|
||||
"USB-B": "Connector_USB:USB_B_OST_USB-B1HSxx_Horizontal",
|
||||
"USB-Mini-B": "Connector_USB:USB_Mini-B_Lumberg_2486_01_Horizontal",
|
||||
"USB-Micro-B": "Connector_USB:USB_Micro-B_Molex-105017-0001",
|
||||
"USB-C": "Connector_USB:USB_C_Receptacle_GCT_USB4085",
|
||||
|
||||
"1x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical",
|
||||
"1x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical",
|
||||
"1x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical",
|
||||
"1x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_Vertical",
|
||||
"1x6 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical",
|
||||
"1x8 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical",
|
||||
"1x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x10_P2.54mm_Vertical",
|
||||
"2x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x02_P2.54mm_Vertical",
|
||||
"2x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_Vertical",
|
||||
"2x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x04_P2.54mm_Vertical",
|
||||
"2x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical",
|
||||
"2x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x10_P2.54mm_Vertical",
|
||||
"2x20 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x20_P2.54mm_Vertical",
|
||||
"SIP-3-2.54mm": "Package_SIP:SIP-3_P2.54mm",
|
||||
"SIP-4-2.54mm": "Package_SIP:SIP-4_P2.54mm",
|
||||
"SIP-5-2.54mm": "Package_SIP:SIP-5_P2.54mm"
|
||||
},
|
||||
"categories": {
|
||||
"Electrolytic": "Device:C_Polarized",
|
||||
"Polarized": "Device:C_Polarized",
|
||||
"Tantalum": "Device:C_Polarized",
|
||||
"Zener": "Device:D_Zener",
|
||||
"Schottky": "Device:D_Schottky",
|
||||
"TVS": "Device:D_TVS",
|
||||
"LED": "Device:LED",
|
||||
"NPN": "Device:Q_NPN_BCE",
|
||||
"PNP": "Device:Q_PNP_BCE",
|
||||
"N-MOSFET": "Device:Q_NMOS_GDS",
|
||||
"NMOS": "Device:Q_NMOS_GDS",
|
||||
"N-MOS": "Device:Q_NMOS_GDS",
|
||||
"P-MOSFET": "Device:Q_PMOS_GDS",
|
||||
"PMOS": "Device:Q_PMOS_GDS",
|
||||
"P-MOS": "Device:Q_PMOS_GDS",
|
||||
"MOSFET": "Device:Q_NMOS_GDS",
|
||||
"JFET": "Device:Q_NJFET_DSG",
|
||||
"Ferrite": "Device:Ferrite_Bead",
|
||||
"Crystal": "Device:Crystal",
|
||||
"Oscillator": "Oscillator:Oscillator_Crystal",
|
||||
"Fuse": "Device:Fuse",
|
||||
"Transformer": "Device:Transformer_1P_1S",
|
||||
"Resistor": "Device:R",
|
||||
"Capacitor": "Device:C",
|
||||
"Inductor": "Device:L",
|
||||
"Diode": "Device:D",
|
||||
"Transistor": "Device:Q_NPN_BCE",
|
||||
"Voltage Regulator": "Regulator_Linear:LM317_TO-220",
|
||||
"LDO": "Regulator_Linear:AMS1117-3.3",
|
||||
"Op-Amp": "Amplifier_Operational:LM358",
|
||||
"Comparator": "Comparator:LM393",
|
||||
"Optocoupler": "Isolator:PC817",
|
||||
"Relay": "Relay:Relay_DPDT",
|
||||
"Connector": "Connector:Conn_01x02",
|
||||
"Switch": "Switch:SW_Push",
|
||||
"Button": "Switch:SW_Push",
|
||||
"Potentiometer": "Device:R_POT",
|
||||
"Trimpot": "Device:R_POT",
|
||||
"Thermistor": "Device:Thermistor",
|
||||
"Varistor": "Device:Varistor",
|
||||
"Photo": "Device:LED"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { Toast } from 'bootstrap';
|
||||
|
||||
/**
|
||||
* The purpose of this controller, is to show all containers.
|
||||
* They should already be added via turbo-streams, but have to be called for to show them.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
//Move all toasts from the page into our toast container and show them
|
||||
@@ -33,4 +37,4 @@ export default class extends Controller {
|
||||
const toast = new Toast(this.element);
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
|
||||
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
//codeReader = null;
|
||||
|
||||
_scanner = null;
|
||||
|
||||
_submitting = false;
|
||||
_lastDecodedText = "";
|
||||
_onInfoChange = null;
|
||||
|
||||
connect() {
|
||||
console.log('Init Scanner');
|
||||
|
||||
// Prevent double init if connect fires twice
|
||||
if (this._scanner) return;
|
||||
|
||||
// clear last decoded barcode when state changes on info box
|
||||
const info = document.getElementById("scan_dialog_info_mode");
|
||||
if (info) {
|
||||
this._onInfoChange = () => {
|
||||
this._lastDecodedText = "";
|
||||
};
|
||||
info.addEventListener("change", this._onInfoChange);
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
|
||||
//This function ensures, that the qrbox is 70% of the total viewport
|
||||
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
|
||||
@@ -45,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
|
||||
Html5Qrcode.getCameras().catch((devices) => {
|
||||
document.getElementById('scanner-warning').classList.remove('d-none');
|
||||
Html5Qrcode.getCameras().catch(() => {
|
||||
document.getElementById("scanner-warning")?.classList.remove("d-none");
|
||||
});
|
||||
|
||||
this._scanner = new Html5QrcodeScanner(this.element.id, {
|
||||
fps: 10,
|
||||
qrbox: qrboxFunction,
|
||||
// Key change: shrink preview height on mobile
|
||||
...(isMobile ? { aspectRatio: 1.0 } : {}),
|
||||
experimentalFeatures: {
|
||||
//This option improves reading quality on android chrome
|
||||
useBarCodeDetectorIfSupported: true
|
||||
}
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
},
|
||||
}, false);
|
||||
|
||||
this._scanner.render(this.onScanSuccess.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._scanner.pause();
|
||||
this._scanner.clear();
|
||||
|
||||
// If we already stopped/cleared before submit, nothing to do.
|
||||
const scanner = this._scanner;
|
||||
this._scanner = null;
|
||||
this._lastDecodedText = "";
|
||||
|
||||
// Unbind info-mode change handler (always do this, even if scanner is null)
|
||||
const info = document.getElementById("scan_dialog_info_mode");
|
||||
if (info && this._onInfoChange) {
|
||||
info.removeEventListener("change", this._onInfoChange);
|
||||
}
|
||||
this._onInfoChange = null;
|
||||
|
||||
if (!scanner) return;
|
||||
|
||||
try {
|
||||
const p = scanner.clear?.();
|
||||
if (p && typeof p.then === "function") p.catch(() => {});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onScanSuccess(decodedText, decodedResult) {
|
||||
//Put our decoded Text into the input box
|
||||
document.getElementById('scan_dialog_input').value = decodedText;
|
||||
|
||||
onScanSuccess(decodedText) {
|
||||
if (!decodedText) return;
|
||||
|
||||
const normalized = String(decodedText).trim();
|
||||
if (!normalized) return;
|
||||
|
||||
// scan once per barcode
|
||||
if (normalized === this._lastDecodedText) return;
|
||||
|
||||
// Mark as handled immediately (prevents spam even if callback fires repeatedly)
|
||||
this._lastDecodedText = normalized;
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.part-info-image {
|
||||
max-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ class RegisterEventHelper {
|
||||
constructor() {
|
||||
this.registerTooltips();
|
||||
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
|
||||
this.registerLoadHandler(() => {
|
||||
|
||||
840
composer.lock
generated
840
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,5 +25,5 @@ framework:
|
||||
adapter: cache.app
|
||||
|
||||
cache.settings:
|
||||
adapter: cache.app
|
||||
adapter: cache.system
|
||||
tags: true
|
||||
|
||||
@@ -208,29 +208,29 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* initial_marking?: list<scalar|Param|null>,
|
||||
* events_to_dispatch?: list<string|Param>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* metadata?: list<mixed>,
|
||||
* name?: scalar|Param|null,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* transitions: list<array{ // Default: []
|
||||
* name: string|Param,
|
||||
* transitions?: list<array{ // Default: []
|
||||
* name?: string|Param,
|
||||
* guard?: string|Param, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* metadata?: list<mixed>,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* metadata?: list<mixed>,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resource: scalar|Param|null,
|
||||
* resource?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
|
||||
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
@@ -360,10 +360,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|Param|null>,
|
||||
* },
|
||||
* default_context?: list<mixed>,
|
||||
* default_context?: array<string, mixed>,
|
||||
* named_serializers?: array<string, array{ // Default: []
|
||||
* name_converter?: scalar|Param|null,
|
||||
* default_context?: list<mixed>,
|
||||
* default_context?: array<string, mixed>,
|
||||
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
|
||||
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
|
||||
* }>,
|
||||
@@ -427,7 +427,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* serializer?: array{
|
||||
@@ -440,7 +440,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* transports?: array<string, string|array{ // Default: []
|
||||
* dsn?: scalar|Param|null,
|
||||
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
|
||||
* options?: list<mixed>,
|
||||
* options?: array<string, mixed>,
|
||||
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* retry_strategy?: string|array{
|
||||
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
|
||||
@@ -462,7 +462,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* allow_no_senders?: bool|Param, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
@@ -634,7 +634,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|Param|null>,
|
||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
@@ -679,7 +679,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* secret?: scalar|Param|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
@@ -694,7 +694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* dbal?: array{
|
||||
* default_connection?: scalar|Param|null,
|
||||
* types?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* commented?: bool|Param, // Deprecated: The doctrine-bundle type commenting features were removed; the corresponding config parameter was deprecated in 2.0 and will be dropped in 3.0.
|
||||
* }>,
|
||||
* driver_schemes?: array<string, scalar|Param|null>,
|
||||
@@ -910,7 +910,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* datetime_functions?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* filters?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* parameters?: array<string, mixed>,
|
||||
* }>,
|
||||
@@ -975,7 +975,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* providers?: list<scalar|Param|null>,
|
||||
* },
|
||||
* entity?: array{
|
||||
* class: scalar|Param|null, // The full entity class name of your user class.
|
||||
* class?: scalar|Param|null, // The full entity class name of your user class.
|
||||
* property?: scalar|Param|null, // Default: null
|
||||
* manager_name?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
@@ -986,8 +986,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* ldap?: array{
|
||||
* service: scalar|Param|null,
|
||||
* base_dn: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* base_dn?: scalar|Param|null,
|
||||
* search_dn?: scalar|Param|null, // Default: null
|
||||
* search_password?: scalar|Param|null, // Default: null
|
||||
* extra_fields?: list<scalar|Param|null>,
|
||||
@@ -998,11 +998,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* password_attribute?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* saml?: array{
|
||||
* user_class: scalar|Param|null,
|
||||
* user_class?: scalar|Param|null,
|
||||
* default_roles?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* firewalls: array<string, array{ // Default: []
|
||||
* firewalls?: array<string, array{ // Default: []
|
||||
* pattern?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* methods?: list<scalar|Param|null>,
|
||||
@@ -1136,9 +1136,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* failure_path_parameter?: scalar|Param|null, // Default: "_failure_path"
|
||||
* },
|
||||
* login_link?: array{
|
||||
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||
* signature_properties: list<scalar|Param|null>,
|
||||
* signature_properties?: list<scalar|Param|null>,
|
||||
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
|
||||
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
|
||||
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
|
||||
@@ -1240,13 +1240,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* failure_handler?: scalar|Param|null,
|
||||
* realm?: scalar|Param|null, // Default: null
|
||||
* token_extractors?: list<scalar|Param|null>,
|
||||
* token_handler: string|array{
|
||||
* token_handler?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* oidc_user_info?: string|array{
|
||||
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
|
||||
@@ -1254,27 +1254,27 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* oidc?: array{
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* base_uri: list<scalar|Param|null>,
|
||||
* base_uri?: list<scalar|Param|null>,
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
||||
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers: list<scalar|Param|null>,
|
||||
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers?: list<scalar|Param|null>,
|
||||
* algorithm?: array<mixed>,
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key).
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* },
|
||||
* },
|
||||
* cas?: array{
|
||||
* validation_url: scalar|Param|null, // CAS server validation URL
|
||||
* validation_url?: scalar|Param|null, // CAS server validation URL
|
||||
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
|
||||
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
|
||||
* },
|
||||
@@ -1379,7 +1379,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
@@ -1502,7 +1502,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
@@ -1531,7 +1531,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* generate_final_entities?: bool|Param, // Default: false
|
||||
* }
|
||||
* @psalm-type WebpackEncoreConfig = array{
|
||||
* output_path: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
|
||||
* output_path?: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
|
||||
* crossorigin?: false|"anonymous"|"use-credentials"|Param, // crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials // Default: false
|
||||
* preload?: bool|Param, // preload all rendered script and link tags automatically via the http2 Link header. // Default: false
|
||||
* cache?: bool|Param, // Enable caching of the entry point file(s) // Default: false
|
||||
@@ -1561,27 +1561,27 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache_prefix?: scalar|Param|null, // Default: "media/cache"
|
||||
* },
|
||||
* aws_s3?: array{
|
||||
* bucket: scalar|Param|null,
|
||||
* bucket?: scalar|Param|null,
|
||||
* cache?: scalar|Param|null, // Default: false
|
||||
* use_psr_cache?: bool|Param, // Default: false
|
||||
* acl?: scalar|Param|null, // Default: "public-read"
|
||||
* cache_prefix?: scalar|Param|null, // Default: ""
|
||||
* client_id?: scalar|Param|null, // Default: null
|
||||
* client_config: list<mixed>,
|
||||
* client_config?: list<mixed>,
|
||||
* get_options?: array<string, scalar|Param|null>,
|
||||
* put_options?: array<string, scalar|Param|null>,
|
||||
* proxies?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* flysystem?: array{
|
||||
* filesystem_service: scalar|Param|null,
|
||||
* filesystem_service?: scalar|Param|null,
|
||||
* cache_prefix?: scalar|Param|null, // Default: ""
|
||||
* root_url: scalar|Param|null,
|
||||
* root_url?: scalar|Param|null,
|
||||
* visibility?: "public"|"private"|"noPredefinedVisibility"|Param, // Default: "public"
|
||||
* },
|
||||
* }>,
|
||||
* loaders?: array<string, array{ // Default: []
|
||||
* stream?: array{
|
||||
* wrapper: scalar|Param|null,
|
||||
* wrapper?: scalar|Param|null,
|
||||
* context?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* filesystem?: array{
|
||||
@@ -1595,11 +1595,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* flysystem?: array{
|
||||
* filesystem_service: scalar|Param|null,
|
||||
* filesystem_service?: scalar|Param|null,
|
||||
* },
|
||||
* asset_mapper?: array<mixed>,
|
||||
* chain?: array{
|
||||
* loaders: list<scalar|Param|null>,
|
||||
* loaders?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* driver?: scalar|Param|null, // Default: "gd"
|
||||
@@ -1746,23 +1746,23 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* providers?: array{
|
||||
* apilayer_fixer?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* apilayer_currency_data?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* apilayer_exchange_rates_data?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* abstract_api?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* fixer?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* cryptonator?: array{
|
||||
@@ -1770,7 +1770,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* exchange_rates_api?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* webservicex?: array{
|
||||
@@ -1805,38 +1805,38 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* currency_data_feed?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* currency_layer?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* forge?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* open_exchange_rates?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* app_id: scalar|Param|null,
|
||||
* app_id?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* xignite?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* token: scalar|Param|null,
|
||||
* token?: scalar|Param|null,
|
||||
* },
|
||||
* xchangeapi?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* currency_converter?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* array?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* latestRates: mixed,
|
||||
* latestRates?: mixed,
|
||||
* historicalRates?: mixed,
|
||||
* },
|
||||
* },
|
||||
@@ -2098,9 +2098,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* counter_checker?: scalar|Param|null, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid"
|
||||
* top_origin_validator?: scalar|Param|null, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null
|
||||
* creation_profiles?: array<string, array{ // Default: []
|
||||
* rp: array{
|
||||
* rp?: array{
|
||||
* id?: scalar|Param|null, // Default: null
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* icon?: scalar|Param|null, // Deprecated: The child node "icon" at path "webauthn.creation_profiles..rp.icon" is deprecated and has no effect. // Default: null
|
||||
* },
|
||||
* challenge_length?: int|Param, // Default: 32
|
||||
@@ -2124,21 +2124,21 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* metadata?: bool|array{ // Enable the support of the Metadata Statements. Please read the documentation for this feature.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* mds_repository: scalar|Param|null, // The Metadata Statement repository.
|
||||
* status_report_repository: scalar|Param|null, // The Status Report repository.
|
||||
* mds_repository?: scalar|Param|null, // The Metadata Statement repository.
|
||||
* status_report_repository?: scalar|Param|null, // The Status Report repository.
|
||||
* certificate_chain_checker?: scalar|Param|null, // A Certificate Chain checker. // Default: "Webauthn\\MetadataService\\CertificateChain\\PhpCertificateChainValidator"
|
||||
* },
|
||||
* controllers?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* creation?: array<string, array{ // Default: []
|
||||
* options_method?: scalar|Param|null, // Default: "POST"
|
||||
* options_path: scalar|Param|null,
|
||||
* options_path?: scalar|Param|null,
|
||||
* result_method?: scalar|Param|null, // Default: "POST"
|
||||
* result_path?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
* profile?: scalar|Param|null, // Default: "default"
|
||||
* options_builder?: scalar|Param|null, // When set, corresponds to the ID of the Public Key Credential Creation Builder. The profile-based ebuilder is ignored. // Default: null
|
||||
* user_entity_guesser: scalar|Param|null,
|
||||
* user_entity_guesser?: scalar|Param|null,
|
||||
* hide_existing_credentials?: scalar|Param|null, // In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users. // Default: false
|
||||
* options_storage?: scalar|Param|null, // Deprecated: The child node "options_storage" at path "webauthn.controllers.creation..options_storage" is deprecated. Please use the root option "options_storage" instead. // Service responsible of the options/user entity storage during the ceremony // Default: null
|
||||
* success_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultSuccessHandler"
|
||||
@@ -2150,7 +2150,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* request?: array<string, array{ // Default: []
|
||||
* options_method?: scalar|Param|null, // Default: "POST"
|
||||
* options_path: scalar|Param|null,
|
||||
* options_path?: scalar|Param|null,
|
||||
* result_method?: scalar|Param|null, // Default: "POST"
|
||||
* result_path?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
@@ -2171,10 +2171,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* baseurl?: scalar|Param|null, // Default: "<request_scheme_and_host>/saml/"
|
||||
* strict?: bool|Param,
|
||||
* debug?: bool|Param,
|
||||
* idp: array{
|
||||
* entityId: scalar|Param|null,
|
||||
* singleSignOnService: array{
|
||||
* url: scalar|Param|null,
|
||||
* idp?: array{
|
||||
* entityId?: scalar|Param|null,
|
||||
* singleSignOnService?: array{
|
||||
* url?: scalar|Param|null,
|
||||
* binding?: scalar|Param|null,
|
||||
* },
|
||||
* singleLogoutService?: array{
|
||||
@@ -2245,30 +2245,30 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* contactPerson?: array{
|
||||
* technical?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* support?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* administrative?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* billing?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* other?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* },
|
||||
* organization?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* displayname: scalar|Param|null,
|
||||
* url: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* displayname?: scalar|Param|null,
|
||||
* url?: scalar|Param|null,
|
||||
* }>,
|
||||
* }>,
|
||||
* use_proxy_vars?: bool|Param, // Default: false
|
||||
@@ -2304,7 +2304,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* auto_install?: bool|Param, // Default: false
|
||||
* fonts?: list<array{ // Default: []
|
||||
* normal: scalar|Param|null,
|
||||
* normal?: scalar|Param|null,
|
||||
* bold?: scalar|Param|null,
|
||||
* italic?: scalar|Param|null,
|
||||
* bold_italic?: scalar|Param|null,
|
||||
@@ -2455,7 +2455,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* max_query_depth?: int|Param, // Default: 20
|
||||
* graphql_playground?: array<mixed>,
|
||||
* graphql_playground?: bool|array{ // Deprecated: The "graphql_playground" configuration is deprecated and will be ignored.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* max_query_complexity?: int|Param, // Default: 500
|
||||
* nesting_separator?: scalar|Param|null, // The separator to use to filter nested fields. // Default: "_"
|
||||
* collection?: array{
|
||||
@@ -2512,7 +2514,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
|
||||
* tags?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* description?: scalar|Param|null, // Default: null
|
||||
* }>,
|
||||
* license?: array{
|
||||
@@ -2804,7 +2806,10 @@ final class App
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return AppReference::config($config);
|
||||
/** @var ConfigType $config */
|
||||
$config = AppReference::config($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
* `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
|
||||
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.
|
||||
@@ -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.
|
||||
* `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.
|
||||
* `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)
|
||||
|
||||
|
||||
@@ -88,3 +88,6 @@ The value of the environment variable is copied to the settings database, so the
|
||||
* `php bin/console partdb:attachments:download`: Download all attachments that are not already downloaded to the
|
||||
local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote, and
|
||||
also makes picture thumbnails available for the frontend for them.
|
||||
|
||||
## EDA integration commands
|
||||
* `php bin/console partdb:kicad:populate`: Populate KiCad footprint paths and symbol paths for footprints and categories based on their names. Use `--dry-run` to preview changes without applying them, and `--list` to list current values. See the [EDA integration documentation](eda_integration.md) for more details.
|
||||
|
||||
@@ -87,3 +87,31 @@ To show more levels of categories, you can set this value to a higher number.
|
||||
If you set this value to -1, all parts are shown inside a single category in KiCad, without any subcategories.
|
||||
|
||||
You can view the "real" category path of a part in the part details dialog in KiCad.
|
||||
|
||||
### Kicad:populate command
|
||||
|
||||
Part-DB also provides a command that attempts to automatically populate the KiCad symbol and footprint fields based on the part's category and footprint names.
|
||||
This is especially useful if you have a large database and want to quickly assign symbols and footprints to parts without doing it manually.
|
||||
|
||||
For this run `bin/console partdb:kicad:populate --dry-run` in the terminal, it will show you a list of suggestions for mappings for your existing categories and footprints.
|
||||
It uses names and alternative names, when the primary name doesn't match, to find the right mapping.
|
||||
If you are happy with the suggestions, you can run the command without the `--dry-run` option to apply the changes to your database. By default, only empty values are updated, but you can use the `--force` option to overwrite existing values as well.
|
||||
|
||||
It uses the mapping under `assets/commands/kicad_populate_default_mappings.json` by default, but you can extend/override it by providing your own mapping file
|
||||
with the `--mapping-file` option.
|
||||
The mapping file is a JSON file with the following structure, where the key is the name of the footprint or category, and the value is the corresponding KiCad library path:
|
||||
```json
|
||||
{
|
||||
"footprints": {
|
||||
"MyCustomPackage": "MyLibrary:MyFootprint",
|
||||
"0805": "Capacitor_SMD:C_0805_2012Metric"
|
||||
},
|
||||
"categories": {
|
||||
"Sensor": "Sensor:Sensor_Temperature",
|
||||
"MCU": "MCU_Microchip:PIC16F877A"
|
||||
}
|
||||
}
|
||||
```
|
||||
Its okay if the file contains just one of the `footprints` or `categories` keys, so you can choose to only provide mappings for one of them if you want.
|
||||
|
||||
It is recommended to take a backup of your database before running this command.
|
||||
|
||||
@@ -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:
|
||||
* `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
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
52
migrations/Version20260211000000.php
Normal file
52
migrations/Version20260211000000.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
final class Version20260211000000 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add eda_visibility nullable boolean column to parameters and orderdetails tables';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters ADD eda_visibility TINYINT(1) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE `orderdetails` ADD eda_visibility TINYINT(1) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
|
||||
$this->addSql('ALTER TABLE `orderdetails` DROP COLUMN eda_visibility');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters ADD COLUMN eda_visibility BOOLEAN DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD COLUMN eda_visibility BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
|
||||
$this->addSql('ALTER TABLE orderdetails DROP COLUMN eda_visibility');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters ADD eda_visibility BOOLEAN DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD eda_visibility BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
|
||||
$this->addSql('ALTER TABLE orderdetails DROP COLUMN eda_visibility');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
364
src/Command/PopulateKicadCommand.php
Normal file
364
src/Command/PopulateKicadCommand.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand('partdb:kicad:populate', 'Populate KiCad footprint paths and symbol paths for footprints and categories')]
|
||||
final class PopulateKicadCommand extends Command
|
||||
{
|
||||
private const DEFAULT_MAPPING_FILE = 'assets/commands/kicad_populate_default_mappings.json';
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, #[Autowire("%kernel.project_dir%")] private readonly string $projectDir)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setHelp('This command populates KiCad footprint paths on Footprint entities and KiCad symbol paths on Category entities based on their names.');
|
||||
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without applying them')
|
||||
->addOption('footprints', null, InputOption::VALUE_NONE, 'Only update footprint entities')
|
||||
->addOption('categories', null, InputOption::VALUE_NONE, 'Only update category entities')
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing values (by default, only empty values are updated)')
|
||||
->addOption('list', null, InputOption::VALUE_NONE, 'List all footprints and categories with their current KiCad values')
|
||||
->addOption('mapping-file', null, InputOption::VALUE_REQUIRED, 'Path to a JSON file with custom mappings (merges with built-in defaults)')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$footprintsOnly = $input->getOption('footprints');
|
||||
$categoriesOnly = $input->getOption('categories');
|
||||
$force = $input->getOption('force');
|
||||
$list = $input->getOption('list');
|
||||
$mappingFile = $input->getOption('mapping-file');
|
||||
|
||||
// If neither specified, do both
|
||||
$doFootprints = !$categoriesOnly || $footprintsOnly;
|
||||
$doCategories = !$footprintsOnly || $categoriesOnly;
|
||||
|
||||
if ($list) {
|
||||
$this->listCurrentValues($io);
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Load mappings: start with built-in defaults, then merge user-supplied file
|
||||
['footprints' => $footprintMappings, 'categories' => $categoryMappings] = $this->getDefaultMappings();
|
||||
|
||||
if ($mappingFile !== null) {
|
||||
$customMappings = $this->loadMappingFile($mappingFile, $io);
|
||||
if ($customMappings === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (isset($customMappings['footprints']) && is_array($customMappings['footprints'])) {
|
||||
// User mappings take priority (overwrite defaults)
|
||||
$footprintMappings = array_merge($footprintMappings, $customMappings['footprints']);
|
||||
$io->text(sprintf('Loaded %d custom footprint mappings from %s', count($customMappings['footprints']), $mappingFile));
|
||||
}
|
||||
if (isset($customMappings['categories']) && is_array($customMappings['categories'])) {
|
||||
$categoryMappings = array_merge($categoryMappings, $customMappings['categories']);
|
||||
$io->text(sprintf('Loaded %d custom category mappings from %s', count($customMappings['categories']), $mappingFile));
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
$totalUpdated = 0;
|
||||
|
||||
if ($doFootprints) {
|
||||
$updated = $this->updateFootprints($io, $dryRun, $force, $footprintMappings);
|
||||
$totalUpdated += $updated;
|
||||
}
|
||||
|
||||
if ($doCategories) {
|
||||
$updated = $this->updateCategories($io, $dryRun, $force, $categoryMappings);
|
||||
$totalUpdated += $updated;
|
||||
}
|
||||
|
||||
if (!$dryRun && $totalUpdated > 0) {
|
||||
$this->entityManager->flush();
|
||||
$io->success(sprintf('Updated %d entities. Run "php bin/console cache:clear" to clear the cache.', $totalUpdated));
|
||||
} elseif ($dryRun && $totalUpdated > 0) {
|
||||
$io->info(sprintf('DRY RUN: Would update %d entities. Run without --dry-run to apply changes.', $totalUpdated));
|
||||
} else {
|
||||
$io->info('No entities needed updating.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function listCurrentValues(SymfonyStyle $io): void
|
||||
{
|
||||
$io->section('Current Footprint KiCad Values');
|
||||
|
||||
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
|
||||
/** @var Footprint[] $footprints */
|
||||
$footprints = $footprintRepo->findAll();
|
||||
|
||||
$rows = [];
|
||||
foreach ($footprints as $footprint) {
|
||||
$kicadValue = $footprint->getEdaInfo()->getKicadFootprint();
|
||||
$rows[] = [
|
||||
$footprint->getId(),
|
||||
$footprint->getName(),
|
||||
$kicadValue ?? '(empty)',
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['ID', 'Name', 'KiCad Footprint'], $rows);
|
||||
|
||||
$io->section('Current Category KiCad Values');
|
||||
|
||||
$categoryRepo = $this->entityManager->getRepository(Category::class);
|
||||
/** @var Category[] $categories */
|
||||
$categories = $categoryRepo->findAll();
|
||||
|
||||
$rows = [];
|
||||
foreach ($categories as $category) {
|
||||
$kicadValue = $category->getEdaInfo()->getKicadSymbol();
|
||||
$rows[] = [
|
||||
$category->getId(),
|
||||
$category->getName(),
|
||||
$kicadValue ?? '(empty)',
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['ID', 'Name', 'KiCad Symbol'], $rows);
|
||||
}
|
||||
|
||||
private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
|
||||
{
|
||||
$io->section('Updating Footprint Entities');
|
||||
|
||||
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
|
||||
/** @var Footprint[] $footprints */
|
||||
$footprints = $footprintRepo->findAll();
|
||||
|
||||
$updated = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($footprints as $footprint) {
|
||||
$name = $footprint->getName();
|
||||
$currentValue = $footprint->getEdaInfo()->getKicadFootprint();
|
||||
|
||||
// Skip if already has value and not forcing
|
||||
if (!$force && $currentValue !== null && $currentValue !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for exact match on name first, then try alternative names
|
||||
$matchedValue = $this->findFootprintMapping($mappings, $name, $footprint->getAlternativeNames());
|
||||
|
||||
if ($matchedValue !== null) {
|
||||
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue));
|
||||
|
||||
if (!$dryRun) {
|
||||
$footprint->getEdaInfo()->setKicadFootprint($matchedValue);
|
||||
}
|
||||
$updated++;
|
||||
} else {
|
||||
// No mapping found
|
||||
$skipped[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->text(sprintf('Updated: %d footprints', $updated));
|
||||
|
||||
if (count($skipped) > 0) {
|
||||
$io->warning(sprintf('No mapping found for %d footprints:', count($skipped)));
|
||||
foreach ($skipped as $name) {
|
||||
$io->text(' - ' . $name);
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
|
||||
{
|
||||
$io->section('Updating Category Entities');
|
||||
|
||||
$categoryRepo = $this->entityManager->getRepository(Category::class);
|
||||
/** @var Category[] $categories */
|
||||
$categories = $categoryRepo->findAll();
|
||||
|
||||
$updated = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$name = $category->getName();
|
||||
$currentValue = $category->getEdaInfo()->getKicadSymbol();
|
||||
|
||||
// Skip if already has value and not forcing
|
||||
if (!$force && $currentValue !== null && $currentValue !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for matches using the pattern-based mappings (also check alternative names)
|
||||
$matchedValue = $this->findCategoryMapping($mappings, $name, $category->getAlternativeNames());
|
||||
|
||||
if ($matchedValue !== null) {
|
||||
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue));
|
||||
|
||||
if (!$dryRun) {
|
||||
$category->getEdaInfo()->setKicadSymbol($matchedValue);
|
||||
}
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->text(sprintf('Updated: %d categories', $updated));
|
||||
|
||||
if (count($skipped) > 0) {
|
||||
$io->note(sprintf('No mapping found for %d categories (this is often expected):', count($skipped)));
|
||||
foreach ($skipped as $name) {
|
||||
$io->text(' - ' . $name);
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a JSON mapping file and returns the parsed data.
|
||||
* Expected format: {"footprints": {"Name": "KiCad:Path"}, "categories": {"Pattern": "KiCad:Path"}}
|
||||
*
|
||||
* @return array|null The parsed mappings, or null on error
|
||||
*/
|
||||
private function loadMappingFile(string $path, SymfonyStyle $io): ?array
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
$io->error(sprintf('Mapping file not found: %s', $path));
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
$io->error(sprintf('Could not read mapping file: %s', $path));
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data)) {
|
||||
$io->error(sprintf('Invalid JSON in mapping file: %s', $path));
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function matchesPattern(string $name, string $pattern): bool
|
||||
{
|
||||
// Check for exact match
|
||||
if ($pattern === $name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for case-insensitive contains
|
||||
if (stripos($name, $pattern) !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a footprint mapping by checking the entity name and its alternative names.
|
||||
* Footprints use exact matching.
|
||||
*
|
||||
* @param array<string, string> $mappings
|
||||
* @param string $name The primary name of the footprint
|
||||
* @param string|null $alternativeNames Comma-separated alternative names
|
||||
* @return string|null The matched KiCad path, or null if no match found
|
||||
*/
|
||||
private function findFootprintMapping(array $mappings, string $name, ?string $alternativeNames): ?string
|
||||
{
|
||||
// Check primary name
|
||||
if (isset($mappings[$name])) {
|
||||
return $mappings[$name];
|
||||
}
|
||||
|
||||
// Check alternative names
|
||||
if ($alternativeNames !== null && $alternativeNames !== '') {
|
||||
foreach (explode(',', $alternativeNames) as $altName) {
|
||||
$altName = trim($altName);
|
||||
if ($altName !== '' && isset($mappings[$altName])) {
|
||||
return $mappings[$altName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a category mapping by checking the entity name and its alternative names.
|
||||
* Categories use pattern-based matching (case-insensitive contains).
|
||||
*
|
||||
* @param array<string, string> $mappings
|
||||
* @param string $name The primary name of the category
|
||||
* @param string|null $alternativeNames Comma-separated alternative names
|
||||
* @return string|null The matched KiCad symbol path, or null if no match found
|
||||
*/
|
||||
private function findCategoryMapping(array $mappings, string $name, ?string $alternativeNames): ?string
|
||||
{
|
||||
// Check primary name against all patterns
|
||||
foreach ($mappings as $pattern => $kicadSymbol) {
|
||||
if ($this->matchesPattern($name, $pattern)) {
|
||||
return $kicadSymbol;
|
||||
}
|
||||
}
|
||||
|
||||
// Check alternative names against all patterns
|
||||
if ($alternativeNames !== null && $alternativeNames !== '') {
|
||||
foreach (explode(',', $alternativeNames) as $altName) {
|
||||
$altName = trim($altName);
|
||||
if ($altName === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($mappings as $pattern => $kicadSymbol) {
|
||||
if ($this->matchesPattern($altName, $pattern)) {
|
||||
return $kicadSymbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default mappings for footprints and categories.
|
||||
* @return array{footprints: array<string, string>, categories: array<string, string>}
|
||||
* @throws \JsonException
|
||||
*/
|
||||
private function getDefaultMappings(): array
|
||||
{
|
||||
$path = $this->projectDir . '/' . self::DEFAULT_MAPPING_FILE;
|
||||
$content = file_get_contents($path);
|
||||
|
||||
return json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,8 @@ abstract class BaseAdminController extends AbstractController
|
||||
|
||||
$this->commentHelper->setMessage($form['log_comment']->getData());
|
||||
|
||||
//In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes.
|
||||
$this->denyAccessUnlessGranted('edit', $entity);
|
||||
$em->persist($entity);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'entity.edit_flash');
|
||||
|
||||
@@ -30,6 +30,7 @@ use App\Form\Filters\AttachmentFilterType;
|
||||
use App\Services\Attachments\AttachmentManager;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -41,31 +42,56 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
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.
|
||||
*/
|
||||
#[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()) {
|
||||
$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);
|
||||
$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_ATTACHMENT);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $attachment->getFilename());
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -74,7 +100,35 @@ class AttachmentFileController extends AbstractController
|
||||
* View the attachment.
|
||||
*/
|
||||
#[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);
|
||||
|
||||
@@ -86,17 +140,9 @@ class AttachmentFileController extends AbstractController
|
||||
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!');
|
||||
}
|
||||
|
||||
$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')]
|
||||
|
||||
117
src/Controller/BatchEdaController.php
Normal file
117
src/Controller/BatchEdaController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Form\Part\EDA\BatchEdaType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class BatchEdaController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute shared EDA values across all parts. If all parts have the same value for a field, return it.
|
||||
* @param Part[] $parts
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getSharedEdaValues(array $parts): array
|
||||
{
|
||||
$fields = [
|
||||
'reference_prefix' => static fn (Part $p) => $p->getEdaInfo()->getReferencePrefix(),
|
||||
'value' => static fn (Part $p) => $p->getEdaInfo()->getValue(),
|
||||
'kicad_symbol' => static fn (Part $p) => $p->getEdaInfo()->getKicadSymbol(),
|
||||
'kicad_footprint' => static fn (Part $p) => $p->getEdaInfo()->getKicadFootprint(),
|
||||
'visibility' => static fn (Part $p) => $p->getEdaInfo()->getVisibility(),
|
||||
'exclude_from_bom' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromBom(),
|
||||
'exclude_from_board' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromBoard(),
|
||||
'exclude_from_sim' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromSim(),
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($fields as $key => $getter) {
|
||||
$values = array_map($getter, $parts);
|
||||
$unique = array_unique($values, SORT_REGULAR);
|
||||
if (count($unique) === 1) {
|
||||
$data[$key] = $unique[array_key_first($unique)];
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
#[Route('/tools/batch_eda_edit', name: 'batch_eda_edit')]
|
||||
public function batchEdaEdit(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@parts.edit');
|
||||
|
||||
$ids = $request->query->getString('ids', '');
|
||||
$redirectUrl = $request->query->getString('_redirect', '');
|
||||
|
||||
//Parse part IDs and load parts
|
||||
$idArray = array_filter(array_map(intval(...), explode(',', $ids)), static fn (int $id): bool => $id > 0);
|
||||
$parts = $this->entityManager->getRepository(Part::class)->findBy(['id' => $idArray]);
|
||||
|
||||
if ($parts === []) {
|
||||
$this->addFlash('error', 'batch_eda.no_parts_selected');
|
||||
|
||||
return $redirectUrl !== '' ? $this->redirect($redirectUrl) : $this->redirectToRoute('parts_show_all');
|
||||
}
|
||||
|
||||
//Pre-populate form with shared values (when all parts have the same value)
|
||||
$initialData = $this->getSharedEdaValues($parts);
|
||||
$form = $this->createForm(BatchEdaType::class, $initialData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
foreach ($parts as $part) {
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$edaInfo = $part->getEdaInfo();
|
||||
|
||||
if ($form->get('apply_reference_prefix')->getData()) {
|
||||
$edaInfo->setReferencePrefix($form->get('reference_prefix')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_value')->getData()) {
|
||||
$edaInfo->setValue($form->get('value')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_kicad_symbol')->getData()) {
|
||||
$edaInfo->setKicadSymbol($form->get('kicad_symbol')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_kicad_footprint')->getData()) {
|
||||
$edaInfo->setKicadFootprint($form->get('kicad_footprint')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_visibility')->getData()) {
|
||||
$edaInfo->setVisibility($form->get('visibility')->getData());
|
||||
}
|
||||
if ($form->get('apply_exclude_from_bom')->getData()) {
|
||||
$edaInfo->setExcludeFromBom($form->get('exclude_from_bom')->getData());
|
||||
}
|
||||
if ($form->get('apply_exclude_from_board')->getData()) {
|
||||
$edaInfo->setExcludeFromBoard($form->get('exclude_from_board')->getData());
|
||||
}
|
||||
if ($form->get('apply_exclude_from_sim')->getData()) {
|
||||
$edaInfo->setExcludeFromSim($form->get('exclude_from_sim')->getData());
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'batch_eda.success');
|
||||
|
||||
return $redirectUrl !== '' ? $this->redirect($redirectUrl) : $this->redirectToRoute('parts_show_all');
|
||||
}
|
||||
|
||||
return $this->render('parts/batch_eda_edit.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'parts' => $parts,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\EDA\KiCadHelper;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -55,15 +57,16 @@ class KiCadApiController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/categories.json', name: 'kicad_api_categories')]
|
||||
public function categories(): Response
|
||||
public function categories(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@categories.read');
|
||||
|
||||
return $this->json($this->kiCADHelper->getCategories());
|
||||
$data = $this->kiCADHelper->getCategories();
|
||||
return $this->createCacheableJsonResponse($request, $data, 300);
|
||||
}
|
||||
|
||||
#[Route('/parts/category/{category}.json', name: 'kicad_api_category')]
|
||||
public function categoryParts(?Category $category): Response
|
||||
public function categoryParts(Request $request, ?Category $category): Response
|
||||
{
|
||||
if ($category !== null) {
|
||||
$this->denyAccessUnlessGranted('read', $category);
|
||||
@@ -72,14 +75,31 @@ class KiCadApiController extends AbstractController
|
||||
}
|
||||
$this->denyAccessUnlessGranted('@parts.read');
|
||||
|
||||
return $this->json($this->kiCADHelper->getCategoryParts($category));
|
||||
$minimal = $request->query->getBoolean('minimal', false);
|
||||
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
|
||||
return $this->createCacheableJsonResponse($request, $data, 300);
|
||||
}
|
||||
|
||||
#[Route('/parts/{part}.json', name: 'kicad_api_part')]
|
||||
public function partDetails(Part $part): Response
|
||||
public function partDetails(Request $request, Part $part): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
|
||||
return $this->json($this->kiCADHelper->getKiCADPart($part));
|
||||
$data = $this->kiCADHelper->getKiCADPart($part);
|
||||
return $this->createCacheableJsonResponse($request, $data, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON response with HTTP cache headers (ETag and Cache-Control).
|
||||
* Returns 304 Not Modified if the client's ETag matches.
|
||||
*/
|
||||
private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
|
||||
{
|
||||
$response = new JsonResponse($data);
|
||||
$response->setEtag(md5(json_encode($data)));
|
||||
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
|
||||
$response->isNotModified($request);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Form\LabelSystem\ScanDialogType;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
|
||||
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use App\Entity\Parts\Part;
|
||||
use \App\Entity\Parts\StorageLocation;
|
||||
use Symfony\UX\Turbo\TurboBundle;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Controller\ScanControllerTest
|
||||
@@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
#[Route(path: '/scan')]
|
||||
class ScanController extends AbstractController
|
||||
{
|
||||
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer)
|
||||
{
|
||||
}
|
||||
public function __construct(
|
||||
protected BarcodeScanResultHandler $resultHandler,
|
||||
protected BarcodeScanHelper $barcodeNormalizer,
|
||||
) {}
|
||||
|
||||
#[Route(path: '', name: 'scan_dialog')]
|
||||
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
|
||||
@@ -72,35 +85,86 @@ class ScanController extends AbstractController
|
||||
$form = $this->createForm(ScanDialogType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
// If JS is working, scanning uses /scan/lookup and this action just renders the page.
|
||||
// This fallback only runs if user submits the form manually or uses ?input=...
|
||||
if ($input === null && $form->isSubmitted() && $form->isValid()) {
|
||||
$input = $form['input']->getData();
|
||||
$mode = $form['mode']->getData();
|
||||
}
|
||||
|
||||
$infoModeData = null;
|
||||
|
||||
if ($input !== null) {
|
||||
if ($input !== null && $input !== '') {
|
||||
$mode = $form->isSubmitted() ? $form['mode']->getData() : null;
|
||||
$infoMode = $form->isSubmitted() && $form['info_mode']->getData();
|
||||
|
||||
try {
|
||||
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
||||
//Perform a redirect if the info mode is not enabled
|
||||
if (!$form['info_mode']->getData()) {
|
||||
try {
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
||||
} catch (EntityNotFoundException) {
|
||||
$this->addFlash('success', 'scan.qr_not_found');
|
||||
$scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
||||
|
||||
// If not in info mode, mimic “normal scan” behavior: redirect if possible.
|
||||
if (!$infoMode) {
|
||||
|
||||
// Try to get an Info URL if possible
|
||||
$url = $this->resultHandler->getInfoURL($scan);
|
||||
if ($url !== null) {
|
||||
return $this->redirect($url);
|
||||
}
|
||||
|
||||
//Try to get an creation URL if possible (only for vendor codes)
|
||||
$createUrl = $this->buildCreateUrlForScanResult($scan);
|
||||
if ($createUrl !== null) {
|
||||
return $this->redirect($createUrl);
|
||||
}
|
||||
|
||||
//// Otherwise: show “not found” (not “format unknown”)
|
||||
$this->addFlash('warning', 'scan.qr_not_found');
|
||||
} else { // Info mode
|
||||
// Info mode fallback: render page with prefilled result
|
||||
$decoded = $scan->getDecodedForInfoMode();
|
||||
|
||||
//Try to resolve to an entity, to enhance info mode with entity-specific data
|
||||
$dbEntity = $this->resultHandler->resolveEntity($scan);
|
||||
$resolvedPart = $this->resultHandler->resolvePart($scan);
|
||||
$openUrl = $this->resultHandler->getInfoURL($scan);
|
||||
|
||||
//If no entity is found, try to create an URL for creating a new part (only for vendor codes)
|
||||
$createUrl = null;
|
||||
if ($dbEntity === null) {
|
||||
$createUrl = $this->buildCreateUrlForScanResult($scan);
|
||||
}
|
||||
|
||||
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
|
||||
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||
return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [
|
||||
'decoded' => $decoded,
|
||||
'entity' => $dbEntity,
|
||||
'part' => $resolvedPart,
|
||||
'openUrl' => $openUrl,
|
||||
'createUrl' => $createUrl,
|
||||
]);
|
||||
}
|
||||
} else { //Otherwise retrieve infoModeData
|
||||
$infoModeData = $scan_result->getDecodedForInfoMode();
|
||||
|
||||
}
|
||||
} catch (InvalidArgumentException) {
|
||||
$this->addFlash('error', 'scan.format_unknown');
|
||||
} catch (\Throwable $e) {
|
||||
// Keep fallback user-friendly; avoid 500
|
||||
$this->addFlash('warning', 'scan.format_unknown');
|
||||
}
|
||||
}
|
||||
|
||||
//When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload
|
||||
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
|
||||
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||
//Only send our flash message, so the client can show it without a full page reload
|
||||
return $this->renderBlock('_turbo_control.html.twig', 'flashes');
|
||||
}
|
||||
|
||||
return $this->render('label_system/scanner/scanner.html.twig', [
|
||||
'form' => $form,
|
||||
'infoModeData' => $infoModeData,
|
||||
|
||||
//Info mode
|
||||
'decoded' => $decoded ?? null,
|
||||
'entity' => $dbEntity ?? null,
|
||||
'part' => $resolvedPart ?? null,
|
||||
'openUrl' => $openUrl ?? null,
|
||||
'createUrl' => $createUrl ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -125,11 +189,30 @@ class ScanController extends AbstractController
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
||||
return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found"));
|
||||
} catch (EntityNotFoundException) {
|
||||
$this->addFlash('success', 'scan.qr_not_found');
|
||||
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation.
|
||||
* @param BarcodeScanResultInterface $scanResult
|
||||
* @return string|null
|
||||
*/
|
||||
private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string
|
||||
{
|
||||
try {
|
||||
return $this->resultHandler->getCreationURL($scanResult);
|
||||
} catch (InfoProviderNotActiveException $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
} catch (\Throwable) {
|
||||
// Don’t break scanning UX if provider lookup fails
|
||||
$this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ class TypeaheadController extends AbstractController
|
||||
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
|
||||
public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse
|
||||
{
|
||||
$query = $request->get('query');
|
||||
//Ensure that the user can access Part-DB at all
|
||||
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||
|
||||
$query = $request->query->getString('query');
|
||||
$array = $finder->find($query);
|
||||
|
||||
$result = [];
|
||||
@@ -205,9 +208,16 @@ class TypeaheadController extends AbstractController
|
||||
/** @var Category|null $category */
|
||||
$category = $entityManager->getRepository(Category::class)->find($categoryId);
|
||||
|
||||
//Ensure the user has access to both the part and the category
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
if ($category !== null) {
|
||||
$this->denyAccessUnlessGranted('read', $category);
|
||||
}
|
||||
|
||||
$clonedPart = clone $part;
|
||||
$clonedPart->setCategory($category);
|
||||
|
||||
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
|
||||
|
||||
|
||||
@@ -115,6 +115,61 @@ class PartDataTableHelper
|
||||
return implode('<br>', $tmp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an EDA/KiCad completeness indicator for the given part.
|
||||
* Shows icons for symbol, footprint, and value status.
|
||||
*/
|
||||
public function renderEdaStatus(Part $context): string
|
||||
{
|
||||
$edaInfo = $context->getEdaInfo();
|
||||
$category = $context->getCategory();
|
||||
$footprint = $context->getFootprint();
|
||||
|
||||
// Determine effective values (direct or inherited)
|
||||
$hasSymbol = $edaInfo->getKicadSymbol() !== null || $category?->getEdaInfo()->getKicadSymbol() !== null;
|
||||
$hasFootprint = $edaInfo->getKicadFootprint() !== null || $footprint?->getEdaInfo()->getKicadFootprint() !== null;
|
||||
$hasReference = $edaInfo->getReferencePrefix() !== null || $category?->getEdaInfo()->getReferencePrefix() !== null;
|
||||
|
||||
$symbolInherited = $edaInfo->getKicadSymbol() === null && $category?->getEdaInfo()->getKicadSymbol() !== null;
|
||||
$footprintInherited = $edaInfo->getKicadFootprint() === null && $footprint?->getEdaInfo()->getKicadFootprint() !== null;
|
||||
|
||||
$icons = [];
|
||||
|
||||
// Symbol status
|
||||
if ($hasSymbol) {
|
||||
$title = $this->translator->trans('eda.status.symbol_set');
|
||||
$class = $symbolInherited ? 'text-info' : 'text-success';
|
||||
$icons[] = sprintf('<i class="fa-solid fa-microchip fa-fw %s" title="%s"></i>', $class, $title);
|
||||
}
|
||||
|
||||
// Footprint status
|
||||
if ($hasFootprint) {
|
||||
$title = $this->translator->trans('eda.status.footprint_set');
|
||||
$class = $footprintInherited ? 'text-info' : 'text-success';
|
||||
$icons[] = sprintf('<i class="fa-solid fa-stamp fa-fw %s" title="%s"></i>', $class, $title);
|
||||
}
|
||||
|
||||
// Reference prefix status
|
||||
if ($hasReference) {
|
||||
$icons[] = sprintf('<i class="fa-solid fa-font fa-fw text-success" title="%s"></i>',
|
||||
$this->translator->trans('eda.status.reference_set'));
|
||||
}
|
||||
|
||||
if (empty($icons)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Overall status: all 3 = green check, partial = yellow
|
||||
$allSet = $hasSymbol && $hasFootprint && $hasReference;
|
||||
$statusIcon = $allSet
|
||||
? sprintf('<i class="fa-solid fa-bolt fa-fw text-success" title="%s"></i>', $this->translator->trans('eda.status.complete'))
|
||||
: sprintf('<i class="fa-solid fa-bolt fa-fw text-warning" title="%s"></i>', $this->translator->trans('eda.status.partial'));
|
||||
|
||||
// Wrap in link to EDA settings tab (data-turbo=false to ensure hash is read on page load)
|
||||
$editUrl = $this->entityURLGenerator->editURL($context) . '#eda';
|
||||
return sprintf('<a href="%s" data-turbo="false">%s</a>', $editUrl, $statusIcon);
|
||||
}
|
||||
|
||||
public function renderAmount(Part $context): string
|
||||
{
|
||||
$amount = $context->getAmountSum();
|
||||
|
||||
@@ -89,6 +89,10 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
$this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
/*************************************************************************************************************
|
||||
* When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings!
|
||||
*************************************************************************************************************/
|
||||
|
||||
$this->csh
|
||||
//Color the table rows depending on the review and favorite status
|
||||
->add('row_color', RowClassColumn::class, [
|
||||
@@ -228,6 +232,21 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
])
|
||||
->add('attachments', PartAttachmentsColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.attachments'),
|
||||
])
|
||||
->add('eda_reference', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.eda_reference'),
|
||||
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getReferencePrefix() ?? ''),
|
||||
'orderField' => 'NATSORT(part.eda_info.reference_prefix)'
|
||||
])
|
||||
->add('eda_value', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.eda_value'),
|
||||
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getValue() ?? ''),
|
||||
'orderField' => 'NATSORT(part.eda_info.value)'
|
||||
])
|
||||
->add('eda_status', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.eda_status'),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
|
||||
'className' => 'text-center',
|
||||
]);
|
||||
|
||||
//Add a column to list the projects where the part is used, when the user has the permission to see the projects
|
||||
|
||||
@@ -296,6 +296,22 @@ abstract class Attachment extends AbstractNamedDBElement
|
||||
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
|
||||
*
|
||||
|
||||
@@ -172,6 +172,13 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
||||
#[Assert\Length(max: 255)]
|
||||
protected string $group = '';
|
||||
|
||||
/**
|
||||
* @var bool|null Whether this parameter should be exported as a field in the EDA HTTP library API. Null means use system default.
|
||||
*/
|
||||
#[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
|
||||
protected ?bool $eda_visibility = null;
|
||||
|
||||
/**
|
||||
* Mapping is done in subclasses.
|
||||
*
|
||||
@@ -471,6 +478,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
||||
return static::ALLOWED_ELEMENT_CLASS;
|
||||
}
|
||||
|
||||
public function isEdaVisibility(): ?bool
|
||||
{
|
||||
return $this->eda_visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setEdaVisibility(?bool $eda_visibility): self
|
||||
{
|
||||
$this->eda_visibility = $eda_visibility;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComparableFields(): array
|
||||
{
|
||||
return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];
|
||||
|
||||
@@ -122,6 +122,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
protected bool $obsolete = false;
|
||||
|
||||
/**
|
||||
* @var bool|null Whether this orderdetail's supplier part number should be exported as an EDA field. Null means use system default.
|
||||
*/
|
||||
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
|
||||
protected ?bool $eda_visibility = null;
|
||||
|
||||
/**
|
||||
* @var string The URL to the product on the supplier's website
|
||||
*/
|
||||
@@ -418,6 +425,21 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEdaVisibility(): ?bool
|
||||
{
|
||||
return $this->eda_visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setEdaVisibility(?bool $eda_visibility): self
|
||||
{
|
||||
$this->eda_visibility = $eda_visibility;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->getSupplierPartNr();
|
||||
|
||||
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'] ?? '???');
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ class BaseEntityAdminForm extends AbstractType
|
||||
'label' => 'entity.edit.alternative_names.label',
|
||||
'help' => 'entity.edit.alternative_names.help',
|
||||
'empty_data' => null,
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
'attr' => [
|
||||
'class' => 'tagsinput',
|
||||
'data-controller' => 'elements--tagsinput',
|
||||
|
||||
@@ -61,6 +61,8 @@ class ScanDialogType extends AbstractType
|
||||
'attr' => [
|
||||
'autofocus' => true,
|
||||
'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',
|
||||
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
|
||||
null => 'scan_dialog.mode.auto',
|
||||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
||||
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',
|
||||
default => 'scan_dialog.mode.' . $enum->value,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ use App\Entity\Parameters\StorageLocationParameter;
|
||||
use App\Entity\Parameters\SupplierParameter;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Form\Type\ExponentialNumberType;
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -147,6 +149,14 @@ class ParameterType extends AbstractType
|
||||
'class' => 'form-control-sm',
|
||||
],
|
||||
]);
|
||||
|
||||
// Only show the EDA visibility field for part parameters, as it has no function for other entities
|
||||
if ($options['data_class'] === PartParameter::class) {
|
||||
$builder->add('eda_visibility', TriStateCheckboxType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
||||
|
||||
116
src/Form/Part/EDA/BatchEdaType.php
Normal file
116
src/Form/Part/EDA/BatchEdaType.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Part\EDA;
|
||||
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
/**
|
||||
* Form type for batch editing EDA/KiCad fields on multiple parts at once.
|
||||
* Each field has an "apply" checkbox — only checked fields are applied.
|
||||
*/
|
||||
class BatchEdaType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('reference_prefix', TextType::class, [
|
||||
'label' => 'eda_info.reference_prefix',
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.reference_prefix.placeholder')],
|
||||
])
|
||||
->add('apply_reference_prefix', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('value', TextType::class, [
|
||||
'label' => 'eda_info.value',
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.value.placeholder')],
|
||||
])
|
||||
->add('apply_value', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('kicad_symbol', KicadFieldAutocompleteType::class, [
|
||||
'label' => 'eda_info.kicad_symbol',
|
||||
'type' => KicadFieldAutocompleteType::TYPE_SYMBOL,
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.kicad_symbol.placeholder')],
|
||||
])
|
||||
->add('apply_kicad_symbol', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('kicad_footprint', KicadFieldAutocompleteType::class, [
|
||||
'label' => 'eda_info.kicad_footprint',
|
||||
'type' => KicadFieldAutocompleteType::TYPE_FOOTPRINT,
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.kicad_footprint.placeholder')],
|
||||
])
|
||||
->add('apply_kicad_footprint', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('visibility', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.visibility',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_visibility', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('exclude_from_bom', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.exclude_from_bom',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_exclude_from_bom', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('exclude_from_board', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.exclude_from_board',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_exclude_from_board', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('exclude_from_sim', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.exclude_from_sim',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_exclude_from_sim', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('submit', SubmitType::class, [
|
||||
'label' => 'batch_eda.submit',
|
||||
'attr' => ['class' => 'btn btn-primary'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ class OrderdetailType extends AbstractType
|
||||
'label' => 'orderdetails.edit.prices_includes_vat',
|
||||
]);
|
||||
|
||||
$builder->add('eda_visibility', TriStateCheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'orderdetails.edit.eda_visibility',
|
||||
]);
|
||||
|
||||
//Add pricedetails after we know the data, so we can set the default currency
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
|
||||
/** @var Orderdetail $orderdetail */
|
||||
|
||||
100
src/Helpers/RandomizeUseragentHttpClient.php
Normal file
100
src/Helpers/RandomizeUseragentHttpClient.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?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\Helpers;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
|
||||
|
||||
/**
|
||||
* HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us.
|
||||
* When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent, until we run out of retries.
|
||||
*/
|
||||
final class RandomizeUseragentHttpClient implements HttpClientInterface
|
||||
{
|
||||
public const USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows; U; Windows NT 10.0; Win64; x64) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/52.0.1359.302 Safari/600.6 Edge/15.25690",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6",
|
||||
"Mozilla/5.0 (Android; Android 4.4.4; E:number:20-23:00 Build/24.0.B.1.34) AppleWebKit/603.18 (KHTML, like Gecko) Chrome/47.0.1559.384 Mobile Safari/600.5",
|
||||
"Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)",
|
||||
"Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_4_2; like Mac OS X) AppleWebKit/537.24 (KHTML, like Gecko) Chrome/51.0.2432.275 Mobile Safari/535.6",
|
||||
"Mozilla/5.0 (U; Linux i680 ) Gecko/20100101 Firefox/57.5",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 8_8_6; en-US) Gecko/20100101 Firefox/53.9",
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_6_7) AppleWebKit/534.46 (KHTML, like Gecko) Chrome/55.0.3276.345 Safari/535",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.5;) AppleWebKit/535.42 (KHTML, like Gecko) Chrome/53.0.1176.353 Safari/534.0 Edge/11.95743",
|
||||
"Mozilla/5.0 (Linux; Android 5.1.1; MOTO G Build/LPH223) AppleWebKit/600.27 (KHTML, like Gecko) Chrome/47.0.1604.204 Mobile Safari/535.1",
|
||||
"Mozilla/5.0 (iPod; CPU iPod OS 7_4_8; like Mac OS X) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/50.0.1632.146 Mobile Safari/600.4",
|
||||
"Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9",
|
||||
"Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.5; x64; en-US) AppleWebKit/603.39 (KHTML, like Gecko) Chrome/52.0.1443.139 Safari/536.6 Edge/13.79436",
|
||||
"Mozilla/5.0 (Linux; U; Android 5.1; SM-G9350T Build/MMB29M) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/55.0.2552.307 Mobile Safari/600.8",
|
||||
"Mozilla/5.0 (Android; Android 6.0; SAMSUNG SM-D9350V Build/MDB08L) AppleWebKit/535.30 (KHTML, like Gecko) Chrome/53.0.1345.278 Mobile Safari/537.4",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.0;) AppleWebKit/534.44 (KHTML, like Gecko) Chrome/47.0.3503.387 Safari/601",
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly array $userAgents = self::USER_AGENTS,
|
||||
private readonly int $repeatOnFailure = 1,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRandomUserAgent(): string
|
||||
{
|
||||
return $this->userAgents[array_rand($this->userAgents)];
|
||||
}
|
||||
|
||||
public function request(string $method, string $url, array $options = []): ResponseInterface
|
||||
{
|
||||
$repeatsLeft = $this->repeatOnFailure;
|
||||
do {
|
||||
$modifiedOptions = $options;
|
||||
if (!isset($modifiedOptions['headers']['User-Agent'])) {
|
||||
$modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent();
|
||||
}
|
||||
$response = $this->client->request($method, $url, $modifiedOptions);
|
||||
|
||||
//When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent
|
||||
if (!in_array($response->getStatusCode(), [403, 429, 503], true)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
//Otherwise we try again with a different user agent, until we run out of retries
|
||||
} while ($repeatsLeft-- > 0);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
|
||||
{
|
||||
return $this->client->stream($responses, $timeout);
|
||||
}
|
||||
|
||||
public function withOptions(array $options): static
|
||||
{
|
||||
return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure);
|
||||
}
|
||||
}
|
||||
@@ -389,4 +389,93 @@ class PartRepository extends NamedDBElementRepository
|
||||
return $baseIpn . '_' . ($maxSuffix + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a part based on the provided info provider key and ID, with an option for case sensitivity.
|
||||
* If no part is found with the given provider key and ID, null is returned.
|
||||
* @param string $providerID
|
||||
* @param string|null $providerKey If null, the provider key will not be included in the search criteria, and only the provider ID will be used for matching.
|
||||
* @param bool $caseInsensitive If true, the provider ID comparison will be case-insensitive. Default is true.
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getPartByProviderInfo(string $providerID, ?string $providerKey = null, bool $caseInsensitive = true): ?Part
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
$qb->select('part');
|
||||
|
||||
if ($providerKey) {
|
||||
$qb->where("part.providerReference.provider_key = :providerKey");
|
||||
$qb->setParameter('providerKey', $providerKey);
|
||||
}
|
||||
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->andWhere("LOWER(part.providerReference.provider_id) = LOWER(:providerID)");
|
||||
} else {
|
||||
$qb->andWhere("part.providerReference.provider_id = :providerID");
|
||||
}
|
||||
|
||||
$qb->setParameter('providerID', $providerID);
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a part based on the provided MPN (Manufacturer Part Number), with an option for case sensitivity.
|
||||
* If no part is found with the given MPN, null is returned.
|
||||
* @param string $mpn
|
||||
* @param string|null $manufacturerName If provided, the search will also include a match for the manufacturer's name. If null, the manufacturer name will not be included in the search criteria.
|
||||
* @param bool $caseInsensitive If true, the MPN comparison will be case-insensitive. Default is true (case-insensitive).
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $caseInsensitive = true): ?Part
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
$qb->select('part');
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->where("LOWER(part.manufacturer_product_number) = LOWER(:mpn)");
|
||||
} else {
|
||||
$qb->where("part.manufacturer_product_number = :mpn");
|
||||
}
|
||||
|
||||
if ($manufacturerName !== null) {
|
||||
$qb->leftJoin('part.manufacturer', 'manufacturer');
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->andWhere("LOWER(manufacturer.name) = LOWER(:manufacturerName)");
|
||||
} else {
|
||||
$qb->andWhere("manufacturer.name = :manufacturerName");
|
||||
}
|
||||
$qb->setParameter('manufacturerName', $manufacturerName);
|
||||
}
|
||||
|
||||
$qb->setParameter('mpn', $mpn);
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||
'spn' => 'supplier_part_number',
|
||||
'supplier_product_number' => 'supplier_part_number',
|
||||
'storage_location' => 'storelocation',
|
||||
//EDA/KiCad field aliases
|
||||
'kicad_symbol' => 'eda_kicad_symbol',
|
||||
'kicad_footprint' => 'eda_kicad_footprint',
|
||||
'kicad_reference' => 'eda_reference_prefix',
|
||||
'kicad_value' => 'eda_value',
|
||||
'eda_exclude_bom' => 'eda_exclude_from_bom',
|
||||
'eda_exclude_board' => 'eda_exclude_from_board',
|
||||
'eda_exclude_sim' => 'eda_exclude_from_sim',
|
||||
'eda_invisible' => 'eda_visibility',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -190,9 +199,45 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||
}
|
||||
}
|
||||
|
||||
//Handle EDA/KiCad fields
|
||||
$this->applyEdaFields($object, $data);
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply EDA/KiCad fields from CSV data to the Part's EDAPartInfo.
|
||||
*/
|
||||
private function applyEdaFields(Part $part, array $data): void
|
||||
{
|
||||
$edaInfo = $part->getEdaInfo();
|
||||
|
||||
if (!empty($data['eda_kicad_symbol'])) {
|
||||
$edaInfo->setKicadSymbol(trim((string) $data['eda_kicad_symbol']));
|
||||
}
|
||||
if (!empty($data['eda_kicad_footprint'])) {
|
||||
$edaInfo->setKicadFootprint(trim((string) $data['eda_kicad_footprint']));
|
||||
}
|
||||
if (!empty($data['eda_reference_prefix'])) {
|
||||
$edaInfo->setReferencePrefix(trim((string) $data['eda_reference_prefix']));
|
||||
}
|
||||
if (!empty($data['eda_value'])) {
|
||||
$edaInfo->setValue(trim((string) $data['eda_value']));
|
||||
}
|
||||
if (isset($data['eda_exclude_from_bom']) && $data['eda_exclude_from_bom'] !== '') {
|
||||
$edaInfo->setExcludeFromBom(filter_var($data['eda_exclude_from_bom'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
if (isset($data['eda_exclude_from_board']) && $data['eda_exclude_from_board'] !== '') {
|
||||
$edaInfo->setExcludeFromBoard(filter_var($data['eda_exclude_from_board'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
if (isset($data['eda_exclude_from_sim']) && $data['eda_exclude_from_sim'] !== '') {
|
||||
$edaInfo->setExcludeFromSim(filter_var($data['eda_exclude_from_sim'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
if (isset($data['eda_visibility']) && $data['eda_visibility'] !== '') {
|
||||
$edaInfo->setVisibility(filter_var($data['eda_visibility'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool[]
|
||||
*/
|
||||
|
||||
@@ -137,7 +137,10 @@ class AttachmentSubmitHandler
|
||||
$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;
|
||||
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Imagine\Exception\RuntimeException;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use InvalidArgumentException;
|
||||
@@ -40,7 +41,7 @@ class AttachmentURLGenerator
|
||||
|
||||
public function __construct(protected Packages $assets, protected AttachmentPathResolver $pathResolver,
|
||||
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)
|
||||
$this->public_path = $this->pathResolver->parameterToAbsolutePath('public');
|
||||
@@ -99,6 +100,10 @@ class AttachmentURLGenerator
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->attachmentsSettings->showHTMLAttachments && $attachment->isLocalHTMLFile()) {
|
||||
return $this->urlGenerator->generate('attachment_html_sandbox', ['id' => $attachment->getID()]);
|
||||
}
|
||||
|
||||
$asset_path = $this->absolutePathToAssetPath($absolute_path);
|
||||
//If path is not relative to public path or marked as secure, serve it via controller
|
||||
if (null === $asset_path || $attachment->isSecure()) {
|
||||
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EDA;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
@@ -43,6 +44,9 @@ class KiCadHelper
|
||||
/** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
|
||||
private readonly int $category_depth;
|
||||
|
||||
/** @var bool Whether to resolve actual datasheet PDF URLs (true) or use Part-DB page links (false) */
|
||||
private readonly bool $datasheetAsPdf;
|
||||
|
||||
public function __construct(
|
||||
private readonly NodesListBuilder $nodesListBuilder,
|
||||
private readonly TagAwareCacheInterface $kicadCache,
|
||||
@@ -51,9 +55,10 @@ class KiCadHelper
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
KiCadEDASettings $kiCadEDASettings,
|
||||
private readonly KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
$this->category_depth = $kiCadEDASettings->categoryDepth;
|
||||
$this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,11 +120,16 @@ class KiCadHelper
|
||||
}
|
||||
|
||||
//Format the category for KiCAD
|
||||
// Use the category comment as description if available, otherwise use the Part-DB URL
|
||||
$description = $category->getComment();
|
||||
if ($description === null || $description === '') {
|
||||
$description = $this->entityURLGenerator->listPartsURL($category);
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => (string)$category->getId(),
|
||||
'name' => $category->getFullPath('/'),
|
||||
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
|
||||
'description' => $this->entityURLGenerator->listPartsURL($category),
|
||||
'description' => $description,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -131,11 +141,13 @@ class KiCadHelper
|
||||
* Returns an array of objects containing all parts for the given category in the format required by KiCAD.
|
||||
* The result is cached for performance and invalidated on category or part changes.
|
||||
* @param Category|null $category
|
||||
* @param bool $minimal If true, only return id and name (faster for symbol chooser listing)
|
||||
* @return array
|
||||
*/
|
||||
public function getCategoryParts(?Category $category): array
|
||||
public function getCategoryParts(?Category $category, bool $minimal = false): array
|
||||
{
|
||||
return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth,
|
||||
$cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : '');
|
||||
return $this->kicadCache->get($cacheKey,
|
||||
function (ItemInterface $item) use ($category) {
|
||||
$item->tag([
|
||||
$this->tagGenerator->getElementTypeCacheTag(Category::class),
|
||||
@@ -198,14 +210,22 @@ class KiCadHelper
|
||||
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
|
||||
$result["fields"]["keywords"] = $this->createField($part->getTags());
|
||||
|
||||
//Use the part info page as datasheet link. It must be an absolute URL.
|
||||
$result["fields"]["datasheet"] = $this->createField(
|
||||
$this->urlGenerator->generate(
|
||||
'part_info',
|
||||
['id' => $part->getId()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL)
|
||||
//Use the part info page as Part-DB link. It must be an absolute URL.
|
||||
$partUrl = $this->urlGenerator->generate(
|
||||
'part_info',
|
||||
['id' => $part->getId()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
);
|
||||
|
||||
//Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link)
|
||||
if ($this->datasheetAsPdf) {
|
||||
$datasheetUrl = $this->findDatasheetUrl($part);
|
||||
$result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
|
||||
} else {
|
||||
$result["fields"]["datasheet"] = $this->createField($partUrl);
|
||||
}
|
||||
$result["fields"]["Part-DB URL"] = $this->createField($partUrl);
|
||||
|
||||
//Add basic fields
|
||||
$result["fields"]["description"] = $this->createField($part->getDescription());
|
||||
if ($part->getCategory() !== null) {
|
||||
@@ -245,32 +265,7 @@ class KiCadHelper
|
||||
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
|
||||
}
|
||||
|
||||
// Add supplier information from orderdetails (include obsolete orderdetails)
|
||||
if ($part->getOrderdetails(false)->count() > 0) {
|
||||
$supplierCounts = [];
|
||||
|
||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
||||
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||
$supplierName = $orderdetail->getSupplier()->getName();
|
||||
|
||||
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
|
||||
|
||||
if (!isset($supplierCounts[$supplierName])) {
|
||||
$supplierCounts[$supplierName] = 0;
|
||||
}
|
||||
$supplierCounts[$supplierName]++;
|
||||
|
||||
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
|
||||
$fieldName = $supplierCounts[$supplierName] > 1
|
||||
? $supplierName . ' ' . $supplierCounts[$supplierName]
|
||||
: $supplierName;
|
||||
|
||||
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Add fields for KiCost:
|
||||
//Add KiCost manufacturer fields (always present, independent of orderdetails)
|
||||
if ($part->getManufacturer() !== null) {
|
||||
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
|
||||
}
|
||||
@@ -278,13 +273,74 @@ class KiCadHelper
|
||||
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
|
||||
}
|
||||
|
||||
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
|
||||
if ($part->getOrderdetails(false)->count() > 0) {
|
||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
||||
// Add supplier information from orderdetails (include obsolete orderdetails)
|
||||
// If any orderdetail has eda_visibility explicitly set to true, only export those;
|
||||
// otherwise export all (backward compat when no flags are set)
|
||||
$allOrderdetails = $part->getOrderdetails(false);
|
||||
if ($allOrderdetails->count() > 0) {
|
||||
$hasExplicitEdaVisibility = false;
|
||||
foreach ($allOrderdetails as $od) {
|
||||
if ($od->isEdaVisibility() !== null) {
|
||||
$hasExplicitEdaVisibility = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$supplierCounts = [];
|
||||
foreach ($allOrderdetails as $orderdetail) {
|
||||
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
|
||||
// When explicit flags exist, filter by resolved visibility
|
||||
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->kiCadEDASettings->defaultOrderdetailsVisibility;
|
||||
if ($hasExplicitEdaVisibility && !$resolvedVisibility) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$supplierName = $orderdetail->getSupplier()->getName() . ' SPN';
|
||||
|
||||
if (!isset($supplierCounts[$supplierName])) {
|
||||
$supplierCounts[$supplierName] = 0;
|
||||
}
|
||||
$supplierCounts[$supplierName]++;
|
||||
|
||||
// Create field name with sequential number if more than one from same supplier
|
||||
$fieldName = $supplierCounts[$supplierName] > 1
|
||||
? $supplierName . ' ' . $supplierCounts[$supplierName]
|
||||
: $supplierName;
|
||||
|
||||
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
|
||||
//Also add a KiCost-compatible field (supplier_name# = SPN)
|
||||
$kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
|
||||
$result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Add stock quantity and storage locations (only count non-expired lots with known quantity)
|
||||
$totalStock = 0;
|
||||
$locations = [];
|
||||
foreach ($part->getPartLots() as $lot) {
|
||||
$isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
|
||||
if ($isAvailable) {
|
||||
$totalStock += $lot->getAmount();
|
||||
if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
|
||||
$locations[] = $lot->getStorageLocation()->getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
$result['fields']['Stock'] = $this->createField($totalStock);
|
||||
if ($locations !== []) {
|
||||
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
|
||||
}
|
||||
|
||||
//Add parameters marked for EDA export (explicit true, or system default when null)
|
||||
foreach ($part->getParameters() as $parameter) {
|
||||
$paramVisibility = $parameter->isEdaVisibility() ?? $this->kiCadEDASettings->defaultParameterVisibility;
|
||||
if ($paramVisibility && $parameter->getName() !== '') {
|
||||
$fieldName = $parameter->getName();
|
||||
//Don't overwrite hardcoded fields
|
||||
if (!isset($result['fields'][$fieldName])) {
|
||||
$result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +400,7 @@ class KiCadHelper
|
||||
|
||||
//If the user set a visibility, then use it
|
||||
if ($eda_info->getVisibility() !== null) {
|
||||
return $part->getEdaInfo()->getVisibility();
|
||||
return $eda_info->getVisibility();
|
||||
}
|
||||
|
||||
//If the part has a category, then use the category visibility if possible
|
||||
@@ -395,4 +451,64 @@ class KiCadHelper
|
||||
'visible' => $this->boolToKicadBool($visible),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the URL to the actual datasheet file for the given part.
|
||||
* Searches attachments by type name, attachment name, and file extension.
|
||||
* @return string|null The datasheet URL, or null if no datasheet was found.
|
||||
*/
|
||||
private function findDatasheetUrl(Part $part): ?string
|
||||
{
|
||||
$firstPdf = null;
|
||||
|
||||
foreach ($part->getAttachments() as $attachment) {
|
||||
//Check if the attachment type name contains "datasheet"
|
||||
$typeName = $attachment->getAttachmentType()?->getName() ?? '';
|
||||
if (str_contains(mb_strtolower($typeName), 'datasheet')) {
|
||||
return $this->getAttachmentUrl($attachment);
|
||||
}
|
||||
|
||||
//Check if the attachment name contains "datasheet"
|
||||
$name = mb_strtolower($attachment->getName());
|
||||
if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
|
||||
return $this->getAttachmentUrl($attachment);
|
||||
}
|
||||
|
||||
//Track first PDF as fallback (check internal extension or external URL path)
|
||||
if ($firstPdf === null) {
|
||||
$extension = $attachment->getExtension();
|
||||
if ($extension === null && $attachment->hasExternal()) {
|
||||
$urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
|
||||
$extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
|
||||
}
|
||||
if ($extension === 'pdf') {
|
||||
$firstPdf = $attachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Use first PDF attachment as fallback
|
||||
if ($firstPdf !== null) {
|
||||
return $this->getAttachmentUrl($firstPdf);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute URL for viewing the given attachment.
|
||||
* Prefers the external URL (direct link) over the internal view route.
|
||||
*/
|
||||
private function getAttachmentUrl(Attachment $attachment): string
|
||||
{
|
||||
if ($attachment->hasExternal()) {
|
||||
return $attachment->getExternalPath();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate(
|
||||
'attachment_view',
|
||||
['id' => $attachment->getId()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,10 +396,14 @@ class BOMImporter
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
// Create unique key for this entry.
|
||||
// When linked to a Part-DB part, use the part ID as key (merges footprint variants).
|
||||
// Otherwise, use name (which includes package) to avoid merging unrelated components.
|
||||
$entry_key = $part !== null
|
||||
? 'part:' . $part->getID()
|
||||
: 'name:' . $name;
|
||||
|
||||
// Check if we already have an entry with the same name and part
|
||||
// Check if we already have an entry with the same key
|
||||
if (isset($entries_by_key[$entry_key])) {
|
||||
// Merge with existing entry
|
||||
$existing_entry = $entries_by_key[$entry_key];
|
||||
@@ -413,14 +417,22 @@ class BOMImporter
|
||||
$existing_quantity = $existing_entry->getQuantity();
|
||||
$existing_entry->setQuantity($existing_quantity + $quantity);
|
||||
|
||||
// Track footprint variants in comment when merging entries with different packages
|
||||
$currentPackage = trim($mapped_entry['Package'] ?? '');
|
||||
if ($currentPackage !== '' && !str_contains($existing_entry->getComment(), $currentPackage)) {
|
||||
$comment = $existing_entry->getComment();
|
||||
$existing_entry->setComment($comment . ', Footprint variant: ' . $currentPackage);
|
||||
}
|
||||
|
||||
$this->logger->info('Merged duplicate BOM entry', [
|
||||
'name' => $name,
|
||||
'part_id' => $part ? $part->getID() : null,
|
||||
'part_id' => $part?->getID(),
|
||||
'original_quantity' => $existing_quantity,
|
||||
'added_quantity' => $quantity,
|
||||
'new_quantity' => $existing_quantity + $quantity,
|
||||
'original_mountnames' => $existing_mountnames,
|
||||
'added_mountnames' => $designator,
|
||||
'package' => $currentPackage,
|
||||
]);
|
||||
|
||||
continue; // Skip creating new entry
|
||||
|
||||
@@ -24,10 +24,15 @@ declare(strict_types=1);
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Exceptions\OAuthReconnectRequiredException;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
@@ -49,6 +54,11 @@ final class PartInfoRetriever
|
||||
* @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances
|
||||
* @param string $keyword The keyword to search for
|
||||
* @return SearchResultDTO[] The search results
|
||||
* @throws InfoProviderNotActiveException if any of the given providers is not active
|
||||
* @throws ClientException if any of the providers throws an exception during the search
|
||||
* @throws \InvalidArgumentException if any of the given providers is not a valid provider key or instance
|
||||
* @throws TransportException if any of the providers throws an exception during the search
|
||||
* @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed
|
||||
*/
|
||||
public function searchByKeyword(string $keyword, array $providers): array
|
||||
{
|
||||
@@ -61,7 +71,7 @@ final class PartInfoRetriever
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
|
||||
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||
}
|
||||
|
||||
if (!$provider instanceof InfoProviderInterface) {
|
||||
@@ -85,7 +95,7 @@ final class PartInfoRetriever
|
||||
$escaped_keyword = hash('xxh3', $keyword);
|
||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||
//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);
|
||||
});
|
||||
@@ -97,6 +107,7 @@ final class PartInfoRetriever
|
||||
* @param string $provider_key
|
||||
* @param string $part_id
|
||||
* @return PartDetailDTO
|
||||
* @throws InfoProviderNotActiveException if the the given providers is not active
|
||||
*/
|
||||
public function getDetails(string $provider_key, string $part_id): PartDetailDTO
|
||||
{
|
||||
@@ -104,14 +115,14 @@ final class PartInfoRetriever
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key $provider_key is not active!");
|
||||
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||
}
|
||||
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_part_id = hash('xxh3', $part_id);
|
||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||
//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);
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
$files = [];
|
||||
foreach ($productMedia['manuals'] as $manual) {
|
||||
foreach ($productMedia['manuals'] ?? [] as $manual) {
|
||||
//Filter out unwanted languages
|
||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||
continue;
|
||||
|
||||
@@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
use App\Helpers\RandomizeUseragentHttpClient;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
@@ -54,11 +55,8 @@ class GenericWebProvider implements InfoProviderInterface
|
||||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
||||
)
|
||||
{
|
||||
$this->httpClient = $httpClient->withOptions(
|
||||
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions(
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Helpers\RandomizeUseragentHttpClient;
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
@@ -30,7 +31,6 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ReicheltSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
@@ -39,10 +39,13 @@ class ReicheltProvider implements InfoProviderInterface
|
||||
|
||||
public const DISTRIBUTOR_NAME = "Reichelt";
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
private readonly HttpClientInterface $client;
|
||||
|
||||
public function __construct(HttpClientInterface $client,
|
||||
private readonly ReicheltSettings $settings,
|
||||
)
|
||||
{
|
||||
$this->client = new RandomizeUseragentHttpClient($client);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?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)
|
||||
* 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
|
||||
@@ -17,15 +18,29 @@
|
||||
* 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() {
|
||||
//If we encounter an element with this, then change the title of our document according to data-title
|
||||
this.changeTitle(this.element.dataset.title);
|
||||
|
||||
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
|
||||
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) {
|
||||
document.title = title;
|
||||
public static function isAmazonBarcode(string $input): bool
|
||||
{
|
||||
//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) {
|
||||
return $this->parseEIGP114Barcode($input);
|
||||
}
|
||||
|
||||
if ($type === BarcodeSourceType::GTIN) {
|
||||
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
|
||||
$result = $this->parseInternalBarcode($input);
|
||||
|
||||
@@ -125,6 +134,16 @@ final class BarcodeScanHelper
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -138,6 +157,11 @@ final class BarcodeScanHelper
|
||||
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
||||
}
|
||||
|
||||
private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
|
||||
{
|
||||
return LCSCBarcodeScanResult::parse($input);
|
||||
}
|
||||
|
||||
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
||||
{
|
||||
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
||||
|
||||
@@ -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: supplierPartNumber directly
|
||||
if ($vendor === 'digikey') {
|
||||
return [
|
||||
'providerKey' => 'digikey',
|
||||
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Digikey barcode does not contain required supplier part number'),
|
||||
];
|
||||
}
|
||||
|
||||
// Element14: can use supplierPartNumber directly
|
||||
if ($vendor === 'element14') {
|
||||
return [
|
||||
'providerKey' => 'element14',
|
||||
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Element14 barcode does not contain required supplier part number'),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
|
||||
* @return array<string, string|int|float|null>
|
||||
*/
|
||||
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
|
||||
*/
|
||||
enum BarcodeSourceType
|
||||
enum BarcodeSourceType: string
|
||||
{
|
||||
/** This Barcode was generated using Part-DB internal recommended barcode generator */
|
||||
case INTERNAL;
|
||||
case INTERNAL = 'internal';
|
||||
/** This barcode is containing an internal part number (IPN) */
|
||||
case IPN;
|
||||
case IPN = 'ipn';
|
||||
|
||||
/**
|
||||
* This barcode is a user defined barcode defined on a part lot
|
||||
*/
|
||||
case USER_DEFINED;
|
||||
case USER_DEFINED = 'user';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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),
|
||||
* , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097
|
||||
*/
|
||||
class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @var string|null Ship date in format YYYYMMDD
|
||||
*/
|
||||
public readonly ?string $shipDate;
|
||||
public ?string $shipDate;
|
||||
|
||||
/**
|
||||
* @var string|null Customer assigned part number – Optional based on
|
||||
* agreements between Distributor and Supplier
|
||||
*/
|
||||
public readonly ?string $customerPartNumber;
|
||||
public ?string $customerPartNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Supplier assigned part number
|
||||
*/
|
||||
public readonly ?string $supplierPartNumber;
|
||||
public ?string $supplierPartNumber;
|
||||
|
||||
/**
|
||||
* @var int|null Quantity of product
|
||||
*/
|
||||
public readonly ?int $quantity;
|
||||
public ?int $quantity;
|
||||
|
||||
/**
|
||||
* @var string|null Customer assigned purchase order number
|
||||
*/
|
||||
public readonly ?string $customerPO;
|
||||
public ?string $customerPO;
|
||||
|
||||
/**
|
||||
* @var string|null Line item number from PO. Required on Logistic Label when
|
||||
* used on back of Packing Slip. See Section 4.9
|
||||
*/
|
||||
public readonly ?string $customerPOLine;
|
||||
public ?string $customerPOLine;
|
||||
|
||||
/**
|
||||
* 9D - YYWW (Year and Week of Manufacture). ) If no date code is used
|
||||
@@ -69,7 +69,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* to indicate the product is Not Traceable by this data field.
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $dateCode;
|
||||
public ?string $dateCode;
|
||||
|
||||
/**
|
||||
* 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
|
||||
@@ -77,7 +77,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* to indicate the product is Not Traceable by this data field.
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $alternativeDateCode;
|
||||
public ?string $alternativeDateCode;
|
||||
|
||||
/**
|
||||
* Traceability number assigned to a batch or group of items. If
|
||||
@@ -86,14 +86,14 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* by this data field.
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $lotCode;
|
||||
public ?string $lotCode;
|
||||
|
||||
/**
|
||||
* Country where part was manufactured. Two-letter code from
|
||||
* ISO 3166 country code list
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $countryOfOrigin;
|
||||
public ?string $countryOfOrigin;
|
||||
|
||||
/**
|
||||
* @var string|null Unique alphanumeric number assigned by supplier
|
||||
@@ -101,85 +101,85 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* Carton. Always used in conjunction with a mixed logistic label
|
||||
* with a 5S data identifier for Package ID.
|
||||
*/
|
||||
public readonly ?string $packageId1;
|
||||
public ?string $packageId1;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
* 4S - Package ID for Logistic Carton with like items
|
||||
*/
|
||||
public readonly ?string $packageId2;
|
||||
public ?string $packageId2;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
* 5S - Package ID for Logistic Carton with mixed items
|
||||
*/
|
||||
public readonly ?string $packageId3;
|
||||
public ?string $packageId3;
|
||||
|
||||
/**
|
||||
* @var string|null Unique alphanumeric number assigned by supplier.
|
||||
*/
|
||||
public readonly ?string $packingListNumber;
|
||||
public ?string $packingListNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Ship date in format YYYYMMDD
|
||||
*/
|
||||
public readonly ?string $serialNumber;
|
||||
public ?string $serialNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Code for sorting and classifying LEDs. Use when applicable
|
||||
*/
|
||||
public readonly ?string $binCode;
|
||||
public ?string $binCode;
|
||||
|
||||
/**
|
||||
* @var int|null Sequential carton count in format “#/#” or “# of #”
|
||||
*/
|
||||
public readonly ?int $packageCount;
|
||||
public ?int $packageCount;
|
||||
|
||||
/**
|
||||
* @var string|null Alphanumeric string assigned by the supplier to distinguish
|
||||
* from one closely-related design variation to another. Use as
|
||||
* required or when applicable
|
||||
*/
|
||||
public readonly ?string $revisionNumber;
|
||||
public ?string $revisionNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number.
|
||||
*/
|
||||
public readonly ?string $digikeyPartNumber;
|
||||
public ?string $digikeyPartNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.)
|
||||
*/
|
||||
public readonly ?string $digikeySalesOrderNumber;
|
||||
public ?string $digikeySalesOrderNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers
|
||||
*/
|
||||
public readonly ?string $digikeyInvoiceNumber;
|
||||
public ?string $digikeyInvoiceNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type.
|
||||
*/
|
||||
public readonly ?string $digikeyLabelType;
|
||||
public ?string $digikeyLabelType;
|
||||
|
||||
/**
|
||||
* @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907
|
||||
*/
|
||||
public readonly ?string $digikeyPartID;
|
||||
public ?string $digikeyPartID;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: For internal use of Digikey. Probably not needed
|
||||
*/
|
||||
public readonly ?string $digikeyNA;
|
||||
public ?string $digikeyNA;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore.
|
||||
*/
|
||||
public readonly ?string $digikeyPadding;
|
||||
public ?string $digikeyPadding;
|
||||
|
||||
public readonly ?string $mouserPositionInOrder;
|
||||
public ?string $mouserPositionInOrder;
|
||||
|
||||
public readonly ?string $mouserManufacturer;
|
||||
public ?string $mouserManufacturer;
|
||||
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
*
|
||||
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
|
||||
*/
|
||||
public function __construct(public readonly array $data)
|
||||
public function __construct(public array $data)
|
||||
{
|
||||
//IDs per EIGP 114.2018
|
||||
$this->shipDate = $data['6D'] ?? null;
|
||||
@@ -329,4 +329,4 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
* like an internally generated barcode or a barcode that was added manually to the system by a user
|
||||
*/
|
||||
class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||
readonly class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LabelSupportedElement $target_type,
|
||||
public readonly int $target_id,
|
||||
public readonly BarcodeSourceType $source_type,
|
||||
public LabelSupportedElement $target_type,
|
||||
public int $target_id,
|
||||
public BarcodeSourceType $source_type,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,4 +46,4 @@ class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||
'Target ID' => $this->target_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,15 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||
);
|
||||
}
|
||||
|
||||
if ($action === 'batch_edit_eda') {
|
||||
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->generate('batch_eda_edit', [
|
||||
'ids' => $ids,
|
||||
'_redirect' => $redirect_url
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
//Iterate over the parts and apply the action to it:
|
||||
foreach ($selected_parts as $part) {
|
||||
|
||||
@@ -420,7 +420,7 @@ class UpdateExecutor
|
||||
// Step 11: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'php', 'bin/console', 'cache:pool:clear', '--all',
|
||||
'--env=prod',
|
||||
'--no-interaction',
|
||||
], 'Clear cache', 120);
|
||||
@@ -489,7 +489,7 @@ class UpdateExecutor
|
||||
|
||||
// Clear cache after rollback
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'php', 'bin/console', 'cache:pool:clear', '--all',
|
||||
'--env=prod',
|
||||
], 'Clear cache after rollback', 120);
|
||||
$log('rollback_cache', 'Cleared cache after rollback', true);
|
||||
|
||||
@@ -41,4 +41,7 @@ class BehaviorSettings
|
||||
|
||||
#[EmbeddedSettings]
|
||||
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;
|
||||
}
|
||||
@@ -51,6 +51,13 @@ enum PartTableColumns : string implements TranslatableInterface
|
||||
case GTIN = "gtin";
|
||||
case TAGS = "tags";
|
||||
case ATTACHMENTS = "attachments";
|
||||
|
||||
case EDA_REFERENCE = "eda_reference";
|
||||
|
||||
case EDA_VALUE = "eda_value";
|
||||
|
||||
case EDA_STATUS = "eda_status";
|
||||
|
||||
case EDIT = "edit";
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
|
||||
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]
|
||||
public ?ConradSettings $conrad = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?CanopySettings $canopy = null;
|
||||
}
|
||||
|
||||
@@ -43,4 +43,23 @@ class KiCadEDASettings
|
||||
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Range(min: -1)]
|
||||
public int $categoryDepth = 0;
|
||||
}
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
|
||||
description: new TM("settings.misc.kicad_eda.datasheet_link.help")
|
||||
)]
|
||||
public ?bool $datasheetAsPdf = true;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.kicad_eda.default_parameter_visibility"),
|
||||
description: new TM("settings.misc.kicad_eda.default_parameter_visibility.help"),
|
||||
|
||||
)]
|
||||
public bool $defaultParameterVisibility = false;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.kicad_eda.default_orderdetails_visibility"),
|
||||
description: new TM("settings.misc.kicad_eda.default_orderdetails_visibility.help"),
|
||||
|
||||
)]
|
||||
public bool $defaultOrderdetailsVisibility = false;
|
||||
}
|
||||
|
||||
@@ -58,4 +58,11 @@ class AttachmentsSettings
|
||||
envVar: "bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
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;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Attachments\AttachmentURLGenerator;
|
||||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\Misc\FAIconGenerator;
|
||||
use Twig\Attribute\AsTwigFunction;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
@@ -31,7 +34,7 @@ use Twig\TwigFunction;
|
||||
|
||||
final readonly class AttachmentExtension
|
||||
{
|
||||
public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator)
|
||||
public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator, private PartPreviewGenerator $partPreviewGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -44,6 +47,26 @@ final readonly class AttachmentExtension
|
||||
return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of the thumbnail of the given element. Returns null if no thumbnail is available.
|
||||
* For parts, a special preview image is generated, for other entities, the master picture is used as preview (if available).
|
||||
*/
|
||||
#[AsTwigFunction("entity_thumbnail")]
|
||||
public function entityThumbnail(AttachmentContainingDBElement $element, string $filter_name = 'thumbnail_sm'): ?string
|
||||
{
|
||||
if ($element instanceof Part) {
|
||||
$preview_attachment = $this->partPreviewGenerator->getTablePreviewAttachment($element);
|
||||
} else { // For other entities, we just use the master picture as preview, if available
|
||||
$preview_attachment = $element->getMasterPictureAttachment();
|
||||
}
|
||||
|
||||
if ($preview_attachment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, $filter_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available.
|
||||
* Null is allowed for files withot extension
|
||||
|
||||
@@ -60,6 +60,15 @@
|
||||
</a>
|
||||
</li>
|
||||
{% 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 %}
|
||||
|
||||
{% if is_granted('@parts.import') %}
|
||||
@@ -159,7 +168,7 @@
|
||||
<li role="separator" class="dropdown-divider"></li>
|
||||
<h6 class="dropdown-header">{% trans %}user.language_select{% endtrans %}</h6>
|
||||
<div id="locale-select-menu">
|
||||
{# This menu is filled by 'turbo/locale_menu' controller from the _turbo_control.html.twig template, to always have the correct path #}
|
||||
{# This menu is filled by a turbo-stream in _turbo_contro.html.twig #}
|
||||
</div>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
{# Insert flashes #}
|
||||
<div class="toasts-global d-none">
|
||||
{% for label, messages in app.flashes() %}
|
||||
{% for message in messages %}
|
||||
{{ include('_toast.html.twig', {
|
||||
'label': label,
|
||||
'message': message
|
||||
}) }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# 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 %}
|
||||
{% block flashes %}
|
||||
{# Insert flashes #}
|
||||
<turbo-stream action="update" action="morph" target="toast-container">
|
||||
<template>
|
||||
{% for label, messages in app.flashes() %}
|
||||
{% for message in messages %}
|
||||
{{ include('_toast.html.twig', {
|
||||
'label': label,
|
||||
'message': message
|
||||
}) }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endblock %}
|
||||
|
||||
{# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #}
|
||||
<span id="sidebar-last-time-updated" style="display: none;" data-last-update="{{ sidebar_tree_updater.lastTreeUpdate.format("Y-m-d\\TH:i:sP") }}"></span>
|
||||
|
||||
{# The title block is already escaped, therefore we dont require any additional escaping here #}
|
||||
<div class="d-none" data-title="{{ current_page_title|trim|raw }}" {{ stimulus_controller('turbo/title') }}></div>
|
||||
<turbo-stream action="update" target="locale-select-menu">
|
||||
<template>
|
||||
{% set locales = settings_instance('localization').languageMenuEntries %}
|
||||
{% if locales is empty %}
|
||||
{% set locales = locale_menu %}
|
||||
{% endif %}
|
||||
|
||||
{% for locale in locales %}
|
||||
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
|
||||
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
|
||||
{{ locale|language_name }} ({{ locale|upper }})</a>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
<div class="d-none" {{ stimulus_controller('turbo/locale_menu') }}>
|
||||
{% set locales = settings_instance('localization').languageMenuEntries %}
|
||||
{% if locales is empty %}
|
||||
{% set locales = locale_menu %}
|
||||
{% endif %}
|
||||
|
||||
{% for locale in locales %}
|
||||
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
|
||||
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
|
||||
{{ locale|language_name }} ({{ locale|upper }})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -195,10 +195,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Include turbo control things, so we can still control page title and reloading #}
|
||||
{% include "_turbo_control.html.twig" %}
|
||||
</turbo-frame>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
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>
|
||||
<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) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -26,6 +26,11 @@
|
||||
<meta name="turbo-refresh-method" content="morph">
|
||||
<meta name="turbo-refresh-scroll" content="preserve">
|
||||
|
||||
{# Allow pages to request a fully reload of everything #}
|
||||
{% if global_reload_needed is defined and global_reload_needed %}
|
||||
<meta name="turbo-visit-control" content="reload">
|
||||
{% endif %}
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{{ asset('favicon.ico') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('icon/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" href="{{ asset('icon/favicon-32x32.png') }}" sizes="32x32">
|
||||
@@ -68,7 +73,17 @@
|
||||
{{ encore_entry_script_tags('webauthn_tfa') }}
|
||||
{% endblock %}
|
||||
</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 %}
|
||||
<header>
|
||||
<turbo-frame id="navbar-frame" target="content" data-turbo-action="advance">
|
||||
@@ -114,13 +129,13 @@
|
||||
|
||||
<!-- 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 %}"
|
||||
{{ 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>
|
||||
</button>
|
||||
|
||||
{# 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 %}"
|
||||
{{ 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>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<b><i>Can not load frontend assets.</i></b><p>Try following things:</p>
|
||||
<ul>
|
||||
<li>Run <kbd>yarn install</kbd> and <kbd>yarn build</kbd> in Part-DB folder.</li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd></li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
|
||||
</ul>
|
||||
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\InvalidFieldNameException"
|
||||
or exception.class == "Doctrine\\DBAL\\Exception\\TableNotFoundException"
|
||||
@@ -26,21 +26,21 @@
|
||||
<ul>
|
||||
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
|
||||
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema</li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd></li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
|
||||
</ul>
|
||||
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\DriverException" %}
|
||||
<b><i>Error while executing database query.</i></b><br>This is maybe caused by an old database schema.<br><p>Try following things:</p>
|
||||
<ul>
|
||||
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
|
||||
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema (if upgrade is available)</li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd></li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
|
||||
<li>If this issue persist, create a ticket at <a href="https://github.com/Part-DB/Part-DB-symfony/issues" rel="noopener">GitHub</a>.</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
You could try following things, if this error is unexpected:
|
||||
<ul>
|
||||
<li>Check <code>var/log/prod.log</code> (or <code>docker logs</code> when Part-DB is running inside a docker container) for additional informations</li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd> to clear cache</li>
|
||||
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd> to clear caches</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
<option {% if not is_granted('@projects.read') %}disabled{% endif %} value="add_to_project" data-url="{{ path('select_project')}}">{% trans %}part_list.action.projects.add_to_project{% endtrans %}</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="{% trans %}part_list.action.group.eda{% endtrans %}">
|
||||
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="batch_edit_eda" data-turbo="false">{% trans %}part_list.action.batch_edit_eda{% endtrans %}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{% trans %}part_list.action.action.delete{% endtrans %}">
|
||||
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
|
||||
</optgroup>
|
||||
|
||||
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="form-group row">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
|
||||
<div class="img-thumbnail" style="max-width: 600px;">
|
||||
<div id="reader-box" {{ stimulus_controller('pages/barcode_scan') }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scan-augmented-result" class="mt-3">
|
||||
{% include "label_system/scanner/_info_mode.html.twig" %}
|
||||
</div>
|
||||
|
||||
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
|
||||
{{ form_start(form, {'attr': {'id': 'scan_dialog_form'}}) }}
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
|
||||
{% if infoModeData %}
|
||||
<hr>
|
||||
<h4>{% trans %}label_scanner.decoded_info.title{% endtrans %}</h4>
|
||||
|
||||
<table class="table table-striped table-hover table-bordered table-sm">
|
||||
<tbody>
|
||||
{% for key, value in infoModeData %}
|
||||
<tr>
|
||||
<td>{{ key }}</td>
|
||||
<td><code>{{ value }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scan_results %}
|
||||
<turbo-stream action="replace" action="morph" target="scan-augmented-result">
|
||||
<template>
|
||||
<div id="scan-augmented-result" class="mt-3">
|
||||
{% include "label_system/scanner/_info_mode.html.twig" %}
|
||||
</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endblock %}
|
||||
|
||||
88
templates/parts/batch_eda_edit.html.twig
Normal file
88
templates/parts/batch_eda_edit.html.twig
Normal file
@@ -0,0 +1,88 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{% trans %}batch_eda.title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-bolt"></i> {% trans %}batch_eda.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div class="mb-3">
|
||||
<p>{% trans with {'%count%': parts|length} %}batch_eda.description{% endtrans %}</p>
|
||||
<details>
|
||||
<summary>{% trans %}batch_eda.show_parts{% endtrans %}</summary>
|
||||
<ul class="list-unstyled ms-3 mt-1">
|
||||
{% for part in parts %}
|
||||
<li><a href="{{ path('part_edit', {id: part.id}) }}">{{ part.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{{ form_start(form) }}
|
||||
|
||||
<p class="text-muted small">{% trans %}batch_eda.apply_hint{% endtrans %}</p>
|
||||
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30px;">{% trans %}batch_eda.apply{% endtrans %}</th>
|
||||
<th>{% trans %}batch_eda.field{% endtrans %}</th>
|
||||
<th>{% trans %}batch_eda.value{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_reference_prefix) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.reference_prefix) }}</td>
|
||||
<td>{{ form_widget(form.reference_prefix, {'attr': {'class': 'form-control-sm'}}) }}{{ form_errors(form.reference_prefix) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_value) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.value) }}</td>
|
||||
<td>{{ form_widget(form.value, {'attr': {'class': 'form-control-sm'}}) }}{{ form_errors(form.value) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_kicad_symbol) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.kicad_symbol) }}</td>
|
||||
<td>{{ form_widget(form.kicad_symbol) }}{{ form_errors(form.kicad_symbol) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_kicad_footprint) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.kicad_footprint) }}</td>
|
||||
<td>{{ form_widget(form.kicad_footprint) }}{{ form_errors(form.kicad_footprint) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_visibility) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.visibility) }}</td>
|
||||
<td>{{ form_widget(form.visibility) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_exclude_from_bom) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.exclude_from_bom) }}</td>
|
||||
<td>{{ form_widget(form.exclude_from_bom) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_exclude_from_board) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.exclude_from_board) }}</td>
|
||||
<td>{{ form_widget(form.exclude_from_board) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-center align-middle">{{ form_widget(form.apply_exclude_from_sim) }}</td>
|
||||
<td class="align-middle">{{ form_label(form.exclude_from_sim) }}</td>
|
||||
<td>{{ form_widget(form.exclude_from_sim) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
{% if redirect_url %}
|
||||
<a href="{{ redirect_url }}" class="btn btn-secondary">{% trans %}batch_eda.cancel{% endtrans %}</a>
|
||||
{% else %}
|
||||
<a href="{{ path('parts_show_all') }}" class="btn btn-secondary">{% trans %}batch_eda.cancel{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{{ form_widget(form.submit) }}
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -14,6 +14,7 @@
|
||||
<th>{% trans %}specifications.unit{% endtrans %}</th>
|
||||
<th>{% trans %}specifications.text{% endtrans %}</th>
|
||||
<th>{% trans %}specifications.group{% endtrans %}</th>
|
||||
<th title="{% trans %}specifications.eda_visibility.help{% endtrans %}"><i class="fas fa-bolt fa-fw"></i></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
{{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }}
|
||||
{{ form_widget(form.obsolete) }}
|
||||
{{ form_widget(form.pricesIncludesVAT) }}
|
||||
{{ form_widget(form.eda_visibility) }}
|
||||
</td>
|
||||
<td>
|
||||
<div {{ collection.controller(form.pricedetails, 'pricedetails.edit.delete.confirm') }}>
|
||||
@@ -79,6 +80,9 @@
|
||||
<td {{ stimulus_controller('pages/latex_preview', {"unit": true}) }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
|
||||
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
|
||||
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
|
||||
{% if form.eda_visibility is defined %}
|
||||
<td class="text-center">{{ form_widget(form.eda_visibility) }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-sm order_btn_delete position-relative {% if form.parent.vars.allow_delete is defined and not form.parent.vars.allow_delete %}disabled{% endif %}"
|
||||
{{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">
|
||||
|
||||
478
tests/Command/PopulateKicadCommandTest.php
Normal file
478
tests/Command/PopulateKicadCommandTest.php
Normal file
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\PopulateKicadCommand;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class PopulateKicadCommandTest extends KernelTestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
|
||||
$command = $application->find('partdb:kicad:populate');
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testListOption(): void
|
||||
{
|
||||
$this->commandTester->execute(['--list' => true]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
// Should show footprints and categories tables
|
||||
$this->assertStringContainsString('Current Footprint KiCad Values', $output);
|
||||
$this->assertStringContainsString('Current Category KiCad Values', $output);
|
||||
$this->assertStringContainsString('ID', $output);
|
||||
$this->assertStringContainsString('Name', $output);
|
||||
|
||||
$this->assertEquals(0, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDryRunDoesNotModifyDatabase(): void
|
||||
{
|
||||
// Create a test footprint without KiCad value
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('SOT-23');
|
||||
$this->entityManager->persist($footprint);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
|
||||
// Run in dry-run mode
|
||||
$this->commandTester->execute(['--dry-run' => true, '--footprints' => true]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('DRY RUN MODE', $output);
|
||||
$this->assertStringContainsString('SOT-23', $output);
|
||||
|
||||
// Clear entity manager to force reload from DB
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Verify footprint was NOT updated in the database
|
||||
$reloadedFootprint = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertNull($reloadedFootprint->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloadedFootprint);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testFootprintMappingUpdatesCorrectly(): void
|
||||
{
|
||||
// Create test footprints
|
||||
$footprint1 = new Footprint();
|
||||
$footprint1->setName('SOT-23');
|
||||
|
||||
$footprint2 = new Footprint();
|
||||
$footprint2->setName('0805');
|
||||
|
||||
$footprint3 = new Footprint();
|
||||
$footprint3->setName('DIP-8');
|
||||
|
||||
$this->entityManager->persist($footprint1);
|
||||
$this->entityManager->persist($footprint2);
|
||||
$this->entityManager->persist($footprint3);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$ids = [$footprint1->getId(), $footprint2->getId(), $footprint3->getId()];
|
||||
|
||||
// Run the command
|
||||
$this->commandTester->execute(['--footprints' => true]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals(0, $this->commandTester->getStatusCode());
|
||||
|
||||
// Clear and reload
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Verify mappings were applied
|
||||
$reloaded1 = $this->entityManager->find(Footprint::class, $ids[0]);
|
||||
$this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded1->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
$reloaded2 = $this->entityManager->find(Footprint::class, $ids[1]);
|
||||
$this->assertEquals('Resistor_SMD:R_0805_2012Metric', $reloaded2->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
$reloaded3 = $this->entityManager->find(Footprint::class, $ids[2]);
|
||||
$this->assertEquals('Package_DIP:DIP-8_W7.62mm', $reloaded3->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded1);
|
||||
$this->entityManager->remove($reloaded2);
|
||||
$this->entityManager->remove($reloaded3);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testSkipsExistingValuesWithoutForce(): void
|
||||
{
|
||||
// Create footprint with existing value
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('SOT-23');
|
||||
$footprint->getEdaInfo()->setKicadFootprint('Custom:MyFootprint');
|
||||
$this->entityManager->persist($footprint);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
|
||||
// Run without --force
|
||||
$this->commandTester->execute(['--footprints' => true]);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Should keep original value
|
||||
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertEquals('Custom:MyFootprint', $reloaded->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testForceOptionOverwritesExistingValues(): void
|
||||
{
|
||||
// Create footprint with existing value
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('SOT-23');
|
||||
$footprint->getEdaInfo()->setKicadFootprint('Custom:MyFootprint');
|
||||
$this->entityManager->persist($footprint);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
|
||||
// Run with --force
|
||||
$this->commandTester->execute(['--footprints' => true, '--force' => true]);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Should overwrite with mapped value
|
||||
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testCategoryMappingUpdatesCorrectly(): void
|
||||
{
|
||||
// Create test categories
|
||||
$category1 = new Category();
|
||||
$category1->setName('Resistors');
|
||||
|
||||
$category2 = new Category();
|
||||
$category2->setName('LED Indicators');
|
||||
|
||||
$category3 = new Category();
|
||||
$category3->setName('Zener Diodes');
|
||||
|
||||
$this->entityManager->persist($category1);
|
||||
$this->entityManager->persist($category2);
|
||||
$this->entityManager->persist($category3);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$ids = [$category1->getId(), $category2->getId(), $category3->getId()];
|
||||
|
||||
// Run the command
|
||||
$this->commandTester->execute(['--categories' => true]);
|
||||
|
||||
$this->assertEquals(0, $this->commandTester->getStatusCode());
|
||||
|
||||
// Clear and reload
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Verify mappings were applied (using pattern matching)
|
||||
$reloaded1 = $this->entityManager->find(Category::class, $ids[0]);
|
||||
$this->assertEquals('Device:R', $reloaded1->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
$reloaded2 = $this->entityManager->find(Category::class, $ids[1]);
|
||||
$this->assertEquals('Device:LED', $reloaded2->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
$reloaded3 = $this->entityManager->find(Category::class, $ids[2]);
|
||||
$this->assertEquals('Device:D_Zener', $reloaded3->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded1);
|
||||
$this->entityManager->remove($reloaded2);
|
||||
$this->entityManager->remove($reloaded3);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testUnmappedFootprintsAreListed(): void
|
||||
{
|
||||
// Create footprint with no mapping
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('CustomPackage-XYZ');
|
||||
$this->entityManager->persist($footprint);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
|
||||
// Run the command
|
||||
$this->commandTester->execute(['--footprints' => true]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
// Should list the unmapped footprint
|
||||
$this->assertStringContainsString('No mapping found', $output);
|
||||
$this->assertStringContainsString('CustomPackage-XYZ', $output);
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->clear();
|
||||
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testMappingFileOverridesDefaults(): void
|
||||
{
|
||||
// Create a footprint that has a built-in mapping (SOT-23 -> Package_TO_SOT_SMD:SOT-23)
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('SOT-23');
|
||||
$this->entityManager->persist($footprint);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
|
||||
// Create a temporary JSON mapping file that overrides SOT-23
|
||||
$mappingFile = sys_get_temp_dir() . '/partdb_test_mappings_' . uniqid() . '.json';
|
||||
file_put_contents($mappingFile, json_encode([
|
||||
'footprints' => [
|
||||
'SOT-23' => 'Custom_Library:Custom_SOT-23',
|
||||
],
|
||||
]));
|
||||
|
||||
try {
|
||||
// Run with mapping file
|
||||
$this->commandTester->execute(['--footprints' => true, '--mapping-file' => $mappingFile]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals(0, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('custom footprint mappings', $output);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Should use the custom mapping, not the built-in one
|
||||
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertEquals('Custom_Library:Custom_SOT-23', $reloaded->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
} finally {
|
||||
@unlink($mappingFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMappingFileInvalidJsonReturnsFailure(): void
|
||||
{
|
||||
$mappingFile = sys_get_temp_dir() . '/partdb_test_invalid_' . uniqid() . '.json';
|
||||
file_put_contents($mappingFile, 'not valid json{{{');
|
||||
|
||||
try {
|
||||
$this->commandTester->execute(['--mapping-file' => $mappingFile]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals(1, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('Invalid JSON', $output);
|
||||
} finally {
|
||||
@unlink($mappingFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMappingFileNotFoundReturnsFailure(): void
|
||||
{
|
||||
$this->commandTester->execute(['--mapping-file' => '/nonexistent/path/mappings.json']);
|
||||
|
||||
$this->assertEquals(1, $this->commandTester->getStatusCode());
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Mapping file not found', $output);
|
||||
}
|
||||
|
||||
public function testFootprintAlternativeNameMatching(): void
|
||||
{
|
||||
// Create a footprint with a primary name that has no mapping,
|
||||
// but an alternative name that does
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('MyCustomSOT23');
|
||||
$footprint->setAlternativeNames('SOT-23, SOT23-3L');
|
||||
$this->entityManager->persist($footprint);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
|
||||
$this->commandTester->execute(['--footprints' => true]);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Should match via alternative name "SOT-23"
|
||||
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testCategoryAlternativeNameMatching(): void
|
||||
{
|
||||
// Create a category with a primary name that has no mapping,
|
||||
// but an alternative name that matches a pattern
|
||||
$category = new Category();
|
||||
$category->setName('SMD Components');
|
||||
$category->setAlternativeNames('Resistor SMD, Chip Resistors');
|
||||
$this->entityManager->persist($category);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$categoryId = $category->getId();
|
||||
|
||||
$this->commandTester->execute(['--categories' => true]);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Should match via alternative name "Resistor SMD" matching pattern "Resistor"
|
||||
$reloaded = $this->entityManager->find(Category::class, $categoryId);
|
||||
$this->assertEquals('Device:R', $reloaded->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testBothFootprintsAndCategoriesUpdatedByDefault(): void
|
||||
{
|
||||
// Create one of each
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('TO-220');
|
||||
$this->entityManager->persist($footprint);
|
||||
|
||||
$category = new Category();
|
||||
$category->setName('Capacitors');
|
||||
$this->entityManager->persist($category);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
$categoryId = $category->getId();
|
||||
|
||||
// Run without specific options (should do both)
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Updating Footprint Entities', $output);
|
||||
$this->assertStringContainsString('Updating Category Entities', $output);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
// Both should be updated
|
||||
$reloadedFootprint = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertEquals('Package_TO_SOT_THT:TO-220-3_Vertical', $reloadedFootprint->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
$reloadedCategory = $this->entityManager->find(Category::class, $categoryId);
|
||||
$this->assertEquals('Device:C', $reloadedCategory->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloadedFootprint);
|
||||
$this->entityManager->remove($reloadedCategory);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testMappingFileWithBothFootprintsAndCategories(): void
|
||||
{
|
||||
$footprint = new Footprint();
|
||||
$footprint->setName('CustomPkg');
|
||||
$this->entityManager->persist($footprint);
|
||||
|
||||
$category = new Category();
|
||||
$category->setName('CustomType');
|
||||
$this->entityManager->persist($category);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$footprintId = $footprint->getId();
|
||||
$categoryId = $category->getId();
|
||||
|
||||
$mappingFile = sys_get_temp_dir() . '/partdb_test_both_' . uniqid() . '.json';
|
||||
file_put_contents($mappingFile, json_encode([
|
||||
'footprints' => [
|
||||
'CustomPkg' => 'Custom:Footprint',
|
||||
],
|
||||
'categories' => [
|
||||
'CustomType' => 'Custom:Symbol',
|
||||
],
|
||||
]));
|
||||
|
||||
try {
|
||||
$this->commandTester->execute(['--mapping-file' => $mappingFile]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals(0, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('custom footprint mappings', $output);
|
||||
$this->assertStringContainsString('custom category mappings', $output);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
$reloadedFp = $this->entityManager->find(Footprint::class, $footprintId);
|
||||
$this->assertEquals('Custom:Footprint', $reloadedFp->getEdaInfo()->getKicadFootprint());
|
||||
|
||||
$reloadedCat = $this->entityManager->find(Category::class, $categoryId);
|
||||
$this->assertEquals('Custom:Symbol', $reloadedCat->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
// Cleanup
|
||||
$this->entityManager->remove($reloadedFp);
|
||||
$this->entityManager->remove($reloadedCat);
|
||||
$this->entityManager->flush();
|
||||
} finally {
|
||||
@unlink($mappingFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMappingFileWithOnlyCategoriesSection(): void
|
||||
{
|
||||
$category = new Category();
|
||||
$category->setName('OnlyCatType');
|
||||
$this->entityManager->persist($category);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$categoryId = $category->getId();
|
||||
|
||||
$mappingFile = sys_get_temp_dir() . '/partdb_test_catonly_' . uniqid() . '.json';
|
||||
file_put_contents($mappingFile, json_encode([
|
||||
'categories' => [
|
||||
'OnlyCatType' => 'Custom:CatSymbol',
|
||||
],
|
||||
]));
|
||||
|
||||
try {
|
||||
$this->commandTester->execute(['--categories' => true, '--mapping-file' => $mappingFile]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals(0, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('custom category mappings', $output);
|
||||
// Should NOT mention footprint mappings since they weren't in the file
|
||||
$this->assertStringNotContainsString('custom footprint mappings', $output);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
$reloaded = $this->entityManager->find(Category::class, $categoryId);
|
||||
$this->assertEquals('Custom:CatSymbol', $reloaded->getEdaInfo()->getKicadSymbol());
|
||||
|
||||
$this->entityManager->remove($reloaded);
|
||||
$this->entityManager->flush();
|
||||
} finally {
|
||||
@unlink($mappingFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
tests/Controller/BatchEdaControllerTest.php
Normal file
171
tests/Controller/BatchEdaControllerTest.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Tests\Controller;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
#[Group("slow")]
|
||||
#[Group("DB")]
|
||||
final class BatchEdaControllerTest extends WebTestCase
|
||||
{
|
||||
private function loginAsUser($client, string $username): void
|
||||
{
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => $username]);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped("User {$username} not found");
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
}
|
||||
|
||||
public function testBatchEdaPageLoads(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2,3']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testBatchEdaPageWithoutPartsRedirects(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/en/tools/batch_eda_edit');
|
||||
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testBatchEdaPageWithoutPartsRedirectsToCustomUrl(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
// Empty IDs with a custom redirect URL
|
||||
$client->request('GET', '/en/tools/batch_eda_edit', [
|
||||
'ids' => '',
|
||||
'_redirect' => '/en/parts',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/en/parts');
|
||||
}
|
||||
|
||||
public function testBatchEdaFormSubmission(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('batch_eda[submit]')->form();
|
||||
$form['batch_eda[apply_reference_prefix]'] = true;
|
||||
$form['batch_eda[reference_prefix]'] = 'R';
|
||||
|
||||
$client->submit($form);
|
||||
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testBatchEdaFormSubmissionAppliesAllFields(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('batch_eda[submit]')->form();
|
||||
|
||||
// Apply all text fields
|
||||
$form['batch_eda[apply_reference_prefix]'] = true;
|
||||
$form['batch_eda[reference_prefix]'] = 'C';
|
||||
$form['batch_eda[apply_value]'] = true;
|
||||
$form['batch_eda[value]'] = '100nF';
|
||||
$form['batch_eda[apply_kicad_symbol]'] = true;
|
||||
$form['batch_eda[kicad_symbol]'] = 'Device:C';
|
||||
$form['batch_eda[apply_kicad_footprint]'] = true;
|
||||
$form['batch_eda[kicad_footprint]'] = 'Capacitor_SMD:C_0402';
|
||||
|
||||
// Apply all tri-state checkboxes
|
||||
$form['batch_eda[apply_visibility]'] = true;
|
||||
$form['batch_eda[apply_exclude_from_bom]'] = true;
|
||||
$form['batch_eda[apply_exclude_from_board]'] = true;
|
||||
$form['batch_eda[apply_exclude_from_sim]'] = true;
|
||||
|
||||
$client->submit($form);
|
||||
|
||||
// All field branches in the controller are now exercised; redirect confirms success
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testBatchEdaFormSubmissionWithRedirectUrl(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$crawler = $client->request('GET', '/en/tools/batch_eda_edit', [
|
||||
'ids' => '1',
|
||||
'_redirect' => '/en/parts',
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('batch_eda[submit]')->form();
|
||||
$form['batch_eda[apply_reference_prefix]'] = true;
|
||||
$form['batch_eda[reference_prefix]'] = 'U';
|
||||
|
||||
$client->submit($form);
|
||||
|
||||
// Should redirect to the custom URL, not the default route
|
||||
self::assertResponseRedirects('/en/parts');
|
||||
}
|
||||
|
||||
public function testBatchEdaFormWithPartialFields(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '3']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('batch_eda[submit]')->form();
|
||||
// Only apply value and kicad_footprint, leave other apply checkboxes unchecked
|
||||
$form['batch_eda[apply_value]'] = true;
|
||||
$form['batch_eda[value]'] = 'TestValue';
|
||||
$form['batch_eda[apply_kicad_footprint]'] = true;
|
||||
$form['batch_eda[kicad_footprint]'] = 'Package_SO:SOIC-8';
|
||||
|
||||
$client->submit($form);
|
||||
|
||||
// Redirect confirms the partial submission was processed
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,11 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||
'value' => 'http://localhost/en/part/1/info',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Part-DB URL' =>
|
||||
array(
|
||||
'value' => 'http://localhost/en/part/1/info',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'description' =>
|
||||
array(
|
||||
'value' => '',
|
||||
@@ -168,6 +173,11 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||
'value' => '1',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Stock' =>
|
||||
array(
|
||||
'value' => '0',
|
||||
'visible' => 'False',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -177,20 +187,19 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||
public function testPartDetailsPart2(): void
|
||||
{
|
||||
$client = $this->createClientWithCredentials();
|
||||
$client->request('GET', self::BASE_URL.'/parts/1.json');
|
||||
$client->request('GET', self::BASE_URL.'/parts/2.json');
|
||||
|
||||
//Response should still be successful, but the result should be empty
|
||||
self::assertResponseIsSuccessful();
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertJson($content);
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
//For part 2 things info should be taken from the category and footprint
|
||||
//For part 2, EDA info should be inherited from category and footprint (no part-level overrides)
|
||||
$expected = array (
|
||||
'id' => '1',
|
||||
'name' => 'Part 1',
|
||||
'symbolIdStr' => 'Part:1',
|
||||
'id' => '2',
|
||||
'name' => 'Part 2',
|
||||
'symbolIdStr' => 'Category:1',
|
||||
'exclude_from_bom' => 'False',
|
||||
'exclude_from_board' => 'True',
|
||||
'exclude_from_sim' => 'False',
|
||||
@@ -198,27 +207,32 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||
array (
|
||||
'footprint' =>
|
||||
array (
|
||||
'value' => 'Part:1',
|
||||
'value' => 'Footprint:1',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'reference' =>
|
||||
array (
|
||||
'value' => 'P',
|
||||
'value' => 'C',
|
||||
'visible' => 'True',
|
||||
),
|
||||
'value' =>
|
||||
array (
|
||||
'value' => 'Part 1',
|
||||
'value' => 'Part 2',
|
||||
'visible' => 'True',
|
||||
),
|
||||
'keywords' =>
|
||||
array (
|
||||
'value' => '',
|
||||
'value' => 'test, Test, Part2',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'datasheet' =>
|
||||
array (
|
||||
'value' => 'http://localhost/en/part/1/info',
|
||||
'value' => 'http://localhost/en/part/2/info',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Part-DB URL' =>
|
||||
array (
|
||||
'value' => 'http://localhost/en/part/2/info',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'description' =>
|
||||
@@ -231,14 +245,44 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||
'value' => 'Node 1',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Manufacturer' =>
|
||||
array (
|
||||
'value' => 'Node 1',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Manufacturing Status' =>
|
||||
array (
|
||||
'value' => '',
|
||||
'value' => 'Active',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Part-DB Footprint' =>
|
||||
array (
|
||||
'value' => 'Node 1',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Mass' =>
|
||||
array (
|
||||
'value' => '100.2 g',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Part-DB ID' =>
|
||||
array (
|
||||
'value' => '1',
|
||||
'value' => '2',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Part-DB IPN' =>
|
||||
array (
|
||||
'value' => 'IPN123',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'manf' =>
|
||||
array (
|
||||
'value' => 'Node 1',
|
||||
'visible' => 'False',
|
||||
),
|
||||
'Stock' =>
|
||||
array (
|
||||
'value' => '0',
|
||||
'visible' => 'False',
|
||||
),
|
||||
),
|
||||
@@ -247,4 +291,31 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||
self::assertEquals($expected, $data);
|
||||
}
|
||||
|
||||
public function testCategoriesHasCacheHeaders(): void
|
||||
{
|
||||
$client = $this->createClientWithCredentials();
|
||||
$client->request('GET', self::BASE_URL.'/categories.json');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$response = $client->getResponse();
|
||||
self::assertNotNull($response->headers->get('ETag'));
|
||||
self::assertStringContainsString('max-age=', $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function testConditionalRequestReturns304(): void
|
||||
{
|
||||
$client = $this->createClientWithCredentials();
|
||||
$client->request('GET', self::BASE_URL.'/categories.json');
|
||||
|
||||
$etag = $client->getResponse()->headers->get('ETag');
|
||||
self::assertNotNull($etag);
|
||||
|
||||
//Make a conditional request with the ETag
|
||||
$client->request('GET', self::BASE_URL.'/categories.json', [], [], [
|
||||
'HTTP_IF_NONE_MATCH' => $etag,
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(304);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -279,4 +279,32 @@ final class AttachmentTest extends TestCase
|
||||
$reflection_property->setAccessible(true);
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,4 +136,44 @@ final class PartNormalizerTest extends WebTestCase
|
||||
$this->assertEqualsWithDelta(1.0, $priceDetail->getPriceRelatedQuantity(), PHP_FLOAT_EPSILON);
|
||||
$this->assertEqualsWithDelta(1.0, $priceDetail->getMinDiscountQuantity(), PHP_FLOAT_EPSILON);
|
||||
}
|
||||
|
||||
public function testDenormalizeEdaFields(): void
|
||||
{
|
||||
$input = [
|
||||
'name' => 'EDA Test Part',
|
||||
'kicad_symbol' => 'Device:R',
|
||||
'kicad_footprint' => 'Resistor_SMD:R_0805_2012Metric',
|
||||
'kicad_reference' => 'R',
|
||||
'kicad_value' => '10k',
|
||||
'eda_exclude_bom' => 'true',
|
||||
'eda_exclude_board' => 'false',
|
||||
];
|
||||
|
||||
$part = $this->service->denormalize($input, Part::class, 'json', ['groups' => ['import'], 'partdb_import' => true]);
|
||||
$this->assertInstanceOf(Part::class, $part);
|
||||
$this->assertSame('EDA Test Part', $part->getName());
|
||||
|
||||
$edaInfo = $part->getEdaInfo();
|
||||
$this->assertSame('Device:R', $edaInfo->getKicadSymbol());
|
||||
$this->assertSame('Resistor_SMD:R_0805_2012Metric', $edaInfo->getKicadFootprint());
|
||||
$this->assertSame('R', $edaInfo->getReferencePrefix());
|
||||
$this->assertSame('10k', $edaInfo->getValue());
|
||||
$this->assertTrue($edaInfo->getExcludeFromBom());
|
||||
$this->assertFalse($edaInfo->getExcludeFromBoard());
|
||||
}
|
||||
|
||||
public function testDenormalizeEdaFieldsEmptyValuesIgnored(): void
|
||||
{
|
||||
$input = [
|
||||
'name' => 'Part Without EDA',
|
||||
'kicad_symbol' => '',
|
||||
'kicad_footprint' => '',
|
||||
];
|
||||
|
||||
$part = $this->service->denormalize($input, Part::class, 'json', ['groups' => ['import'], 'partdb_import' => true]);
|
||||
|
||||
$edaInfo = $part->getEdaInfo();
|
||||
$this->assertNull($edaInfo->getKicadSymbol());
|
||||
$this->assertNull($edaInfo->getKicadFootprint());
|
||||
}
|
||||
}
|
||||
|
||||
604
tests/Services/EDA/KiCadHelperTest.php
Normal file
604
tests/Services/EDA/KiCadHelperTest.php
Normal file
@@ -0,0 +1,604 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Tests\Services\EDA;
|
||||
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Parameters\PartParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Services\EDA\KiCadHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
#[Group('DB')]
|
||||
final class KiCadHelperTest extends KernelTestCase
|
||||
{
|
||||
private KiCadHelper $helper;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->helper = self::getContainer()->get(KiCadHelper::class);
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part 1 (from fixtures) has no stock lots. Stock should be 0.
|
||||
*/
|
||||
public function testPartWithoutStockHasZeroStock(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 1);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayHasKey('Stock', $result['fields']);
|
||||
self::assertSame('0', $result['fields']['Stock']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part 3 (from fixtures) has a lot with amount=1.0 in StorageLocation 1.
|
||||
*/
|
||||
public function testPartWithStockShowsCorrectQuantity(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 3);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayHasKey('Stock', $result['fields']);
|
||||
self::assertSame('1', $result['fields']['Stock']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part 3 has a lot with amount > 0 in StorageLocation "Node 1".
|
||||
*/
|
||||
public function testPartWithStorageLocationShowsLocation(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 3);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayHasKey('Storage Location', $result['fields']);
|
||||
self::assertSame('Node 1', $result['fields']['Storage Location']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part 1 has no stock lots, so no storage location should be shown.
|
||||
*/
|
||||
public function testPartWithoutStorageLocationOmitsField(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 1);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayNotHasKey('Storage Location', $result['fields']);
|
||||
}
|
||||
|
||||
/**
|
||||
* All parts should have a "Part-DB URL" field pointing to the part info page.
|
||||
*/
|
||||
public function testPartDbUrlFieldIsPresent(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 1);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayHasKey('Part-DB URL', $result['fields']);
|
||||
self::assertStringContainsString('/part/1/info', $result['fields']['Part-DB URL']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part 1 has no attachments, so the datasheet should fall back to the Part-DB page URL.
|
||||
*/
|
||||
public function testDatasheetFallbackToPartUrlWhenNoAttachments(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 1);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// With no attachments, datasheet should equal Part-DB URL
|
||||
self::assertSame(
|
||||
$result['fields']['Part-DB URL']['value'],
|
||||
$result['fields']['datasheet']['value']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part 3 has attachments but none named "datasheet" and none are PDFs,
|
||||
* so the datasheet should fall back to the Part-DB page URL.
|
||||
*/
|
||||
public function testDatasheetFallbackWhenNoMatchingAttachments(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 3);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// "TestAttachment" (url: www.foo.bar) and "Test2" (internal: invalid) don't match datasheet patterns
|
||||
self::assertSame(
|
||||
$result['fields']['Part-DB URL']['value'],
|
||||
$result['fields']['datasheet']['value']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that an attachment with type name containing "Datasheet" is found.
|
||||
*/
|
||||
public function testDatasheetFoundByAttachmentTypeName(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
// Create an attachment type named "Datasheets"
|
||||
$datasheetType = new AttachmentType();
|
||||
$datasheetType->setName('Datasheets');
|
||||
$this->em->persist($datasheetType);
|
||||
|
||||
// Create a part with a datasheet attachment
|
||||
$part = new Part();
|
||||
$part->setName('Part with Datasheet Type');
|
||||
$part->setCategory($category);
|
||||
|
||||
$attachment = new PartAttachment();
|
||||
$attachment->setName('Component Spec');
|
||||
$attachment->setURL('https://example.com/spec.pdf');
|
||||
$attachment->setAttachmentType($datasheetType);
|
||||
$part->addAttachment($attachment);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertSame('https://example.com/spec.pdf', $result['fields']['datasheet']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that an attachment named "Datasheet" is found (regardless of type).
|
||||
*/
|
||||
public function testDatasheetFoundByAttachmentName(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
$attachmentType = $this->em->find(AttachmentType::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Named Datasheet');
|
||||
$part->setCategory($category);
|
||||
|
||||
$attachment = new PartAttachment();
|
||||
$attachment->setName('Datasheet BC547');
|
||||
$attachment->setURL('https://example.com/bc547-datasheet.pdf');
|
||||
$attachment->setAttachmentType($attachmentType);
|
||||
$part->addAttachment($attachment);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertSame('https://example.com/bc547-datasheet.pdf', $result['fields']['datasheet']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a PDF attachment is used as fallback when no "datasheet" match exists.
|
||||
*/
|
||||
public function testDatasheetFallbackToFirstPdfAttachment(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
$attachmentType = $this->em->find(AttachmentType::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with PDF');
|
||||
$part->setCategory($category);
|
||||
|
||||
// Non-PDF attachment first
|
||||
$attachment1 = new PartAttachment();
|
||||
$attachment1->setName('Photo');
|
||||
$attachment1->setURL('https://example.com/photo.jpg');
|
||||
$attachment1->setAttachmentType($attachmentType);
|
||||
$part->addAttachment($attachment1);
|
||||
|
||||
// PDF attachment second
|
||||
$attachment2 = new PartAttachment();
|
||||
$attachment2->setName('Specifications');
|
||||
$attachment2->setURL('https://example.com/specs.pdf');
|
||||
$attachment2->setAttachmentType($attachmentType);
|
||||
$part->addAttachment($attachment2);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// Should find the .pdf file as fallback
|
||||
self::assertSame('https://example.com/specs.pdf', $result['fields']['datasheet']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a "data sheet" variant (with space) is also matched by name.
|
||||
*/
|
||||
public function testDatasheetMatchesDataSheetWithSpace(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
$attachmentType = $this->em->find(AttachmentType::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Data Sheet');
|
||||
$part->setCategory($category);
|
||||
|
||||
$attachment = new PartAttachment();
|
||||
$attachment->setName('Data Sheet v1.2');
|
||||
$attachment->setURL('https://example.com/data-sheet.pdf');
|
||||
$attachment->setAttachmentType($attachmentType);
|
||||
$part->addAttachment($attachment);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertSame('https://example.com/data-sheet.pdf', $result['fields']['datasheet']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test stock calculation excludes expired lots.
|
||||
*/
|
||||
public function testStockExcludesExpiredLots(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Expired Stock');
|
||||
$part->setCategory($category);
|
||||
|
||||
// Active lot
|
||||
$lot1 = new PartLot();
|
||||
$lot1->setAmount(10.0);
|
||||
$part->addPartLot($lot1);
|
||||
|
||||
// Expired lot
|
||||
$lot2 = new PartLot();
|
||||
$lot2->setAmount(5.0);
|
||||
$lot2->setExpirationDate(new \DateTimeImmutable('-1 day'));
|
||||
$part->addPartLot($lot2);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// Only the active lot should be counted
|
||||
self::assertSame('10', $result['fields']['Stock']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test stock calculation excludes lots with unknown stock.
|
||||
*/
|
||||
public function testStockExcludesUnknownLots(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Unknown Stock');
|
||||
$part->setCategory($category);
|
||||
|
||||
// Known lot
|
||||
$lot1 = new PartLot();
|
||||
$lot1->setAmount(7.0);
|
||||
$part->addPartLot($lot1);
|
||||
|
||||
// Unknown lot
|
||||
$lot2 = new PartLot();
|
||||
$lot2->setInstockUnknown(true);
|
||||
$part->addPartLot($lot2);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertSame('7', $result['fields']['Stock']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test stock sums across multiple lots.
|
||||
*/
|
||||
public function testStockSumsMultipleLots(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
$location1 = $this->em->find(StorageLocation::class, 1);
|
||||
$location2 = $this->em->find(StorageLocation::class, 2);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part in Multiple Locations');
|
||||
$part->setCategory($category);
|
||||
|
||||
$lot1 = new PartLot();
|
||||
$lot1->setAmount(15.0);
|
||||
$lot1->setStorageLocation($location1);
|
||||
$part->addPartLot($lot1);
|
||||
|
||||
$lot2 = new PartLot();
|
||||
$lot2->setAmount(25.0);
|
||||
$lot2->setStorageLocation($location2);
|
||||
$part->addPartLot($lot2);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertSame('40', $result['fields']['Stock']['value']);
|
||||
self::assertArrayHasKey('Storage Location', $result['fields']);
|
||||
// Both locations should be listed
|
||||
self::assertStringContainsString('Node 1', $result['fields']['Storage Location']['value']);
|
||||
self::assertStringContainsString('Node 2', $result['fields']['Storage Location']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the Stock field visibility is "False" (not visible in schematic by default).
|
||||
*/
|
||||
public function testStockFieldIsNotVisible(): void
|
||||
{
|
||||
$part = $this->em->find(Part::class, 1);
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertSame('False', $result['fields']['Stock']['visible']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a parameter with eda_visibility=true appears in the KiCad fields.
|
||||
*/
|
||||
public function testParameterWithEdaVisibilityAppearsInFields(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Exported Parameter');
|
||||
$part->setCategory($category);
|
||||
|
||||
$param = new PartParameter();
|
||||
$param->setName('Voltage Rating');
|
||||
$param->setValueTypical(3.3);
|
||||
$param->setUnit('V');
|
||||
$param->setEdaVisibility(true);
|
||||
$part->addParameter($param);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayHasKey('Voltage Rating', $result['fields']);
|
||||
self::assertSame('3.3 V', $result['fields']['Voltage Rating']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a parameter with eda_visibility=false does NOT appear in the KiCad fields.
|
||||
*/
|
||||
public function testParameterWithoutEdaVisibilityDoesNotAppear(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Non-exported Parameter');
|
||||
$part->setCategory($category);
|
||||
|
||||
$param = new PartParameter();
|
||||
$param->setName('Internal Note');
|
||||
$param->setValueText('for testing only');
|
||||
$param->setEdaVisibility(false);
|
||||
$part->addParameter($param);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayNotHasKey('Internal Note', $result['fields']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a parameter with eda_visibility=null (system default) does NOT appear in the KiCad fields.
|
||||
*/
|
||||
public function testParameterWithNullEdaVisibilityDoesNotAppear(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Default Parameter');
|
||||
$part->setCategory($category);
|
||||
|
||||
$param = new PartParameter();
|
||||
$param->setName('Default Param');
|
||||
$param->setValueText('some value');
|
||||
// eda_visibility is null by default
|
||||
$part->addParameter($param);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayNotHasKey('Default Param', $result['fields']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that an exported parameter named "description" does NOT overwrite the hardcoded description field.
|
||||
*/
|
||||
public function testExportedParameterDoesNotOverwriteHardcodedField(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Conflicting Parameter');
|
||||
$part->setDescription('The real description');
|
||||
$part->setCategory($category);
|
||||
|
||||
$param = new PartParameter();
|
||||
$param->setName('description');
|
||||
$param->setValueText('should not overwrite');
|
||||
$param->setEdaVisibility(true);
|
||||
$part->addParameter($param);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// The hardcoded description should win
|
||||
self::assertSame('The real description', $result['fields']['description']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that orderdetails without explicit eda_visibility are all exported (backward compat).
|
||||
*/
|
||||
public function testOrderdetailsExportedWhenNoEdaVisibilitySet(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$supplier = new Supplier();
|
||||
$supplier->setName('TestSupplier');
|
||||
$this->em->persist($supplier);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Supplier');
|
||||
$part->setCategory($category);
|
||||
|
||||
$od = new Orderdetail();
|
||||
$od->setSupplier($supplier);
|
||||
$od->setSupplierpartnr('TS-001');
|
||||
// eda_visibility is null (default)
|
||||
$part->addOrderdetail($od);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// Should export since no explicit flags are set (backward compat)
|
||||
self::assertArrayHasKey('TestSupplier SPN', $result['fields']);
|
||||
self::assertSame('TS-001', $result['fields']['TestSupplier SPN']['value']);
|
||||
// KiCost field should also be present
|
||||
self::assertArrayHasKey('testsupplier#', $result['fields']);
|
||||
self::assertSame('TS-001', $result['fields']['testsupplier#']['value']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that only orderdetails with eda_visibility=true are exported when explicit flags exist.
|
||||
*/
|
||||
public function testOrderdetailsFilteredByExplicitEdaVisibility(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$supplier1 = new Supplier();
|
||||
$supplier1->setName('VisibleSupplier');
|
||||
$this->em->persist($supplier1);
|
||||
|
||||
$supplier2 = new Supplier();
|
||||
$supplier2->setName('HiddenSupplier');
|
||||
$this->em->persist($supplier2);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Mixed Visibility');
|
||||
$part->setCategory($category);
|
||||
|
||||
$od1 = new Orderdetail();
|
||||
$od1->setSupplier($supplier1);
|
||||
$od1->setSupplierpartnr('VIS-001');
|
||||
$od1->setEdaVisibility(true);
|
||||
$part->addOrderdetail($od1);
|
||||
|
||||
$od2 = new Orderdetail();
|
||||
$od2->setSupplier($supplier2);
|
||||
$od2->setSupplierpartnr('HID-001');
|
||||
$od2->setEdaVisibility(false);
|
||||
$part->addOrderdetail($od2);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// Visible supplier should be exported
|
||||
self::assertArrayHasKey('VisibleSupplier SPN', $result['fields']);
|
||||
self::assertSame('VIS-001', $result['fields']['VisibleSupplier SPN']['value']);
|
||||
|
||||
// Hidden supplier should NOT be exported
|
||||
self::assertArrayNotHasKey('HiddenSupplier SPN', $result['fields']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that manufacturer fields (manf, manf#) are always exported.
|
||||
*/
|
||||
public function testManufacturerFieldsExported(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$manufacturer = new Manufacturer();
|
||||
$manufacturer->setName('Acme Corp');
|
||||
$this->em->persist($manufacturer);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Acme Widget');
|
||||
$part->setCategory($category);
|
||||
$part->setManufacturer($manufacturer);
|
||||
$part->setManufacturerProductNumber('ACM-1234');
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
self::assertArrayHasKey('manf', $result['fields']);
|
||||
self::assertSame('Acme Corp', $result['fields']['manf']['value']);
|
||||
self::assertArrayHasKey('manf#', $result['fields']);
|
||||
self::assertSame('ACM-1234', $result['fields']['manf#']['value']);
|
||||
self::assertArrayHasKey('Manufacturer', $result['fields']);
|
||||
self::assertArrayHasKey('MPN', $result['fields']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a parameter with empty name is not exported even with eda_visibility=true.
|
||||
*/
|
||||
public function testParameterWithEmptyNameIsSkipped(): void
|
||||
{
|
||||
$category = $this->em->find(Category::class, 1);
|
||||
|
||||
$part = new Part();
|
||||
$part->setName('Part with Empty Param Name');
|
||||
$part->setCategory($category);
|
||||
|
||||
$param = new PartParameter();
|
||||
$param->setName('');
|
||||
$param->setValueText('some value');
|
||||
$param->setEdaVisibility(true);
|
||||
$part->addParameter($param);
|
||||
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->helper->getKiCADPart($part);
|
||||
|
||||
// Empty-named parameter should not appear
|
||||
self::assertArrayNotHasKey('', $result['fields']);
|
||||
}
|
||||
}
|
||||
@@ -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\LocalBarcodeScanResult;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||
|
||||
final class BarcodeScanHelperTest extends WebTestCase
|
||||
{
|
||||
@@ -124,6 +125,14 @@ final class BarcodeScanHelperTest extends WebTestCase
|
||||
]);
|
||||
|
||||
yield [$eigp114Result, "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"];
|
||||
|
||||
$lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}';
|
||||
$lcscResult = new LCSCBarcodeScanResult(
|
||||
['pc' => 'C138033', 'pm' => 'RC0402FR-071ML', 'qty' => '10'],
|
||||
$lcscInput
|
||||
);
|
||||
|
||||
yield [$lcscResult, $lcscInput];
|
||||
}
|
||||
|
||||
public static function invalidDataProvider(): \Iterator
|
||||
@@ -153,4 +162,33 @@ final class BarcodeScanHelperTest extends WebTestCase
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->service->scanBarcodeContent($input);
|
||||
}
|
||||
|
||||
public function testAutoDetectLcscBarcode(): void
|
||||
{
|
||||
$input = '{pbn:PB1,on:ON1,pc:C138033,pm:RC0402FR-071ML,qty:10}';
|
||||
|
||||
$result = $this->service->scanBarcodeContent($input);
|
||||
|
||||
$this->assertInstanceOf(LCSCBarcodeScanResult::class, $result);
|
||||
$this->assertSame('C138033', $result->lcscCode);
|
||||
$this->assertSame('RC0402FR-071ML', $result->mpn);
|
||||
}
|
||||
|
||||
public function testLcscExplicitTypeParses(): void
|
||||
{
|
||||
$input = '{pc:C138033,pm:RC0402FR-071ML,qty:10}';
|
||||
|
||||
$result = $this->service->scanBarcodeContent($input, BarcodeSourceType::LCSC);
|
||||
|
||||
$this->assertInstanceOf(LCSCBarcodeScanResult::class, $result);
|
||||
$this->assertSame('C138033', $result->lcscCode);
|
||||
$this->assertSame('RC0402FR-071ML', $result->mpn);
|
||||
}
|
||||
|
||||
public function testLcscExplicitTypeRejectsNonLcsc(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->service->scanBarcodeContent('not-an-lcsc', BarcodeSourceType::LCSC);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)']);
|
||||
}
|
||||
}
|
||||
@@ -641,6 +641,12 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
||||
<target>Sektion</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8rz303Z" name="specifications.eda_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>specifications.eda_visibility.help</source>
|
||||
<target>Diesen Parameter als EDA Feld exportieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XclPxI9" name="specification.create">
|
||||
<segment state="translated">
|
||||
<source>specification.create</source>
|
||||
@@ -2923,6 +2929,42 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
<target>Dateianhänge</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f3Dggp6" name="part.table.eda_status">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_status</source>
|
||||
<target>EDA</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q_myBuD" name="eda.status.symbol_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.symbol_set</source>
|
||||
<target>KiCad Symbol gesetzt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QGLfvit" name="eda.status.footprint_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.footprint_set</source>
|
||||
<target>KiCad Footprint gesetzt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hkze9M." name="eda.status.reference_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.reference_set</source>
|
||||
<target>Referenzpräfix gesetzt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="OTXbAfL" name="eda.status.complete">
|
||||
<segment state="translated">
|
||||
<source>eda.status.complete</source>
|
||||
<target>EDA Felder vollständig (Symbol, Footprint, Referenz)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="z9E5RB." name="eda.status.partial">
|
||||
<segment state="translated">
|
||||
<source>eda.status.partial</source>
|
||||
<target>EDA Felder teilweise gesetzt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bMkafCp" name="flash.login_successful">
|
||||
<segment state="translated">
|
||||
<source>flash.login_successful</source>
|
||||
@@ -3265,6 +3307,12 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||
<target>Nicht mehr lieferbar</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6H0WQWq" name="orderdetails.edit.eda_visibility">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.eda_visibility</source>
|
||||
<target>Sichtbar in EDA</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.supplierpartnr.placeholder</source>
|
||||
@@ -9499,6 +9547,12 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>EIGP 114 Barcode (z. B. der Datamatrix-Code auf Digikey und Mouser Bauteilen)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BnqcKWx" name="scan_dialog.mode.lcsc">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.mode.lcsc</source>
|
||||
<target>LCSC.com Barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.info_mode</source>
|
||||
@@ -9511,6 +9565,24 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>Dekodierte Informationen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kQnodbA" name="label_scanner.target_found">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.target_found</source>
|
||||
<target>Artikel in Datenbank gefunden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7Arfw2q" name="label_scanner.scan_result.title">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.scan_result.title</source>
|
||||
<target>Scan-Ergebnis</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PTh4EK_" name="label_scanner.no_locations">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.no_locations</source>
|
||||
<target>Bauteil ist an keinem Standort gespeichert.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nmXQWcS" name="label_generator.edit_profiles">
|
||||
<segment state="translated">
|
||||
<source>label_generator.edit_profiles</source>
|
||||
@@ -9944,6 +10016,18 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>Dieser Wert bestimmt die Tiefe des Kategoriebaums, der in KiCad sichtbar ist. 0 bedeutet, dass nur die Kategorien der obersten Ebene sichtbar sind. Setzen Sie den Wert auf > 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="X5.rQdO" name="settings.misc.kicad_eda.datasheet_link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.datasheet_link</source>
|
||||
<target>Datasheet Feld verlinkt auf PDF</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Fm1QTCs" name="settings.misc.kicad_eda.datasheet_link.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.datasheet_link.help</source>
|
||||
<target>Wenn aktiviert, verlinkt das Datenblatt-Feld in KiCad auf die tatsächliche PDF-Datei (sofern gefunden). Wenn deaktiviert, führt es stattdessen zur Part-DB-Seite. Der Link zur Part-DB-Seite ist immer als separates "Part-DB URL"-Feld verfügbar.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar</source>
|
||||
@@ -10286,6 +10370,24 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>Zeigen Sie die Bildoverlay mit den Details zum Anhang an, wenn Sie mit der Maus über die Teilebildgalerie fahren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0iYdzdk" name="settings.behavior.keybindings">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.keybindings</source>
|
||||
<target>Tastenkürzel</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>Tastenkürzel für Sonderzeichen aktivieren</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>Aktivieren Sie Alt+Taste-Kürzel zum Einfügen von Sonderzeichen (griechische Buchstaben, mathematische Symbole etc.) in Texteingabefeldern. Deaktivieren, wenn die Kürzel mit Ihrer Tastaturbelegung oder Systemkürzeln kollidieren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ALfPkeR" name="perm.config.change_system_settings">
|
||||
<segment state="translated">
|
||||
<source>perm.config.change_system_settings</source>
|
||||
@@ -10910,6 +11012,84 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>Massenimport von Datenquellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VtS1yT7" name="part_list.action.group.eda">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.group.eda</source>
|
||||
<target>EDA / KiCad</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="swU1Rp2" name="part_list.action.batch_edit_eda">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.batch_edit_eda</source>
|
||||
<target>EDA-Felder in Stapel bearbeiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZaS_Hg5" name="batch_eda.title">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.title</source>
|
||||
<target>EDA-Felder in Stapel bearbeiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k2FDo7A" name="batch_eda.description">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.description</source>
|
||||
<target>Bearbeiten Sie EDA/KiCad-Felder für %count% ausgewählte Bauteile. Aktivieren Sie das Kontrollkästchen "Anwenden" neben jedem Feld, das Sie ändern möchten.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WVHbic3" name="batch_eda.show_parts">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.show_parts</source>
|
||||
<target>Ausgewählte Bauteile anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ubQd6G4" name="batch_eda.apply_hint">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.apply_hint</source>
|
||||
<target>Nur Felder mit aktiviertem Kontrollkästchen "Anwenden" werden geändert. Deaktivierte Felder bleiben unverändert.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="w.5FGYL" name="batch_eda.apply">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.apply</source>
|
||||
<target>Anwenden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9EmHp5C" name="batch_eda.field">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.field</source>
|
||||
<target>Feld</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xHaCnEQ" name="batch_eda.value">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.value</source>
|
||||
<target>Wert</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PLqIBvC" name="batch_eda.submit">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.submit</source>
|
||||
<target>Auf ausgewählte Bauteile anwenden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5nO7Fpq" name="batch_eda.cancel">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.cancel</source>
|
||||
<target>Abbrechen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vhlPBNU" name="batch_eda.success">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.success</source>
|
||||
<target>EDA-Felder erfolgreich aktualisiert.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2fMo760" name="batch_eda.no_parts_selected">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.no_parts_selected</source>
|
||||
<target>Für die Stapelbearbeitung wurden keine Bauteile ausgewählt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yzpXFkB" name="info_providers.bulk_import.step1.spn_recommendation">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.step1.spn_recommendation</source>
|
||||
@@ -12507,5 +12687,137 @@ Buerklin-API-Authentication-Server:
|
||||
<target>Letzte Inventur</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GNWhoTW" name="part.table.eda_reference">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_reference</source>
|
||||
<target>EDA-Referenz</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tW4yCbf" name="part.table.eda_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_value</source>
|
||||
<target>EDA-Wert</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s1pgReC" name="settings.misc.kicad_eda.default_parameter_visibility">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_parameter_visibility</source>
|
||||
<target>Standard EDA-Sichtbarkeit für Parameter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Z78QunV" name="settings.misc.kicad_eda.default_parameter_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_parameter_visibility.help</source>
|
||||
<target>EDA-Sichtbarkeit für alle [Part]-Parameter, die keine explizite Sichtbarkeit gesetzt haben. Wenn aktiviert, sind alle Parameter standardmäßig in der EDA-Software sichtbar.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="J6pYnaC" name="settings.misc.kicad_eda.default_orderdetails_visibility">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_orderdetails_visibility</source>
|
||||
<target>Standard EDA-Sichtbarkeit für Einkaufsinformationen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Hiye4C." name="settings.misc.kicad_eda.default_orderdetails_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_orderdetails_visibility.help</source>
|
||||
<target>EDA-Sichtbarkeit für alle Bestellinformationen, die keine explizite Sichtbarkeit gesetzt haben. Bei Aktivierung sind alle Bestellinfos standardmäßig in der EDA-Software sichtbar.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aEgd0if" name="label_scanner.open">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.open</source>
|
||||
<target>Details anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vw_0Qws" name="label_scanner.db_part_found">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.db_part_found</source>
|
||||
<target>Datenbank-[Part] für Barcode gefunden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zntajcd" name="label_scanner.part_can_be_created">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.part_can_be_created</source>
|
||||
<target>[Part] kann erstellt werden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cLTbd9w" name="label_scanner.part_can_be_created.help">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.part_can_be_created.help</source>
|
||||
<target>Kein passendes [Part] in der Datenbank gefunden, aber Sie können ein neues [Part] basierend auf diesem Barcode erstellen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="FfHA3Yf" name="label_scanner.part_create_btn">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.part_create_btn</source>
|
||||
<target>[Part] aus Barcode erstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xH258F." name="parts.create_from_scan.title">
|
||||
<segment state="translated">
|
||||
<source>parts.create_from_scan.title</source>
|
||||
<target>[Part] aus Scan erstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8WZYwRJ" name="scan_dialog.mode.amazon">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.mode.amazon</source>
|
||||
<target>Amazon Barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BQWuR_G" name="settings.ips.canopy">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy</source>
|
||||
<target>Canopy</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="44BfYzy" name="settings.ips.canopy.alwaysGetDetails">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy.alwaysGetDetails</source>
|
||||
<target>Details immer abrufen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="so_ms3t" name="settings.ips.canopy.alwaysGetDetails.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy.alwaysGetDetails.help</source>
|
||||
<target>Wenn aktiviert, werden beim Erstellen eines Bauteils mehr Details von canopy abgerufen. Das verursacht eine zusätzliche API-Anfrage, liefert aber Produkt-Bulletpoints und Kategoriedaten.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="D055xh8" name="attachment.sandbox.warning">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.warning</source>
|
||||
<target>WARNUNG: Sie betrachten einen von einem Nutzer hochgeladenen Anhang. Dabei handelt es sich um nicht vertrauenswürdigen Inhalt. Bitte vorsichtig fortfahren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bRcdnJK" name="attachment.sandbox.back_to_partdb">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.back_to_partdb</source>
|
||||
<target>Zurück zu Part-DB</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MzyA7N8" name="settings.system.attachments.showHTMLAttachments">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.showHTMLAttachments</source>
|
||||
<target>Hochgeladene HTML-Dateianhänge anzeigen (sandboxed)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="V_LJkRy" name="settings.system.attachments.showHTMLAttachments.help">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.showHTMLAttachments.help</source>
|
||||
<target>⚠️ Wenn aktiviert, können vom Nutzer hochgeladene HTML-Anhänge direkt im Browser angezeigt werden. Viele potenziell schädliche Funktionen sind eingeschränkt, dennoch besteht ein Sicherheitsrisiko und sollte nur aktiviert werden, wenn den hochladenden Nutzern vertraut wird.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BQo2xWi" name="attachment.sandbox.title">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.title</source>
|
||||
<target>HTML [Attachment]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sJ6v9uJ" name="attachment.sandbox.as_plain_text">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.as_plain_text</source>
|
||||
<target>Als Klartext anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -642,6 +642,12 @@ Sub elements will be moved upwards.</target>
|
||||
<target>Group</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8rz303Z" name="specifications.eda_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>specifications.eda_visibility.help</source>
|
||||
<target>Export this parameter as an EDA field</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XclPxI9" name="specification.create">
|
||||
<segment state="translated">
|
||||
<source>specification.create</source>
|
||||
@@ -2924,6 +2930,42 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
|
||||
<target>Attachments</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f3Dggp6" name="part.table.eda_status">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_status</source>
|
||||
<target>EDA</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q_myBuD" name="eda.status.symbol_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.symbol_set</source>
|
||||
<target>KiCad symbol set</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QGLfvit" name="eda.status.footprint_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.footprint_set</source>
|
||||
<target>KiCad footprint set</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hkze9M." name="eda.status.reference_set">
|
||||
<segment state="translated">
|
||||
<source>eda.status.reference_set</source>
|
||||
<target>Reference prefix set</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="OTXbAfL" name="eda.status.complete">
|
||||
<segment state="translated">
|
||||
<source>eda.status.complete</source>
|
||||
<target>EDA fields complete (symbol, footprint, reference)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="z9E5RB." name="eda.status.partial">
|
||||
<segment state="translated">
|
||||
<source>eda.status.partial</source>
|
||||
<target>EDA fields partially set</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bMkafCp" name="flash.login_successful">
|
||||
<segment state="translated">
|
||||
<source>flash.login_successful</source>
|
||||
@@ -3266,6 +3308,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
|
||||
<target>No longer available</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6H0WQWq" name="orderdetails.edit.eda_visibility">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.eda_visibility</source>
|
||||
<target>Visible in EDA</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
|
||||
<segment state="translated">
|
||||
<source>orderdetails.edit.supplierpartnr.placeholder</source>
|
||||
@@ -9500,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>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BnqcKWx" name="scan_dialog.mode.lcsc">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.mode.lcsc</source>
|
||||
<target>LCSC.com barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="QSMS_Bd" name="scan_dialog.info_mode">
|
||||
<segment state="translated">
|
||||
<source>scan_dialog.info_mode</source>
|
||||
@@ -9512,6 +9566,24 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||
<target>Decoded information</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kQnodbA" name="label_scanner.target_found">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.target_found</source>
|
||||
<target>Item found in database</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7Arfw2q" name="label_scanner.scan_result.title">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.scan_result.title</source>
|
||||
<target>Scan result</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PTh4EK_" name="label_scanner.no_locations">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.no_locations</source>
|
||||
<target>Part is not stored at any location.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nmXQWcS" name="label_generator.edit_profiles">
|
||||
<segment state="translated">
|
||||
<source>label_generator.edit_profiles</source>
|
||||
@@ -9945,6 +10017,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||
<target>This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="X5.rQdO" name="settings.misc.kicad_eda.datasheet_link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.datasheet_link</source>
|
||||
<target>Datasheet field links to PDF</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Fm1QTCs" name="settings.misc.kicad_eda.datasheet_link.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.datasheet_link.help</source>
|
||||
<target>When enabled, the datasheet field in KiCad will link to the actual PDF file (if found). When disabled, it will link to the Part-DB page instead. The Part-DB page link is always available as a separate "Part-DB URL" field.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar</source>
|
||||
@@ -10287,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>
|
||||
</segment>
|
||||
</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">
|
||||
<segment state="translated">
|
||||
<source>perm.config.change_system_settings</source>
|
||||
@@ -10911,6 +11013,84 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||
<target>Bulk Info Provider Import</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VtS1yT7" name="part_list.action.group.eda">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.group.eda</source>
|
||||
<target>EDA / KiCad</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="swU1Rp2" name="part_list.action.batch_edit_eda">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.batch_edit_eda</source>
|
||||
<target>Batch Edit EDA Fields</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZaS_Hg5" name="batch_eda.title">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.title</source>
|
||||
<target>Batch Edit EDA Fields</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k2FDo7A" name="batch_eda.description">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.description</source>
|
||||
<target>Edit EDA/KiCad fields for %count% selected parts. Check the "Apply" box next to each field you want to change.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WVHbic3" name="batch_eda.show_parts">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.show_parts</source>
|
||||
<target>Show selected parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ubQd6G4" name="batch_eda.apply_hint">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.apply_hint</source>
|
||||
<target>Only fields with the "Apply" checkbox checked will be changed. Unchecked fields are left unchanged.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="w.5FGYL" name="batch_eda.apply">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.apply</source>
|
||||
<target>Apply</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9EmHp5C" name="batch_eda.field">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.field</source>
|
||||
<target>Field</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xHaCnEQ" name="batch_eda.value">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.value</source>
|
||||
<target>Value</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PLqIBvC" name="batch_eda.submit">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.submit</source>
|
||||
<target>Apply to Selected Parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5nO7Fpq" name="batch_eda.cancel">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.cancel</source>
|
||||
<target>Cancel</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vhlPBNU" name="batch_eda.success">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.success</source>
|
||||
<target>EDA fields updated successfully.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2fMo760" name="batch_eda.no_parts_selected">
|
||||
<segment state="translated">
|
||||
<source>batch_eda.no_parts_selected</source>
|
||||
<target>No parts were selected for batch editing.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yzpXFkB" name="info_providers.bulk_import.step1.spn_recommendation">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.step1.spn_recommendation</source>
|
||||
@@ -12509,5 +12689,137 @@ Buerklin-API Authentication server:
|
||||
<target>Last stocktake</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GNWhoTW" name="part.table.eda_reference">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_reference</source>
|
||||
<target>EDA Reference</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tW4yCbf" name="part.table.eda_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.eda_value</source>
|
||||
<target>EDA value</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s1pgReC" name="settings.misc.kicad_eda.default_parameter_visibility">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_parameter_visibility</source>
|
||||
<target>Default EDA visibility of parameters</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Z78QunV" name="settings.misc.kicad_eda.default_parameter_visibility.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_parameter_visibility.help</source>
|
||||
<target>EDA visibility for all [part] parameters who does not have an explicit visibility set. When enabled all parameters will be visible in the EDA software by default.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="J6pYnaC" name="settings.misc.kicad_eda.default_orderdetails_visibility">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.default_orderdetails_visibility</source>
|
||||
<target>Default EDA visibility of purchase infos</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Hiye4C." name="settings.misc.kicad_eda.default_orderdetails_visibility.help">
|
||||
<segment state="translated">
|
||||
<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>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aEgd0if" name="label_scanner.open">
|
||||
<segment state="translated">
|
||||
<source>label_scanner.open</source>
|
||||
<target>View details</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vw_0Qws" name="label_scanner.db_part_found">
|
||||
<segment state="translated">
|
||||
<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 state="translated">
|
||||
<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 state="translated">
|
||||
<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 state="translated">
|
||||
<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 state="translated">
|
||||
<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 state="translated">
|
||||
<source>scan_dialog.mode.amazon</source>
|
||||
<target>Amazon barcode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BQWuR_G" name="settings.ips.canopy">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy</source>
|
||||
<target>Canopy</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="44BfYzy" name="settings.ips.canopy.alwaysGetDetails">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.canopy.alwaysGetDetails</source>
|
||||
<target>Always fetch details</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="so_ms3t" name="settings.ips.canopy.alwaysGetDetails.help">
|
||||
<segment state="translated">
|
||||
<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 state="translated">
|
||||
<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 state="translated">
|
||||
<source>attachment.sandbox.back_to_partdb</source>
|
||||
<target>Back to Part-DB</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MzyA7N8" name="settings.system.attachments.showHTMLAttachments">
|
||||
<segment state="translated">
|
||||
<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 state="translated">
|
||||
<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 state="translated">
|
||||
<source>attachment.sandbox.title</source>
|
||||
<target>HTML [Attachment]</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sJ6v9uJ" name="attachment.sandbox.as_plain_text">
|
||||
<segment state="translated">
|
||||
<source>attachment.sandbox.as_plain_text</source>
|
||||
<target>View as plain text</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -223,6 +223,12 @@
|
||||
<target>A causa di limitazioni tecniche, non è possibile selezionare date successive al 19-01-2038 su sistemi a 32 bit!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>Formato di dimensione file non valido. Utilizzare un numero intero più K, M, G come suffisso per Kilo, Mega or Gigabytes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZFxQ0BZ" name="validator.invalid_range">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
@@ -235,5 +241,17 @@
|
||||
<target>Codice non valido. Controlla che la tua app di autenticazione sia impostata correttamente e che sia il server che il dispositivo di autenticazione abbiano l'ora impostata correttamente.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
|
||||
<segment state="translated">
|
||||
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
|
||||
<target>Esiste già una traduzione definita per questo tipo e questa lingua!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_gtin</source>
|
||||
<target>Questo non è un valido GTIN / EAN!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user