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

@ -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;
}