This commit is contained in:
Sebastian Almberg 2026-02-25 14:57:28 +13:00 committed by GitHub
commit 489f3213ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3277 additions and 64 deletions

View 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)

View 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"
}
}

View 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');
}
}

View 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
];
}
}

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

View file

@ -27,6 +27,8 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Services\EDA\KiCadHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -55,15 +57,16 @@ class KiCadApiController extends AbstractController
}
#[Route('/categories.json', name: 'kicad_api_categories')]
public function categories(): Response
public function categories(Request $request): Response
{
$this->denyAccessUnlessGranted('@categories.read');
return $this->json($this->kiCADHelper->getCategories());
$data = $this->kiCADHelper->getCategories();
return $this->createCacheableJsonResponse($request, $data, 300);
}
#[Route('/parts/category/{category}.json', name: 'kicad_api_category')]
public function categoryParts(?Category $category): Response
public function categoryParts(Request $request, ?Category $category): Response
{
if ($category !== null) {
$this->denyAccessUnlessGranted('read', $category);
@ -72,14 +75,31 @@ class KiCadApiController extends AbstractController
}
$this->denyAccessUnlessGranted('@parts.read');
return $this->json($this->kiCADHelper->getCategoryParts($category));
$minimal = $request->query->getBoolean('minimal', false);
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
return $this->createCacheableJsonResponse($request, $data, 300);
}
#[Route('/parts/{part}.json', name: 'kicad_api_part')]
public function partDetails(Part $part): Response
public function partDetails(Request $request, Part $part): Response
{
$this->denyAccessUnlessGranted('read', $part);
return $this->json($this->kiCADHelper->getKiCADPart($part));
$data = $this->kiCADHelper->getKiCADPart($part);
return $this->createCacheableJsonResponse($request, $data, 60);
}
/**
* Creates a JSON response with HTTP cache headers (ETag and Cache-Control).
* Returns 304 Not Modified if the client's ETag matches.
*/
private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
{
$response = new JsonResponse($data);
$response->setEtag(md5(json_encode($data)));
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
$response->isNotModified($request);
return $response;
}
}

View file

