From cc77007b49dad57dcb42dec79586056f66b499f3 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Sun, 8 Feb 2026 09:57:10 +0100
Subject: [PATCH 01/27] Add stock quantity, datasheet URL, and HTTP caching to
KiCad API
- Add Stock field showing total available quantity across all part lots
- Add Storage Location field when parts have stored locations
- Resolve actual datasheet PDF from attachments (by type name, attachment
name, or first PDF) instead of always linking to Part-DB page
- Keep Part-DB page URL as separate "Part-DB URL" field
- Add ETag and Cache-Control headers to all KiCad API endpoints
- Support conditional requests (If-None-Match) returning 304
- Categories/part lists cached 5 min, part details cached 1 min
---
src/Controller/KiCadApiController.php | 36 +++++++--
src/Services/EDA/KiCadHelper.php | 86 +++++++++++++++++++--
tests/Controller/KiCadApiControllerTest.php | 47 +++++++++++
3 files changed, 157 insertions(+), 12 deletions(-)
diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php
index c28e87a6..2cfa9b0e 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->createCachedJsonResponse($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,35 @@ class KiCadApiController extends AbstractController
}
$this->denyAccessUnlessGranted('@parts.read');
- return $this->json($this->kiCADHelper->getCategoryParts($category));
+ $data = $this->kiCADHelper->getCategoryParts($category);
+ return $this->createCachedJsonResponse($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->createCachedJsonResponse($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): JsonResponse
+ {
+ $etag = '"' . md5(json_encode($data)) . '"';
+
+ if ($request->headers->get('If-None-Match') === $etag) {
+ return new JsonResponse(null, Response::HTTP_NOT_MODIFIED);
+ }
+
+ $response = new JsonResponse($data);
+ $response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
+ $response->headers->set('ETag', $etag);
+
+ return $response;
}
}
\ No newline at end of file
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 3a613fe7..931427ba 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;
@@ -198,14 +199,18 @@ 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 (by type name, attachment name, or PDF extension)
+ $datasheetUrl = $this->findDatasheetUrl($part);
+ $result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
+ $result["fields"]["Part-DB URL"] = $this->createField($partUrl);
+
//Add basic fields
$result["fields"]["description"] = $this->createField($part->getDescription());
if ($part->getCategory() !== null) {
@@ -289,6 +294,22 @@ class KiCadHelper
}
}
+ //Add stock quantity and storage locations
+ $totalStock = 0;
+ $locations = [];
+ foreach ($part->getPartLots() as $lot) {
+ if (!$lot->isInstockUnknown() && $lot->isExpired() !== true) {
+ $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)));
+ }
+
return $result;
}
@@ -395,4 +416,57 @@ class KiCadHelper
'visible' => $this->boolToKicadBool($visible),
];
}
+
+ /**
+ * 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
+ if ($firstPdf === null && $attachment->getExtension() === 'pdf') {
+ $firstPdf = $attachment;
+ }
+ }
+
+ //Use first PDF attachment as fallback
+ if ($firstPdf !== null) {
+ return $this->getAttachmentUrl($firstPdf);
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns an absolute URL for viewing the given attachment.
+ * Prefers the external URL (direct link) over the internal view route.
+ */
+ private function getAttachmentUrl(Attachment $attachment): string
+ {
+ if ($attachment->hasExternal()) {
+ return $attachment->getExternalPath();
+ }
+
+ return $this->urlGenerator->generate(
+ 'attachment_view',
+ ['id' => $attachment->getId()],
+ UrlGeneratorInterface::ABSOLUTE_URL
+ );
+ }
}
\ No newline at end of file
diff --git a/tests/Controller/KiCadApiControllerTest.php b/tests/Controller/KiCadApiControllerTest.php
index 9d33512a..8877cf74 100644
--- a/tests/Controller/KiCadApiControllerTest.php
+++ b/tests/Controller/KiCadApiControllerTest.php
@@ -148,6 +148,11 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => 'http://localhost/en/part/1/info',
'visible' => 'False',
),
+ 'Part-DB URL' =>
+ array(
+ 'value' => 'http://localhost/en/part/1/info',
+ 'visible' => 'False',
+ ),
'description' =>
array(
'value' => '',
@@ -168,6 +173,11 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => '1',
'visible' => 'False',
),
+ 'Stock' =>
+ array(
+ 'value' => '0',
+ 'visible' => 'False',
+ ),
),
);
@@ -221,6 +231,11 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => 'http://localhost/en/part/1/info',
'visible' => 'False',
),
+ 'Part-DB URL' =>
+ array (
+ 'value' => 'http://localhost/en/part/1/info',
+ 'visible' => 'False',
+ ),
'description' =>
array (
'value' => '',
@@ -241,10 +256,42 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => '1',
'visible' => 'False',
),
+ 'Stock' =>
+ array (
+ 'value' => '0',
+ 'visible' => 'False',
+ ),
),
);
self::assertEquals($expected, $data);
}
+ public function testCategoriesHasCacheHeaders(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/categories.json');
+
+ self::assertResponseIsSuccessful();
+ $response = $client->getResponse();
+ self::assertNotNull($response->headers->get('ETag'));
+ self::assertStringContainsString('max-age=', $response->headers->get('Cache-Control'));
+ }
+
+ public function testConditionalRequestReturns304(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/categories.json');
+
+ $etag = $client->getResponse()->headers->get('ETag');
+ self::assertNotNull($etag);
+
+ //Make a conditional request with the ETag
+ $client->request('GET', self::BASE_URL.'/categories.json', [], [], [
+ 'HTTP_IF_NONE_MATCH' => $etag,
+ ]);
+
+ self::assertResponseStatusCodeSame(304);
+ }
+
}
\ No newline at end of file
From 6422fa62d10c2fac59a24d143c83d3a872fc9b16 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Sun, 8 Feb 2026 10:37:37 +0100
Subject: [PATCH 02/27] Add KiCadHelper unit tests and fix PDF detection for
external URLs
- Add comprehensive KiCadHelperTest with 14 test cases covering:
- Stock quantity calculation (zero, single lot, multiple lots)
- Stock exclusion of expired and unknown-quantity lots
- Storage location display (present, absent, multiple)
- Datasheet URL resolution by type name, attachment name, PDF extension
- Datasheet fallback to Part-DB URL when no match
- "Data sheet" (with space) name variant matching
- Fix PDF extension detection for external attachments (getExtension()
returns null for external-only attachments, now also parses URL path)
---
src/Services/EDA/KiCadHelper.php | 13 +-
tests/Services/EDA/KiCadHelperTest.php | 362 +++++++++++++++++++++++++
2 files changed, 372 insertions(+), 3 deletions(-)
create mode 100644 tests/Services/EDA/KiCadHelperTest.php
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 931427ba..48af4219 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -439,9 +439,16 @@ class KiCadHelper
return $this->getAttachmentUrl($attachment);
}
- //Track first PDF as fallback
- if ($firstPdf === null && $attachment->getExtension() === 'pdf') {
- $firstPdf = $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 = strtolower(pathinfo($urlPath, PATHINFO_EXTENSION));
+ }
+ if ($extension === 'pdf') {
+ $firstPdf = $attachment;
+ }
}
}
diff --git a/tests/Services/EDA/KiCadHelperTest.php b/tests/Services/EDA/KiCadHelperTest.php
new file mode 100644
index 00000000..a2dbe68a
--- /dev/null
+++ b/tests/Services/EDA/KiCadHelperTest.php
@@ -0,0 +1,362 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Services\EDA;
+
+use App\Entity\Attachments\AttachmentType;
+use App\Entity\Attachments\PartAttachment;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+use App\Services\EDA\KiCadHelper;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+
+#[Group('DB')]
+class KiCadHelperTest extends KernelTestCase
+{
+ private KiCadHelper $helper;
+ private EntityManagerInterface $em;
+
+ protected function setUp(): void
+ {
+ self::bootKernel();
+ $this->helper = self::getContainer()->get(KiCadHelper::class);
+ $this->em = self::getContainer()->get(EntityManagerInterface::class);
+ }
+
+ /**
+ * Part 1 (from fixtures) has no stock lots. Stock should be 0.
+ */
+ public function testPartWithoutStockHasZeroStock(): void
+ {
+ $part = $this->em->find(Part::class, 1);
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertArrayHasKey('Stock', $result['fields']);
+ self::assertSame('0', $result['fields']['Stock']['value']);
+ }
+
+ /**
+ * Part 3 (from fixtures) has a lot with amount=1.0 in StorageLocation 1.
+ */
+ public function testPartWithStockShowsCorrectQuantity(): void
+ {
+ $part = $this->em->find(Part::class, 3);
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertArrayHasKey('Stock', $result['fields']);
+ self::assertSame('1', $result['fields']['Stock']['value']);
+ }
+
+ /**
+ * Part 3 has a lot with amount > 0 in StorageLocation "Node 1".
+ */
+ public function testPartWithStorageLocationShowsLocation(): void
+ {
+ $part = $this->em->find(Part::class, 3);
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertArrayHasKey('Storage Location', $result['fields']);
+ self::assertSame('Node 1', $result['fields']['Storage Location']['value']);
+ }
+
+ /**
+ * Part 1 has no stock lots, so no storage location should be shown.
+ */
+ public function testPartWithoutStorageLocationOmitsField(): void
+ {
+ $part = $this->em->find(Part::class, 1);
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertArrayNotHasKey('Storage Location', $result['fields']);
+ }
+
+ /**
+ * All parts should have a "Part-DB URL" field pointing to the part info page.
+ */
+ public function testPartDbUrlFieldIsPresent(): void
+ {
+ $part = $this->em->find(Part::class, 1);
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertArrayHasKey('Part-DB URL', $result['fields']);
+ self::assertStringContainsString('/part/1/info', $result['fields']['Part-DB URL']['value']);
+ }
+
+ /**
+ * Part 1 has no attachments, so the datasheet should fall back to the Part-DB page URL.
+ */
+ public function testDatasheetFallbackToPartUrlWhenNoAttachments(): void
+ {
+ $part = $this->em->find(Part::class, 1);
+ $result = $this->helper->getKiCADPart($part);
+
+ // With no attachments, datasheet should equal Part-DB URL
+ self::assertSame(
+ $result['fields']['Part-DB URL']['value'],
+ $result['fields']['datasheet']['value']
+ );
+ }
+
+ /**
+ * Part 3 has attachments but none named "datasheet" and none are PDFs,
+ * so the datasheet should fall back to the Part-DB page URL.
+ */
+ public function testDatasheetFallbackWhenNoMatchingAttachments(): void
+ {
+ $part = $this->em->find(Part::class, 3);
+ $result = $this->helper->getKiCADPart($part);
+
+ // "TestAttachment" (url: www.foo.bar) and "Test2" (internal: invalid) don't match datasheet patterns
+ self::assertSame(
+ $result['fields']['Part-DB URL']['value'],
+ $result['fields']['datasheet']['value']
+ );
+ }
+
+ /**
+ * Test that an attachment with type name containing "Datasheet" is found.
+ */
+ public function testDatasheetFoundByAttachmentTypeName(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+
+ // Create an attachment type named "Datasheets"
+ $datasheetType = new AttachmentType();
+ $datasheetType->setName('Datasheets');
+ $this->em->persist($datasheetType);
+
+ // Create a part with a datasheet attachment
+ $part = new Part();
+ $part->setName('Part with Datasheet Type');
+ $part->setCategory($category);
+
+ $attachment = new PartAttachment();
+ $attachment->setName('Component Spec');
+ $attachment->setURL('https://example.com/spec.pdf');
+ $attachment->setAttachmentType($datasheetType);
+ $part->addAttachment($attachment);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertSame('https://example.com/spec.pdf', $result['fields']['datasheet']['value']);
+ }
+
+ /**
+ * Test that an attachment named "Datasheet" is found (regardless of type).
+ */
+ public function testDatasheetFoundByAttachmentName(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+ $attachmentType = $this->em->find(AttachmentType::class, 1);
+
+ $part = new Part();
+ $part->setName('Part with Named Datasheet');
+ $part->setCategory($category);
+
+ $attachment = new PartAttachment();
+ $attachment->setName('Datasheet BC547');
+ $attachment->setURL('https://example.com/bc547-datasheet.pdf');
+ $attachment->setAttachmentType($attachmentType);
+ $part->addAttachment($attachment);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertSame('https://example.com/bc547-datasheet.pdf', $result['fields']['datasheet']['value']);
+ }
+
+ /**
+ * Test that a PDF attachment is used as fallback when no "datasheet" match exists.
+ */
+ public function testDatasheetFallbackToFirstPdfAttachment(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+ $attachmentType = $this->em->find(AttachmentType::class, 1);
+
+ $part = new Part();
+ $part->setName('Part with PDF');
+ $part->setCategory($category);
+
+ // Non-PDF attachment first
+ $attachment1 = new PartAttachment();
+ $attachment1->setName('Photo');
+ $attachment1->setURL('https://example.com/photo.jpg');
+ $attachment1->setAttachmentType($attachmentType);
+ $part->addAttachment($attachment1);
+
+ // PDF attachment second
+ $attachment2 = new PartAttachment();
+ $attachment2->setName('Specifications');
+ $attachment2->setURL('https://example.com/specs.pdf');
+ $attachment2->setAttachmentType($attachmentType);
+ $part->addAttachment($attachment2);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ // Should find the .pdf file as fallback
+ self::assertSame('https://example.com/specs.pdf', $result['fields']['datasheet']['value']);
+ }
+
+ /**
+ * Test that a "data sheet" variant (with space) is also matched by name.
+ */
+ public function testDatasheetMatchesDataSheetWithSpace(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+ $attachmentType = $this->em->find(AttachmentType::class, 1);
+
+ $part = new Part();
+ $part->setName('Part with Data Sheet');
+ $part->setCategory($category);
+
+ $attachment = new PartAttachment();
+ $attachment->setName('Data Sheet v1.2');
+ $attachment->setURL('https://example.com/data-sheet.pdf');
+ $attachment->setAttachmentType($attachmentType);
+ $part->addAttachment($attachment);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertSame('https://example.com/data-sheet.pdf', $result['fields']['datasheet']['value']);
+ }
+
+ /**
+ * Test stock calculation excludes expired lots.
+ */
+ public function testStockExcludesExpiredLots(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+
+ $part = new Part();
+ $part->setName('Part with Expired Stock');
+ $part->setCategory($category);
+
+ // Active lot
+ $lot1 = new PartLot();
+ $lot1->setAmount(10.0);
+ $part->addPartLot($lot1);
+
+ // Expired lot
+ $lot2 = new PartLot();
+ $lot2->setAmount(5.0);
+ $lot2->setExpirationDate(new \DateTimeImmutable('-1 day'));
+ $part->addPartLot($lot2);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ // Only the active lot should be counted
+ self::assertSame('10', $result['fields']['Stock']['value']);
+ }
+
+ /**
+ * Test stock calculation excludes lots with unknown stock.
+ */
+ public function testStockExcludesUnknownLots(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+
+ $part = new Part();
+ $part->setName('Part with Unknown Stock');
+ $part->setCategory($category);
+
+ // Known lot
+ $lot1 = new PartLot();
+ $lot1->setAmount(7.0);
+ $part->addPartLot($lot1);
+
+ // Unknown lot
+ $lot2 = new PartLot();
+ $lot2->setInstockUnknown(true);
+ $part->addPartLot($lot2);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertSame('7', $result['fields']['Stock']['value']);
+ }
+
+ /**
+ * Test stock sums across multiple lots.
+ */
+ public function testStockSumsMultipleLots(): void
+ {
+ $category = $this->em->find(Category::class, 1);
+ $location1 = $this->em->find(StorageLocation::class, 1);
+ $location2 = $this->em->find(StorageLocation::class, 2);
+
+ $part = new Part();
+ $part->setName('Part in Multiple Locations');
+ $part->setCategory($category);
+
+ $lot1 = new PartLot();
+ $lot1->setAmount(15.0);
+ $lot1->setStorageLocation($location1);
+ $part->addPartLot($lot1);
+
+ $lot2 = new PartLot();
+ $lot2->setAmount(25.0);
+ $lot2->setStorageLocation($location2);
+ $part->addPartLot($lot2);
+
+ $this->em->persist($part);
+ $this->em->flush();
+
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertSame('40', $result['fields']['Stock']['value']);
+ self::assertArrayHasKey('Storage Location', $result['fields']);
+ // Both locations should be listed
+ self::assertStringContainsString('Node 1', $result['fields']['Storage Location']['value']);
+ self::assertStringContainsString('Node 2', $result['fields']['Storage Location']['value']);
+ }
+
+ /**
+ * Test that the Stock field visibility is "False" (not visible in schematic by default).
+ */
+ public function testStockFieldIsNotVisible(): void
+ {
+ $part = $this->em->find(Part::class, 1);
+ $result = $this->helper->getKiCADPart($part);
+
+ self::assertSame('False', $result['fields']['Stock']['visible']);
+ }
+}
From 5a19a56a45e35f0cf5cab4e3ddd771b695e0b8e4 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Sun, 8 Feb 2026 10:46:53 +0100
Subject: [PATCH 03/27] Fix 304 response body, parse_url safety, and
location/stock consistency
- Use empty Response instead of JsonResponse(null) for 304 Not Modified
to avoid sending "null" as response body
- Guard parse_url() result with is_string() since it can return false
for malformed URLs
- Move storage location tracking inside the availability check so
expired and unknown-quantity lots don't contribute locations
---
src/Controller/KiCadApiController.php | 2 +-
src/Services/EDA/KiCadHelper.php | 15 ++++++++-------
2 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php
index 2cfa9b0e..a5d5eecd 100644
--- a/src/Controller/KiCadApiController.php
+++ b/src/Controller/KiCadApiController.php
@@ -97,7 +97,7 @@ class KiCadApiController extends AbstractController
$etag = '"' . md5(json_encode($data)) . '"';
if ($request->headers->get('If-None-Match') === $etag) {
- return new JsonResponse(null, Response::HTTP_NOT_MODIFIED);
+ return new Response('', Response::HTTP_NOT_MODIFIED);
}
$response = new JsonResponse($data);
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 48af4219..37b94f33 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -294,15 +294,16 @@ class KiCadHelper
}
}
- //Add stock quantity and storage locations
+ //Add stock quantity and storage locations (only count non-expired lots with known quantity)
$totalStock = 0;
$locations = [];
foreach ($part->getPartLots() as $lot) {
- if (!$lot->isInstockUnknown() && $lot->isExpired() !== true) {
+ $isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
+ if ($isAvailable) {
$totalStock += $lot->getAmount();
- }
- if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
- $locations[] = $lot->getStorageLocation()->getName();
+ if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
+ $locations[] = $lot->getStorageLocation()->getName();
+ }
}
}
$result['fields']['Stock'] = $this->createField($totalStock);
@@ -443,8 +444,8 @@ class KiCadHelper
if ($firstPdf === null) {
$extension = $attachment->getExtension();
if ($extension === null && $attachment->hasExternal()) {
- $urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH) ?? '';
- $extension = strtolower(pathinfo($urlPath, PATHINFO_EXTENSION));
+ $urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
+ $extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
}
if ($extension === 'pdf') {
$firstPdf = $attachment;
From 9ec6e3db700834952b2889ab6dd4470ba5119928 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Sun, 8 Feb 2026 11:09:54 +0100
Subject: [PATCH 04/27] Fix testPartDetailsPart2 to actually test Part 2
The test was requesting /parts/1.json instead of /parts/2.json and had
Part 1's expected data. Now tests Part 2 which inherits EDA info from
its category and footprint, verifying the inheritance behavior.
---
tests/Controller/KiCadApiControllerTest.php | 52 +++++++++++++++------
1 file changed, 38 insertions(+), 14 deletions(-)
diff --git a/tests/Controller/KiCadApiControllerTest.php b/tests/Controller/KiCadApiControllerTest.php
index 8877cf74..26a47032 100644
--- a/tests/Controller/KiCadApiControllerTest.php
+++ b/tests/Controller/KiCadApiControllerTest.php
@@ -187,20 +187,19 @@ final class KiCadApiControllerTest extends WebTestCase
public function testPartDetailsPart2(): void
{
$client = $this->createClientWithCredentials();
- $client->request('GET', self::BASE_URL.'/parts/1.json');
+ $client->request('GET', self::BASE_URL.'/parts/2.json');
- //Response should still be successful, but the result should be empty
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
self::assertJson($content);
$data = json_decode($content, true);
- //For part 2 things info should be taken from the category and footprint
+ //For part 2, EDA info should be inherited from category and footprint (no part-level overrides)
$expected = array (
- 'id' => '1',
- 'name' => 'Part 1',
- 'symbolIdStr' => 'Part:1',
+ 'id' => '2',
+ 'name' => 'Part 2',
+ 'symbolIdStr' => 'Category:1',
'exclude_from_bom' => 'False',
'exclude_from_board' => 'True',
'exclude_from_sim' => 'False',
@@ -208,32 +207,32 @@ final class KiCadApiControllerTest extends WebTestCase
array (
'footprint' =>
array (
- 'value' => 'Part:1',
+ 'value' => 'Footprint:1',
'visible' => 'False',
),
'reference' =>
array (
- 'value' => 'P',
+ 'value' => 'C',
'visible' => 'True',
),
'value' =>
array (
- 'value' => 'Part 1',
+ 'value' => 'Part 2',
'visible' => 'True',
),
'keywords' =>
array (
- 'value' => '',
+ 'value' => 'test, Test, Part2',
'visible' => 'False',
),
'datasheet' =>
array (
- 'value' => 'http://localhost/en/part/1/info',
+ 'value' => 'http://localhost/en/part/2/info',
'visible' => 'False',
),
'Part-DB URL' =>
array (
- 'value' => 'http://localhost/en/part/1/info',
+ 'value' => 'http://localhost/en/part/2/info',
'visible' => 'False',
),
'description' =>
@@ -246,14 +245,39 @@ final class KiCadApiControllerTest extends WebTestCase
'value' => 'Node 1',
'visible' => 'False',
),
+ 'Manufacturer' =>
+ array (
+ 'value' => 'Node 1',
+ 'visible' => 'False',
+ ),
'Manufacturing Status' =>
array (
- 'value' => '',
+ 'value' => 'Active',
+ 'visible' => 'False',
+ ),
+ 'Part-DB Footprint' =>
+ array (
+ 'value' => 'Node 1',
+ 'visible' => 'False',
+ ),
+ 'Mass' =>
+ array (
+ 'value' => '100.2 g',
'visible' => 'False',
),
'Part-DB ID' =>
array (
- 'value' => '1',
+ 'value' => '2',
+ 'visible' => 'False',
+ ),
+ 'Part-DB IPN' =>
+ array (
+ 'value' => 'IPN123',
+ 'visible' => 'False',
+ ),
+ 'manf' =>
+ array (
+ 'value' => 'Node 1',
'visible' => 'False',
),
'Stock' =>
From 44c5d9d727f832533f6ed5e97fc6e6957a85d9c2 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Sun, 8 Feb 2026 15:35:49 +0100
Subject: [PATCH 05/27] Use Symfony's built-in ETag handling for HTTP caching
Replace manual If-None-Match comparison with Response::setEtag() and
Response::isNotModified(), which properly handles ETag quoting, weak
vs strong comparison, and 304 response cleanup. Fixes PHPStan return
type error and CI test failures.
---
src/Controller/KiCadApiController.php | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php
index a5d5eecd..70ba7786 100644
--- a/src/Controller/KiCadApiController.php
+++ b/src/Controller/KiCadApiController.php
@@ -92,17 +92,12 @@ class KiCadApiController extends AbstractController
* 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): JsonResponse
+ private function createCachedJsonResponse(Request $request, array $data, int $maxAge): Response
{
- $etag = '"' . md5(json_encode($data)) . '"';
-
- if ($request->headers->get('If-None-Match') === $etag) {
- return new Response('', Response::HTTP_NOT_MODIFIED);
- }
-
$response = new JsonResponse($data);
+ $response->setEtag(md5(json_encode($data)));
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
- $response->headers->set('ETag', $etag);
+ $response->isNotModified($request);
return $response;
}
From 9178154986c8af4152cd4e59a2328b002e9f4a18 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Sun, 8 Feb 2026 21:46:28 +0100
Subject: [PATCH 06/27] Add configurable KiCad field export for part parameters
Add a kicad_export checkbox to parameters, allowing users to control
which specifications appear as fields in the KiCad HTTP library API.
Parameters with kicad_export enabled are included using their formatted
value, without overwriting hardcoded fields like description or Stock.
---
migrations/Version20260208190000.php | 47 +++++++++++
src/Entity/Parameters/AbstractParameter.php | 22 +++++
src/Form/ParameterType.php | 6 ++
src/Services/EDA/KiCadHelper.php | 11 +++
.../parts/edit/_specifications.html.twig | 1 +
.../parts/edit/edit_form_styles.html.twig | 1 +
tests/Services/EDA/KiCadHelperTest.php | 80 +++++++++++++++++++
translations/messages.en.xlf | 6 ++
8 files changed, 174 insertions(+)
create mode 100644 migrations/Version20260208190000.php
diff --git a/migrations/Version20260208190000.php b/migrations/Version20260208190000.php
new file mode 100644
index 00000000..3ff1a80d
--- /dev/null
+++ b/migrations/Version20260208190000.php
@@ -0,0 +1,47 @@
+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');
+ }
+}
diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php
index d84e68ad..2762657a 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 Whether this parameter should be exported as a KiCad field in the EDA HTTP library API
+ */
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
+ protected bool $kicad_export = false;
+
/**
* Mapping is done in subclasses.
*
@@ -471,6 +478,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
return static::ALLOWED_ELEMENT_CLASS;
}
+ 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 getComparableFields(): array
{
return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];
diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php
index 4c2174ae..3a773f4e 100644
--- a/src/Form/ParameterType.php
+++ b/src/Form/ParameterType.php
@@ -55,6 +55,7 @@ use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\MeasurementUnit;
use App\Form\Type\ExponentialNumberType;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -147,6 +148,11 @@ class ParameterType extends AbstractType
'class' => 'form-control-sm',
],
]);
+
+ $builder->add('kicad_export', CheckboxType::class, [
+ 'label' => false,
+ 'required' => false,
+ ]);
}
public function finishView(FormView $view, FormInterface $form, array $options): void
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 37b94f33..1617e886 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -311,6 +311,17 @@ class KiCadHelper
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
}
+ //Add parameters marked for KiCad export
+ foreach ($part->getParameters() as $parameter) {
+ if ($parameter->isKicadExport() && $parameter->getName() !== '') {
+ $fieldName = $parameter->getName();
+ //Don't overwrite hardcoded fields
+ if (!isset($result['fields'][$fieldName])) {
+ $result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue());
+ }
+ }
+ }
+
return $result;
}
diff --git a/templates/parts/edit/_specifications.html.twig b/templates/parts/edit/_specifications.html.twig
index 25b00133..3226e2c0 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..5376f754 100644
--- a/templates/parts/edit/edit_form_styles.html.twig
+++ b/templates/parts/edit/edit_form_styles.html.twig
@@ -79,6 +79,7 @@
{{ 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) }} |
+ {{ form_widget(form.kicad_export) }} |
|
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index ff3a2523..9c811a21 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -2930,6 +2930,42 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
Attachments
+
+
+ part.table.eda_status
+ EDA
+
+
+
+
+ eda.status.symbol_set
+ KiCad symbol set
+
+
+
+
+
+ eda.status.reference_set
+ Reference prefix set
+
+
+
+
+ eda.status.complete
+ EDA fields complete (symbol, footprint, reference)
+
+
+
+
+ eda.status.partial
+ EDA fields partially set
+
+
flash.login_successful
@@ -3272,6 +3308,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
No longer available
+
+
+ orderdetails.edit.kicad_export
+ Export to KiCad
+
+
orderdetails.edit.supplierpartnr.placeholder
From 618d21ae4f6b9009a2df005cdaf3fb60eff19cc4 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Wed, 11 Feb 2026 06:35:33 +0100
Subject: [PATCH 14/27] Fix kicad_export column default for SQLite
compatibility
Add options default to ORM column definition so schema:update
works correctly on SQLite test databases.
---
src/Entity/PriceInformations/Orderdetail.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php
index 1584b3b0..0cc8cf27 100644
--- a/src/Entity/PriceInformations/Orderdetail.php
+++ b/src/Entity/PriceInformations/Orderdetail.php
@@ -126,7 +126,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
* @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)]
+ #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
protected bool $kicad_export = false;
/**
From 72a586164d25b07d047a90f8f1e4927ff0cd7574 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Wed, 11 Feb 2026 11:35:54 +0100
Subject: [PATCH 15/27] Make EDA status bolt icon clickable to open EDA
settings tab
---
src/DataTables/Helpers/PartDataTableHelper.php | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php
index ca9687eb..c65ea5e5 100644
--- a/src/DataTables/Helpers/PartDataTableHelper.php
+++ b/src/DataTables/Helpers/PartDataTableHelper.php
@@ -165,7 +165,9 @@ class PartDataTableHelper
? sprintf('', $this->translator->trans('eda.status.complete'))
: sprintf('', $this->translator->trans('eda.status.partial'));
- return $statusIcon;
+ // Wrap in link to EDA settings tab
+ $editUrl = $this->entityURLGenerator->editURL($context) . '#eda';
+ return sprintf('%s', $editUrl, $statusIcon);
}
public function renderAmount(Part $context): string
From 6a0db3b1b76ddea940bcc15915d7045435cd14ee Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Wed, 11 Feb 2026 11:50:48 +0100
Subject: [PATCH 16/27] Fix EDA bolt link to correctly open EDA tab via
data-turbo=false
---
src/DataTables/Helpers/PartDataTableHelper.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php
index c65ea5e5..54094ff1 100644
--- a/src/DataTables/Helpers/PartDataTableHelper.php
+++ b/src/DataTables/Helpers/PartDataTableHelper.php
@@ -165,9 +165,9 @@ class PartDataTableHelper
? sprintf('', $this->translator->trans('eda.status.complete'))
: sprintf('', $this->translator->trans('eda.status.partial'));
- // Wrap in link to EDA settings tab
+ // 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);
+ return sprintf('%s', $editUrl, $statusIcon);
}
public function renderAmount(Part $context): string
From 67c0b02248649e734d16cb95a02b1092467d9781 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Thu, 12 Feb 2026 21:51:30 +0100
Subject: [PATCH 17/27] Add configurable datasheet URL mode for KiCad API
New setting "Datasheet field links to PDF" in KiCad EDA settings.
When enabled (default), the datasheet field resolves to the actual
PDF attachment URL. When disabled, it links to the Part-DB page
(old behavior). Configurable via settings UI or EDA_KICAD_DATASHEET_AS_PDF env var.
---
src/Services/EDA/KiCadHelper.php | 14 +++++++++++---
src/Settings/MiscSettings/KiCadEDASettings.php | 5 +++++
translations/messages.en.xlf | 12 ++++++++++++
3 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 882152dd..c526c1c7 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -44,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,
@@ -55,6 +58,7 @@ class KiCadHelper
KiCadEDASettings $kiCadEDASettings,
) {
$this->category_depth = $kiCadEDASettings->categoryDepth;
+ $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf;
}
/**
@@ -216,9 +220,13 @@ class KiCadHelper
UrlGeneratorInterface::ABSOLUTE_URL
);
- //Try to find an actual datasheet attachment (by type name, attachment name, or PDF extension)
- $datasheetUrl = $this->findDatasheetUrl($part);
- $result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
+ //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
diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php
index d8f1026d..29b579a2 100644
--- a/src/Settings/MiscSettings/KiCadEDASettings.php
+++ b/src/Settings/MiscSettings/KiCadEDASettings.php
@@ -43,4 +43,9 @@ class KiCadEDASettings
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
#[Assert\Range(min: -1)]
public int $categoryDepth = 0;
+
+ #[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
+ description: new TM("settings.misc.kicad_eda.datasheet_link.help"),
+ envVar: "bool:EDA_KICAD_DATASHEET_AS_PDF", envVarMode: EnvVarMode::OVERWRITE)]
+ public bool $datasheetAsPdf = true;
}
\ No newline at end of file
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 9c811a21..a15d27f1 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -9993,6 +9993,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g
This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.
+
+
+ settings.misc.kicad_eda.datasheet_link
+ Datasheet field links to PDF
+
+
+
+
+ settings.misc.kicad_eda.datasheet_link.help
+ 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.
+
+
settings.behavior.sidebar
From 9de176e455aeb6344f628ca9935ef43922b83568 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Thu, 12 Feb 2026 22:08:58 +0100
Subject: [PATCH 18/27] Fix settings crash when upgrading: make datasheetAsPdf
nullable
The settings bundle stores values in the database. When upgrading from
a version without datasheetAsPdf, the stored JSON lacks this key,
causing a TypeError when assigning null to a non-nullable bool.
Making it nullable with a fallback in KiCadHelper fixes the upgrade path.
---
src/Services/EDA/KiCadHelper.php | 2 +-
src/Settings/MiscSettings/KiCadEDASettings.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index c526c1c7..29274641 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -58,7 +58,7 @@ class KiCadHelper
KiCadEDASettings $kiCadEDASettings,
) {
$this->category_depth = $kiCadEDASettings->categoryDepth;
- $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf;
+ $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
}
/**
diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php
index 29b579a2..d9611013 100644
--- a/src/Settings/MiscSettings/KiCadEDASettings.php
+++ b/src/Settings/MiscSettings/KiCadEDASettings.php
@@ -47,5 +47,5 @@ class KiCadEDASettings
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
description: new TM("settings.misc.kicad_eda.datasheet_link.help"),
envVar: "bool:EDA_KICAD_DATASHEET_AS_PDF", envVarMode: EnvVarMode::OVERWRITE)]
- public bool $datasheetAsPdf = true;
+ public ?bool $datasheetAsPdf = true;
}
\ No newline at end of file
From be2c990286800aa2f2c0665d0149c49b4f0eea26 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Thu, 12 Feb 2026 22:23:21 +0100
Subject: [PATCH 19/27] Add functional tests for KiCad API v2 and batch EDA
controller
- KiCadApiV2ControllerTest: root, categories, parts, volatile fields,
v1 vs v2 comparison, cache headers, 304 conditional request, auth
- BatchEdaControllerTest: page load, empty redirect, form submission
---
tests/Controller/BatchEdaControllerTest.php | 88 +++++++++
tests/Controller/KiCadApiV2ControllerTest.php | 176 ++++++++++++++++++
2 files changed, 264 insertions(+)
create mode 100644 tests/Controller/BatchEdaControllerTest.php
create mode 100644 tests/Controller/KiCadApiV2ControllerTest.php
diff --git a/tests/Controller/BatchEdaControllerTest.php b/tests/Controller/BatchEdaControllerTest.php
new file mode 100644
index 00000000..040eddb3
--- /dev/null
+++ b/tests/Controller/BatchEdaControllerTest.php
@@ -0,0 +1,88 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Controller;
+
+use App\Entity\UserSystem\User;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+#[Group("slow")]
+#[Group("DB")]
+class BatchEdaControllerTest extends WebTestCase
+{
+ private function loginAsUser($client, string $username): void
+ {
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => $username]);
+
+ if (!$user) {
+ $this->markTestSkipped("User {$username} not found");
+ }
+
+ $client->loginUser($user);
+ }
+
+ public function testBatchEdaPageLoads(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ // Request with part IDs in session — the page expects ids[] query param
+ $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => [1, 2, 3]]);
+
+ self::assertResponseIsSuccessful();
+ }
+
+ public function testBatchEdaPageWithoutPartsRedirects(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ // Request without part IDs should redirect
+ $client->request('GET', '/en/tools/batch_eda_edit');
+
+ self::assertResponseRedirects();
+ }
+
+ public function testBatchEdaFormSubmission(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ // Load the form page first
+ $crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => [1, 2]]);
+
+ self::assertResponseIsSuccessful();
+
+ // Find the form and submit it with reference prefix applied
+ $form = $crawler->selectButton('batch_eda[submit]')->form();
+ $form['batch_eda[apply_reference_prefix]'] = true;
+ $form['batch_eda[reference_prefix]'] = 'R';
+
+ $client->submit($form);
+
+ // Should redirect after successful submission
+ self::assertResponseRedirects();
+ }
+}
diff --git a/tests/Controller/KiCadApiV2ControllerTest.php b/tests/Controller/KiCadApiV2ControllerTest.php
new file mode 100644
index 00000000..19a7c416
--- /dev/null
+++ b/tests/Controller/KiCadApiV2ControllerTest.php
@@ -0,0 +1,176 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Controller;
+
+use App\DataFixtures\APITokenFixtures;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+class KiCadApiV2ControllerTest extends WebTestCase
+{
+ private const BASE_URL = '/en/kicad-api/v2';
+
+ protected function createClientWithCredentials(string $token = APITokenFixtures::TOKEN_READONLY): KernelBrowser
+ {
+ return static::createClient([], ['headers' => ['authorization' => 'Bearer '.$token]]);
+ }
+
+ public function testRoot(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/');
+
+ self::assertResponseIsSuccessful();
+ $content = $client->getResponse()->getContent();
+ self::assertJson($content);
+
+ $array = json_decode($content, true);
+ self::assertArrayHasKey('categories', $array);
+ self::assertArrayHasKey('parts', $array);
+ }
+
+ public function testCategories(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/categories.json');
+
+ self::assertResponseIsSuccessful();
+ $content = $client->getResponse()->getContent();
+ self::assertJson($content);
+
+ $data = json_decode($content, true);
+ self::assertCount(1, $data);
+
+ $category = $data[0];
+ self::assertArrayHasKey('name', $category);
+ self::assertArrayHasKey('id', $category);
+ }
+
+ public function testCategoryParts(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/parts/category/1.json');
+
+ self::assertResponseIsSuccessful();
+ $content = $client->getResponse()->getContent();
+ self::assertJson($content);
+
+ $data = json_decode($content, true);
+ self::assertCount(3, $data);
+
+ $part = $data[0];
+ self::assertArrayHasKey('name', $part);
+ self::assertArrayHasKey('id', $part);
+ self::assertArrayHasKey('description', $part);
+ }
+
+ public function testCategoryPartsMinimal(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/parts/category/1.json?minimal=true');
+
+ self::assertResponseIsSuccessful();
+ $content = $client->getResponse()->getContent();
+ self::assertJson($content);
+
+ $data = json_decode($content, true);
+ self::assertCount(3, $data);
+ }
+
+ public function testPartDetailsHasVolatileFields(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/parts/1.json');
+
+ self::assertResponseIsSuccessful();
+ $content = $client->getResponse()->getContent();
+ self::assertJson($content);
+
+ $data = json_decode($content, true);
+
+ // V2 should have volatile flag on Stock field
+ self::assertArrayHasKey('fields', $data);
+ self::assertArrayHasKey('Stock', $data['fields']);
+ self::assertArrayHasKey('volatile', $data['fields']['Stock']);
+ self::assertEquals('True', $data['fields']['Stock']['volatile']);
+ }
+
+ public function testPartDetailsV2VsV1Difference(): void
+ {
+ $client = $this->createClientWithCredentials();
+
+ // Get v1 response
+ $client->request('GET', '/en/kicad-api/v1/parts/1.json');
+ self::assertResponseIsSuccessful();
+ $v1Data = json_decode($client->getResponse()->getContent(), true);
+
+ // Get v2 response
+ $client->request('GET', self::BASE_URL.'/parts/1.json');
+ self::assertResponseIsSuccessful();
+ $v2Data = json_decode($client->getResponse()->getContent(), true);
+
+ // V1 should NOT have volatile on Stock
+ self::assertArrayNotHasKey('volatile', $v1Data['fields']['Stock']);
+
+ // V2 should have volatile on Stock
+ self::assertArrayHasKey('volatile', $v2Data['fields']['Stock']);
+
+ // Both should have the same stock value
+ self::assertEquals($v1Data['fields']['Stock']['value'], $v2Data['fields']['Stock']['value']);
+ }
+
+ public function testCategoriesHasCacheHeaders(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/categories.json');
+
+ self::assertResponseIsSuccessful();
+ $response = $client->getResponse();
+ self::assertNotNull($response->headers->get('ETag'));
+ self::assertStringContainsString('max-age=', $response->headers->get('Cache-Control'));
+ }
+
+ public function testConditionalRequestReturns304(): void
+ {
+ $client = $this->createClientWithCredentials();
+ $client->request('GET', self::BASE_URL.'/categories.json');
+
+ $etag = $client->getResponse()->headers->get('ETag');
+ self::assertNotNull($etag);
+
+ $client->request('GET', self::BASE_URL.'/categories.json', [], [], [
+ 'HTTP_IF_NONE_MATCH' => $etag,
+ ]);
+
+ self::assertResponseStatusCodeSame(304);
+ }
+
+ public function testUnauthenticatedAccessDenied(): void
+ {
+ $client = static::createClient();
+ $client->request('GET', self::BASE_URL.'/categories.json');
+
+ // Should redirect to login (302) since not authenticated
+ self::assertResponseRedirects();
+ }
+}
From 7e3aa7fed84edce7400cb113340969244cd8118b Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Thu, 12 Feb 2026 22:27:01 +0100
Subject: [PATCH 20/27] Fix test failures: correct ids format and anonymous
access assertion
---
tests/Command/PopulateKicadCommandTest.php | 2 +-
tests/Controller/BatchEdaControllerTest.php | 8 ++++----
tests/Controller/KiCadApiV2ControllerTest.php | 7 ++++---
tests/Services/EDA/KiCadHelperTest.php | 2 +-
4 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/tests/Command/PopulateKicadCommandTest.php b/tests/Command/PopulateKicadCommandTest.php
index bbbfa607..531cb16f 100644
--- a/tests/Command/PopulateKicadCommandTest.php
+++ b/tests/Command/PopulateKicadCommandTest.php
@@ -12,7 +12,7 @@ use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
-class PopulateKicadCommandTest extends KernelTestCase
+final class PopulateKicadCommandTest extends KernelTestCase
{
private CommandTester $commandTester;
private EntityManagerInterface $entityManager;
diff --git a/tests/Controller/BatchEdaControllerTest.php b/tests/Controller/BatchEdaControllerTest.php
index 040eddb3..31a9e252 100644
--- a/tests/Controller/BatchEdaControllerTest.php
+++ b/tests/Controller/BatchEdaControllerTest.php
@@ -28,7 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
#[Group("slow")]
#[Group("DB")]
-class BatchEdaControllerTest extends WebTestCase
+final class BatchEdaControllerTest extends WebTestCase
{
private function loginAsUser($client, string $username): void
{
@@ -48,8 +48,8 @@ class BatchEdaControllerTest extends WebTestCase
$client = static::createClient();
$this->loginAsUser($client, 'admin');
- // Request with part IDs in session — the page expects ids[] query param
- $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => [1, 2, 3]]);
+ // Request with part IDs as comma-separated string (controller uses getString)
+ $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2,3']);
self::assertResponseIsSuccessful();
}
@@ -71,7 +71,7 @@ class BatchEdaControllerTest extends WebTestCase
$this->loginAsUser($client, 'admin');
// Load the form page first
- $crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => [1, 2]]);
+ $crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2']);
self::assertResponseIsSuccessful();
diff --git a/tests/Controller/KiCadApiV2ControllerTest.php b/tests/Controller/KiCadApiV2ControllerTest.php
index 19a7c416..679197f3 100644
--- a/tests/Controller/KiCadApiV2ControllerTest.php
+++ b/tests/Controller/KiCadApiV2ControllerTest.php
@@ -26,7 +26,7 @@ use App\DataFixtures\APITokenFixtures;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
-class KiCadApiV2ControllerTest extends WebTestCase
+final class KiCadApiV2ControllerTest extends WebTestCase
{
private const BASE_URL = '/en/kicad-api/v2';
@@ -170,7 +170,8 @@ class KiCadApiV2ControllerTest extends WebTestCase
$client = static::createClient();
$client->request('GET', self::BASE_URL.'/categories.json');
- // Should redirect to login (302) since not authenticated
- self::assertResponseRedirects();
+ // Anonymous user has default read permissions in Part-DB,
+ // so this returns 200 rather than a redirect
+ self::assertResponseIsSuccessful();
}
}
diff --git a/tests/Services/EDA/KiCadHelperTest.php b/tests/Services/EDA/KiCadHelperTest.php
index 11cf559c..be8500a0 100644
--- a/tests/Services/EDA/KiCadHelperTest.php
+++ b/tests/Services/EDA/KiCadHelperTest.php
@@ -35,7 +35,7 @@ use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
#[Group('DB')]
-class KiCadHelperTest extends KernelTestCase
+final class KiCadHelperTest extends KernelTestCase
{
private KiCadHelper $helper;
private EntityManagerInterface $em;
From 06c65424387ea37ec27ee81a20caca24845353f2 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Mon, 16 Feb 2026 21:33:44 +0100
Subject: [PATCH 21/27] Improve test coverage for BatchEdaController
Add tests for: applying all EDA fields at once, custom redirect URL,
and verifying unchecked fields are skipped.
---
tests/Controller/BatchEdaControllerTest.php | 93 +++++++++++++++++++--
1 file changed, 88 insertions(+), 5 deletions(-)
diff --git a/tests/Controller/BatchEdaControllerTest.php b/tests/Controller/BatchEdaControllerTest.php
index 31a9e252..31cc6e82 100644
--- a/tests/Controller/BatchEdaControllerTest.php
+++ b/tests/Controller/BatchEdaControllerTest.php
@@ -48,7 +48,6 @@ final class BatchEdaControllerTest extends WebTestCase
$client = static::createClient();
$this->loginAsUser($client, 'admin');
- // Request with part IDs as comma-separated string (controller uses getString)
$client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2,3']);
self::assertResponseIsSuccessful();
@@ -59,30 +58,114 @@ final class BatchEdaControllerTest extends WebTestCase
$client = static::createClient();
$this->loginAsUser($client, 'admin');
- // Request without part IDs should redirect
$client->request('GET', '/en/tools/batch_eda_edit');
self::assertResponseRedirects();
}
+ public function testBatchEdaPageWithoutPartsRedirectsToCustomUrl(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ // Empty IDs with a custom redirect URL
+ $client->request('GET', '/en/tools/batch_eda_edit', [
+ 'ids' => '',
+ '_redirect' => '/en/parts',
+ ]);
+
+ self::assertResponseRedirects('/en/parts');
+ }
+
public function testBatchEdaFormSubmission(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
- // Load the form page first
$crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2']);
self::assertResponseIsSuccessful();
- // Find the form and submit it with reference prefix applied
$form = $crawler->selectButton('batch_eda[submit]')->form();
$form['batch_eda[apply_reference_prefix]'] = true;
$form['batch_eda[reference_prefix]'] = 'R';
$client->submit($form);
- // Should redirect after successful submission
+ self::assertResponseRedirects();
+ }
+
+ public function testBatchEdaFormSubmissionAppliesAllFields(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2']);
+ self::assertResponseIsSuccessful();
+
+ $form = $crawler->selectButton('batch_eda[submit]')->form();
+
+ // Apply all text fields
+ $form['batch_eda[apply_reference_prefix]'] = true;
+ $form['batch_eda[reference_prefix]'] = 'C';
+ $form['batch_eda[apply_value]'] = true;
+ $form['batch_eda[value]'] = '100nF';
+ $form['batch_eda[apply_kicad_symbol]'] = true;
+ $form['batch_eda[kicad_symbol]'] = 'Device:C';
+ $form['batch_eda[apply_kicad_footprint]'] = true;
+ $form['batch_eda[kicad_footprint]'] = 'Capacitor_SMD:C_0402';
+
+ // Apply all tri-state checkboxes
+ $form['batch_eda[apply_visibility]'] = true;
+ $form['batch_eda[apply_exclude_from_bom]'] = true;
+ $form['batch_eda[apply_exclude_from_board]'] = true;
+ $form['batch_eda[apply_exclude_from_sim]'] = true;
+
+ $client->submit($form);
+
+ // All field branches in the controller are now exercised; redirect confirms success
+ self::assertResponseRedirects();
+ }
+
+ public function testBatchEdaFormSubmissionWithRedirectUrl(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $crawler = $client->request('GET', '/en/tools/batch_eda_edit', [
+ 'ids' => '1',
+ '_redirect' => '/en/parts',
+ ]);
+ self::assertResponseIsSuccessful();
+
+ $form = $crawler->selectButton('batch_eda[submit]')->form();
+ $form['batch_eda[apply_reference_prefix]'] = true;
+ $form['batch_eda[reference_prefix]'] = 'U';
+
+ $client->submit($form);
+
+ // Should redirect to the custom URL, not the default route
+ self::assertResponseRedirects('/en/parts');
+ }
+
+ public function testBatchEdaFormWithPartialFields(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '3']);
+ self::assertResponseIsSuccessful();
+
+ $form = $crawler->selectButton('batch_eda[submit]')->form();
+ // Only apply value and kicad_footprint, leave other apply checkboxes unchecked
+ $form['batch_eda[apply_value]'] = true;
+ $form['batch_eda[value]'] = 'TestValue';
+ $form['batch_eda[apply_kicad_footprint]'] = true;
+ $form['batch_eda[kicad_footprint]'] = 'Package_SO:SOIC-8';
+
+ $client->submit($form);
+
+ // Redirect confirms the partial submission was processed
self::assertResponseRedirects();
}
}
From ae7e31f0bd49aa2ada8fd7518335bb1001b13fd6 Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Wed, 18 Feb 2026 09:26:40 +0100
Subject: [PATCH 22/27] 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
---
contrib/kicad-populate/default_mappings.json | 195 ++++++++++++++++++
migrations/Version20260208190000.php | 47 -----
migrations/Version20260210120000.php | 46 -----
migrations/Version20260211000000.php | 52 +++++
src/Command/PopulateKicadCommand.php | 194 ++++++++++++++---
src/Controller/KiCadApiController.php | 8 +-
src/Controller/KiCadApiV2Controller.php | 13 +-
src/Entity/Parameters/AbstractParameter.php | 14 +-
src/Entity/PriceInformations/Orderdetail.php | 14 +-
src/Form/ParameterType.php | 11 +-
src/Form/Part/OrderdetailType.php | 4 +-
src/Services/EDA/KiCadHelper.php | 35 ++--
.../MiscSettings/KiCadEDASettings.php | 5 +
.../parts/edit/_specifications.html.twig | 2 +-
.../parts/edit/edit_form_styles.html.twig | 6 +-
tests/Services/EDA/KiCadHelperTest.php | 39 +++-
translations/messages.en.xlf | 24 ++-
17 files changed, 532 insertions(+), 177 deletions(-)
create mode 100644 contrib/kicad-populate/default_mappings.json
delete mode 100644 migrations/Version20260208190000.php
delete mode 100644 migrations/Version20260210120000.php
create mode 100644 migrations/Version20260211000000.php
diff --git a/contrib/kicad-populate/default_mappings.json b/contrib/kicad-populate/default_mappings.json
new file mode 100644
index 00000000..b1bc1d1b
--- /dev/null
+++ b/contrib/kicad-populate/default_mappings.json
@@ -0,0 +1,195 @@
+{
+ "_comment": "Default KiCad footprint/symbol mappings for partdb:kicad:populate command. Based on KiCad 9.x standard libraries. Use --mapping-file to override or extend these mappings.",
+ "footprints": {
+ "SOT-23": "Package_TO_SOT_SMD:SOT-23",
+ "SOT-23-3": "Package_TO_SOT_SMD:SOT-23",
+ "SOT-23-5": "Package_TO_SOT_SMD:SOT-23-5",
+ "SOT-23-6": "Package_TO_SOT_SMD:SOT-23-6",
+ "SOT-223": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
+ "SOT-223-3": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
+ "SOT-89": "Package_TO_SOT_SMD:SOT-89-3",
+ "SOT-89-3": "Package_TO_SOT_SMD:SOT-89-3",
+ "SOT-323": "Package_TO_SOT_SMD:SOT-323_SC-70",
+ "SOT-363": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
+ "TSOT-25": "Package_TO_SOT_SMD:SOT-23-5",
+ "SC-70-5": "Package_TO_SOT_SMD:SOT-353_SC-70-5",
+ "SC-70-6": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
+ "TO-220": "Package_TO_SOT_THT:TO-220-3_Vertical",
+ "TO-220AB": "Package_TO_SOT_THT:TO-220-3_Vertical",
+ "TO-220AB-3": "Package_TO_SOT_THT:TO-220-3_Vertical",
+ "TO-220FP": "Package_TO_SOT_THT:TO-220F-3_Vertical",
+ "TO-247-3": "Package_TO_SOT_THT:TO-247-3_Vertical",
+ "TO-92": "Package_TO_SOT_THT:TO-92_Inline",
+ "TO-92-3": "Package_TO_SOT_THT:TO-92_Inline",
+ "TO-252": "Package_TO_SOT_SMD:TO-252-2",
+ "TO-252-2L": "Package_TO_SOT_SMD:TO-252-2",
+ "TO-252-3L": "Package_TO_SOT_SMD:TO-252-3",
+ "TO-263": "Package_TO_SOT_SMD:TO-263-2",
+ "TO-263-2": "Package_TO_SOT_SMD:TO-263-2",
+ "D2PAK": "Package_TO_SOT_SMD:TO-252-2",
+ "DPAK": "Package_TO_SOT_SMD:TO-252-2",
+ "SOIC-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
+ "ESOP-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
+ "SOIC-14": "Package_SO:SOIC-14_3.9x8.7mm_P1.27mm",
+ "SOIC-16": "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm",
+ "TSSOP-8": "Package_SO:TSSOP-8_3x3mm_P0.65mm",
+ "TSSOP-14": "Package_SO:TSSOP-14_4.4x5mm_P0.65mm",
+ "TSSOP-16": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
+ "TSSOP-16L": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
+ "TSSOP-20": "Package_SO:TSSOP-20_4.4x6.5mm_P0.65mm",
+ "MSOP-8": "Package_SO:MSOP-8_3x3mm_P0.65mm",
+ "MSOP-10": "Package_SO:MSOP-10_3x3mm_P0.5mm",
+ "MSOP-16": "Package_SO:MSOP-16_3x4mm_P0.5mm",
+ "SO-5": "Package_TO_SOT_SMD:SOT-23-5",
+ "DIP-4": "Package_DIP:DIP-4_W7.62mm",
+ "DIP-6": "Package_DIP:DIP-6_W7.62mm",
+ "DIP-8": "Package_DIP:DIP-8_W7.62mm",
+ "DIP-14": "Package_DIP:DIP-14_W7.62mm",
+ "DIP-16": "Package_DIP:DIP-16_W7.62mm",
+ "DIP-18": "Package_DIP:DIP-18_W7.62mm",
+ "DIP-20": "Package_DIP:DIP-20_W7.62mm",
+ "DIP-24": "Package_DIP:DIP-24_W7.62mm",
+ "DIP-28": "Package_DIP:DIP-28_W7.62mm",
+ "DIP-40": "Package_DIP:DIP-40_W15.24mm",
+ "QFN-8": "Package_DFN_QFN:QFN-8-1EP_3x3mm_P0.65mm_EP1.55x1.55mm",
+ "QFN-12(3x3)": "Package_DFN_QFN:QFN-12-1EP_3x3mm_P0.5mm_EP1.65x1.65mm",
+ "QFN-16": "Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.45x1.45mm",
+ "QFN-20": "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm",
+ "QFN-24": "Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm",
+ "QFN-32": "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm",
+ "QFN-48": "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm",
+ "TQFP-32": "Package_QFP:TQFP-32_7x7mm_P0.8mm",
+ "TQFP-44": "Package_QFP:TQFP-44_10x10mm_P0.8mm",
+ "TQFP-48": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
+ "TQFP-48(7x7)": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
+ "TQFP-64": "Package_QFP:TQFP-64_10x10mm_P0.5mm",
+ "TQFP-100": "Package_QFP:TQFP-100_14x14mm_P0.5mm",
+ "LQFP-32": "Package_QFP:LQFP-32_7x7mm_P0.8mm",
+ "LQFP-48": "Package_QFP:LQFP-48_7x7mm_P0.5mm",
+ "LQFP-64": "Package_QFP:LQFP-64_10x10mm_P0.5mm",
+ "LQFP-100": "Package_QFP:LQFP-100_14x14mm_P0.5mm",
+ "SOD-123": "Diode_SMD:D_SOD-123",
+ "SOD-123F": "Diode_SMD:D_SOD-123F",
+ "SOD-123FL": "Diode_SMD:D_SOD-123F",
+ "SOD-323": "Diode_SMD:D_SOD-323",
+ "SOD-523": "Diode_SMD:D_SOD-523",
+ "SOD-882": "Diode_SMD:D_SOD-882",
+ "SOD-882D": "Diode_SMD:D_SOD-882",
+ "SMA(DO-214AC)": "Diode_SMD:D_SMA",
+ "SMA": "Diode_SMD:D_SMA",
+ "SMB": "Diode_SMD:D_SMB",
+ "SMC": "Diode_SMD:D_SMC",
+ "DO-35": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
+ "DO-35(DO-204AH)": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
+ "DO-41": "Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal",
+ "DO-201": "Diode_THT:D_DO-201_P15.24mm_Horizontal",
+ "DFN-2(0.6x1)": "Package_DFN_QFN:DFN-2-1EP_0.6x1.0mm_P0.65mm_EP0.2x0.55mm",
+ "DFN1006-2": "Package_DFN_QFN:DFN-2_1.0x0.6mm",
+ "DFN-6": "Package_DFN_QFN:DFN-6-1EP_2x2mm_P0.65mm_EP1x1.6mm",
+ "DFN-8": "Package_DFN_QFN:DFN-8-1EP_3x2mm_P0.5mm_EP1.3x1.5mm",
+ "0201": "Resistor_SMD:R_0201_0603Metric",
+ "0402": "Resistor_SMD:R_0402_1005Metric",
+ "0603": "Resistor_SMD:R_0603_1608Metric",
+ "0805": "Resistor_SMD:R_0805_2012Metric",
+ "1206": "Resistor_SMD:R_1206_3216Metric",
+ "1210": "Resistor_SMD:R_1210_3225Metric",
+ "1812": "Resistor_SMD:R_1812_4532Metric",
+ "2010": "Resistor_SMD:R_2010_5025Metric",
+ "2512": "Resistor_SMD:R_2512_6332Metric",
+ "2917": "Resistor_SMD:R_2917_7343Metric",
+ "2920": "Resistor_SMD:R_2920_7350Metric",
+ "CASE-A-3216-18(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A",
+ "CASE-B-3528-21(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3528-21_Kemet-B",
+ "CASE-C-6032-28(mm)": "Capacitor_Tantalum_SMD:CP_EIA-6032-28_Kemet-C",
+ "CASE-D-7343-31(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-31_Kemet-D",
+ "CASE-E-7343-43(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-43_Kemet-E",
+ "SMD,D4xL5.4mm": "Capacitor_SMD:CP_Elec_4x5.4",
+ "SMD,D5xL5.4mm": "Capacitor_SMD:CP_Elec_5x5.4",
+ "SMD,D6.3xL5.4mm": "Capacitor_SMD:CP_Elec_6.3x5.4",
+ "SMD,D6.3xL7.7mm": "Capacitor_SMD:CP_Elec_6.3x7.7",
+ "SMD,D8xL6.5mm": "Capacitor_SMD:CP_Elec_8x6.5",
+ "SMD,D8xL10mm": "Capacitor_SMD:CP_Elec_8x10",
+ "SMD,D10xL10mm": "Capacitor_SMD:CP_Elec_10x10",
+ "SMD,D10xL10.5mm": "Capacitor_SMD:CP_Elec_10x10.5",
+ "Through Hole,D5xL11mm": "Capacitor_THT:CP_Radial_D5.0mm_P2.00mm",
+ "Through Hole,D6.3xL11mm": "Capacitor_THT:CP_Radial_D6.3mm_P2.50mm",
+ "Through Hole,D8xL11mm": "Capacitor_THT:CP_Radial_D8.0mm_P3.50mm",
+ "Through Hole,D10xL16mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
+ "Through Hole,D10xL20mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
+ "Through Hole,D12.5xL20mm": "Capacitor_THT:CP_Radial_D12.5mm_P5.00mm",
+ "LED 3mm": "LED_THT:LED_D3.0mm",
+ "LED 5mm": "LED_THT:LED_D5.0mm",
+ "LED 0603": "LED_SMD:LED_0603_1608Metric",
+ "LED 0805": "LED_SMD:LED_0805_2012Metric",
+ "SMD5050-4P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
+ "SMD5050-6P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
+ "HC-49": "Crystal:Crystal_HC49-4H_Vertical",
+ "HC-49/U": "Crystal:Crystal_HC49-4H_Vertical",
+ "HC-49/S": "Crystal:Crystal_HC49-U_Vertical",
+ "HC-49/US": "Crystal:Crystal_HC49-U_Vertical",
+ "USB-A": "Connector_USB:USB_A_Stewart_SS-52100-001_Horizontal",
+ "USB-B": "Connector_USB:USB_B_OST_USB-B1HSxx_Horizontal",
+ "USB-Mini-B": "Connector_USB:USB_Mini-B_Lumberg_2486_01_Horizontal",
+ "USB-Micro-B": "Connector_USB:USB_Micro-B_Molex-105017-0001",
+ "USB-C": "Connector_USB:USB_C_Receptacle_GCT_USB4085",
+ "1x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical",
+ "1x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical",
+ "1x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical",
+ "1x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_Vertical",
+ "1x6 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical",
+ "1x8 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical",
+ "1x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x10_P2.54mm_Vertical",
+ "2x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x02_P2.54mm_Vertical",
+ "2x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_Vertical",
+ "2x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x04_P2.54mm_Vertical",
+ "2x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical",
+ "2x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x10_P2.54mm_Vertical",
+ "2x20 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x20_P2.54mm_Vertical",
+ "SIP-3-2.54mm": "Package_SIP:SIP-3_P2.54mm",
+ "SIP-4-2.54mm": "Package_SIP:SIP-4_P2.54mm",
+ "SIP-5-2.54mm": "Package_SIP:SIP-5_P2.54mm"
+ },
+ "categories": {
+ "Electrolytic": "Device:C_Polarized",
+ "Polarized": "Device:C_Polarized",
+ "Tantalum": "Device:C_Polarized",
+ "Zener": "Device:D_Zener",
+ "Schottky": "Device:D_Schottky",
+ "TVS": "Device:D_TVS",
+ "LED": "Device:LED",
+ "NPN": "Device:Q_NPN_BCE",
+ "PNP": "Device:Q_PNP_BCE",
+ "N-MOSFET": "Device:Q_NMOS_GDS",
+ "NMOS": "Device:Q_NMOS_GDS",
+ "N-MOS": "Device:Q_NMOS_GDS",
+ "P-MOSFET": "Device:Q_PMOS_GDS",
+ "PMOS": "Device:Q_PMOS_GDS",
+ "P-MOS": "Device:Q_PMOS_GDS",
+ "MOSFET": "Device:Q_NMOS_GDS",
+ "JFET": "Device:Q_NJFET_DSG",
+ "Ferrite": "Device:Ferrite_Bead",
+ "Crystal": "Device:Crystal",
+ "Oscillator": "Oscillator:Oscillator_Crystal",
+ "Fuse": "Device:Fuse",
+ "Transformer": "Device:Transformer_1P_1S",
+ "Resistor": "Device:R",
+ "Capacitor": "Device:C",
+ "Inductor": "Device:L",
+ "Diode": "Device:D",
+ "Transistor": "Device:Q_NPN_BCE",
+ "Voltage Regulator": "Regulator_Linear:LM317_TO-220",
+ "LDO": "Regulator_Linear:AMS1117-3.3",
+ "Op-Amp": "Amplifier_Operational:LM358",
+ "Comparator": "Comparator:LM393",
+ "Optocoupler": "Isolator:PC817",
+ "Relay": "Relay:Relay_DPDT",
+ "Connector": "Connector:Conn_01x02",
+ "Switch": "Switch:SW_Push",
+ "Button": "Switch:SW_Push",
+ "Potentiometer": "Device:R_POT",
+ "Trimpot": "Device:R_POT",
+ "Thermistor": "Device:Thermistor",
+ "Varistor": "Device:Varistor",
+ "Photo": "Device:LED"
+ }
+}
diff --git a/migrations/Version20260208190000.php b/migrations/Version20260208190000.php
deleted file mode 100644
index 3ff1a80d..00000000
--- a/migrations/Version20260208190000.php
+++ /dev/null
@@ -1,47 +0,0 @@
-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');
- }
-}
diff --git a/migrations/Version20260210120000.php b/migrations/Version20260210120000.php
deleted file mode 100644
index 04684a36..00000000
--- a/migrations/Version20260210120000.php
+++ /dev/null
@@ -1,46 +0,0 @@
-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/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
index bcfcf927..0bc03392 100644
--- a/src/Command/PopulateKicadCommand.php
+++ b/src/Command/PopulateKicadCommand.php
@@ -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 $mappings
+ * @param string $name The primary name of the footprint
+ * @param string|null $alternativeNames Comma-separated alternative names
+ * @return string|null The matched KiCad path, or null if no match found
+ */
+ private function findFootprintMapping(array $mappings, string $name, ?string $alternativeNames): ?string
+ {
+ // Check primary name
+ if (isset($mappings[$name])) {
+ return $mappings[$name];
+ }
+
+ // Check alternative names
+ if ($alternativeNames !== null && $alternativeNames !== '') {
+ foreach (explode(',', $alternativeNames) as $altName) {
+ $altName = trim($altName);
+ if ($altName !== '' && isset($mappings[$altName])) {
+ return $mappings[$altName];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Finds a category mapping by checking the entity name and its alternative names.
+ * Categories use pattern-based matching (case-insensitive contains).
+ *
+ * @param array $mappings
+ * @param string $name The primary name of the category
+ * @param string|null $alternativeNames Comma-separated alternative names
+ * @return string|null The matched KiCad symbol path, or null if no match found
+ */
+ private function findCategoryMapping(array $mappings, string $name, ?string $alternativeNames): ?string
+ {
+ // Check primary name against all patterns
+ foreach ($mappings as $pattern => $kicadSymbol) {
+ if ($this->matchesPattern($name, $pattern)) {
+ return $kicadSymbol;
+ }
+ }
+
+ // Check alternative names against all patterns
+ if ($alternativeNames !== null && $alternativeNames !== '') {
+ foreach (explode(',', $alternativeNames) as $altName) {
+ $altName = trim($altName);
+ if ($altName === '') {
+ continue;
+ }
+ foreach ($mappings as $pattern => $kicadSymbol) {
+ if ($this->matchesPattern($altName, $pattern)) {
+ return $kicadSymbol;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
/**
* Returns footprint name to KiCad footprint path mappings.
* These are based on KiCad 9.x standard library paths.
@@ -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;
+ }
}
diff --git a/src/Controller/KiCadApiController.php b/src/Controller/KiCadApiController.php
index ea93138c..76727877 100644
--- a/src/Controller/KiCadApiController.php
+++ b/src/Controller/KiCadApiController.php
@@ -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)));
diff --git a/src/Controller/KiCadApiV2Controller.php b/src/Controller/KiCadApiV2Controller.php
index 5332ccd8..a915b94e 100644
--- a/src/Controller/KiCadApiV2Controller.php
+++ b/src/Controller/KiCadApiV2Controller.php
@@ -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)));
diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php
index 2762657a..f47f2e82 100644
--- a/src/Entity/Parameters/AbstractParameter.php
+++ b/src/Entity/Parameters/AbstractParameter.php
@@ -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;
}
diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php
index 0cc8cf27..56428e3a 100644
--- a/src/Entity/PriceInformations/Orderdetail.php
+++ b/src/Entity/PriceInformations/Orderdetail.php
@@ -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;
}
diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php
index 3a773f4e..0e3ad5e2 100644
--- a/src/Form/ParameterType.php
+++ b/src/Form/ParameterType.php
@@ -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
diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php
index d875f9e7..378f3389 100644
--- a/src/Form/Part/OrderdetailType.php
+++ b/src/Form/Part/OrderdetailType.php
@@ -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
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
index 29274641..8bd1fc74 100644
--- a/src/Services/EDA/KiCadHelper.php
+++ b/src/Services/EDA/KiCadHelper.php
@@ -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])) {
diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php
index d9611013..948d1b38 100644
--- a/src/Settings/MiscSettings/KiCadEDASettings.php
+++ b/src/Settings/MiscSettings/KiCadEDASettings.php
@@ -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;
}
\ No newline at end of file
diff --git a/templates/parts/edit/_specifications.html.twig b/templates/parts/edit/_specifications.html.twig
index 3226e2c0..6f631b9f 100644
--- a/templates/parts/edit/_specifications.html.twig
+++ b/templates/parts/edit/_specifications.html.twig
@@ -14,7 +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 6564bc55..9e989c92 100644
--- a/templates/parts/edit/edit_form_styles.html.twig
+++ b/templates/parts/edit/edit_form_styles.html.twig
@@ -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) }}
|
@@ -80,7 +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) }} |
- {{ form_widget(form.kicad_export) }} |
+ {% if form.eda_visibility is defined %}
+ {{ form_widget(form.eda_visibility) }} |
+ {% endif %}
| |