Address PR review: rename to eda_visibility, merge migrations, API versioning

Changes based on jbtronics' review of PR #1241:

- Rename kicad_export -> eda_visibility (entities, forms, templates,
  translations, tests) with nullable bool for system default support
- Merge two database migrations into one (Version20260211000000)
- Rename createCachedJsonResponse -> createCacheableJsonResponse
- Change bool $apiV2 -> int $apiVersion with version validation
- EDA visibility field only shown for part parameters, not other entities
- PopulateKicadCommand: check alternative names of footprints/categories
- PopulateKicadCommand: support external JSON mapping file (--mapping-file)
- Ship default mappings JSON at contrib/kicad-populate/default_mappings.json
- Add system-wide defaultEdaVisibility setting in KiCadEDASettings
- Add KiCad HTTP Library v2 spec link in controller docs
This commit is contained in:
Sebastian Almberg 2026-02-18 09:26:40 +01:00
parent 06c6542438
commit ae7e31f0bd
17 changed files with 532 additions and 177 deletions

View file

@ -0,0 +1,195 @@
{
"_comment": "Default KiCad footprint/symbol mappings for partdb:kicad:populate command. Based on KiCad 9.x standard libraries. Use --mapping-file to override or extend these mappings.",
"footprints": {
"SOT-23": "Package_TO_SOT_SMD:SOT-23",
"SOT-23-3": "Package_TO_SOT_SMD:SOT-23",
"SOT-23-5": "Package_TO_SOT_SMD:SOT-23-5",
"SOT-23-6": "Package_TO_SOT_SMD:SOT-23-6",
"SOT-223": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
"SOT-223-3": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
"SOT-89": "Package_TO_SOT_SMD:SOT-89-3",
"SOT-89-3": "Package_TO_SOT_SMD:SOT-89-3",
"SOT-323": "Package_TO_SOT_SMD:SOT-323_SC-70",
"SOT-363": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
"TSOT-25": "Package_TO_SOT_SMD:SOT-23-5",
"SC-70-5": "Package_TO_SOT_SMD:SOT-353_SC-70-5",
"SC-70-6": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
"TO-220": "Package_TO_SOT_THT:TO-220-3_Vertical",
"TO-220AB": "Package_TO_SOT_THT:TO-220-3_Vertical",
"TO-220AB-3": "Package_TO_SOT_THT:TO-220-3_Vertical",
"TO-220FP": "Package_TO_SOT_THT:TO-220F-3_Vertical",
"TO-247-3": "Package_TO_SOT_THT:TO-247-3_Vertical",
"TO-92": "Package_TO_SOT_THT:TO-92_Inline",
"TO-92-3": "Package_TO_SOT_THT:TO-92_Inline",
"TO-252": "Package_TO_SOT_SMD:TO-252-2",
"TO-252-2L": "Package_TO_SOT_SMD:TO-252-2",
"TO-252-3L": "Package_TO_SOT_SMD:TO-252-3",
"TO-263": "Package_TO_SOT_SMD:TO-263-2",
"TO-263-2": "Package_TO_SOT_SMD:TO-263-2",
"D2PAK": "Package_TO_SOT_SMD:TO-252-2",
"DPAK": "Package_TO_SOT_SMD:TO-252-2",
"SOIC-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
"ESOP-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
"SOIC-14": "Package_SO:SOIC-14_3.9x8.7mm_P1.27mm",
"SOIC-16": "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm",
"TSSOP-8": "Package_SO:TSSOP-8_3x3mm_P0.65mm",
"TSSOP-14": "Package_SO:TSSOP-14_4.4x5mm_P0.65mm",
"TSSOP-16": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
"TSSOP-16L": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
"TSSOP-20": "Package_SO:TSSOP-20_4.4x6.5mm_P0.65mm",
"MSOP-8": "Package_SO:MSOP-8_3x3mm_P0.65mm",
"MSOP-10": "Package_SO:MSOP-10_3x3mm_P0.5mm",
"MSOP-16": "Package_SO:MSOP-16_3x4mm_P0.5mm",
"SO-5": "Package_TO_SOT_SMD:SOT-23-5",
"DIP-4": "Package_DIP:DIP-4_W7.62mm",
"DIP-6": "Package_DIP:DIP-6_W7.62mm",
"DIP-8": "Package_DIP:DIP-8_W7.62mm",
"DIP-14": "Package_DIP:DIP-14_W7.62mm",
"DIP-16": "Package_DIP:DIP-16_W7.62mm",
"DIP-18": "Package_DIP:DIP-18_W7.62mm",
"DIP-20": "Package_DIP:DIP-20_W7.62mm",
"DIP-24": "Package_DIP:DIP-24_W7.62mm",
"DIP-28": "Package_DIP:DIP-28_W7.62mm",
"DIP-40": "Package_DIP:DIP-40_W15.24mm",
"QFN-8": "Package_DFN_QFN:QFN-8-1EP_3x3mm_P0.65mm_EP1.55x1.55mm",
"QFN-12(3x3)": "Package_DFN_QFN:QFN-12-1EP_3x3mm_P0.5mm_EP1.65x1.65mm",
"QFN-16": "Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.45x1.45mm",
"QFN-20": "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm",
"QFN-24": "Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm",
"QFN-32": "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm",
"QFN-48": "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm",
"TQFP-32": "Package_QFP:TQFP-32_7x7mm_P0.8mm",
"TQFP-44": "Package_QFP:TQFP-44_10x10mm_P0.8mm",
"TQFP-48": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
"TQFP-48(7x7)": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
"TQFP-64": "Package_QFP:TQFP-64_10x10mm_P0.5mm",
"TQFP-100": "Package_QFP:TQFP-100_14x14mm_P0.5mm",
"LQFP-32": "Package_QFP:LQFP-32_7x7mm_P0.8mm",
"LQFP-48": "Package_QFP:LQFP-48_7x7mm_P0.5mm",
"LQFP-64": "Package_QFP:LQFP-64_10x10mm_P0.5mm",
"LQFP-100": "Package_QFP:LQFP-100_14x14mm_P0.5mm",
"SOD-123": "Diode_SMD:D_SOD-123",
"SOD-123F": "Diode_SMD:D_SOD-123F",
"SOD-123FL": "Diode_SMD:D_SOD-123F",
"SOD-323": "Diode_SMD:D_SOD-323",
"SOD-523": "Diode_SMD:D_SOD-523",
"SOD-882": "Diode_SMD:D_SOD-882",
"SOD-882D": "Diode_SMD:D_SOD-882",
"SMA(DO-214AC)": "Diode_SMD:D_SMA",
"SMA": "Diode_SMD:D_SMA",
"SMB": "Diode_SMD:D_SMB",
"SMC": "Diode_SMD:D_SMC",
"DO-35": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
"DO-35(DO-204AH)": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
"DO-41": "Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal",
"DO-201": "Diode_THT:D_DO-201_P15.24mm_Horizontal",
"DFN-2(0.6x1)": "Package_DFN_QFN:DFN-2-1EP_0.6x1.0mm_P0.65mm_EP0.2x0.55mm",
"DFN1006-2": "Package_DFN_QFN:DFN-2_1.0x0.6mm",
"DFN-6": "Package_DFN_QFN:DFN-6-1EP_2x2mm_P0.65mm_EP1x1.6mm",
"DFN-8": "Package_DFN_QFN:DFN-8-1EP_3x2mm_P0.5mm_EP1.3x1.5mm",
"0201": "Resistor_SMD:R_0201_0603Metric",
"0402": "Resistor_SMD:R_0402_1005Metric",
"0603": "Resistor_SMD:R_0603_1608Metric",
"0805": "Resistor_SMD:R_0805_2012Metric",
"1206": "Resistor_SMD:R_1206_3216Metric",
"1210": "Resistor_SMD:R_1210_3225Metric",
"1812": "Resistor_SMD:R_1812_4532Metric",
"2010": "Resistor_SMD:R_2010_5025Metric",
"2512": "Resistor_SMD:R_2512_6332Metric",
"2917": "Resistor_SMD:R_2917_7343Metric",
"2920": "Resistor_SMD:R_2920_7350Metric",
"CASE-A-3216-18(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A",
"CASE-B-3528-21(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3528-21_Kemet-B",
"CASE-C-6032-28(mm)": "Capacitor_Tantalum_SMD:CP_EIA-6032-28_Kemet-C",
"CASE-D-7343-31(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-31_Kemet-D",
"CASE-E-7343-43(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-43_Kemet-E",
"SMD,D4xL5.4mm": "Capacitor_SMD:CP_Elec_4x5.4",
"SMD,D5xL5.4mm": "Capacitor_SMD:CP_Elec_5x5.4",
"SMD,D6.3xL5.4mm": "Capacitor_SMD:CP_Elec_6.3x5.4",
"SMD,D6.3xL7.7mm": "Capacitor_SMD:CP_Elec_6.3x7.7",
"SMD,D8xL6.5mm": "Capacitor_SMD:CP_Elec_8x6.5",
"SMD,D8xL10mm": "Capacitor_SMD:CP_Elec_8x10",
"SMD,D10xL10mm": "Capacitor_SMD:CP_Elec_10x10",
"SMD,D10xL10.5mm": "Capacitor_SMD:CP_Elec_10x10.5",
"Through Hole,D5xL11mm": "Capacitor_THT:CP_Radial_D5.0mm_P2.00mm",
"Through Hole,D6.3xL11mm": "Capacitor_THT:CP_Radial_D6.3mm_P2.50mm",
"Through Hole,D8xL11mm": "Capacitor_THT:CP_Radial_D8.0mm_P3.50mm",
"Through Hole,D10xL16mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
"Through Hole,D10xL20mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
"Through Hole,D12.5xL20mm": "Capacitor_THT:CP_Radial_D12.5mm_P5.00mm",
"LED 3mm": "LED_THT:LED_D3.0mm",
"LED 5mm": "LED_THT:LED_D5.0mm",
"LED 0603": "LED_SMD:LED_0603_1608Metric",
"LED 0805": "LED_SMD:LED_0805_2012Metric",
"SMD5050-4P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
"SMD5050-6P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
"HC-49": "Crystal:Crystal_HC49-4H_Vertical",
"HC-49/U": "Crystal:Crystal_HC49-4H_Vertical",
"HC-49/S": "Crystal:Crystal_HC49-U_Vertical",
"HC-49/US": "Crystal:Crystal_HC49-U_Vertical",
"USB-A": "Connector_USB:USB_A_Stewart_SS-52100-001_Horizontal",
"USB-B": "Connector_USB:USB_B_OST_USB-B1HSxx_Horizontal",
"USB-Mini-B": "Connector_USB:USB_Mini-B_Lumberg_2486_01_Horizontal",
"USB-Micro-B": "Connector_USB:USB_Micro-B_Molex-105017-0001",
"USB-C": "Connector_USB:USB_C_Receptacle_GCT_USB4085",
"1x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical",
"1x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical",
"1x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical",
"1x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_Vertical",
"1x6 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical",
"1x8 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical",
"1x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x10_P2.54mm_Vertical",
"2x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x02_P2.54mm_Vertical",
"2x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_Vertical",
"2x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x04_P2.54mm_Vertical",
"2x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical",
"2x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x10_P2.54mm_Vertical",
"2x20 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x20_P2.54mm_Vertical",
"SIP-3-2.54mm": "Package_SIP:SIP-3_P2.54mm",
"SIP-4-2.54mm": "Package_SIP:SIP-4_P2.54mm",
"SIP-5-2.54mm": "Package_SIP:SIP-5_P2.54mm"
},
"categories": {
"Electrolytic": "Device:C_Polarized",
"Polarized": "Device:C_Polarized",
"Tantalum": "Device:C_Polarized",
"Zener": "Device:D_Zener",
"Schottky": "Device:D_Schottky",
"TVS": "Device:D_TVS",
"LED": "Device:LED",
"NPN": "Device:Q_NPN_BCE",
"PNP": "Device:Q_PNP_BCE",
"N-MOSFET": "Device:Q_NMOS_GDS",
"NMOS": "Device:Q_NMOS_GDS",
"N-MOS": "Device:Q_NMOS_GDS",
"P-MOSFET": "Device:Q_PMOS_GDS",
"PMOS": "Device:Q_PMOS_GDS",
"P-MOS": "Device:Q_PMOS_GDS",
"MOSFET": "Device:Q_NMOS_GDS",
"JFET": "Device:Q_NJFET_DSG",
"Ferrite": "Device:Ferrite_Bead",
"Crystal": "Device:Crystal",
"Oscillator": "Oscillator:Oscillator_Crystal",
"Fuse": "Device:Fuse",
"Transformer": "Device:Transformer_1P_1S",
"Resistor": "Device:R",
"Capacitor": "Device:C",
"Inductor": "Device:L",
"Diode": "Device:D",
"Transistor": "Device:Q_NPN_BCE",
"Voltage Regulator": "Regulator_Linear:LM317_TO-220",
"LDO": "Regulator_Linear:AMS1117-3.3",
"Op-Amp": "Amplifier_Operational:LM358",
"Comparator": "Comparator:LM393",
"Optocoupler": "Isolator:PC817",
"Relay": "Relay:Relay_DPDT",
"Connector": "Connector:Conn_01x02",
"Switch": "Switch:SW_Push",
"Button": "Switch:SW_Push",
"Potentiometer": "Device:R_POT",
"Trimpot": "Device:R_POT",
"Thermistor": "Device:Thermistor",
"Varistor": "Device:Varistor",
"Photo": "Device:LED"
}
}

