mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-01 04:49:36 +00:00
Merge 5126f7ff9c into 1650ade338
This commit is contained in:
commit
489f3213ed
28 changed files with 3277 additions and 64 deletions
183
contrib/kicad-populate/README.md
Normal file
183
contrib/kicad-populate/README.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# KiCad Footprint & Symbol Populate Command
|
||||||
|
|
||||||
|
A Symfony console command for Part-DB that bulk-populates KiCad footprint paths on Footprint entities and KiCad symbol paths on Category entities.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Part-DB's KiCad EDA integration allows parts to inherit KiCad metadata from their Footprint and Category entities. This command automates populating those fields based on standard KiCad library paths.
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Maps footprint names (e.g., `SOT-23`, `0805`, `DIP-8`) to KiCad footprint library paths
|
||||||
|
- Maps category names (e.g., `Resistors`, `Capacitors`, `LED`) to KiCad symbol library paths
|
||||||
|
- Checks alternative names on entities when the primary name doesn't match
|
||||||
|
- Only updates empty values by default (use `--force` to overwrite)
|
||||||
|
- Supports dry-run mode to preview changes
|
||||||
|
- Supports custom mapping files to override or extend the built-in defaults
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The command is included with Part-DB. No additional installation steps needed.
|
||||||
|
|
||||||
|
### Verify installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console list partdb:kicad
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
```
|
||||||
|
partdb:kicad:populate Populate KiCad footprint paths and symbol paths for footprints and categories
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### List current values
|
||||||
|
|
||||||
|
See what's currently in the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console partdb:kicad:populate --list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview changes (recommended first step)
|
||||||
|
|
||||||
|
See what would be updated without making changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console partdb:kicad:populate --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply changes
|
||||||
|
|
||||||
|
Update all empty footprint and category KiCad fields:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console partdb:kicad:populate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `--list` | List all footprints and categories with their current KiCad values |
|
||||||
|
| `--dry-run` | Preview changes without applying them |
|
||||||
|
| `--footprints` | Only update footprint entities |
|
||||||
|
| `--categories` | Only update category entities |
|
||||||
|
| `--force` | Overwrite existing values (default: only fills empty values) |
|
||||||
|
| `--mapping-file <path>` | Path to a JSON file with custom mappings (merges with built-in defaults) |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Only update footprints, preview first
|
||||||
|
php bin/console partdb:kicad:populate --footprints --dry-run
|
||||||
|
|
||||||
|
# Only update categories
|
||||||
|
php bin/console partdb:kicad:populate --categories
|
||||||
|
|
||||||
|
# Force overwrite all values (careful!)
|
||||||
|
php bin/console partdb:kicad:populate --force
|
||||||
|
|
||||||
|
# Use a custom mapping file
|
||||||
|
php bin/console partdb:kicad:populate --mapping-file my_mappings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Name Matching
|
||||||
|
|
||||||
|
### Footprints (exact match)
|
||||||
|
Footprint names are matched exactly against the mapping keys. If the primary entity name doesn't match, the command also checks **alternative names** configured on the Footprint entity.
|
||||||
|
|
||||||
|
For example, if a Footprint is named "SOT23" but has an alternative name "SOT-23", the mapping for "SOT-23" will be used.
|
||||||
|
|
||||||
|
### Categories (pattern match)
|
||||||
|
Category names are matched using case-insensitive substring matching. A category named "Zener Diodes" will match the pattern "Zener". Order matters — more specific patterns are checked first. Alternative names on Category entities are also checked.
|
||||||
|
|
||||||
|
## Custom Mapping Files
|
||||||
|
|
||||||
|
You can provide a JSON file with `--mapping-file` to override or extend the built-in defaults. User mappings take priority over built-in ones.
|
||||||
|
|
||||||
|
### JSON format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"footprints": {
|
||||||
|
"MyCustomPackage": "MyLibrary:MyFootprint",
|
||||||
|
"0805": "Capacitor_SMD:C_0805_2012Metric"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"Sensor": "Sensor:Sensor_Temperature",
|
||||||
|
"MCU": "MCU_Microchip:PIC16F877A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `footprints` and `categories` keys are optional — you can provide just one.
|
||||||
|
|
||||||
|
A reference file with all built-in defaults exported as JSON is available at [`default_mappings.json`](default_mappings.json). You can copy this file as a starting point for your own customizations.
|
||||||
|
|
||||||
|
## Built-in Mappings
|
||||||
|
|
||||||
|
### Footprints (~100 mappings)
|
||||||
|
|
||||||
|
| Package Type | Examples |
|
||||||
|
|--------------|----------|
|
||||||
|
| SOT packages | SOT-23, SOT-23-5, SOT-23-6, SOT-223, SOT-89, SOT-323, SOT-363 |
|
||||||
|
| TO packages | TO-92, TO-220, TO-220AB, TO-247-3, TO-252, TO-263 |
|
||||||
|
| SOIC/TSSOP/MSOP | SOIC-8, SOIC-16, TSSOP-16, MSOP-16 |
|
||||||
|
| DIP | DIP-4 through DIP-40 |
|
||||||
|
| QFN/DFN | QFN-8 through QFN-48, DFN-2, DFN-6, DFN-8 |
|
||||||
|
| TQFP/LQFP | TQFP-32 through TQFP-100, LQFP variants |
|
||||||
|
| Chip sizes | 0201, 0402, 0603, 0805, 1206, 1210, 2512, etc. |
|
||||||
|
| Diode packages | SOD-123, SOD-323, SMA, DO-35, DO-41, etc. |
|
||||||
|
| Electrolytic caps | SMD (D4-D10mm), Through-hole (D5-D12.5mm) |
|
||||||
|
| Tantalum caps | Case A through Case E |
|
||||||
|
| LED packages | 3mm, 5mm, 0603, 0805, WS2812B |
|
||||||
|
| Crystal packages | HC-49, HC-49/S, HC-49/US |
|
||||||
|
| Connectors | USB-A/B/Mini/Micro/C, pin headers (1x2 to 2x20) |
|
||||||
|
| SIP packages | SIP-3 through SIP-5 |
|
||||||
|
|
||||||
|
### Categories (~35 mappings)
|
||||||
|
|
||||||
|
| Component Type | KiCad Symbol |
|
||||||
|
|----------------|--------------|
|
||||||
|
| Resistors | `Device:R` |
|
||||||
|
| Capacitors | `Device:C` |
|
||||||
|
| Electrolytic/Tantalum | `Device:C_Polarized` |
|
||||||
|
| Inductors | `Device:L` |
|
||||||
|
| Diodes | `Device:D` |
|
||||||
|
| Zener Diodes | `Device:D_Zener` |
|
||||||
|
| Schottky Diodes | `Device:D_Schottky` |
|
||||||
|
| TVS | `Device:D_TVS` |
|
||||||
|
| LEDs | `Device:LED` |
|
||||||
|
| NPN Transistors | `Device:Q_NPN_BCE` |
|
||||||
|
| PNP Transistors | `Device:Q_PNP_BCE` |
|
||||||
|
| N-MOSFETs | `Device:Q_NMOS_GDS` |
|
||||||
|
| P-MOSFETs | `Device:Q_PMOS_GDS` |
|
||||||
|
| Ferrite Beads | `Device:Ferrite_Bead` |
|
||||||
|
| Crystals | `Device:Crystal` |
|
||||||
|
| Oscillators | `Oscillator:Oscillator_Crystal` |
|
||||||
|
| Fuses | `Device:Fuse` |
|
||||||
|
| Relays | `Relay:Relay_DPDT` |
|
||||||
|
| Potentiometers | `Device:R_POT` |
|
||||||
|
| Thermistors | `Device:Thermistor` |
|
||||||
|
| Varistors | `Device:Varistor` |
|
||||||
|
| Op-Amps | `Amplifier_Operational:LM358` |
|
||||||
|
| Comparators | `Comparator:LM393` |
|
||||||
|
| Voltage Regulators | `Regulator_Linear:LM317_TO-220` |
|
||||||
|
| LDOs | `Regulator_Linear:AMS1117-3.3` |
|
||||||
|
| Optocouplers | `Isolator:PC817` |
|
||||||
|
| Connectors | `Connector:Conn_01x02` |
|
||||||
|
| Switches/Buttons | `Switch:SW_Push` |
|
||||||
|
| Transformers | `Device:Transformer_1P_1S` |
|
||||||
|
|
||||||
|
## Backup Recommendation
|
||||||
|
|
||||||
|
Always backup before running on production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console partdb:backup --database backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Same as Part-DB (AGPL-3.0)
|
||||||
195
contrib/kicad-populate/default_mappings.json
Normal file
195
contrib/kicad-populate/default_mappings.json
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
{
|
||||||
|
"_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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
606
src/Command/PopulateKicadCommand.php
Normal file
606
src/Command/PopulateKicadCommand.php
Normal file
|
|
@ -0,0 +1,606 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
#[AsCommand('partdb:kicad:populate', 'Populate KiCad footprint paths and symbol paths for footprints and categories')]
|
||||||
|
class PopulateKicadCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
$footprintMappings = $this->getFootprintMappings();
|
||||||
|
$categoryMappings = $this->getCategoryMappings();
|
||||||
|
|
||||||
|
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 footprint name to KiCad footprint path mappings.
|
||||||
|
* These are based on KiCad 9.x standard library paths.
|
||||||
|
*
|
||||||
|
* @return array<int|string, string>
|
||||||
|
*/
|
||||||
|
private function getFootprintMappings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// === SOT packages ===
|
||||||
|
'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 ===
|
||||||
|
'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 packages (through-hole) ===
|
||||||
|
'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 packages (SMD) ===
|
||||||
|
'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 ===
|
||||||
|
'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 / MSOP ===
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === SOT-5 / SO-5 ===
|
||||||
|
'SO-5' => 'Package_TO_SOT_SMD:SOT-23-5',
|
||||||
|
|
||||||
|
// === DIP ===
|
||||||
|
'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 ===
|
||||||
|
'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 / LQFP ===
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === Diode packages ===
|
||||||
|
'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 ===
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === Passive component packages (SMD chip sizes) ===
|
||||||
|
// Using Resistor_SMD as default - capacitors/inductors can override at part level
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === Tantalum / electrolytic capacitor packages ===
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === Electrolytic capacitor (SMD) ===
|
||||||
|
'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 electrolytic capacitors (radial) ===
|
||||||
|
'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 packages ===
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === Crystal packages ===
|
||||||
|
'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 connectors ===
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// === Pin headers ===
|
||||||
|
'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 packages ===
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns category name patterns to KiCad symbol path mappings.
|
||||||
|
* Uses pattern matching - order matters (first match wins).
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function getCategoryMappings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// More specific matches first
|
||||||
|
'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', // Default to N-channel
|
||||||
|
'JFET' => 'Device:Q_NJFET_DSG',
|
||||||
|
'Ferrite' => 'Device:Ferrite_Bead',
|
||||||
|
'Crystal' => 'Device:Crystal',
|
||||||
|
'Oscillator' => 'Oscillator:Oscillator_Crystal',
|
||||||
|
'Fuse' => 'Device:Fuse',
|
||||||
|
'Transformer' => 'Device:Transformer_1P_1S',
|
||||||
|
|
||||||
|
// Generic matches (less specific)
|
||||||
|
'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', // Photodiode/phototransistor
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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\Entity\Parts\Part;
|
||||||
use App\Services\EDA\KiCadHelper;
|
use App\Services\EDA\KiCadHelper;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
|
@ -55,15 +57,16 @@ class KiCadApiController extends AbstractController
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/categories.json', name: 'kicad_api_categories')]
|
#[Route('/categories.json', name: 'kicad_api_categories')]
|
||||||
public function categories(): Response
|
public function categories(Request $request): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('@categories.read');
|
$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')]
|
#[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) {
|
if ($category !== null) {
|
||||||
$this->denyAccessUnlessGranted('read', $category);
|
$this->denyAccessUnlessGranted('read', $category);
|
||||||
|
|
@ -72,14 +75,31 @@ class KiCadApiController extends AbstractController
|
||||||
}
|
}
|
||||||
$this->denyAccessUnlessGranted('@parts.read');
|
$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')]
|
#[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);
|
$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +115,61 @@ class PartDataTableHelper
|
||||||
return implode('<br>', $tmp);
|
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
|
public function renderAmount(Part $context): string
|
||||||
{
|
{
|
||||||
$amount = $context->getAmountSum();
|
$amount = $context->getAmountSum();
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,11 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
])
|
])
|
||||||
->add('attachments', PartAttachmentsColumn::class, [
|
->add('attachments', PartAttachmentsColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.attachments'),
|
'label' => $this->translator->trans('part.table.attachments'),
|
||||||
|
])
|
||||||
|
->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
|
//Add a column to list the projects where the part is used, when the user has the permission to see the projects
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,13 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
||||||
#[Assert\Length(max: 255)]
|
#[Assert\Length(max: 255)]
|
||||||
protected string $group = '';
|
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.
|
* Mapping is done in subclasses.
|
||||||
*
|
*
|
||||||
|
|
@ -471,6 +478,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
||||||
return static::ALLOWED_ELEMENT_CLASS;
|
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
|
public function getComparableFields(): array
|
||||||
{
|
{
|
||||||
return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];
|
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)]
|
#[ORM\Column(type: Types::BOOLEAN)]
|
||||||
protected bool $obsolete = false;
|
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
|
* @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;
|
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
|
public function getName(): string
|
||||||
{
|
{
|
||||||
return $this->getSupplierPartNr();
|
return $this->getSupplierPartNr();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ use App\Entity\Parameters\SupplierParameter;
|
||||||
use App\Entity\Parts\MeasurementUnit;
|
use App\Entity\Parts\MeasurementUnit;
|
||||||
use App\Form\Type\ExponentialNumberType;
|
use App\Form\Type\ExponentialNumberType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
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\NumberType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
|
@ -147,6 +148,14 @@ class ParameterType extends AbstractType
|
||||||
'class' => 'form-control-sm',
|
'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', CheckboxType::class, [
|
||||||
|
'label' => false,
|
||||||
|
'required' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
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',
|
'label' => 'orderdetails.edit.prices_includes_vat',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$builder->add('eda_visibility', CheckboxType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'orderdetails.edit.eda_visibility',
|
||||||
|
]);
|
||||||
|
|
||||||
//Add pricedetails after we know the data, so we can set the default currency
|
//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 {
|
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
|
||||||
/** @var Orderdetail $orderdetail */
|
/** @var Orderdetail $orderdetail */
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,15 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||||
'spn' => 'supplier_part_number',
|
'spn' => 'supplier_part_number',
|
||||||
'supplier_product_number' => 'supplier_part_number',
|
'supplier_product_number' => 'supplier_part_number',
|
||||||
'storage_location' => 'storelocation',
|
'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(
|
public function __construct(
|
||||||
|
|
@ -190,9 +199,45 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Handle EDA/KiCad fields
|
||||||
|
$this->applyEdaFields($object, $data);
|
||||||
|
|
||||||
return $object;
|
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[]
|
* @return bool[]
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services\EDA;
|
namespace App\Services\EDA;
|
||||||
|
|
||||||
|
use App\Entity\Attachments\Attachment;
|
||||||
use App\Entity\Parts\Category;
|
use App\Entity\Parts\Category;
|
||||||
use App\Entity\Parts\Footprint;
|
use App\Entity\Parts\Footprint;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
|
|
@ -43,6 +44,12 @@ 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 */
|
/** @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;
|
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;
|
||||||
|
|
||||||
|
/** @var bool The system-wide default for EDA visibility when not explicitly set on an element */
|
||||||
|
private readonly bool $defaultEdaVisibility;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NodesListBuilder $nodesListBuilder,
|
private readonly NodesListBuilder $nodesListBuilder,
|
||||||
private readonly TagAwareCacheInterface $kicadCache,
|
private readonly TagAwareCacheInterface $kicadCache,
|
||||||
|
|
@ -54,6 +61,8 @@ class KiCadHelper
|
||||||
KiCadEDASettings $kiCadEDASettings,
|
KiCadEDASettings $kiCadEDASettings,
|
||||||
) {
|
) {
|
||||||
$this->category_depth = $kiCadEDASettings->categoryDepth;
|
$this->category_depth = $kiCadEDASettings->categoryDepth;
|
||||||
|
$this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
|
||||||
|
$this->defaultEdaVisibility = $kiCadEDASettings->defaultEdaVisibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -115,11 +124,16 @@ class KiCadHelper
|
||||||
}
|
}
|
||||||
|
|
||||||
//Format the category for KiCAD
|
//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[] = [
|
$result[] = [
|
||||||
'id' => (string)$category->getId(),
|
'id' => (string)$category->getId(),
|
||||||
'name' => $category->getFullPath('/'),
|
'name' => $category->getFullPath('/'),
|
||||||
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
|
'description' => $description,
|
||||||
'description' => $this->entityURLGenerator->listPartsURL($category),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,11 +145,13 @@ class KiCadHelper
|
||||||
* Returns an array of objects containing all parts for the given category in the format required by KiCAD.
|
* 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.
|
* The result is cached for performance and invalidated on category or part changes.
|
||||||
* @param Category|null $category
|
* @param Category|null $category
|
||||||
|
* @param bool $minimal If true, only return id and name (faster for symbol chooser listing)
|
||||||
* @return array
|
* @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) {
|
function (ItemInterface $item) use ($category) {
|
||||||
$item->tag([
|
$item->tag([
|
||||||
$this->tagGenerator->getElementTypeCacheTag(Category::class),
|
$this->tagGenerator->getElementTypeCacheTag(Category::class),
|
||||||
|
|
@ -198,14 +214,22 @@ class KiCadHelper
|
||||||
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
|
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
|
||||||
$result["fields"]["keywords"] = $this->createField($part->getTags());
|
$result["fields"]["keywords"] = $this->createField($part->getTags());
|
||||||
|
|
||||||
//Use the part info page as datasheet link. It must be an absolute URL.
|
//Use the part info page as Part-DB link. It must be an absolute URL.
|
||||||
$result["fields"]["datasheet"] = $this->createField(
|
$partUrl = $this->urlGenerator->generate(
|
||||||
$this->urlGenerator->generate(
|
'part_info',
|
||||||
'part_info',
|
['id' => $part->getId()],
|
||||||
['id' => $part->getId()],
|
UrlGeneratorInterface::ABSOLUTE_URL
|
||||||
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
|
//Add basic fields
|
||||||
$result["fields"]["description"] = $this->createField($part->getDescription());
|
$result["fields"]["description"] = $this->createField($part->getDescription());
|
||||||
if ($part->getCategory() !== null) {
|
if ($part->getCategory() !== null) {
|
||||||
|
|
@ -245,32 +269,7 @@ class KiCadHelper
|
||||||
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
|
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add supplier information from orderdetails (include obsolete orderdetails)
|
//Add KiCost manufacturer fields (always present, independent of 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:
|
|
||||||
if ($part->getManufacturer() !== null) {
|
if ($part->getManufacturer() !== null) {
|
||||||
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
|
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
|
||||||
}
|
}
|
||||||
|
|
@ -278,13 +277,74 @@ class KiCadHelper
|
||||||
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
|
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
|
||||||
}
|
}
|
||||||
|
|
||||||
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
|
// Add supplier information from orderdetails (include obsolete orderdetails)
|
||||||
if ($part->getOrderdetails(false)->count() > 0) {
|
// If any orderdetail has eda_visibility explicitly set to true, only export those;
|
||||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
// 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() !== '') {
|
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||||
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
|
// When explicit flags exist, filter by resolved visibility
|
||||||
|
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->defaultEdaVisibility;
|
||||||
|
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());
|
$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->defaultEdaVisibility;
|
||||||
|
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 +404,7 @@ class KiCadHelper
|
||||||
|
|
||||||
//If the user set a visibility, then use it
|
//If the user set a visibility, then use it
|
||||||
if ($eda_info->getVisibility() !== null) {
|
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
|
//If the part has a category, then use the category visibility if possible
|
||||||
|
|
@ -395,4 +455,64 @@ class KiCadHelper
|
||||||
'visible' => $this->boolToKicadBool($visible),
|
'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)
|
// Create unique key for this entry.
|
||||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
// 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])) {
|
if (isset($entries_by_key[$entry_key])) {
|
||||||
// Merge with existing entry
|
// Merge with existing entry
|
||||||
$existing_entry = $entries_by_key[$entry_key];
|
$existing_entry = $entries_by_key[$entry_key];
|
||||||
|
|
@ -413,14 +417,22 @@ class BOMImporter
|
||||||
$existing_quantity = $existing_entry->getQuantity();
|
$existing_quantity = $existing_entry->getQuantity();
|
||||||
$existing_entry->setQuantity($existing_quantity + $quantity);
|
$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', [
|
$this->logger->info('Merged duplicate BOM entry', [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'part_id' => $part ? $part->getID() : null,
|
'part_id' => $part?->getID(),
|
||||||
'original_quantity' => $existing_quantity,
|
'original_quantity' => $existing_quantity,
|
||||||
'added_quantity' => $quantity,
|
'added_quantity' => $quantity,
|
||||||
'new_quantity' => $existing_quantity + $quantity,
|
'new_quantity' => $existing_quantity + $quantity,
|
||||||
'original_mountnames' => $existing_mountnames,
|
'original_mountnames' => $existing_mountnames,
|
||||||
'added_mountnames' => $designator,
|
'added_mountnames' => $designator,
|
||||||
|
'package' => $currentPackage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
continue; // Skip creating new entry
|
continue; // Skip creating new entry
|
||||||
|
|
|
||||||
|
|
@ -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:
|
//Iterate over the parts and apply the action to it:
|
||||||
foreach ($selected_parts as $part) {
|
foreach ($selected_parts as $part) {
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,14 @@ class KiCadEDASettings
|
||||||
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
|
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
|
||||||
#[Assert\Range(min: -1)]
|
#[Assert\Range(min: -1)]
|
||||||
public int $categoryDepth = 0;
|
public int $categoryDepth = 0;
|
||||||
|
|
||||||
|
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
|
||||||
|
description: new TM("settings.misc.kicad_eda.datasheet_link.help"),
|
||||||
|
envVar: "bool:EDA_KICAD_DATASHEET_AS_PDF", envVarMode: EnvVarMode::OVERWRITE)]
|
||||||
|
public ?bool $datasheetAsPdf = true;
|
||||||
|
|
||||||
|
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.default_eda_visibility"),
|
||||||
|
description: new TM("settings.misc.kicad_eda.default_eda_visibility.help"),
|
||||||
|
envVar: "bool:EDA_KICAD_DEFAULT_VISIBILITY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||||
|
public bool $defaultEdaVisibility = false;
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
<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>
|
||||||
|
|
||||||
|
<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 %}">
|
<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>
|
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
|
|
||||||
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.unit{% endtrans %}</th>
|
||||||
<th>{% trans %}specifications.text{% endtrans %}</th>
|
<th>{% trans %}specifications.text{% endtrans %}</th>
|
||||||
<th>{% trans %}specifications.group{% 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>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
{{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }}
|
{{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }}
|
||||||
{{ form_widget(form.obsolete) }}
|
{{ form_widget(form.obsolete) }}
|
||||||
{{ form_widget(form.pricesIncludesVAT) }}
|
{{ form_widget(form.pricesIncludesVAT) }}
|
||||||
|
{{ form_widget(form.eda_visibility) }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div {{ collection.controller(form.pricedetails, 'pricedetails.edit.delete.confirm') }}>
|
<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 {{ 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.value_text) }}{{ form_errors(form.value_text) }}</td>
|
||||||
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</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>
|
<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 %}"
|
<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 %}">
|
{{ 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',
|
'value' => 'http://localhost/en/part/1/info',
|
||||||
'visible' => 'False',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
|
'Part-DB URL' =>
|
||||||
|
array(
|
||||||
|
'value' => 'http://localhost/en/part/1/info',
|
||||||
|
'visible' => 'False',
|
||||||
|
),
|
||||||
'description' =>
|
'description' =>
|
||||||
array(
|
array(
|
||||||
'value' => '',
|
'value' => '',
|
||||||
|
|
@ -168,6 +173,11 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
'value' => '1',
|
'value' => '1',
|
||||||
'visible' => 'False',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
|
'Stock' =>
|
||||||
|
array(
|
||||||
|
'value' => '0',
|
||||||
|
'visible' => 'False',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -177,20 +187,19 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
public function testPartDetailsPart2(): void
|
public function testPartDetailsPart2(): void
|
||||||
{
|
{
|
||||||
$client = $this->createClientWithCredentials();
|
$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();
|
self::assertResponseIsSuccessful();
|
||||||
$content = $client->getResponse()->getContent();
|
$content = $client->getResponse()->getContent();
|
||||||
self::assertJson($content);
|
self::assertJson($content);
|
||||||
|
|
||||||
$data = json_decode($content, true);
|
$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 (
|
$expected = array (
|
||||||
'id' => '1',
|
'id' => '2',
|
||||||
'name' => 'Part 1',
|
'name' => 'Part 2',
|
||||||
'symbolIdStr' => 'Part:1',
|
'symbolIdStr' => 'Category:1',
|
||||||
'exclude_from_bom' => 'False',
|
'exclude_from_bom' => 'False',
|
||||||
'exclude_from_board' => 'True',
|
'exclude_from_board' => 'True',
|
||||||
'exclude_from_sim' => 'False',
|
'exclude_from_sim' => 'False',
|
||||||
|
|
@ -198,27 +207,32 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
array (
|
array (
|
||||||
'footprint' =>
|
'footprint' =>
|
||||||
array (
|
array (
|
||||||
'value' => 'Part:1',
|
'value' => 'Footprint:1',
|
||||||
'visible' => 'False',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
'reference' =>
|
'reference' =>
|
||||||
array (
|
array (
|
||||||
'value' => 'P',
|
'value' => 'C',
|
||||||
'visible' => 'True',
|
'visible' => 'True',
|
||||||
),
|
),
|
||||||
'value' =>
|
'value' =>
|
||||||
array (
|
array (
|
||||||
'value' => 'Part 1',
|
'value' => 'Part 2',
|
||||||
'visible' => 'True',
|
'visible' => 'True',
|
||||||
),
|
),
|
||||||
'keywords' =>
|
'keywords' =>
|
||||||
array (
|
array (
|
||||||
'value' => '',
|
'value' => 'test, Test, Part2',
|
||||||
'visible' => 'False',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
'datasheet' =>
|
'datasheet' =>
|
||||||
array (
|
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',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
'description' =>
|
'description' =>
|
||||||
|
|
@ -231,14 +245,44 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
'value' => 'Node 1',
|
'value' => 'Node 1',
|
||||||
'visible' => 'False',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
|
'Manufacturer' =>
|
||||||
|
array (
|
||||||
|
'value' => 'Node 1',
|
||||||
|
'visible' => 'False',
|
||||||
|
),
|
||||||
'Manufacturing Status' =>
|
'Manufacturing Status' =>
|
||||||
array (
|
array (
|
||||||
'value' => '',
|
'value' => 'Active',
|
||||||
|
'visible' => 'False',
|
||||||
|
),
|
||||||
|
'Part-DB Footprint' =>
|
||||||
|
array (
|
||||||
|
'value' => 'Node 1',
|
||||||
|
'visible' => 'False',
|
||||||
|
),
|
||||||
|
'Mass' =>
|
||||||
|
array (
|
||||||
|
'value' => '100.2 g',
|
||||||
'visible' => 'False',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
'Part-DB ID' =>
|
'Part-DB ID' =>
|
||||||
array (
|
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',
|
'visible' => 'False',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -247,4 +291,31 @@ final class KiCadApiControllerTest extends WebTestCase
|
||||||
self::assertEquals($expected, $data);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -136,4 +136,44 @@ final class PartNormalizerTest extends WebTestCase
|
||||||
$this->assertEqualsWithDelta(1.0, $priceDetail->getPriceRelatedQuantity(), PHP_FLOAT_EPSILON);
|
$this->assertEqualsWithDelta(1.0, $priceDetail->getPriceRelatedQuantity(), PHP_FLOAT_EPSILON);
|
||||||
$this->assertEqualsWithDelta(1.0, $priceDetail->getMinDiscountQuantity(), 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -642,6 +642,12 @@ Sub elements will be moved upwards.</target>
|
||||||
<target>Group</target>
|
<target>Group</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="edaVisibilityHelp" 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">
|
<unit id="XclPxI9" name="specification.create">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>specification.create</source>
|
<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>
|
<target>Attachments</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="eda_table_status" name="part.table.eda_status">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>part.table.eda_status</source>
|
||||||
|
<target>EDA</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="eda_status_symbol" name="eda.status.symbol_set">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>eda.status.symbol_set</source>
|
||||||
|
<target>KiCad symbol set</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="eda_status_footprint" name="eda.status.footprint_set">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>eda.status.footprint_set</source>
|
||||||
|
<target>KiCad footprint set</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="eda_status_reference" name="eda.status.reference_set">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>eda.status.reference_set</source>
|
||||||
|
<target>Reference prefix set</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="eda_status_complete" name="eda.status.complete">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>eda.status.complete</source>
|
||||||
|
<target>EDA fields complete (symbol, footprint, reference)</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="eda_status_partial" 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">
|
<unit id="bMkafCp" name="flash.login_successful">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>flash.login_successful</source>
|
<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>
|
<target>No longer available</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="eda_od_visibility" name="orderdetails.edit.eda_visibility">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>orderdetails.edit.eda_visibility</source>
|
||||||
|
<target>EDA visibility</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
|
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>orderdetails.edit.supplierpartnr.placeholder</source>
|
<source>orderdetails.edit.supplierpartnr.placeholder</source>
|
||||||
|
|
@ -9969,6 +10017,30 @@ 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>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="kicad_ds_link" 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="kicad_ds_link_help" 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="eda_default_vis" name="settings.misc.kicad_eda.default_eda_visibility">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.misc.kicad_eda.default_eda_visibility</source>
|
||||||
|
<target>Default EDA visibility</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="eda_default_vis_help" name="settings.misc.kicad_eda.default_eda_visibility.help">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>settings.misc.kicad_eda.default_eda_visibility.help</source>
|
||||||
|
<target>Default EDA visibility for parameters and orderdetails that have no explicit value set. When enabled, all parameters and supplier part numbers will be exported to EDA by default.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.behavior.sidebar</source>
|
<source>settings.behavior.sidebar</source>
|
||||||
|
|
@ -10935,6 +11007,84 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||||
<target>Bulk Info Provider Import</target>
|
<target>Bulk Info Provider Import</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="batchEdaGroup" name="part_list.action.group.eda">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>part_list.action.group.eda</source>
|
||||||
|
<target>EDA / KiCad</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaAction" 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="batchEdaTitle" name="batch_eda.title">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.title</source>
|
||||||
|
<target>Batch Edit EDA Fields</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaDesc" 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="batchEdaShowParts" name="batch_eda.show_parts">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.show_parts</source>
|
||||||
|
<target>Show selected parts</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaApplyHint" 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="batchEdaApply" name="batch_eda.apply">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.apply</source>
|
||||||
|
<target>Apply</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaField" name="batch_eda.field">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.field</source>
|
||||||
|
<target>Field</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaValue" name="batch_eda.value">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.value</source>
|
||||||
|
<target>Value</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaSubmit" name="batch_eda.submit">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.submit</source>
|
||||||
|
<target>Apply to Selected Parts</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaCancel" name="batch_eda.cancel">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.cancel</source>
|
||||||
|
<target>Cancel</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaSuccess" name="batch_eda.success">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>batch_eda.success</source>
|
||||||
|
<target>EDA fields updated successfully.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="batchEdaNoParts" 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">
|
<unit id="yzpXFkB" name="info_providers.bulk_import.step1.spn_recommendation">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>info_providers.bulk_import.step1.spn_recommendation</source>
|
<source>info_providers.bulk_import.step1.spn_recommendation</source>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue