From 9831db30c83df38ff13c84e13292968258e81f57 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Wed, 11 Feb 2026 00:29:34 +0100
Subject: [PATCH] Add KiCad API v2, orderdetail export control, EDA status
indicator, BOM improvements
- Add KiCad API v2 endpoints (/kicad-api/v2) with volatile field support
for stock and storage location (shown but not saved to schematic)
- Add kicad_export flag to Orderdetail entity for per-supplier SPN control
(backward compatible: if no flag set, all SPNs exported as before)
- Add EDA completeness indicator column in parts datatable (bolt icon)
- Add ?minimal=true query param for faster category parts loading
- Improve category descriptions (use comment instead of URL when available)
- Improve BOM importer multi-footprint support: merge entries by Part-DB
part ID when linked, tracking footprint variants in comments
- Fix KiCost manf/manf# fields always present (not conditional on orderdetails)
- Fix duplicate getEdaInfo() call in shouldPartBeVisible
- Consolidate supplier SPN and KiCost field generation into single loop
---
migrations/Version20260210120000.php | 46 ++++++++
src/Controller/KiCadApiController.php | 3 +-
src/Controller/KiCadApiV2Controller.php | 104 ++++++++++++++++++
.../Helpers/PartDataTableHelper.php | 53 +++++++++
src/DataTables/PartsDataTable.php | 5 +
src/Entity/PriceInformations/Orderdetail.php | 22 ++++
src/Form/Part/OrderdetailType.php | 5 +
src/Services/EDA/KiCadHelper.php | 103 ++++++++++-------
.../ImportExportSystem/BOMImporter.php | 20 +++-
.../parts/edit/edit_form_styles.html.twig | 1 +
translations/messages.en.xlf | 42 +++++++
11 files changed, 359 insertions(+), 45 deletions(-)
create mode 100644 migrations/Version20260210120000.php
create mode 100644 src/Controller/KiCadApiV2Controller.php
diff --git a/migrations/Version20260210120000.php b/migrations/Version20260210120000.php
new file mode 100644
index 00000000..04684a36
--- /dev/null
+++ b/migrations/Version20260210120000.php
@@ -0,0 +1,46 @@
+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');
+ }
+}
diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php
index 70ba7786..ea93138c 100644
--- a/src/Controller/KiCadApiController.php
+++ b/src/Controller/KiCadApiController.php
@@ -75,7 +75,8 @@ class KiCadApiController extends AbstractController
}
$this->denyAccessUnlessGranted('@parts.read');
- $data = $this->kiCADHelper->getCategoryParts($category);
+ $minimal = $request->query->getBoolean('minimal', false);
+ $data = $this->kiCADHelper->getCategoryParts($category, $minimal);
return $this->createCachedJsonResponse($request, $data, 300);
}
diff --git a/src/Controller/KiCadApiV2Controller.php b/src/Controller/KiCadApiV2Controller.php
new file mode 100644
index 00000000..5332ccd8
--- /dev/null
+++ b/src/Controller/KiCadApiV2Controller.php
@@ -0,0 +1,104 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Part;
+use App\Services\EDA\KiCadHelper;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+/**
+ * KiCad HTTP Library API v2 controller.
+ *
+ * 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
+ * - Response format: Arrays wrapped in objects for extensibility
+ */
+#[Route('/kicad-api/v2')]
+class KiCadApiV2Controller extends AbstractController
+{
+ public function __construct(
+ private readonly KiCadHelper $kiCADHelper,
+ ) {
+ }
+
+ #[Route('/', name: 'kicad_api_v2_root')]
+ public function root(): Response
+ {
+ $this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
+
+ return $this->json([
+ 'categories' => '',
+ 'parts' => '',
+ ]);
+ }
+
+ #[Route('/categories.json', name: 'kicad_api_v2_categories')]
+ public function categories(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('@categories.read');
+
+ $data = $this->kiCADHelper->getCategories();
+ return $this->createCachedJsonResponse($request, $data, 300);
+ }
+
+ #[Route('/parts/category/{category}.json', name: 'kicad_api_v2_category')]
+ public function categoryParts(Request $request, ?Category $category): Response
+ {
+ if ($category !== null) {
+ $this->denyAccessUnlessGranted('read', $category);
+ } else {
+ $this->denyAccessUnlessGranted('@categories.read');
+ }
+ $this->denyAccessUnlessGranted('@parts.read');
+
+ $minimal = $request->query->getBoolean('minimal', false);
+ $data = $this->kiCADHelper->getCategoryParts($category, $minimal);
+ return $this->createCachedJsonResponse($request, $data, 300);
+ }
+
+ #[Route('/parts/{part}.json', name: 'kicad_api_v2_part')]
+ public function partDetails(Request $request, Part $part): Response
+ {
+ $this->denyAccessUnlessGranted('read', $part);
+
+ // Use API v2 format with volatile fields
+ $data = $this->kiCADHelper->getKiCADPart($part, true);
+ return $this->createCachedJsonResponse($request, $data, 60);
+ }
+
+ private function createCachedJsonResponse(Request $request, array $data, int $maxAge): Response
+ {
+ $response = new JsonResponse($data);
+ $response->setEtag(md5(json_encode($data)));
+ $response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
+ $response->isNotModified($request);
+
+ return $response;
+ }
+}
diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php
index c33c3a82..ca9687eb 100644
--- a/src/DataTables/Helpers/PartDataTableHelper.php
+++ b/src/DataTables/Helpers/PartDataTableHelper.php
@@ -115,6 +115,59 @@ 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'));
+
+ return $statusIcon;
+ }
+
public function renderAmount(Part $context): string
{
$amount = $context->getAmountSum();
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index d2faba76..84f1eadd 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -228,6 +228,11 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('attachments', PartAttachmentsColumn::class, [
'label' => $this->translator->trans('part.table.attachments'),
+ ])
+ ->add('eda_status', TextColumn::class, [
+ 'label' => $this->translator->trans('part.table.eda_status'),
+ 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
+ 'className' => 'text-center',
]);
//Add a column to list the projects where the part is used, when the user has the permission to see the projects
diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php
index 58f69598..1584b3b0 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 Whether this orderdetail's supplier part number should be exported as a KiCad field
+ */
+ #[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
+ protected bool $kicad_export = false;
+
/**
* @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 isKicadExport(): bool
+ {
+ return $this->kicad_export;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setKicadExport(bool $kicad_export): self
+ {
+ $this->kicad_export = $kicad_export;
+
+ return $this;
+ }
+
public function getName(): string
{
return $this->getSupplierPartNr();
diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php
index ca295c7e..d875f9e7 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('kicad_export', CheckboxType::class, [
+ 'required' => false,
+ 'label' => 'orderdetails.edit.kicad_export',
+ ]);
+
//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/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 1617e886..882152dd 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -116,11 +116,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,
];
}
@@ -132,11 +137,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),
@@ -182,7 +189,10 @@ class KiCadHelper
});
}
- public function getKiCADPart(Part $part): array
+ /**
+ * @param bool $apiV2 If true, use API v2 format with volatile field support
+ */
+ public function getKiCADPart(Part $part, bool $apiV2 = false): array
{
$result = [
'id' => (string)$part->getId(),
@@ -250,32 +260,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());
}
@@ -283,13 +268,43 @@ 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 kicad_export=true, only export those; otherwise export all (backward compat)
+ $allOrderdetails = $part->getOrderdetails(false);
+ if ($allOrderdetails->count() > 0) {
+ $hasKicadExportFlag = false;
+ foreach ($allOrderdetails as $od) {
+ if ($od->isKicadExport()) {
+ $hasKicadExportFlag = true;
+ break;
+ }
+ }
+
+ $supplierCounts = [];
+ foreach ($allOrderdetails as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
- $fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
+ // Skip orderdetails not marked for export when the flag is used
+ if ($hasKicadExportFlag && !$orderdetail->isKicadExport()) {
+ 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());
}
}
}
@@ -306,9 +321,10 @@ class KiCadHelper
}
}
}
- $result['fields']['Stock'] = $this->createField($totalStock);
+ // In API v2, stock and location are volatile (shown but not saved to schematic)
+ $result['fields']['Stock'] = $this->createField($totalStock, false, $apiV2);
if ($locations !== []) {
- $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
+ $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiV2);
}
//Add parameters marked for KiCad export
@@ -377,7 +393,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
@@ -419,14 +435,21 @@ class KiCadHelper
* Creates a field array for KiCAD
* @param string|int|float $value
* @param bool $visible
+ * @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic
* @return array
*/
- private function createField(string|int|float $value, bool $visible = false): array
+ private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array
{
- return [
+ $field = [
'value' => (string)$value,
'visible' => $this->boolToKicadBool($visible),
];
+
+ if ($volatile) {
+ $field['volatile'] = $this->boolToKicadBool(true);
+ }
+
+ return $field;
}
/**
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/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig
index 5376f754..6564bc55 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.kicad_export) }}