View file

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20260208190000 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add kicad_export boolean column to parameters table';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters ADD kicad_export TINYINT(1) NOT NULL DEFAULT 0');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters DROP COLUMN kicad_export');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters ADD COLUMN kicad_export BOOLEAN NOT NULL DEFAULT 0');
}
public function sqLiteDown(Schema $schema): void
{
// SQLite does not support DROP COLUMN in older versions; recreate table if needed
$this->addSql('ALTER TABLE parameters DROP COLUMN kicad_export');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters ADD kicad_export BOOLEAN NOT NULL DEFAULT FALSE');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters DROP COLUMN kicad_export');
}
}

View file

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20260210120000 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add kicad_export boolean column to orderdetails table';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE `orderdetails` ADD kicad_export TINYINT(1) NOT NULL DEFAULT 0');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE `orderdetails` DROP COLUMN kicad_export');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails ADD COLUMN kicad_export BOOLEAN NOT NULL DEFAULT 0');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails DROP COLUMN kicad_export');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails ADD kicad_export BOOLEAN NOT NULL DEFAULT FALSE');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE orderdetails DROP COLUMN kicad_export');
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20260211000000 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add eda_visibility nullable boolean column to parameters and orderdetails tables';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters ADD eda_visibility TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE `orderdetails` ADD eda_visibility TINYINT(1) DEFAULT NULL');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
$this->addSql('ALTER TABLE `orderdetails` DROP COLUMN eda_visibility');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters ADD COLUMN eda_visibility BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE orderdetails ADD COLUMN eda_visibility BOOLEAN DEFAULT NULL');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
$this->addSql('ALTER TABLE orderdetails DROP COLUMN eda_visibility');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters ADD eda_visibility BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE orderdetails ADD eda_visibility BOOLEAN DEFAULT NULL');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
$this->addSql('ALTER TABLE orderdetails DROP COLUMN eda_visibility');
}
}

