From 2d373301558a8fc6b7180241f396e38a9758f39f Mon Sep 17 00:00:00 2001 From: DanTrackpaw Date: Tue, 17 Mar 2026 15:15:13 +0100 Subject: [PATCH] Add admin editor for KiCad autocomplete lists --- src/Controller/KicadListEditorController.php | 70 ++++++++++++ src/Form/Settings/KicadListEditorType.php | 57 ++++++++++ src/Services/EDA/KicadListFileManager.php | 96 ++++++++++++++++ .../settings/kicad_list_editor.html.twig | 17 +++ templates/settings/settings.html.twig | 9 ++ .../ApplicationAvailabilityFunctionalTest.php | 1 + .../KicadListEditorControllerTest.php | 105 ++++++++++++++++++ translations/messages.en.xlf | 42 +++++++ 8 files changed, 397 insertions(+) create mode 100644 src/Controller/KicadListEditorController.php create mode 100644 src/Form/Settings/KicadListEditorType.php create mode 100644 src/Services/EDA/KicadListFileManager.php create mode 100644 templates/settings/kicad_list_editor.html.twig create mode 100644 tests/Controller/KicadListEditorControllerTest.php diff --git a/src/Controller/KicadListEditorController.php b/src/Controller/KicadListEditorController.php new file mode 100644 index 00000000..ea9289db --- /dev/null +++ b/src/Controller/KicadListEditorController.php @@ -0,0 +1,70 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Form\Settings\KicadListEditorType; +use App\Services\EDA\KicadListFileManager; +use RuntimeException; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +use function Symfony\Component\Translation\t; + +final class KicadListEditorController extends AbstractController +{ + #[Route('/settings/misc/kicad-lists', name: 'settings_kicad_lists')] + public function __invoke(Request $request, KicadListFileManager $fileManager): Response + { + $this->denyAccessUnlessGranted('@config.change_system_settings'); + + $form = $this->createForm(KicadListEditorType::class, [ + 'footprints' => $fileManager->getFootprintsContent(), + 'symbols' => $fileManager->getSymbolsContent(), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + try { + $fileManager->save($data['footprints'], $data['symbols']); + $this->addFlash('success', t('settings.flash.saved')); + + return $this->redirectToRoute('settings_kicad_lists'); + } catch (RuntimeException $exception) { + $this->addFlash('error', $exception->getMessage()); + } + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', t('settings.flash.invalid')); + } + + return $this->render('settings/kicad_list_editor.html.twig', [ + 'form' => $form, + ]); + } +} diff --git a/src/Form/Settings/KicadListEditorType.php b/src/Form/Settings/KicadListEditorType.php new file mode 100644 index 00000000..3c933aee --- /dev/null +++ b/src/Form/Settings/KicadListEditorType.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Settings; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\FormBuilderInterface; + +final class KicadListEditorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('footprints', TextareaType::class, [ + 'label' => 'settings.misc.kicad_eda.editor.footprints', + 'help' => 'settings.misc.kicad_eda.editor.footprints.help', + 'attr' => [ + 'rows' => 16, + 'spellcheck' => 'false', + 'class' => 'font-monospace', + ], + ]) + ->add('symbols', TextareaType::class, [ + 'label' => 'settings.misc.kicad_eda.editor.symbols', + 'help' => 'settings.misc.kicad_eda.editor.symbols.help', + 'attr' => [ + 'rows' => 16, + 'spellcheck' => 'false', + 'class' => 'font-monospace', + ], + ]) + ->add('save', SubmitType::class, [ + 'label' => 'save', + ]); + } +} diff --git a/src/Services/EDA/KicadListFileManager.php b/src/Services/EDA/KicadListFileManager.php new file mode 100644 index 00000000..50922510 --- /dev/null +++ b/src/Services/EDA/KicadListFileManager.php @@ -0,0 +1,96 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\EDA; + +use RuntimeException; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class KicadListFileManager +{ + private const FOOTPRINTS_PATH = '/public/kicad/footprints.txt'; + private const SYMBOLS_PATH = '/public/kicad/symbols.txt'; + + public function __construct( + #[Autowire('%kernel.project_dir%')] + private readonly string $projectDir, + ) { + } + + public function getFootprintsContent(): string + { + return $this->readFile(self::FOOTPRINTS_PATH); + } + + public function getSymbolsContent(): string + { + return $this->readFile(self::SYMBOLS_PATH); + } + + public function save(string $footprints, string $symbols): void + { + $this->writeFile(self::FOOTPRINTS_PATH, $this->normalizeContent($footprints)); + $this->writeFile(self::SYMBOLS_PATH, $this->normalizeContent($symbols)); + } + + private function readFile(string $path): string + { + $fullPath = $this->projectDir . $path; + + if (!is_file($fullPath)) { + return ''; + } + + $content = file_get_contents($fullPath); + if ($content === false) { + throw new RuntimeException(sprintf('Failed to read KiCad list file "%s".', $fullPath)); + } + + return $content; + } + + private function writeFile(string $path, string $content): void + { + $fullPath = $this->projectDir . $path; + $tmpPath = $fullPath . '.tmp'; + + if (file_put_contents($tmpPath, $content, LOCK_EX) === false) { + throw new RuntimeException(sprintf('Failed to write KiCad list file "%s".', $fullPath)); + } + + if (!rename($tmpPath, $fullPath)) { + @unlink($tmpPath); + throw new RuntimeException(sprintf('Failed to replace KiCad list file "%s".', $fullPath)); + } + } + + private function normalizeContent(string $content): string + { + $normalized = str_replace(["\r\n", "\r"], "\n", $content); + + if ($normalized !== '' && !str_ends_with($normalized, "\n")) { + $normalized .= "\n"; + } + + return $normalized; + } +} diff --git a/templates/settings/kicad_list_editor.html.twig b/templates/settings/kicad_list_editor.html.twig new file mode 100644 index 00000000..d83c31ed --- /dev/null +++ b/templates/settings/kicad_list_editor.html.twig @@ -0,0 +1,17 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %} + +{% block card_title %} {% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %} + +{% block card_content %} +

+ {% trans %}settings.misc.kicad_eda.editor.description{% endtrans %} +

+ + {{ form_start(form) }} + {{ form_row(form.footprints) }} + {{ form_row(form.symbols) }} + {{ form_row(form.save) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/settings/settings.html.twig b/templates/settings/settings.html.twig index a2c01085..325118d6 100644 --- a/templates/settings/settings.html.twig +++ b/templates/settings/settings.html.twig @@ -49,6 +49,15 @@ {{ form_widget(section_widget) }} + {% if section_widget.vars.name == 'kicadEDA' %} +
+ +
+ {% endif %} {% if not loop.last %}
diff --git a/tests/ApplicationAvailabilityFunctionalTest.php b/tests/ApplicationAvailabilityFunctionalTest.php index c7449411..3bb222d0 100644 --- a/tests/ApplicationAvailabilityFunctionalTest.php +++ b/tests/ApplicationAvailabilityFunctionalTest.php @@ -60,6 +60,7 @@ final class ApplicationAvailabilityFunctionalTest extends WebTestCase //User related things yield ['/user/settings']; yield ['/user/info']; + yield ['/settings/misc/kicad-lists']; //Login/logout yield ['/login']; diff --git a/tests/Controller/KicadListEditorControllerTest.php b/tests/Controller/KicadListEditorControllerTest.php new file mode 100644 index 00000000..98b091a2 --- /dev/null +++ b/tests/Controller/KicadListEditorControllerTest.php @@ -0,0 +1,105 @@ +. + */ + +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')] +final class KicadListEditorControllerTest extends WebTestCase +{ + private string $footprintsPath; + private string $symbolsPath; + private string $originalFootprints; + private string $originalSymbols; + + protected function setUp(): void + { + parent::setUp(); + + $projectDir = dirname(__DIR__, 2); + $this->footprintsPath = $projectDir . '/public/kicad/footprints.txt'; + $this->symbolsPath = $projectDir . '/public/kicad/symbols.txt'; + $this->originalFootprints = (string) file_get_contents($this->footprintsPath); + $this->originalSymbols = (string) file_get_contents($this->symbolsPath); + } + + protected function tearDown(): void + { + file_put_contents($this->footprintsPath, $this->originalFootprints); + file_put_contents($this->symbolsPath, $this->originalSymbols); + + parent::tearDown(); + } + + public function testEditorRequiresAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', '/en/settings/misc/kicad-lists'); + + $this->assertResponseStatusCodeSame(401); + } + + public function testEditorAccessibleByAdmin(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('GET', '/en/settings/misc/kicad-lists'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('form[name="kicad_list_editor"]'); + } + + public function testEditorSavesFiles(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $crawler = $client->request('GET', '/en/settings/misc/kicad-lists'); + $form = $crawler->filter('form[name="kicad_list_editor"]')->form(); + $form['kicad_list_editor[footprints]'] = "Package_DIP:DIP-8_W7.62mm\n"; + $form['kicad_list_editor[symbols]'] = "Device:R\n"; + + $client->submit($form); + + $this->assertResponseRedirects('/en/settings/misc/kicad-lists'); + $this->assertSame("Package_DIP:DIP-8_W7.62mm\n", (string) file_get_contents($this->footprintsPath)); + $this->assertSame("Device:R\n", (string) file_get_contents($this->symbolsPath)); + } + + 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(sprintf('User "%s" not found in fixtures', $username)); + } + + $client->loginUser($user); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ce92bda6..656c7ecf 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -10029,6 +10029,48 @@ Please note, that you can not impersonate a disabled user. If you try you will g 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.misc.kicad_eda.editor.title + KiCad autocomplete lists + + + + + settings.misc.kicad_eda.editor.link + Edit autocomplete lists + + + + + settings.misc.kicad_eda.editor.description + Edit the contents of public/kicad/footprints.txt and public/kicad/symbols.txt. These files are used by the KiCad autocomplete fields in the admin UI. + + + + + settings.misc.kicad_eda.editor.footprints + Footprints list + + + + + settings.misc.kicad_eda.editor.footprints.help + One entry per line. Used as autocomplete suggestions for KiCad footprint fields. + + + + + settings.misc.kicad_eda.editor.symbols + Symbols list + + + + + settings.misc.kicad_eda.editor.symbols.help + One entry per line. Used as autocomplete suggestions for KiCad symbol fields. + + settings.behavior.sidebar