@ -115,6 +115,61 @@ class PartDataTableHelper
return implode('<br>', $tmp);
}
/**
* Renders an EDA/KiCad completeness indicator for the given part.
* Shows icons for symbol, footprint, and value status.
*/
public function renderEdaStatus(Part $context): string
{
$edaInfo = $context->getEdaInfo();
$category = $context->getCategory();
$footprint = $context->getFootprint();
// Determine effective values (direct or inherited)
$hasSymbol = $edaInfo->getKicadSymbol() !== null || $category?->getEdaInfo()->getKicadSymbol() !== null;
$hasFootprint = $edaInfo->getKicadFootprint() !== null || $footprint?->getEdaInfo()->getKicadFootprint() !== null;
$hasReference = $edaInfo->getReferencePrefix() !== null || $category?->getEdaInfo()->getReferencePrefix() !== null;
$symbolInherited = $edaInfo->getKicadSymbol() === null && $category?->getEdaInfo()->getKicadSymbol() !== null;
$footprintInherited = $edaInfo->getKicadFootprint() === null && $footprint?->getEdaInfo()->getKicadFootprint() !== null;
$icons = [];
// Symbol status
if ($hasSymbol) {
$title = $this->translator->trans('eda.status.symbol_set');
$class = $symbolInherited ? 'text-info' : 'text-success';
$icons[] = sprintf('<i class="fa-solid fa-microchip fa-fw %s" title="%s"></i>', $class, $title);
}
// Footprint status
if ($hasFootprint) {
$title = $this->translator->trans('eda.status.footprint_set');
$class = $footprintInherited ? 'text-info' : 'text-success';
$icons[] = sprintf('<i class="fa-solid fa-stamp fa-fw %s" title="%s"></i>', $class, $title);
}
// Reference prefix status
if ($hasReference) {
$icons[] = sprintf('<i class="fa-solid fa-font fa-fw text-success" title="%s"></i>',
$this->translator->trans('eda.status.reference_set'));
}
if (empty($icons)) {
return '';
}
// Overall status: all 3 = green check, partial = yellow
$allSet = $hasSymbol && $hasFootprint && $hasReference;
$statusIcon = $allSet
? sprintf('<i class="fa-solid fa-bolt fa-fw text-success" title="%s"></i>', $this->translator->trans('eda.status.complete'))
: sprintf('<i class="fa-solid fa-bolt fa-fw text-warning" title="%s"></i>', $this->translator->trans('eda.status.partial'));
// Wrap in link to EDA settings tab (data-turbo=false to ensure hash is read on page load)
$editUrl = $this->entityURLGenerator->editURL($context) . '#eda';
return sprintf('<a href="%s" data-turbo="false">%s</a>', $editUrl, $statusIcon);
}
public function renderAmount(Part $context): string
{
$amount = $context->getAmountSum();

View file

@ -228,6 +228,11 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('attachments', PartAttachmentsColumn::class, [
'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

View file

@ -172,6 +172,13 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
#[Assert\Length(max: 255)]
protected string $group = '';
/**
* @var bool|null Whether this parameter should be exported as a field in the EDA HTTP library API. Null means use system default.
*/
#[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
protected ?bool $eda_visibility = null;
/**
* Mapping is done in subclasses.
*
@ -471,6 +478,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
return static::ALLOWED_ELEMENT_CLASS;
}
public function isEdaVisibility(): ?bool
{
return $this->eda_visibility;
}
/**
* @return $this
*/
public function setEdaVisibility(?bool $eda_visibility): self
{
$this->eda_visibility = $eda_visibility;
return $this;
}
public function getComparableFields(): array
{
return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];

View file

@ -122,6 +122,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $obsolete = false;
/**
* @var bool|null Whether this orderdetail's supplier part number should be exported as an EDA field. Null means use system default.
*/
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
protected ?bool $eda_visibility = null;
/**
* @var string The URL to the product on the supplier's website
*/
@ -418,6 +425,21 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this;
}
public function isEdaVisibility(): ?bool
{
return $this->eda_visibility;
}
/**
* @return $this
*/
public function setEdaVisibility(?bool $eda_visibility): self
{
$this->eda_visibility = $eda_visibility;
return $this;
}
public function getName(): string
{
return $this->getSupplierPartNr();

View file

@ -55,6 +55,7 @@ use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\MeasurementUnit;
use App\Form\Type\ExponentialNumberType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@ -147,6 +148,14 @@ class ParameterType extends AbstractType
'class' => 'form-control-sm',
],
]);
// Only show the EDA visibility field for part parameters, as it has no function for other entities
if ($options['data_class'] === PartParameter::class) {
$builder->add('eda_visibility', CheckboxType::class, [
'label' => false,
'required' => false,
]);
}
}
public function finishView(FormView $view, FormInterface $form, array $options): void

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

View file

@ -79,6 +79,11 @@ class OrderdetailType extends AbstractType
'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
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
/** @var Orderdetail $orderdetail */

View file