View file

@ -32,6 +32,7 @@ class PopulateKicadCommand extends Command
->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)')
;
}
@ -43,6 +44,7 @@ class PopulateKicadCommand extends Command
$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;
@ -53,6 +55,26 @@ class PopulateKicadCommand extends Command
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');
}
@ -60,12 +82,12 @@ class PopulateKicadCommand extends Command
$totalUpdated = 0;
if ($doFootprints) {
$updated = $this->updateFootprints($io, $dryRun, $force);
$updated = $this->updateFootprints($io, $dryRun, $force, $footprintMappings);
$totalUpdated += $updated;
}
if ($doCategories) {
$updated = $this->updateCategories($io, $dryRun, $force);
$updated = $this->updateCategories($io, $dryRun, $force, $categoryMappings);
$totalUpdated += $updated;
}
@ -120,12 +142,10 @@ class PopulateKicadCommand extends Command
$io->table(['ID', 'Name', 'KiCad Symbol'], $rows);
}
private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force): int
private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
{
$io->section('Updating Footprint Entities');
$mappings = $this->getFootprintMappings();
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
/** @var Footprint[] $footprints */
$footprints = $footprintRepo->findAll();
@ -142,13 +162,14 @@ class PopulateKicadCommand extends Command
continue;
}
// Check for exact match first
if (isset($mappings[$name])) {
$newValue = $mappings[$name];
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $newValue));
// 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($newValue);
$footprint->getEdaInfo()->setKicadFootprint($matchedValue);
}
$updated++;
} else {
@ -170,12 +191,10 @@ class PopulateKicadCommand extends Command
return $updated;
}
private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force): int
private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
{
$io->section('Updating Category Entities');
$mappings = $this->getCategoryMappings();
$categoryRepo = $this->entityManager->getRepository(Category::class);
/** @var Category[] $categories */
$categories = $categoryRepo->findAll();
@ -192,22 +211,17 @@ class PopulateKicadCommand extends Command
continue;
}
// Check for matches using the pattern-based mappings
$matched = false;
foreach ($mappings as $pattern => $kicadSymbol) {
if ($this->matchesPattern($name, $pattern)) {
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $kicadSymbol));
// Check for matches using the pattern-based mappings (also check alternative names)
$matchedValue = $this->findCategoryMapping($mappings, $name, $category->getAlternativeNames());
if (!$dryRun) {
$category->getEdaInfo()->setKicadSymbol($kicadSymbol);
}
$updated++;
$matched = true;
break;
if ($matchedValue !== null) {
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue));
if (!$dryRun) {
$category->getEdaInfo()->setKicadSymbol($matchedValue);
}
}
if (!$matched) {
$updated++;
} else {
$skipped[] = $name;
}
}
@ -225,6 +239,34 @@ class PopulateKicadCommand extends Command
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
@ -240,6 +282,71 @@ class PopulateKicadCommand extends Command
return false;
}
/**
* Finds a footprint mapping by checking the entity name and its alternative names.
* Footprints use exact matching.
*
* @param array<string, string> $mappings
* @param string $name The primary name of the footprint
* @param string|null $alternativeNames Comma-separated alternative names
* @return string|null The matched KiCad path, or null if no match found
*/
private function findFootprintMapping(array $mappings, string $name, ?string $alternativeNames): ?string
{
// Check primary name
if (isset($mappings[$name])) {
return $mappings[$name];
}
// Check alternative names
if ($alternativeNames !== null && $alternativeNames !== '') {
foreach (explode(',', $alternativeNames) as $altName) {
$altName = trim($altName);
if ($altName !== '' && isset($mappings[$altName])) {
return $mappings[$altName];
}
}
}
return null;
}
/**
* Finds a category mapping by checking the entity name and its alternative names.
* Categories use pattern-based matching (case-insensitive contains).
*
* @param array<string, string> $mappings
* @param string $name The primary name of the category
* @param string|null $alternativeNames Comma-separated alternative names
* @return string|null The matched KiCad symbol path, or null if no match found
*/
private function findCategoryMapping(array $mappings, string $name, ?string $alternativeNames): ?string
{
// Check primary name against all patterns
foreach ($mappings as $pattern => $kicadSymbol) {
if ($this->matchesPattern($name, $pattern)) {
return $kicadSymbol;
}
}
// Check alternative names against all patterns
if ($alternativeNames !== null && $alternativeNames !== '') {
foreach (explode(',', $alternativeNames) as $altName) {
$altName = trim($altName);
if ($altName === '') {
continue;
}
foreach ($mappings as $pattern => $kicadSymbol) {
if ($this->matchesPattern($altName, $pattern)) {
return $kicadSymbol;
}
}
}
}
return null;
}
/**
* Returns footprint name to KiCad footprint path mappings.
* These are based on KiCad 9.x standard library paths.
@ -496,4 +603,37 @@ class PopulateKicadCommand extends Command
'Photo' => 'Device:LED', // Photodiode/phototransistor
];
}
/**
* Load a custom mapping file (JSON format).
*
* Expected format:
* {
* "footprints": { "SOT-23": "Package_TO_SOT_SMD:SOT-23", ... },
* "categories": { "Resistor": "Device:R", ... }
* }
*
* @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', json_last_error_msg()));
return null;
}
return $data;
}
}

View file

@ -62,7 +62,7 @@ class KiCadApiController extends AbstractController
$this->denyAccessUnlessGranted('@categories.read');
$data = $this->kiCADHelper->getCategories();
return $this->createCachedJsonResponse($request, $data, 300);
return $this->createCacheableJsonResponse($request, $data, 300);
}
#[Route('/parts/category/{category}.json', name: 'kicad_api_category')]
@ -77,7 +77,7 @@ class KiCadApiController extends AbstractController
$minimal = $request->query->getBoolean('minimal', false);
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
return $this->createCachedJsonResponse($request, $data, 300);
return $this->createCacheableJsonResponse($request, $data, 300);
}
#[Route('/parts/{part}.json', name: 'kicad_api_part')]
@ -86,14 +86,14 @@ class KiCadApiController extends AbstractController
$this->denyAccessUnlessGranted('read', $part);
$data = $this->kiCADHelper->getKiCADPart($part);
return $this->createCachedJsonResponse($request, $data, 60);
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 createCachedJsonResponse(Request $request, array $data, int $maxAge): Response
private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
{
$response = new JsonResponse($data);
$response->setEtag(md5(json_encode($data)));

View file

@ -34,6 +34,9 @@ use Symfony\Component\Routing\Attribute\Route;
/**
* 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)
* - Category descriptions: Uses actual category comments instead of URLs
@ -64,7 +67,7 @@ class KiCadApiV2Controller extends AbstractController
$this->denyAccessUnlessGranted('@categories.read');
$data = $this->kiCADHelper->getCategories();
return $this->createCachedJsonResponse($request, $data, 300);
return $this->createCacheableJsonResponse($request, $data, 300);
}
#[Route('/parts/category/{category}.json', name: 'kicad_api_v2_category')]
@ -79,7 +82,7 @@ class KiCadApiV2Controller extends AbstractController
$minimal = $request->query->getBoolean('minimal', false);
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
return $this->createCachedJsonResponse($request, $data, 300);
return $this->createCacheableJsonResponse($request, $data, 300);
}
#[Route('/parts/{part}.json', name: 'kicad_api_v2_part')]
@ -88,11 +91,11 @@ class KiCadApiV2Controller extends AbstractController
$this->denyAccessUnlessGranted('read', $part);
// Use API v2 format with volatile fields
$data = $this->kiCADHelper->getKiCADPart($part, true);
return $this->createCachedJsonResponse($request, $data, 60);
$data = $this->kiCADHelper->getKiCADPart($part, 2);
return $this->createCacheableJsonResponse($request, $data, 60);
}
private function createCachedJsonResponse(Request $request, array $data, int $maxAge): Response
private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
{
$response = new JsonResponse($data);
$response->setEtag(md5(json_encode($data)));

View file

@ -173,11 +173,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
protected string $group = '';
/**
* @var bool Whether this parameter should be exported as a KiCad field in the EDA HTTP library API
* @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)]
protected bool $kicad_export = false;
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
protected ?bool $eda_visibility = null;
/**
* Mapping is done in subclasses.
@ -478,17 +478,17 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
return static::ALLOWED_ELEMENT_CLASS;
}
public function isKicadExport(): bool
public function isEdaVisibility(): ?bool
{
return $this->kicad_export;
return $this->eda_visibility;
}
/**
* @return $this
*/
public function setKicadExport(bool $kicad_export): self
public function setEdaVisibility(?bool $eda_visibility): self
{
$this->kicad_export = $kicad_export;
$this->eda_visibility = $eda_visibility;
return $this;
}

