diff --git a/contrib/kicad-populate/README.md b/contrib/kicad-populate/README.md new file mode 100644 index 000000000..bd918d145 --- /dev/null +++ b/contrib/kicad-populate/README.md @@ -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 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) diff --git a/contrib/kicad-populate/default_mappings.json b/contrib/kicad-populate/default_mappings.json new file mode 100644 index 000000000..b1bc1d1b7 --- /dev/null +++ b/contrib/kicad-populate/default_mappings.json @@ -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" + } +} diff --git a/migrations/Version20260211000000.php b/migrations/Version20260211000000.php new file mode 100644 index 000000000..33f3db57c --- /dev/null +++ b/migrations/Version20260211000000.php @@ -0,0 +1,52 @@ +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'); + } +} diff --git a/src/Command/PopulateKicadCommand.php b/src/Command/PopulateKicadCommand.php new file mode 100644 index 000000000..ec7b8c943 --- /dev/null +++ b/src/Command/PopulateKicadCommand.php @@ -0,0 +1,606 @@ +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 $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 $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 + */ + 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 + */ + 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 + ]; + } +} diff --git a/src/Controller/BatchEdaController.php b/src/Controller/BatchEdaController.php new file mode 100644 index 000000000..4a3ead7b2 --- /dev/null +++ b/src/Controller/BatchEdaController.php @@ -0,0 +1,117 @@ + + */ + 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, + ]); + } +} diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php index c28e87a64..76727877b 100644 --- a/src/Controller/KiCadApiController.php +++ b/src/Controller/KiCadApiController.php @@ -27,6 +27,8 @@ 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 @@ public function root(): Response } #[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 @@ public function categoryParts(?Category $category): Response } $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; } } \ No newline at end of file diff --git a/src/Controller/KiCadApiV2Controller.php b/src/Controller/KiCadApiV2Controller.php new file mode 100644 index 000000000..31b185b14 --- /dev/null +++ b/src/Controller/KiCadApiV2Controller.php @@ -0,0 +1,107 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +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; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * KiCad HTTP Library API v2 controller. + * + * v1 spec: https://dev-docs.kicad.org/en/apis-and-binding/http-libraries/index.html + * v2 spec (draft): https://gitlab.com/RosyDev/kicad-dev-docs/-/blob/http-lib-v2/content/apis-and-binding/http-libraries/http-lib-v2-00.adoc + * + * Differences from v1: + * - Volatile fields: Stock and Storage Location are marked volatile (shown in KiCad but NOT saved to schematic) + * - Root endpoint returns links to categories and parts endpoints + */ +#[Route('/kicad-api/v2')] +class KiCadApiV2Controller extends AbstractController +{ + public function __construct( + private readonly KiCadHelper $kiCADHelper, + ) { + } + + #[Route('/', name: 'kicad_api_v2_root')] + public function root(): Response + { + $this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS'); + + return $this->json([ + 'categories' => $this->generateUrl('kicad_api_v2_categories', [], UrlGeneratorInterface::ABSOLUTE_URL), + 'parts' => '', + ]); + } + + #[Route('/categories.json', name: 'kicad_api_v2_categories')] + public function categories(Request $request): Response + { + $this->denyAccessUnlessGranted('@categories.read'); + + $data = $this->kiCADHelper->getCategories(); + return $this->createCacheableJsonResponse($request, $data, 300); + } + + #[Route('/parts/category/{category}.json', name: 'kicad_api_v2_category')] + public function categoryParts(Request $request, ?Category $category): Response + { + if ($category !== null) { + $this->denyAccessUnlessGranted('read', $category); + } else { + $this->denyAccessUnlessGranted('@categories.read'); + } + $this->denyAccessUnlessGranted('@parts.read'); + + $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_v2_part')] + public function partDetails(Request $request, Part $part): Response + { + $this->denyAccessUnlessGranted('read', $part); + + // Use API v2 format with volatile fields + $data = $this->kiCADHelper->getKiCADPart($part, 2); + return $this->createCacheableJsonResponse($request, $data, 60); + } + + 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; + } +} diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php index c33c3a820..54094ff1c 100644 --- a/src/DataTables/Helpers/PartDataTableHelper.php +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -115,6 +115,61 @@ public function renderStorageLocations(Part $context): string return implode('
', $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('', $class, $title); + } + + // Footprint status + if ($hasFootprint) { + $title = $this->translator->trans('eda.status.footprint_set'); + $class = $footprintInherited ? 'text-info' : 'text-success'; + $icons[] = sprintf('', $class, $title); + } + + // Reference prefix status + if ($hasReference) { + $icons[] = sprintf('', + $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('', $this->translator->trans('eda.status.complete')) + : sprintf('', $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('%s', $editUrl, $statusIcon); + } + public function renderAmount(Part $context): string { $amount = $context->getAmountSum(); diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index d2faba766..84f1eaddc 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -228,6 +228,11 @@ public function configure(DataTable $dataTable, array $options): void ]) ->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 diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index d84e68adf..f47f2e82e 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -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 @@ public function getElementClass(): string 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()]; diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 58f695987..56428e3a2 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -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 @@ public function setPricesIncludesVAT(?bool $includesVat): self 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(); diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php index 4c2174ae9..0e3ad5e29 100644 --- a/src/Form/ParameterType.php +++ b/src/Form/ParameterType.php @@ -55,6 +55,7 @@ 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 @@ public function buildForm(FormBuilderInterface $builder, array $options): void '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 diff --git a/src/Form/Part/EDA/BatchEdaType.php b/src/Form/Part/EDA/BatchEdaType.php new file mode 100644 index 000000000..28fc4a416 --- /dev/null +++ b/src/Form/Part/EDA/BatchEdaType.php @@ -0,0 +1,116 @@ +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, + ]); + } +} diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php index ca295c7e6..378f3389e 100644 --- a/src/Form/Part/OrderdetailType.php +++ b/src/Form/Part/OrderdetailType.php @@ -79,6 +79,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void '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 */ diff --git a/src/Serializer/PartNormalizer.php b/src/Serializer/PartNormalizer.php index 775df77f0..8486a634a 100644 --- a/src/Serializer/PartNormalizer.php +++ b/src/Serializer/PartNormalizer.php @@ -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 @@ public function denormalize($data, string $type, ?string $format = null, array $ } } + //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[] */ diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index 3a613fe7e..8bd1fc744 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -23,6 +23,7 @@ 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 @@ public function __construct( KiCadEDASettings $kiCadEDASettings, ) { $this->category_depth = $kiCadEDASettings->categoryDepth; + $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true; + $this->defaultEdaVisibility = $kiCadEDASettings->defaultEdaVisibility; } /** @@ -115,11 +124,16 @@ public function getCategories(): array } //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 @@ public function getCategories(): array * 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), @@ -181,8 +197,15 @@ function (ItemInterface $item) use ($category) { }); } - public function getKiCADPart(Part $part): array + /** + * @param int $apiVersion The API version to use (1 or 2). Version 2 adds volatile field support. + */ + public function getKiCADPart(Part $part, int $apiVersion = 1): array { + if ($apiVersion < 1 || $apiVersion > 2) { + throw new \InvalidArgumentException(sprintf('Unsupported API version %d. Supported versions: 1, 2.', $apiVersion)); + } + $result = [ 'id' => (string)$part->getId(), 'name' => $part->getName(), @@ -198,14 +221,22 @@ public function getKiCADPart(Part $part): array $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,46 +276,83 @@ public function getKiCADPart(Part $part): array $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn()); } + //Add KiCost manufacturer fields (always present, independent of orderdetails) + if ($part->getManufacturer() !== null) { + $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); + } + if ($part->getManufacturerProductNumber() !== "") { + $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); + } + // Add supplier information from orderdetails (include obsolete orderdetails) - if ($part->getOrderdetails(false)->count() > 0) { + // If any orderdetail has eda_visibility explicitly set to true, only export those; + // 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 ($part->getOrderdetails(false) as $orderdetail) { + foreach ($allOrderdetails as $orderdetail) { if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { - $supplierName = $orderdetail->getSupplier()->getName(); + // When explicit flags exist, filter by resolved visibility + $resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->defaultEdaVisibility; + if ($hasExplicitEdaVisibility && !$resolvedVisibility) { + continue; + } - $supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number + $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 (e.g. "Mouser", "Mouser 2", etc.) - $fieldName = $supplierCounts[$supplierName] > 1 + + // 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 fields for KiCost: - if ($part->getManufacturer() !== null) { - $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); + //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(); + } + } } - if ($part->getManufacturerProductNumber() !== "") { - $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); + // In API v2, stock and location are volatile (shown but not saved to schematic) + $result['fields']['Stock'] = $this->createField($totalStock, false, $apiVersion >= 2); + if ($locations !== []) { + $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiVersion >= 2); } - //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) { - if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { - $fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; - - $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); + //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 +412,7 @@ private function shouldPartBeVisible(Part $part): bool //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 @@ -386,13 +454,80 @@ private function boolToKicadBool(bool $value): string * Creates a field array for KiCAD * @param string|int|float $value * @param bool $visible + * @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic * @return array */ - private function createField(string|int|float $value, bool $visible = false): array + private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array { - return [ + $field = [ 'value' => (string)$value, 'visible' => $this->boolToKicadBool($visible), ]; + + if ($volatile) { + $field['volatile'] = $this->boolToKicadBool(true); + } + + return $field; + } + + /** + * 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 + ); } } \ No newline at end of file diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index abf72d747..e65186874 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -396,10 +396,14 @@ private function parseKiCADSchematic(string $data, array $options = []): array } } - // Create unique key for this entry (name + part ID) - $entry_key = $name . '|' . ($part ? $part->getID() : 'null'); - - // Check if we already have an entry with the same name and part + // 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 key if (isset($entries_by_key[$entry_key])) { // Merge with existing entry $existing_entry = $entries_by_key[$entry_key]; @@ -413,14 +417,22 @@ private function parseKiCADSchematic(string $data, array $options = []): array $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 diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 945cff7b7..b0353e29f 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -127,6 +127,15 @@ public function handleAction(string $action, array $selected_parts, ?int $target ); } + 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) { diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php index d8f1026d3..948d1b38b 100644 --- a/src/Settings/MiscSettings/KiCadEDASettings.php +++ b/src/Settings/MiscSettings/KiCadEDASettings.php @@ -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; } \ No newline at end of file diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index d78734988..90f8a3e18 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -62,6 +62,9 @@ + + + diff --git a/templates/parts/batch_eda_edit.html.twig b/templates/parts/batch_eda_edit.html.twig new file mode 100644 index 000000000..b1ca533cd --- /dev/null +++ b/templates/parts/batch_eda_edit.html.twig @@ -0,0 +1,88 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}batch_eda.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}batch_eda.title{% endtrans %} +{% endblock %} + +{% block card_content %} +
+