@ -55,6 +55,15 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
'spn' => 'supplier_part_number',
'supplier_product_number' => 'supplier_part_number',
'storage_location' => 'storelocation',
//EDA/KiCad field aliases
'kicad_symbol' => 'eda_kicad_symbol',
'kicad_footprint' => 'eda_kicad_footprint',
'kicad_reference' => 'eda_reference_prefix',
'kicad_value' => 'eda_value',
'eda_exclude_bom' => 'eda_exclude_from_bom',
'eda_exclude_board' => 'eda_exclude_from_board',
'eda_exclude_sim' => 'eda_exclude_from_sim',
'eda_invisible' => 'eda_visibility',
];
public function __construct(
@ -190,9 +199,45 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
}
}
//Handle EDA/KiCad fields
$this->applyEdaFields($object, $data);
return $object;
}
/**
* Apply EDA/KiCad fields from CSV data to the Part's EDAPartInfo.
*/
private function applyEdaFields(Part $part, array $data): void
{
$edaInfo = $part->getEdaInfo();
if (!empty($data['eda_kicad_symbol'])) {
$edaInfo->setKicadSymbol(trim((string) $data['eda_kicad_symbol']));
}
if (!empty($data['eda_kicad_footprint'])) {
$edaInfo->setKicadFootprint(trim((string) $data['eda_kicad_footprint']));
}
if (!empty($data['eda_reference_prefix'])) {
$edaInfo->setReferencePrefix(trim((string) $data['eda_reference_prefix']));
}
if (!empty($data['eda_value'])) {
$edaInfo->setValue(trim((string) $data['eda_value']));
}
if (isset($data['eda_exclude_from_bom']) && $data['eda_exclude_from_bom'] !== '') {
$edaInfo->setExcludeFromBom(filter_var($data['eda_exclude_from_bom'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($data['eda_exclude_from_board']) && $data['eda_exclude_from_board'] !== '') {
$edaInfo->setExcludeFromBoard(filter_var($data['eda_exclude_from_board'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($data['eda_exclude_from_sim']) && $data['eda_exclude_from_sim'] !== '') {
$edaInfo->setExcludeFromSim(filter_var($data['eda_exclude_from_sim'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($data['eda_visibility']) && $data['eda_visibility'] !== '') {
$edaInfo->setVisibility(filter_var($data['eda_visibility'], FILTER_VALIDATE_BOOLEAN));
}
}
/**
* @return bool[]
*/

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\EDA;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
@ -43,6 +44,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 */
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(
private readonly NodesListBuilder $nodesListBuilder,
private readonly TagAwareCacheInterface $kicadCache,
@ -54,6 +61,8 @@ class KiCadHelper
KiCadEDASettings $kiCadEDASettings,
) {
$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
// Use the category comment as description if available, otherwise use the Part-DB URL
$description = $category->getComment();
if ($description === null || $description === '') {
$description = $this->entityURLGenerator->listPartsURL($category);
}
$result[] = [
'id' => (string)$category->getId(),
'name' => $category->getFullPath('/'),
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
'description' => $this->entityURLGenerator->listPartsURL($category),
'description' => $description,
];
}
@ -131,11 +145,13 @@ class KiCadHelper
* Returns an array of objects containing all parts for the given category in the format required by KiCAD.
* The result is cached for performance and invalidated on category or part changes.
* @param Category|null $category
* @param bool $minimal If true, only return id and name (faster for symbol chooser listing)
* @return array
*/
public function getCategoryParts(?Category $category): array
public function getCategoryParts(?Category $category, bool $minimal = false): array
{
return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth,
$cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : '');
return $this->kicadCache->get($cacheKey,
function (ItemInterface $item) use ($category) {
$item->tag([
$this->tagGenerator->getElementTypeCacheTag(Category::class),
@ -198,14 +214,22 @@ class KiCadHelper
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
$result["fields"]["keywords"] = $this->createField($part->getTags());
//Use the part info page as datasheet link. It must be an absolute URL.
$result["fields"]["datasheet"] = $this->createField(
$this->urlGenerator->generate(
'part_info',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_URL)
//Use the part info page as Part-DB link. It must be an absolute URL.
$partUrl = $this->urlGenerator->generate(
'part_info',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);
//Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link)
if ($this->datasheetAsPdf) {
$datasheetUrl = $this->findDatasheetUrl($part);
$result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
} else {
$result["fields"]["datasheet"] = $this->createField($partUrl);
}
$result["fields"]["Part-DB URL"] = $this->createField($partUrl);
//Add basic fields
$result["fields"]["description"] = $this->createField($part->getDescription());
if ($part->getCategory() !== null) {
@ -245,32 +269,7 @@ class KiCadHelper
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
}
// Add supplier information from orderdetails (include obsolete orderdetails)
if ($part->getOrderdetails(false)->count() > 0) {
$supplierCounts = [];
foreach ($part->getOrderdetails(false) as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$supplierName = $orderdetail->getSupplier()->getName();
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
if (!isset($supplierCounts[$supplierName])) {
$supplierCounts[$supplierName] = 0;
}
$supplierCounts[$supplierName]++;
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
$fieldName = $supplierCounts[$supplierName] > 1
? $supplierName . ' ' . $supplierCounts[$supplierName]
: $supplierName;
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
//Add fields for KiCost:
//Add KiCost manufacturer fields (always present, independent of orderdetails)
if ($part->getManufacturer() !== null) {
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
}
@ -278,13 +277,74 @@ class KiCadHelper
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
}
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
if ($part->getOrderdetails(false)->count() > 0) {
foreach ($part->getOrderdetails(false) as $orderdetail) {
// Add supplier information from orderdetails (include obsolete orderdetails)
// If any orderdetail has eda_visibility explicitly set to true, only export those;
// otherwise export all (backward compat when no flags are set)
$allOrderdetails = $part->getOrderdetails(false);
if ($allOrderdetails->count() > 0) {
$hasExplicitEdaVisibility = false;
foreach ($allOrderdetails as $od) {
if ($od->isEdaVisibility() !== null) {
$hasExplicitEdaVisibility = true;
break;
}
}
$supplierCounts = [];
foreach ($allOrderdetails as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
// When explicit flags exist, filter by resolved visibility
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->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());
//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 ($eda_info->getVisibility() !== null) {
return $part->getEdaInfo()->getVisibility();
return $eda_info->getVisibility();
}
//If the part has a category, then use the category visibility if possible
@ -395,4 +455,64 @@ class KiCadHelper
'visible' => $this->boolToKicadBool($visible),
];
}
/**
* Finds the URL to the actual datasheet file for the given part.
* Searches attachments by type name, attachment name, and file extension.
* @return string|null The datasheet URL, or null if no datasheet was found.
*/
private function findDatasheetUrl(Part $part): ?string
{
$firstPdf = null;
foreach ($part->getAttachments() as $attachment) {
//Check if the attachment type name contains "datasheet"
$typeName = $attachment->getAttachmentType()?->getName() ?? '';
if (str_contains(mb_strtolower($typeName), 'datasheet')) {
return $this->getAttachmentUrl($attachment);
}
//Check if the attachment name contains "datasheet"
$name = mb_strtolower($attachment->getName());
if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
return $this->getAttachmentUrl($attachment);
}
//Track first PDF as fallback (check internal extension or external URL path)
if ($firstPdf === null) {
$extension = $attachment->getExtension();
if ($extension === null && $attachment->hasExternal()) {
$urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
$extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
}
if ($extension === 'pdf') {
$firstPdf = $attachment;
}
}
}
//Use first PDF attachment as fallback
if ($firstPdf !== null) {
return $this->getAttachmentUrl($firstPdf);
}
return null;
}
/**
* Returns an absolute URL for viewing the given attachment.
* Prefers the external URL (direct link) over the internal view route.
*/
private function getAttachmentUrl(Attachment $attachment): string
{
if ($attachment->hasExternal()) {
return $attachment->getExternalPath();
}
return $this->urlGenerator->generate(
'attachment_view',
['id' => $attachment->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}

View file

@ -396,10 +396,14 @@ class BOMImporter
}
}
// Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
// Create unique key for this entry.
// When linked to a Part-DB part, use the part ID as key (merges footprint variants).
// Otherwise, use name (which includes package) to avoid merging unrelated components.
$entry_key = $part !== null
? 'part:' . $part->getID()
: 'name:' . $name;
// Check if we already have an entry with the same name and part
// Check if we already have an entry with the same key
if (isset($entries_by_key[$entry_key])) {
// Merge with existing entry
$existing_entry = $entries_by_key[$entry_key];
@ -413,14 +417,22 @@ class BOMImporter
$existing_quantity = $existing_entry->getQuantity();
$existing_entry->setQuantity($existing_quantity + $quantity);
// Track footprint variants in comment when merging entries with different packages
$currentPackage = trim($mapped_entry['Package'] ?? '');
if ($currentPackage !== '' && !str_contains($existing_entry->getComment(), $currentPackage)) {
$comment = $existing_entry->getComment();
$existing_entry->setComment($comment . ', Footprint variant: ' . $currentPackage);
}
$this->logger->info('Merged duplicate BOM entry', [
'name' => $name,
'part_id' => $part ? $part->getID() : null,
'part_id' => $part?->getID(),
'original_quantity' => $existing_quantity,
'added_quantity' => $quantity,
'new_quantity' => $existing_quantity + $quantity,
'original_mountnames' => $existing_mountnames,
'added_mountnames' => $designator,
'package' => $currentPackage,
]);
continue; // Skip creating new entry

View file

@ -127,6 +127,15 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
);
}
if ($action === 'batch_edit_eda') {
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
return new RedirectResponse(
$this->urlGenerator->generate('batch_eda_edit', [
'ids' => $ids,
'_redirect' => $redirect_url
])
);
}
//Iterate over the parts and apply the action to it:
foreach ($selected_parts as $part) {

View file

@ -43,4 +43,14 @@ class KiCadEDASettings
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
#[Assert\Range(min: -1)]
public int $categoryDepth = 0;
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
description: new TM("settings.misc.kicad_eda.datasheet_link.help"),
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;
}

View file

@ -62,6 +62,9 @@
<option {% if not is_granted('@projects.read') %}disabled{% endif %} value="add_to_project" data-url="{{ path('select_project')}}">{% trans %}part_list.action.projects.add_to_project{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.group.eda{% endtrans %}">
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="batch_edit_eda" data-turbo="false">{% trans %}part_list.action.batch_edit_eda{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.action.delete{% endtrans %}">
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
</optgroup>

View 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 %}

View file

@ -14,6 +14,7 @@
<th>{% trans %}specifications.unit{% endtrans %}</th>
<th>{% trans %}specifications.text{% endtrans %}</th>
<th>{% trans %}specifications.group{% endtrans %}</th>
<th title="{% trans %}specifications.eda_visibility.help{% endtrans %}"><i class="fas fa-bolt fa-fw"></i></th>
<th></th>
</tr>
</thead>

View file

@ -33,6 +33,7 @@
{{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }}
{{ form_widget(form.obsolete) }}
{{ form_widget(form.pricesIncludesVAT) }}
{{ form_widget(form.eda_visibility) }}
</td>
<td>
<div {{ collection.controller(form.pricedetails, 'pricedetails.edit.delete.confirm') }}>
@ -79,6 +80,9 @@
<td {{ stimulus_controller('pages/latex_preview', {"unit": true}) }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
{% if form.eda_visibility is defined %}
<td class="text-center">{{ form_widget(form.eda_visibility) }}</td>
{% endif %}
<td>
<button type="button" class="btn btn-danger btn-sm order_btn_delete position-relative {% if form.parent.vars.allow_delete is defined and not form.parent.vars.allow_delete %}disabled{% endif %}"
{{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">

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

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

View file

@ -148,6 +148,11 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => 'http://localhost/en/part/1/info',
'visible' => 'False',
),
'Part-DB URL' =>
array(
'value' => 'http://localhost/en/part/1/info',
'visible' => 'False',
),
'description' =>
array(
'value' => '',
@ -168,6 +173,11 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => '1',
'visible' => 'False',
),
'Stock' =>
array(
'value' => '0',
'visible' => 'False',
),
),
);
@ -177,20 +187,19 @@ final class KiCadApiControllerTest extends WebTestCase
public function testPartDetailsPart2(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/parts/1.json');
$client->request('GET', self::BASE_URL.'/parts/2.json');
//Response should still be successful, but the result should be empty
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);
$data = json_decode($content, true);
//For part 2 things info should be taken from the category and footprint
//For part 2, EDA info should be inherited from category and footprint (no part-level overrides)
$expected = array (
'id' => '1',
'name' => 'Part 1',
'symbolIdStr' => 'Part:1',
'id' => '2',
'name' => 'Part 2',
'symbolIdStr' => 'Category:1',
'exclude_from_bom' => 'False',
'exclude_from_board' => 'True',
'exclude_from_sim' => 'False',
@ -198,27 +207,32 @@ final class KiCadApiControllerTest extends WebTestCase
array (
'footprint' =>
array (
'value' => 'Part:1',
'value' => 'Footprint:1',
'visible' => 'False',
),
'reference' =>
array (
'value' => 'P',
'value' => 'C',
'visible' => 'True',
),
'value' =>
array (
'value' => 'Part 1',
'value' => 'Part 2',
'visible' => 'True',
),
'keywords' =>
array (
'value' => '',
'value' => 'test, Test, Part2',
'visible' => 'False',
),
'datasheet' =>
array (
'value' => 'http://localhost/en/part/1/info',
'value' => 'http://localhost/en/part/2/info',
'visible' => 'False',
),
'Part-DB URL' =>
array (
'value' => 'http://localhost/en/part/2/info',
'visible' => 'False',
),
'description' =>
@ -231,14 +245,44 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => 'Node 1',
'visible' => 'False',
),
'Manufacturer' =>
array (
'value' => 'Node 1',
'visible' => 'False',
),
'Manufacturing Status' =>
array (
'value' => '',
'value' => 'Active',
'visible' => 'False',
),
'Part-DB Footprint' =>
array (
'value' => 'Node 1',
'visible' => 'False',
),
'Mass' =>
array (
'value' => '100.2 g',
'visible' => 'False',
),
'Part-DB ID' =>
array (
'value' => '1',
'value' => '2',
'visible' => 'False',
),
'Part-DB IPN' =>
array (
'value' => 'IPN123',
'visible' => 'False',
),
'manf' =>
array (
'value' => 'Node 1',
'visible' => 'False',
),
'Stock' =>
array (
'value' => '0',
'visible' => 'False',
),
),
@ -247,4 +291,31 @@ final class KiCadApiControllerTest extends WebTestCase
self::assertEquals($expected, $data);
}
public function testCategoriesHasCacheHeaders(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/categories.json');
self::assertResponseIsSuccessful();
$response = $client->getResponse();
self::assertNotNull($response->headers->get('ETag'));
self::assertStringContainsString('max-age=', $response->headers->get('Cache-Control'));
}
public function testConditionalRequestReturns304(): void
{
$client = $this->createClientWithCredentials();
$client->request('GET', self::BASE_URL.'/categories.json');
$etag = $client->getResponse()->headers->get('ETag');
self::assertNotNull($etag);
//Make a conditional request with the ETag
$client->request('GET', self::BASE_URL.'/categories.json', [], [], [
'HTTP_IF_NONE_MATCH' => $etag,
]);
self::assertResponseStatusCodeSame(304);
}
}

View file

@ -136,4 +136,44 @@ final class PartNormalizerTest extends WebTestCase
$this->assertEqualsWithDelta(1.0, $priceDetail->getPriceRelatedQuantity(), PHP_FLOAT_EPSILON);
$this->assertEqualsWithDelta(1.0, $priceDetail->getMinDiscountQuantity(), PHP_FLOAT_EPSILON);
}
public function testDenormalizeEdaFields(): void
{
$input = [
'name' => 'EDA Test Part',
'kicad_symbol' => 'Device:R',
'kicad_footprint' => 'Resistor_SMD:R_0805_2012Metric',
'kicad_reference' => 'R',
'kicad_value' => '10k',
'eda_exclude_bom' => 'true',
'eda_exclude_board' => 'false',
];
$part = $this->service->denormalize($input, Part::class, 'json', ['groups' => ['import'], 'partdb_import' => true]);
$this->assertInstanceOf(Part::class, $part);
$this->assertSame('EDA Test Part', $part->getName());
$edaInfo = $part->getEdaInfo();
$this->assertSame('Device:R', $edaInfo->getKicadSymbol());
$this->assertSame('Resistor_SMD:R_0805_2012Metric', $edaInfo->getKicadFootprint());
$this->assertSame('R', $edaInfo->getReferencePrefix());
$this->assertSame('10k', $edaInfo->getValue());
$this->assertTrue($edaInfo->getExcludeFromBom());
$this->assertFalse($edaInfo->getExcludeFromBoard());
}
public function testDenormalizeEdaFieldsEmptyValuesIgnored(): void
{
$input = [
'name' => 'Part Without EDA',
'kicad_symbol' => '',
'kicad_footprint' => '',
];
$part = $this->service->denormalize($input, Part::class, 'json', ['groups' => ['import'], 'partdb_import' => true]);
$edaInfo = $part->getEdaInfo();
$this->assertNull($edaInfo->getKicadSymbol());
$this->assertNull($edaInfo->getKicadFootprint());
}
}

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

View file

@ -642,6 +642,12 @@ Sub elements will be moved upwards.</target>
<target>Group</target>
</segment>
</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">
<segment state="translated">
<source>specification.create</source>
@ -2924,6 +2930,42 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
<target>Attachments</target>
</segment>
</unit>
<unit id="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">
<segment state="translated">
<source>flash.login_successful</source>
@ -3266,6 +3308,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
<target>No longer available</target>
</segment>
</unit>
<unit id="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">
<segment state="translated">
<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 &gt; 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.</target>
</segment>
</unit>
<unit id="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">
<segment state="translated">
<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>
</segment>
</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">
<segment state="translated">
<source>info_providers.bulk_import.step1.spn_recommendation</source>