View file

@ -123,11 +123,11 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
protected bool $obsolete = false;
/**
* @var bool Whether this orderdetail's supplier part number should be exported as a KiCad field
* @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, options: ['default' => false])]
protected bool $kicad_export = false;
#[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
@ -425,17 +425,17 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this;
}
public function isKicadExport(): bool
public function isEdaVisibility(): ?bool
{
return $this->kicad_export;
return $this->eda_visibility;
}
/**
* @return $this
*/
public function setKicadExport(bool $kicad_export): self
public function setEdaVisibility(?bool $eda_visibility): self
{
$this->kicad_export = $kicad_export;
$this->eda_visibility = $eda_visibility;
return $this;
}

View file

@ -149,10 +149,13 @@ class ParameterType extends AbstractType
],
]);
$builder->add('kicad_export', CheckboxType::class, [
'label' => false,
'required' => false,
]);
// Only show the EDA visibility field for part parameters, as it has no function for other entities
if ($options['data_class'] === PartParameter::class) {
$builder->add('eda_visibility', CheckboxType::class, [
'label' => false,
'required' => false,
]);
}
}
public function finishView(FormView $view, FormInterface $form, array $options): void

View file

@ -79,9 +79,9 @@ class OrderdetailType extends AbstractType
'label' => 'orderdetails.edit.prices_includes_vat',
]);
$builder->add('kicad_export', CheckboxType::class, [
$builder->add('eda_visibility', CheckboxType::class, [
'required' => false,
'label' => 'orderdetails.edit.kicad_export',
'label' => 'orderdetails.edit.eda_visibility',
]);
//Add pricedetails after we know the data, so we can set the default currency

View file

@ -47,6 +47,9 @@ class KiCadHelper
/** @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,
@ -59,6 +62,7 @@ class KiCadHelper
) {
$this->category_depth = $kiCadEDASettings->categoryDepth;
$this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
$this->defaultEdaVisibility = $kiCadEDASettings->defaultEdaVisibility;
}
/**
@ -194,10 +198,14 @@ class KiCadHelper
}
/**
* @param bool $apiV2 If true, use API v2 format with volatile field support
* @param int $apiVersion The API version to use (1 or 2). Version 2 adds volatile field support.
*/
public function getKiCADPart(Part $part, bool $apiV2 = false): array
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(),
@ -277,13 +285,14 @@ class KiCadHelper
}
// Add supplier information from orderdetails (include obsolete orderdetails)
// If any orderdetail has kicad_export=true, only export those; otherwise export all (backward compat)
// 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) {
$hasKicadExportFlag = false;
$hasExplicitEdaVisibility = false;
foreach ($allOrderdetails as $od) {
if ($od->isKicadExport()) {
$hasKicadExportFlag = true;
if ($od->isEdaVisibility() !== null) {
$hasExplicitEdaVisibility = true;
break;
}
}
@ -291,8 +300,9 @@ class KiCadHelper
$supplierCounts = [];
foreach ($allOrderdetails as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
// Skip orderdetails not marked for export when the flag is used
if ($hasKicadExportFlag && !$orderdetail->isKicadExport()) {
// When explicit flags exist, filter by resolved visibility
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->defaultEdaVisibility;
if ($hasExplicitEdaVisibility && !$resolvedVisibility) {
continue;
}
@ -330,14 +340,15 @@ class KiCadHelper
}
}
// In API v2, stock and location are volatile (shown but not saved to schematic)
$result['fields']['Stock'] = $this->createField($totalStock, false, $apiV2);
$result['fields']['Stock'] = $this->createField($totalStock, false, $apiVersion >= 2);
if ($locations !== []) {
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiV2);
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiVersion >= 2);
}
//Add parameters marked for KiCad export
//Add parameters marked for EDA export (explicit true, or system default when null)
foreach ($part->getParameters() as $parameter) {
if ($parameter->isKicadExport() && $parameter->getName() !== '') {
$paramVisibility = $parameter->isEdaVisibility() ?? $this->defaultEdaVisibility;
if ($paramVisibility && $parameter->getName() !== '') {
$fieldName = $parameter->getName();
//Don't overwrite hardcoded fields
if (!isset($result['fields'][$fieldName])) {

View file

@ -48,4 +48,9 @@ class KiCadEDASettings
description: new TM("settings.misc.kicad_eda.datasheet_link.help"),
envVar: "bool:EDA_KICAD_DATASHEET_AS_PDF", envVarMode: EnvVarMode::OVERWRITE)]
public ?bool $datasheetAsPdf = true;
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.default_eda_visibility"),
description: new TM("settings.misc.kicad_eda.default_eda_visibility.help"),
envVar: "bool:EDA_KICAD_DEFAULT_VISIBILITY", envVarMode: EnvVarMode::OVERWRITE)]
public bool $defaultEdaVisibility = false;
}