{% trans with {'%count%': parts|length} %}batch_eda.description{% endtrans %}

+
+ {% trans %}batch_eda.show_parts{% endtrans %} + +
+
+ + {{ form_start(form) }} + +

{% trans %}batch_eda.apply_hint{% endtrans %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans %}batch_eda.apply{% endtrans %}{% trans %}batch_eda.field{% endtrans %}{% trans %}batch_eda.value{% endtrans %}
{{ form_widget(form.apply_reference_prefix) }}{{ form_label(form.reference_prefix) }}{{ form_widget(form.reference_prefix, {'attr': {'class': 'form-control-sm'}}) }}{{ form_errors(form.reference_prefix) }}
{{ form_widget(form.apply_value) }}{{ form_label(form.value) }}{{ form_widget(form.value, {'attr': {'class': 'form-control-sm'}}) }}{{ form_errors(form.value) }}
{{ form_widget(form.apply_kicad_symbol) }}{{ form_label(form.kicad_symbol) }}{{ form_widget(form.kicad_symbol) }}{{ form_errors(form.kicad_symbol) }}
{{ form_widget(form.apply_kicad_footprint) }}{{ form_label(form.kicad_footprint) }}{{ form_widget(form.kicad_footprint) }}{{ form_errors(form.kicad_footprint) }}
{{ form_widget(form.apply_visibility) }}{{ form_label(form.visibility) }}{{ form_widget(form.visibility) }}
{{ form_widget(form.apply_exclude_from_bom) }}{{ form_label(form.exclude_from_bom) }}{{ form_widget(form.exclude_from_bom) }}
{{ form_widget(form.apply_exclude_from_board) }}{{ form_label(form.exclude_from_board) }}{{ form_widget(form.exclude_from_board) }}
{{ form_widget(form.apply_exclude_from_sim) }}{{ form_label(form.exclude_from_sim) }}{{ form_widget(form.exclude_from_sim) }}
+ +
+ {% if redirect_url %} + {% trans %}batch_eda.cancel{% endtrans %} + {% else %} + {% trans %}batch_eda.cancel{% endtrans %} + {% endif %} + {{ form_widget(form.submit) }} +
+ + {{ form_end(form) }} +{% endblock %} diff --git a/templates/parts/edit/_specifications.html.twig b/templates/parts/edit/_specifications.html.twig index 25b001339..6f631b9f5 100644 --- a/templates/parts/edit/_specifications.html.twig +++ b/templates/parts/edit/_specifications.html.twig @@ -14,6 +14,7 @@ {% trans %}specifications.unit{% endtrans %} {% trans %}specifications.text{% endtrans %} {% trans %}specifications.group{% endtrans %} + diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index 844c8700a..9e989c92d 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -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) }}
@@ -79,6 +80,9 @@ {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }} {{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }} {{ form_widget(form.group) }}{{ form_errors(form.group) }} + {% if form.eda_visibility is defined %} + {{ form_widget(form.eda_visibility) }} + {% endif %}