diff --git a/assets/commands/kicad_populate_default_mappings.json b/assets/commands/kicad_populate_default_mappings.json new file mode 100644 index 00000000..a942667c --- /dev/null +++ b/assets/commands/kicad_populate_default_mappings.json @@ -0,0 +1,206 @@ +{ + "_comment": "Default KiCad footprint/symbol mappings for partdb:kicad:populate command. Based on KiCad 9.x standard libraries. Use --mapping-file to override or extend these mappings.", + "footprints": { + "SOT-23": "Package_TO_SOT_SMD:SOT-23", + "SOT-23-3": "Package_TO_SOT_SMD:SOT-23", + "SOT-23-5": "Package_TO_SOT_SMD:SOT-23-5", + "SOT-23-6": "Package_TO_SOT_SMD:SOT-23-6", + "SOT-223": "Package_TO_SOT_SMD:SOT-223-3_TabPin2", + "SOT-223-3": "Package_TO_SOT_SMD:SOT-223-3_TabPin2", + "SOT-89": "Package_TO_SOT_SMD:SOT-89-3", + "SOT-89-3": "Package_TO_SOT_SMD:SOT-89-3", + "SOT-323": "Package_TO_SOT_SMD:SOT-323_SC-70", + "SOT-363": "Package_TO_SOT_SMD:SOT-363_SC-70-6", + "TSOT-25": "Package_TO_SOT_SMD:SOT-23-5", + "SC-70-5": "Package_TO_SOT_SMD:SOT-353_SC-70-5", + "SC-70-6": "Package_TO_SOT_SMD:SOT-363_SC-70-6", + "TO-220": "Package_TO_SOT_THT:TO-220-3_Vertical", + "TO-220AB": "Package_TO_SOT_THT:TO-220-3_Vertical", + "TO-220AB-3": "Package_TO_SOT_THT:TO-220-3_Vertical", + "TO-220FP": "Package_TO_SOT_THT:TO-220F-3_Vertical", + "TO-247-3": "Package_TO_SOT_THT:TO-247-3_Vertical", + "TO-92": "Package_TO_SOT_THT:TO-92_Inline", + "TO-92-3": "Package_TO_SOT_THT:TO-92_Inline", + "TO-252": "Package_TO_SOT_SMD:TO-252-2", + "TO-252-2L": "Package_TO_SOT_SMD:TO-252-2", + "TO-252-3L": "Package_TO_SOT_SMD:TO-252-3", + "TO-263": "Package_TO_SOT_SMD:TO-263-2", + "TO-263-2": "Package_TO_SOT_SMD:TO-263-2", + "D2PAK": "Package_TO_SOT_SMD:TO-252-2", + "DPAK": "Package_TO_SOT_SMD:TO-252-2", + "SOIC-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm", + "ESOP-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm", + "SOIC-14": "Package_SO:SOIC-14_3.9x8.7mm_P1.27mm", + "SOIC-16": "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm", + "TSSOP-8": "Package_SO:TSSOP-8_3x3mm_P0.65mm", + "TSSOP-14": "Package_SO:TSSOP-14_4.4x5mm_P0.65mm", + "TSSOP-16": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm", + "TSSOP-16L": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm", + "TSSOP-20": "Package_SO:TSSOP-20_4.4x6.5mm_P0.65mm", + "MSOP-8": "Package_SO:MSOP-8_3x3mm_P0.65mm", + "MSOP-10": "Package_SO:MSOP-10_3x3mm_P0.5mm", + "MSOP-16": "Package_SO:MSOP-16_3x4mm_P0.5mm", + "SO-5": "Package_TO_SOT_SMD:SOT-23-5", + "DIP-4": "Package_DIP:DIP-4_W7.62mm", + "DIP-6": "Package_DIP:DIP-6_W7.62mm", + "DIP-8": "Package_DIP:DIP-8_W7.62mm", + "DIP-14": "Package_DIP:DIP-14_W7.62mm", + "DIP-16": "Package_DIP:DIP-16_W7.62mm", + "DIP-18": "Package_DIP:DIP-18_W7.62mm", + "DIP-20": "Package_DIP:DIP-20_W7.62mm", + "DIP-24": "Package_DIP:DIP-24_W7.62mm", + "DIP-28": "Package_DIP:DIP-28_W7.62mm", + "DIP-40": "Package_DIP:DIP-40_W15.24mm", + "QFN-8": "Package_DFN_QFN:QFN-8-1EP_3x3mm_P0.65mm_EP1.55x1.55mm", + "QFN-12(3x3)": "Package_DFN_QFN:QFN-12-1EP_3x3mm_P0.5mm_EP1.65x1.65mm", + "QFN-16": "Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.45x1.45mm", + "QFN-20": "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm", + "QFN-24": "Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm", + "QFN-32": "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm", + "QFN-48": "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm", + "TQFP-32": "Package_QFP:TQFP-32_7x7mm_P0.8mm", + "TQFP-44": "Package_QFP:TQFP-44_10x10mm_P0.8mm", + "TQFP-48": "Package_QFP:TQFP-48_7x7mm_P0.5mm", + "TQFP-48(7x7)": "Package_QFP:TQFP-48_7x7mm_P0.5mm", + "TQFP-64": "Package_QFP:TQFP-64_10x10mm_P0.5mm", + "TQFP-100": "Package_QFP:TQFP-100_14x14mm_P0.5mm", + "LQFP-32": "Package_QFP:LQFP-32_7x7mm_P0.8mm", + "LQFP-48": "Package_QFP:LQFP-48_7x7mm_P0.5mm", + "LQFP-64": "Package_QFP:LQFP-64_10x10mm_P0.5mm", + "LQFP-100": "Package_QFP:LQFP-100_14x14mm_P0.5mm", + + "SOD-123": "Diode_SMD:D_SOD-123", + "SOD-123F": "Diode_SMD:D_SOD-123F", + "SOD-123FL": "Diode_SMD:D_SOD-123F", + "SOD-323": "Diode_SMD:D_SOD-323", + "SOD-523": "Diode_SMD:D_SOD-523", + "SOD-882": "Diode_SMD:D_SOD-882", + "SOD-882D": "Diode_SMD:D_SOD-882", + "SMA(DO-214AC)": "Diode_SMD:D_SMA", + "SMA": "Diode_SMD:D_SMA", + "SMB": "Diode_SMD:D_SMB", + "SMC": "Diode_SMD:D_SMC", + + "DO-35": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal", + "DO-35(DO-204AH)": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal", + "DO-41": "Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal", + "DO-201": "Diode_THT:D_DO-201_P15.24mm_Horizontal", + + "DFN-2(0.6x1)": "Package_DFN_QFN:DFN-2-1EP_0.6x1.0mm_P0.65mm_EP0.2x0.55mm", + "DFN1006-2": "Package_DFN_QFN:DFN-2_1.0x0.6mm", + "DFN-6": "Package_DFN_QFN:DFN-6-1EP_2x2mm_P0.65mm_EP1x1.6mm", + "DFN-8": "Package_DFN_QFN:DFN-8-1EP_3x2mm_P0.5mm_EP1.3x1.5mm", + + "0201": "Resistor_SMD:R_0201_0603Metric", + "0402": "Resistor_SMD:R_0402_1005Metric", + "0603": "Resistor_SMD:R_0603_1608Metric", + "0805": "Resistor_SMD:R_0805_2012Metric", + "1206": "Resistor_SMD:R_1206_3216Metric", + "1210": "Resistor_SMD:R_1210_3225Metric", + "1812": "Resistor_SMD:R_1812_4532Metric", + "2010": "Resistor_SMD:R_2010_5025Metric", + "2512": "Resistor_SMD:R_2512_6332Metric", + "2917": "Resistor_SMD:R_2917_7343Metric", + "2920": "Resistor_SMD:R_2920_7350Metric", + + "CASE-A-3216-18(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A", + "CASE-B-3528-21(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3528-21_Kemet-B", + "CASE-C-6032-28(mm)": "Capacitor_Tantalum_SMD:CP_EIA-6032-28_Kemet-C", + "CASE-D-7343-31(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-31_Kemet-D", + "CASE-E-7343-43(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-43_Kemet-E", + + "SMD,D4xL5.4mm": "Capacitor_SMD:CP_Elec_4x5.4", + "SMD,D5xL5.4mm": "Capacitor_SMD:CP_Elec_5x5.4", + "SMD,D6.3xL5.4mm": "Capacitor_SMD:CP_Elec_6.3x5.4", + "SMD,D6.3xL7.7mm": "Capacitor_SMD:CP_Elec_6.3x7.7", + "SMD,D8xL6.5mm": "Capacitor_SMD:CP_Elec_8x6.5", + "SMD,D8xL10mm": "Capacitor_SMD:CP_Elec_8x10", + "SMD,D10xL10mm": "Capacitor_SMD:CP_Elec_10x10", + "SMD,D10xL10.5mm": "Capacitor_SMD:CP_Elec_10x10.5", + + "Through Hole,D5xL11mm": "Capacitor_THT:CP_Radial_D5.0mm_P2.00mm", + "Through Hole,D6.3xL11mm": "Capacitor_THT:CP_Radial_D6.3mm_P2.50mm", + "Through Hole,D8xL11mm": "Capacitor_THT:CP_Radial_D8.0mm_P3.50mm", + "Through Hole,D10xL16mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm", + "Through Hole,D10xL20mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm", + "Through Hole,D12.5xL20mm": "Capacitor_THT:CP_Radial_D12.5mm_P5.00mm", + + "LED 3mm": "LED_THT:LED_D3.0mm", + "LED 5mm": "LED_THT:LED_D5.0mm", + "LED 0603": "LED_SMD:LED_0603_1608Metric", + "LED 0805": "LED_SMD:LED_0805_2012Metric", + "SMD5050-4P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm", + "SMD5050-6P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm", + + "HC-49": "Crystal:Crystal_HC49-4H_Vertical", + "HC-49/U": "Crystal:Crystal_HC49-4H_Vertical", + "HC-49/S": "Crystal:Crystal_HC49-U_Vertical", + "HC-49/US": "Crystal:Crystal_HC49-U_Vertical", + + "USB-A": "Connector_USB:USB_A_Stewart_SS-52100-001_Horizontal", + "USB-B": "Connector_USB:USB_B_OST_USB-B1HSxx_Horizontal", + "USB-Mini-B": "Connector_USB:USB_Mini-B_Lumberg_2486_01_Horizontal", + "USB-Micro-B": "Connector_USB:USB_Micro-B_Molex-105017-0001", + "USB-C": "Connector_USB:USB_C_Receptacle_GCT_USB4085", + + "1x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical", + "1x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical", + "1x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical", + "1x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_Vertical", + "1x6 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical", + "1x8 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical", + "1x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x10_P2.54mm_Vertical", + "2x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x02_P2.54mm_Vertical", + "2x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_Vertical", + "2x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x04_P2.54mm_Vertical", + "2x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical", + "2x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x10_P2.54mm_Vertical", + "2x20 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x20_P2.54mm_Vertical", + "SIP-3-2.54mm": "Package_SIP:SIP-3_P2.54mm", + "SIP-4-2.54mm": "Package_SIP:SIP-4_P2.54mm", + "SIP-5-2.54mm": "Package_SIP:SIP-5_P2.54mm" + }, + "categories": { + "Electrolytic": "Device:C_Polarized", + "Polarized": "Device:C_Polarized", + "Tantalum": "Device:C_Polarized", + "Zener": "Device:D_Zener", + "Schottky": "Device:D_Schottky", + "TVS": "Device:D_TVS", + "LED": "Device:LED", + "NPN": "Device:Q_NPN_BCE", + "PNP": "Device:Q_PNP_BCE", + "N-MOSFET": "Device:Q_NMOS_GDS", + "NMOS": "Device:Q_NMOS_GDS", + "N-MOS": "Device:Q_NMOS_GDS", + "P-MOSFET": "Device:Q_PMOS_GDS", + "PMOS": "Device:Q_PMOS_GDS", + "P-MOS": "Device:Q_PMOS_GDS", + "MOSFET": "Device:Q_NMOS_GDS", + "JFET": "Device:Q_NJFET_DSG", + "Ferrite": "Device:Ferrite_Bead", + "Crystal": "Device:Crystal", + "Oscillator": "Oscillator:Oscillator_Crystal", + "Fuse": "Device:Fuse", + "Transformer": "Device:Transformer_1P_1S", + "Resistor": "Device:R", + "Capacitor": "Device:C", + "Inductor": "Device:L", + "Diode": "Device:D", + "Transistor": "Device:Q_NPN_BCE", + "Voltage Regulator": "Regulator_Linear:LM317_TO-220", + "LDO": "Regulator_Linear:AMS1117-3.3", + "Op-Amp": "Amplifier_Operational:LM358", + "Comparator": "Comparator:LM393", + "Optocoupler": "Isolator:PC817", + "Relay": "Relay:Relay_DPDT", + "Connector": "Connector:Conn_01x02", + "Switch": "Switch:SW_Push", + "Button": "Switch:SW_Push", + "Potentiometer": "Device:R_POT", + "Trimpot": "Device:R_POT", + "Thermistor": "Device:Thermistor", + "Varistor": "Device:Varistor", + "Photo": "Device:LED" + } +} diff --git a/docs/usage/console_commands.md b/docs/usage/console_commands.md index 576b3314..bc9bb013 100644 --- a/docs/usage/console_commands.md +++ b/docs/usage/console_commands.md @@ -88,3 +88,6 @@ The value of the environment variable is copied to the settings database, so the * `php bin/console partdb:attachments:download`: Download all attachments that are not already downloaded to the local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote, and also makes picture thumbnails available for the frontend for them. + +## EDA integration commands +* `php bin/console partdb:kicad:populate`: Populate KiCad footprint paths and symbol paths for footprints and categories based on their names. Use `--dry-run` to preview changes without applying them, and `--list` to list current values. See the [EDA integration documentation](eda_integration.md) for more details. diff --git a/docs/usage/eda_integration.md b/docs/usage/eda_integration.md index 28386a91..b99ed4dd 100644 --- a/docs/usage/eda_integration.md +++ b/docs/usage/eda_integration.md @@ -87,3 +87,31 @@ To show more levels of categories, you can set this value to a higher number. If you set this value to -1, all parts are shown inside a single category in KiCad, without any subcategories. You can view the "real" category path of a part in the part details dialog in KiCad. + +### Kicad:populate command + +Part-DB also provides a command that attempts to automatically populate the KiCad symbol and footprint fields based on the part's category and footprint names. +This is especially useful if you have a large database and want to quickly assign symbols and footprints to parts without doing it manually. + +For this run `bin/console partdb:kicad:populate --dry-run` in the terminal, it will show you a list of suggestions for mappings for your existing categories and footprints. +It uses names and alternative names, when the primary name doesn't match, to find the right mapping. +If you are happy with the suggestions, you can run the command without the `--dry-run` option to apply the changes to your database. By default, only empty values are updated, but you can use the `--force` option to overwrite existing values as well. + +It uses the mapping under `assets/commands/kicad_populate_default_mappings.json` by default, but you can extend/override it by providing your own mapping file +with the `--mapping-file` option. +The mapping file is a JSON file with the following structure, where the key is the name of the footprint or category, and the value is the corresponding KiCad library path: +```json +{ + "footprints": { + "MyCustomPackage": "MyLibrary:MyFootprint", + "0805": "Capacitor_SMD:C_0805_2012Metric" + }, + "categories": { + "Sensor": "Sensor:Sensor_Temperature", + "MCU": "MCU_Microchip:PIC16F877A" + } +} +``` +Its okay if the file contains just one of the `footprints` or `categories` keys, so you can choose to only provide mappings for one of them if you want. + +It is recommended to take a backup of your database before running this command. diff --git a/migrations/Version20260211000000.php b/migrations/Version20260211000000.php new file mode 100644 index 00000000..33f3db57 --- /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 00000000..721e7706 --- /dev/null +++ b/src/Command/PopulateKicadCommand.php @@ -0,0 +1,364 @@ +setHelp('This command populates KiCad footprint paths on Footprint entities and KiCad symbol paths on Category entities based on their names.'); + + $this + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without applying them') + ->addOption('footprints', null, InputOption::VALUE_NONE, 'Only update footprint entities') + ->addOption('categories', null, InputOption::VALUE_NONE, 'Only update category entities') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing values (by default, only empty values are updated)') + ->addOption('list', null, InputOption::VALUE_NONE, 'List all footprints and categories with their current KiCad values') + ->addOption('mapping-file', null, InputOption::VALUE_REQUIRED, 'Path to a JSON file with custom mappings (merges with built-in defaults)') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = $input->getOption('dry-run'); + $footprintsOnly = $input->getOption('footprints'); + $categoriesOnly = $input->getOption('categories'); + $force = $input->getOption('force'); + $list = $input->getOption('list'); + $mappingFile = $input->getOption('mapping-file'); + + // If neither specified, do both + $doFootprints = !$categoriesOnly || $footprintsOnly; + $doCategories = !$footprintsOnly || $categoriesOnly; + + if ($list) { + $this->listCurrentValues($io); + return Command::SUCCESS; + } + + // Load mappings: start with built-in defaults, then merge user-supplied file + ['footprints' => $footprintMappings, 'categories' => $categoryMappings] = $this->getDefaultMappings(); + + if ($mappingFile !== null) { + $customMappings = $this->loadMappingFile($mappingFile, $io); + if ($customMappings === null) { + return Command::FAILURE; + } + if (isset($customMappings['footprints']) && is_array($customMappings['footprints'])) { + // User mappings take priority (overwrite defaults) + $footprintMappings = array_merge($footprintMappings, $customMappings['footprints']); + $io->text(sprintf('Loaded %d custom footprint mappings from %s', count($customMappings['footprints']), $mappingFile)); + } + if (isset($customMappings['categories']) && is_array($customMappings['categories'])) { + $categoryMappings = array_merge($categoryMappings, $customMappings['categories']); + $io->text(sprintf('Loaded %d custom category mappings from %s', count($customMappings['categories']), $mappingFile)); + } + } + + if ($dryRun) { + $io->note('DRY RUN MODE - No changes will be made'); + } + + $totalUpdated = 0; + + if ($doFootprints) { + $updated = $this->updateFootprints($io, $dryRun, $force, $footprintMappings); + $totalUpdated += $updated; + } + + if ($doCategories) { + $updated = $this->updateCategories($io, $dryRun, $force, $categoryMappings); + $totalUpdated += $updated; + } + + if (!$dryRun && $totalUpdated > 0) { + $this->entityManager->flush(); + $io->success(sprintf('Updated %d entities. Run "php bin/console cache:clear" to clear the cache.', $totalUpdated)); + } elseif ($dryRun && $totalUpdated > 0) { + $io->info(sprintf('DRY RUN: Would update %d entities. Run without --dry-run to apply changes.', $totalUpdated)); + } else { + $io->info('No entities needed updating.'); + } + + return Command::SUCCESS; + } + + private function listCurrentValues(SymfonyStyle $io): void + { + $io->section('Current Footprint KiCad Values'); + + $footprintRepo = $this->entityManager->getRepository(Footprint::class); + /** @var Footprint[] $footprints */ + $footprints = $footprintRepo->findAll(); + + $rows = []; + foreach ($footprints as $footprint) { + $kicadValue = $footprint->getEdaInfo()->getKicadFootprint(); + $rows[] = [ + $footprint->getId(), + $footprint->getName(), + $kicadValue ?? '(empty)', + ]; + } + + $io->table(['ID', 'Name', 'KiCad Footprint'], $rows); + + $io->section('Current Category KiCad Values'); + + $categoryRepo = $this->entityManager->getRepository(Category::class); + /** @var Category[] $categories */ + $categories = $categoryRepo->findAll(); + + $rows = []; + foreach ($categories as $category) { + $kicadValue = $category->getEdaInfo()->getKicadSymbol(); + $rows[] = [ + $category->getId(), + $category->getName(), + $kicadValue ?? '(empty)', + ]; + } + + $io->table(['ID', 'Name', 'KiCad Symbol'], $rows); + } + + private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int + { + $io->section('Updating Footprint Entities'); + + $footprintRepo = $this->entityManager->getRepository(Footprint::class); + /** @var Footprint[] $footprints */ + $footprints = $footprintRepo->findAll(); + + $updated = 0; + $skipped = []; + + foreach ($footprints as $footprint) { + $name = $footprint->getName(); + $currentValue = $footprint->getEdaInfo()->getKicadFootprint(); + + // Skip if already has value and not forcing + if (!$force && $currentValue !== null && $currentValue !== '') { + continue; + } + + // Check for exact match on name first, then try alternative names + $matchedValue = $this->findFootprintMapping($mappings, $name, $footprint->getAlternativeNames()); + + if ($matchedValue !== null) { + $io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue)); + + if (!$dryRun) { + $footprint->getEdaInfo()->setKicadFootprint($matchedValue); + } + $updated++; + } else { + // No mapping found + $skipped[] = $name; + } + } + + $io->newLine(); + $io->text(sprintf('Updated: %d footprints', $updated)); + + if (count($skipped) > 0) { + $io->warning(sprintf('No mapping found for %d footprints:', count($skipped))); + foreach ($skipped as $name) { + $io->text(' - ' . $name); + } + } + + return $updated; + } + + private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int + { + $io->section('Updating Category Entities'); + + $categoryRepo = $this->entityManager->getRepository(Category::class); + /** @var Category[] $categories */ + $categories = $categoryRepo->findAll(); + + $updated = 0; + $skipped = []; + + foreach ($categories as $category) { + $name = $category->getName(); + $currentValue = $category->getEdaInfo()->getKicadSymbol(); + + // Skip if already has value and not forcing + if (!$force && $currentValue !== null && $currentValue !== '') { + continue; + } + + // Check for matches using the pattern-based mappings (also check alternative names) + $matchedValue = $this->findCategoryMapping($mappings, $name, $category->getAlternativeNames()); + + if ($matchedValue !== null) { + $io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue)); + + if (!$dryRun) { + $category->getEdaInfo()->setKicadSymbol($matchedValue); + } + $updated++; + } else { + $skipped[] = $name; + } + } + + $io->newLine(); + $io->text(sprintf('Updated: %d categories', $updated)); + + if (count($skipped) > 0) { + $io->note(sprintf('No mapping found for %d categories (this is often expected):', count($skipped))); + foreach ($skipped as $name) { + $io->text(' - ' . $name); + } + } + + return $updated; + } + + /** + * Loads a JSON mapping file and returns the parsed data. + * Expected format: {"footprints": {"Name": "KiCad:Path"}, "categories": {"Pattern": "KiCad:Path"}} + * + * @return array|null The parsed mappings, or null on error + */ + private function loadMappingFile(string $path, SymfonyStyle $io): ?array + { + if (!file_exists($path)) { + $io->error(sprintf('Mapping file not found: %s', $path)); + return null; + } + + $content = file_get_contents($path); + if ($content === false) { + $io->error(sprintf('Could not read mapping file: %s', $path)); + return null; + } + + $data = json_decode($content, true); + if (!is_array($data)) { + $io->error(sprintf('Invalid JSON in mapping file: %s', $path)); + return null; + } + + return $data; + } + + private function matchesPattern(string $name, string $pattern): bool + { + // Check for exact match + if ($pattern === $name) { + return true; + } + + // Check for case-insensitive contains + if (stripos($name, $pattern) !== false) { + return true; + } + + return false; + } + + /** + * Finds a footprint mapping by checking the entity name and its alternative names. + * Footprints use exact matching. + * + * @param array $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 the default mappings for footprints and categories. + * @return array{footprints: array, categories: array} + * @throws \JsonException + */ + private function getDefaultMappings(): array + { + $path = $this->projectDir . '/' . self::DEFAULT_MAPPING_FILE; + $content = file_get_contents($path); + + return json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Controller/BatchEdaController.php b/src/Controller/BatchEdaController.php new file mode 100644 index 00000000..82c4bb48 --- /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 c28e87a6..76727877 100644 --- a/src/Controller/KiCadApiController.php +++ b/src/Controller/KiCadApiController.php @@ -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; } } \ No newline at end of file diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php index c33c3a82..54094ff1 100644 --- a/src/DataTables/Helpers/PartDataTableHelper.php +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -115,6 +115,61 @@ class PartDataTableHelper 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 d2faba76..4e301da3 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -89,6 +89,10 @@ final class PartsDataTable implements DataTableTypeInterface $this->configureOptions($resolver); $options = $resolver->resolve($options); + /************************************************************************************************************* + * When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings! + *************************************************************************************************************/ + $this->csh //Color the table rows depending on the review and favorite status ->add('row_color', RowClassColumn::class, [ @@ -228,6 +232,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 diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index d84e68ad..f47f2e82 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 @@ 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()]; diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 58f69598..56428e3a 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 @@ 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(); diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php index 4c2174ae..f68c3921 100644 --- a/src/Form/ParameterType.php +++ b/src/Form/ParameterType.php @@ -54,7 +54,9 @@ use App\Entity\Parameters\StorageLocationParameter; use App\Entity\Parameters\SupplierParameter; use App\Entity\Parts\MeasurementUnit; use App\Form\Type\ExponentialNumberType; +use App\Form\Type\TriStateCheckboxType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -147,6 +149,14 @@ class ParameterType extends AbstractType 'class' => 'form-control-sm', ], ]); + + // Only show the EDA visibility field for part parameters, as it has no function for other entities + if ($options['data_class'] === PartParameter::class) { + $builder->add('eda_visibility', TriStateCheckboxType::class, [ + 'label' => false, + 'required' => false, + ]); + } } public function finishView(FormView $view, FormInterface $form, array $options): void diff --git a/src/Form/Part/EDA/BatchEdaType.php b/src/Form/Part/EDA/BatchEdaType.php new file mode 100644 index 00000000..28fc4a41 --- /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 ca295c7e..6a0dd940 100644 --- a/src/Form/Part/OrderdetailType.php +++ b/src/Form/Part/OrderdetailType.php @@ -79,6 +79,11 @@ class OrderdetailType extends AbstractType 'label' => 'orderdetails.edit.prices_includes_vat', ]); + $builder->add('eda_visibility', TriStateCheckboxType::class, [ + 'required' => false, + 'label' => 'orderdetails.edit.eda_visibility', + ]); + //Add pricedetails after we know the data, so we can set the default currency $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void { /** @var Orderdetail $orderdetail */ diff --git a/src/Serializer/PartNormalizer.php b/src/Serializer/PartNormalizer.php index 775df77f..8486a634 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 @@ 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[] */ diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index 3a613fe7..be4532ce 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\EDA; +use App\Entity\Attachments\Attachment; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Part; @@ -43,6 +44,9 @@ class KiCadHelper /** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */ private readonly int $category_depth; + /** @var bool Whether to resolve actual datasheet PDF URLs (true) or use Part-DB page links (false) */ + private readonly bool $datasheetAsPdf; + public function __construct( private readonly NodesListBuilder $nodesListBuilder, private readonly TagAwareCacheInterface $kicadCache, @@ -51,9 +55,10 @@ class KiCadHelper private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityURLGenerator $entityURLGenerator, private readonly TranslatorInterface $translator, - KiCadEDASettings $kiCadEDASettings, + private readonly KiCadEDASettings $kiCadEDASettings, ) { $this->category_depth = $kiCadEDASettings->categoryDepth; + $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true; } /** @@ -115,11 +120,16 @@ class KiCadHelper } //Format the category for KiCAD + // Use the category comment as description if available, otherwise use the Part-DB URL + $description = $category->getComment(); + if ($description === null || $description === '') { + $description = $this->entityURLGenerator->listPartsURL($category); + } + $result[] = [ 'id' => (string)$category->getId(), 'name' => $category->getFullPath('/'), - //Show the category link as the category description, this also fixes an segfault in KiCad see issue #878 - 'description' => $this->entityURLGenerator->listPartsURL($category), + 'description' => $description, ]; } @@ -131,11 +141,13 @@ class KiCadHelper * Returns an array of objects containing all parts for the given category in the format required by KiCAD. * The result is cached for performance and invalidated on category or part changes. * @param Category|null $category + * @param bool $minimal If true, only return id and name (faster for symbol chooser listing) * @return array */ - public function getCategoryParts(?Category $category): array + public function getCategoryParts(?Category $category, bool $minimal = false): array { - return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth, + $cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : ''); + return $this->kicadCache->get($cacheKey, function (ItemInterface $item) use ($category) { $item->tag([ $this->tagGenerator->getElementTypeCacheTag(Category::class), @@ -198,14 +210,22 @@ class KiCadHelper $result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true); $result["fields"]["keywords"] = $this->createField($part->getTags()); - //Use the part info page as datasheet link. It must be an absolute URL. - $result["fields"]["datasheet"] = $this->createField( - $this->urlGenerator->generate( - 'part_info', - ['id' => $part->getId()], - UrlGeneratorInterface::ABSOLUTE_URL) + //Use the part info page as Part-DB link. It must be an absolute URL. + $partUrl = $this->urlGenerator->generate( + 'part_info', + ['id' => $part->getId()], + UrlGeneratorInterface::ABSOLUTE_URL ); + //Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link) + if ($this->datasheetAsPdf) { + $datasheetUrl = $this->findDatasheetUrl($part); + $result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl); + } else { + $result["fields"]["datasheet"] = $this->createField($partUrl); + } + $result["fields"]["Part-DB URL"] = $this->createField($partUrl); + //Add basic fields $result["fields"]["description"] = $this->createField($part->getDescription()); if ($part->getCategory() !== null) { @@ -245,32 +265,7 @@ class KiCadHelper $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn()); } - // Add supplier information from orderdetails (include obsolete orderdetails) - if ($part->getOrderdetails(false)->count() > 0) { - $supplierCounts = []; - - foreach ($part->getOrderdetails(false) as $orderdetail) { - if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { - $supplierName = $orderdetail->getSupplier()->getName(); - - $supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number - - if (!isset($supplierCounts[$supplierName])) { - $supplierCounts[$supplierName] = 0; - } - $supplierCounts[$supplierName]++; - - // Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.) - $fieldName = $supplierCounts[$supplierName] > 1 - ? $supplierName . ' ' . $supplierCounts[$supplierName] - : $supplierName; - - $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); - } - } - } - - //Add fields for KiCost: + //Add KiCost manufacturer fields (always present, independent of orderdetails) if ($part->getManufacturer() !== null) { $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); } @@ -278,13 +273,74 @@ class KiCadHelper $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); } - //For each supplier, add a field with the supplier name and the supplier part number for KiCost - if ($part->getOrderdetails(false)->count() > 0) { - foreach ($part->getOrderdetails(false) as $orderdetail) { + // Add supplier information from orderdetails (include obsolete orderdetails) + // If any orderdetail has eda_visibility explicitly set to true, only export those; + // otherwise export all (backward compat when no flags are set) + $allOrderdetails = $part->getOrderdetails(false); + if ($allOrderdetails->count() > 0) { + $hasExplicitEdaVisibility = false; + foreach ($allOrderdetails as $od) { + if ($od->isEdaVisibility() !== null) { + $hasExplicitEdaVisibility = true; + break; + } + } + + $supplierCounts = []; + foreach ($allOrderdetails as $orderdetail) { if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { - $fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; + // When explicit flags exist, filter by resolved visibility + $resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->kiCadEDASettings->defaultOrderdetailsVisibility; + if ($hasExplicitEdaVisibility && !$resolvedVisibility) { + continue; + } + + $supplierName = $orderdetail->getSupplier()->getName() . ' SPN'; + + if (!isset($supplierCounts[$supplierName])) { + $supplierCounts[$supplierName] = 0; + } + $supplierCounts[$supplierName]++; + + // Create field name with sequential number if more than one from same supplier + $fieldName = $supplierCounts[$supplierName] > 1 + ? $supplierName . ' ' . $supplierCounts[$supplierName] + : $supplierName; $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); + + //Also add a KiCost-compatible field (supplier_name# = SPN) + $kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; + $result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr()); + } + } + } + + //Add stock quantity and storage locations (only count non-expired lots with known quantity) + $totalStock = 0; + $locations = []; + foreach ($part->getPartLots() as $lot) { + $isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true; + if ($isAvailable) { + $totalStock += $lot->getAmount(); + if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) { + $locations[] = $lot->getStorageLocation()->getName(); + } + } + } + $result['fields']['Stock'] = $this->createField($totalStock); + if ($locations !== []) { + $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations))); + } + + //Add parameters marked for EDA export (explicit true, or system default when null) + foreach ($part->getParameters() as $parameter) { + $paramVisibility = $parameter->isEdaVisibility() ?? $this->kiCadEDASettings->defaultParameterVisibility; + if ($paramVisibility && $parameter->getName() !== '') { + $fieldName = $parameter->getName(); + //Don't overwrite hardcoded fields + if (!isset($result['fields'][$fieldName])) { + $result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue()); } } } @@ -344,7 +400,7 @@ class KiCadHelper //If the user set a visibility, then use it if ($eda_info->getVisibility() !== null) { - return $part->getEdaInfo()->getVisibility(); + return $eda_info->getVisibility(); } //If the part has a category, then use the category visibility if possible @@ -395,4 +451,64 @@ class KiCadHelper 'visible' => $this->boolToKicadBool($visible), ]; } -} \ No newline at end of file + + /** + * 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 + ); + } +} diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index abf72d74..e6518687 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -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 diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 945cff7b..b0353e29 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -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) { diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php index 2ea66525..cb9a0a4f 100644 --- a/src/Settings/BehaviorSettings/PartTableColumns.php +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -51,6 +51,9 @@ enum PartTableColumns : string implements TranslatableInterface case GTIN = "gtin"; case TAGS = "tags"; case ATTACHMENTS = "attachments"; + + case EDA_STATUS = "eda_status"; + case EDIT = "edit"; public function trans(TranslatorInterface $translator, ?string $locale = null): string diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php index d8f1026d..cf31bd95 100644 --- a/src/Settings/MiscSettings/KiCadEDASettings.php +++ b/src/Settings/MiscSettings/KiCadEDASettings.php @@ -43,4 +43,23 @@ class KiCadEDASettings envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)] #[Assert\Range(min: -1)] public int $categoryDepth = 0; -} \ No newline at end of file + + #[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"), + description: new TM("settings.misc.kicad_eda.datasheet_link.help") + )] + public ?bool $datasheetAsPdf = true; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.default_parameter_visibility"), + description: new TM("settings.misc.kicad_eda.default_parameter_visibility.help"), + + )] + public bool $defaultParameterVisibility = false; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.default_orderdetails_visibility"), + description: new TM("settings.misc.kicad_eda.default_orderdetails_visibility.help"), + + )] + public bool $defaultOrderdetailsVisibility = false; +} diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index d7873498..90f8a3e1 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 00000000..b1ca533c --- /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 25b00133..6f631b9f 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 844c8700..9e989c92 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 %}