View file

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

View file

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

View file

@ -362,9 +362,9 @@ final class KiCadHelperTest extends KernelTestCase
}
/**
* Test that a parameter with kicad_export=true appears in the KiCad fields.
* Test that a parameter with eda_visibility=true appears in the KiCad fields.
*/
public function testParameterWithKicadExportAppearsInFields(): void
public function testParameterWithEdaVisibilityAppearsInFields(): void
{
$category = $this->em->find(Category::class, 1);
@ -376,7 +376,7 @@ final class KiCadHelperTest extends KernelTestCase
$param->setName('Voltage Rating');
$param->setValueTypical(3.3);
$param->setUnit('V');
$param->setKicadExport(true);
$param->setEdaVisibility(true);
$part->addParameter($param);
$this->em->persist($part);
@ -389,9 +389,9 @@ final class KiCadHelperTest extends KernelTestCase
}
/**
* Test that a parameter with kicad_export=false does NOT appear in the KiCad fields.
* Test that a parameter with eda_visibility=false does NOT appear in the KiCad fields.
*/
public function testParameterWithoutKicadExportDoesNotAppear(): void
public function testParameterWithoutEdaVisibilityDoesNotAppear(): void
{
$category = $this->em->find(Category::class, 1);
@ -402,7 +402,7 @@ final class KiCadHelperTest extends KernelTestCase
$param = new PartParameter();
$param->setName('Internal Note');
$param->setValueText('for testing only');
$param->setKicadExport(false);
$param->setEdaVisibility(false);
$part->addParameter($param);
$this->em->persist($part);
@ -413,6 +413,31 @@ final class KiCadHelperTest extends KernelTestCase
self::assertArrayNotHasKey('Internal Note', $result['fields']);
}
/**
* Test that a parameter with eda_visibility=null (system default) does NOT appear in the KiCad fields.
*/
public function testParameterWithNullEdaVisibilityDoesNotAppear(): void
{
$category = $this->em->find(Category::class, 1);
$part = new Part();
$part->setName('Part with Default Parameter');
$part->setCategory($category);
$param = new PartParameter();
$param->setName('Default Param');
$param->setValueText('some value');
// eda_visibility is null by default
$part->addParameter($param);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
self::assertArrayNotHasKey('Default Param', $result['fields']);
}
/**
* Test that an exported parameter named "description" does NOT overwrite the hardcoded description field.
*/
@ -428,7 +453,7 @@ final class KiCadHelperTest extends KernelTestCase
$param = new PartParameter();
$param->setName('description');
$param->setValueText('should not overwrite');
$param->setKicadExport(true);
$param->setEdaVisibility(true);
$part->addParameter($param);
$this->em->persist($part);

View file

@ -642,10 +642,10 @@ Sub elements will be moved upwards.</target>
<target>Group</target>
</segment>
</unit>
<unit id="kicadExportHelp" name="specifications.kicad_export.help">
<unit id="edaVisibilityHelp" name="specifications.eda_visibility.help">
<segment state="translated">
<source>specifications.kicad_export.help</source>
<target>Export this parameter as a KiCad field</target>
<source>specifications.eda_visibility.help</source>
<target>Export this parameter as an EDA field</target>
</segment>
</unit>
<unit id="XclPxI9" name="specification.create">
@ -3308,10 +3308,10 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
<target>No longer available</target>
</segment>
</unit>
<unit id="kicad_od_export" name="orderdetails.edit.kicad_export">
<unit id="eda_od_visibility" name="orderdetails.edit.eda_visibility">
<segment state="translated">
<source>orderdetails.edit.kicad_export</source>
<target>Export to KiCad</target>
<source>orderdetails.edit.eda_visibility</source>
<target>EDA visibility</target>
</segment>
</unit>
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
@ -10005,6 +10005,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>When enabled, the datasheet field in KiCad will link to the actual PDF file (if found). When disabled, it will link to the Part-DB page instead. The Part-DB page link is always available as a separate "Part-DB URL" field.</target>
</segment>
</unit>
<unit id="eda_default_vis" name="settings.misc.kicad_eda.default_eda_visibility">
<segment state="translated">
<source>settings.misc.kicad_eda.default_eda_visibility</source>
<target>Default EDA visibility</target>
</segment>
</unit>
<unit id="eda_default_vis_help" name="settings.misc.kicad_eda.default_eda_visibility.help">
<segment state="translated">
<source>settings.misc.kicad_eda.default_eda_visibility.help</source>
<target>Default EDA visibility for parameters and orderdetails that have no explicit value set. When enabled, all parameters and supplier part numbers will be exported to EDA by default.</target>
</segment>
</unit>
<unit id="VwvmcWE" name="settings.behavior.sidebar">
<segment state="translated">
<source>settings.behavior.sidebar</source>