Enhance KiCad integration: API v2, batch EDA editing, field export control (#1241)

* 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

* 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)

* 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

* 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.

* 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.

* 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.

* Add partdb:kicad:populate command for bulk KiCad path assignment

Console command that populates KiCad footprint/symbol paths on Footprint
and Category entities based on name-to-library mappings. Supports dry-run,
force overwrite, and list modes. Includes 130+ footprint mappings and 30+
category symbol mappings for KiCad 9.x standard libraries.

* Add CSV import support for EDA/KiCad fields

Add user-friendly column aliases (kicad_symbol, kicad_footprint,
kicad_reference, kicad_value, eda_exclude_bom, etc.) to the CSV import
system. Users can now bulk-set KiCad symbols, footprints, and other EDA
metadata via CSV/Excel import without knowing the internal dot notation.

* Add batch EDA field editing from parts table

Users can now select multiple parts in any parts table and batch-edit
their EDA/KiCad fields (symbol, footprint, reference prefix, value,
visibility, exclude from BOM/board/sim). Each field has an "Apply"
checkbox so users control exactly which fields are changed.

* Remove unused counter variable in BatchEdaController

* Fix PHPStan errors in PopulateKicadCommand and BatchEdaController

Add @var type annotations for Doctrine repository findAll() calls so
PHPStan can resolve getEdaInfo() on Footprint/Category entities. Fix
array return type for numeric-string keys and add explicit callback to
array_filter to satisfy strict rules.

* Fix batch EDA edit: required validation and pre-populate shared values

- Add required=false to TriStateCheckboxType fields so HTML5 validation
  doesn't force users to check visibility/BOM/board checkboxes
- Pre-populate form fields when all selected parts share the same EDA
  value, so users can see current state before editing

* Add KiCad API v2, orderdetail export control, EDA status indicator, BOM improvements

- Add KiCad API v2 endpoints (/kicad-api/v2) with volatile field support
  for stock and storage location (shown but not saved to schematic)
- Add kicad_export flag to Orderdetail entity for per-supplier SPN control
  (backward compatible: if no flag set, all SPNs exported as before)
- Add EDA completeness indicator column in parts datatable (bolt icon)
- Add ?minimal=true query param for faster category parts loading
- Improve category descriptions (use comment instead of URL when available)
- Improve BOM importer multi-footprint support: merge entries by Part-DB
  part ID when linked, tracking footprint variants in comments
- Fix KiCost manf/manf# fields always present (not conditional on orderdetails)
- Fix duplicate getEdaInfo() call in shouldPartBeVisible
- Consolidate supplier SPN and KiCost field generation into single loop

* Fix kicad_export column default for SQLite compatibility

Add options default to ORM column definition so schema:update
works correctly on SQLite test databases.

* Make EDA status bolt icon clickable to open EDA settings tab

* Fix EDA bolt link to correctly open EDA tab via data-turbo=false

* 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.

* 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.

* 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

* Fix test failures: correct ids format and anonymous access assertion

* Improve test coverage for BatchEdaController

Add tests for: applying all EDA fields at once, custom redirect URL,
and verifying unchecked fields are skipped.

* 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

* Fix duplicate loadMappingFile method causing PHP fatal error

* Add tests for mapping file and alternative name matching, update populate command docs

Add 5 new tests for PopulateKicadCommand covering:
- Custom mapping file overriding defaults
- Invalid JSON mapping file error handling
- Missing mapping file error handling
- Footprint alternative name matching
- Category alternative name matching

Update contrib README to document --mapping-file option,
alternative name matching, and custom JSON mapping format.

* Split out KiCad API v2 into separate PR as requested by maintainer

Remove v2 controller, tests, and volatile field support from this PR.
The v2 API will be submitted as a separate PR for focused discussion.

* Improve test coverage for KiCadHelper and PopulateKicadCommand

KiCadHelper: Add tests for orderdetail eda_visibility filtering,
backward compatibility when no flags set, manufacturer/KiCost fields,
and parameter with empty name skipping.

PopulateKicadCommand: Add tests for mapping file with both footprints
and categories sections, and mapping file with only categories.

* Load populate Kicad default mappings from json file

* Moved kicad:populate documentation to central docs

* Added introduced column to PartTableColumns to make it configurable in the settings

* Use TristateCheckboxes for parameter and orderdetail types

* Fixed translation keys

* Split up default eda visibility for parameters and purchase infos

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
Sebastian Almberg 2026-03-01 22:10:13 +01:00 committed by GitHub
parent 46617f01a4
commit b9d940ae33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2920 additions and 67 deletions

View file

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

View file

@ -88,3 +88,6 @@ The value of the environment variable is copied to the settings database, so the
* `php bin/console partdb:attachments:download`: Download all attachments that are not already downloaded to the
local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote, and
also makes picture thumbnails available for the frontend for them.
## EDA integration commands
* `php bin/console partdb:kicad:populate`: Populate KiCad footprint paths and symbol paths for footprints and categories based on their names. Use `--dry-run` to preview changes without applying them, and `--list` to list current values. See the [EDA integration documentation](eda_integration.md) for more details.

View file

@ -87,3 +87,31 @@ To show more levels of categories, you can set this value to a higher number.
If you set this value to -1, all parts are shown inside a single category in KiCad, without any subcategories.
You can view the "real" category path of a part in the part details dialog in KiCad.
### Kicad:populate command
Part-DB also provides a command that attempts to automatically populate the KiCad symbol and footprint fields based on the part's category and footprint names.
This is especially useful if you have a large database and want to quickly assign symbols and footprints to parts without doing it manually.
For this run `bin/console partdb:kicad:populate --dry-run` in the terminal, it will show you a list of suggestions for mappings for your existing categories and footprints.
It uses names and alternative names, when the primary name doesn't match, to find the right mapping.
If you are happy with the suggestions, you can run the command without the `--dry-run` option to apply the changes to your database. By default, only empty values are updated, but you can use the `--force` option to overwrite existing values as well.
It uses the mapping under `assets/commands/kicad_populate_default_mappings.json` by default, but you can extend/override it by providing your own mapping file
with the `--mapping-file` option.
The mapping file is a JSON file with the following structure, where the key is the name of the footprint or category, and the value is the corresponding KiCad library path:
```json
{
"footprints": {
"MyCustomPackage": "MyLibrary:MyFootprint",
"0805": "Capacitor_SMD:C_0805_2012Metric"
},
"categories": {
"Sensor": "Sensor:Sensor_Temperature",
"MCU": "MCU_Microchip:PIC16F877A"
}
}
```
Its okay if the file contains just one of the `footprints` or `categories` keys, so you can choose to only provide mappings for one of them if you want.
It is recommended to take a backup of your database before running this command.

View file

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

View file

@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand('partdb:kicad:populate', 'Populate KiCad footprint paths and symbol paths for footprints and categories')]
final class PopulateKicadCommand extends Command
{
private const DEFAULT_MAPPING_FILE = 'assets/commands/kicad_populate_default_mappings.json';
public function __construct(private readonly EntityManagerInterface $entityManager, #[Autowire("%kernel.project_dir%")] private readonly string $projectDir)
{
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('This command populates KiCad footprint paths on Footprint entities and KiCad symbol paths on Category entities based on their names.');
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without applying them')
->addOption('footprints', null, InputOption::VALUE_NONE, 'Only update footprint entities')
->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)')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$footprintsOnly = $input->getOption('footprints');
$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;
$doCategories = !$footprintsOnly || $categoriesOnly;
if ($list) {
$this->listCurrentValues($io);
return Command::SUCCESS;
}
// Load mappings: start with built-in defaults, then merge user-supplied file
['footprints' => $footprintMappings, 'categories' => $categoryMappings] = $this->getDefaultMappings();
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');
}
$totalUpdated = 0;
if ($doFootprints) {
$updated = $this->updateFootprints($io, $dryRun, $force, $footprintMappings);
$totalUpdated += $updated;
}
if ($doCategories) {
$updated = $this->updateCategories($io, $dryRun, $force, $categoryMappings);
$totalUpdated += $updated;
}
if (!$dryRun && $totalUpdated > 0) {
$this->entityManager->flush();
$io->success(sprintf('Updated %d entities. Run "php bin/console cache:clear" to clear the cache.', $totalUpdated));
} elseif ($dryRun && $totalUpdated > 0) {
$io->info(sprintf('DRY RUN: Would update %d entities. Run without --dry-run to apply changes.', $totalUpdated));
} else {
$io->info('No entities needed updating.');
}
return Command::SUCCESS;
}
private function listCurrentValues(SymfonyStyle $io): void
{
$io->section('Current Footprint KiCad Values');
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
/** @var Footprint[] $footprints */
$footprints = $footprintRepo->findAll();
$rows = [];
foreach ($footprints as $footprint) {
$kicadValue = $footprint->getEdaInfo()->getKicadFootprint();
$rows[] = [
$footprint->getId(),
$footprint->getName(),
$kicadValue ?? '(empty)',
];
}
$io->table(['ID', 'Name', 'KiCad Footprint'], $rows);
$io->section('Current Category KiCad Values');
$categoryRepo = $this->entityManager->getRepository(Category::class);
/** @var Category[] $categories */
$categories = $categoryRepo->findAll();
$rows = [];
foreach ($categories as $category) {
$kicadValue = $category->getEdaInfo()->getKicadSymbol();
$rows[] = [
$category->getId(),
$category->getName(),
$kicadValue ?? '(empty)',
];
}
$io->table(['ID', 'Name', 'KiCad Symbol'], $rows);
}
private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
{
$io->section('Updating Footprint Entities');
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
/** @var Footprint[] $footprints */
$footprints = $footprintRepo->findAll();
$updated = 0;
$skipped = [];
foreach ($footprints as $footprint) {
$name = $footprint->getName();
$currentValue = $footprint->getEdaInfo()->getKicadFootprint();
// Skip if already has value and not forcing
if (!$force && $currentValue !== null && $currentValue !== '') {
continue;
}
// 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($matchedValue);
}
$updated++;
} else {
// No mapping found
$skipped[] = $name;
}
}
$io->newLine();
$io->text(sprintf('Updated: %d footprints', $updated));
if (count($skipped) > 0) {
$io->warning(sprintf('No mapping found for %d footprints:', count($skipped)));
foreach ($skipped as $name) {
$io->text(' - ' . $name);
}
}
return $updated;
}
private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
{
$io->section('Updating Category Entities');
$categoryRepo = $this->entityManager->getRepository(Category::class);
/** @var Category[] $categories */
$categories = $categoryRepo->findAll();
$updated = 0;
$skipped = [];
foreach ($categories as $category) {
$name = $category->getName();
$currentValue = $category->getEdaInfo()->getKicadSymbol();
// Skip if already has value and not forcing
if (!$force && $currentValue !== null && $currentValue !== '') {
continue;
}
// Check for matches using the pattern-based mappings (also check alternative names)
$matchedValue = $this->findCategoryMapping($mappings, $name, $category->getAlternativeNames());
if ($matchedValue !== null) {
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue));
if (!$dryRun) {
$category->getEdaInfo()->setKicadSymbol($matchedValue);
}
$updated++;
} else {
$skipped[] = $name;
}
}
$io->newLine();
$io->text(sprintf('Updated: %d categories', $updated));
if (count($skipped) > 0) {
$io->note(sprintf('No mapping found for %d categories (this is often expected):', count($skipped)));
foreach ($skipped as $name) {
$io->text(' - ' . $name);
}
}
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
if ($pattern === $name) {
return true;
}
// Check for case-insensitive contains
if (stripos($name, $pattern) !== false) {
return true;
}
return false;
}
/**
* Finds a footprint mapping by checking the entity name and its alternative names.
* Footprints use exact matching.
*
* @param array<string, string> $mappings
* @param string $name The primary name of the footprint
* @param string|null $alternativeNames Comma-separated alternative names
* @return string|null The matched KiCad path, or null if no match found
*/
private function findFootprintMapping(array $mappings, string $name, ?string $alternativeNames): ?string
{
// Check primary name
if (isset($mappings[$name])) {
return $mappings[$name];
}
// Check alternative names
if ($alternativeNames !== null && $alternativeNames !== '') {
foreach (explode(',', $alternativeNames) as $altName) {
$altName = trim($altName);
if ($altName !== '' && isset($mappings[$altName])) {
return $mappings[$altName];
}
}
}
return null;
}
/**
* Finds a category mapping by checking the entity name and its alternative names.
* Categories use pattern-based matching (case-insensitive contains).
*
* @param array<string, string> $mappings
* @param string $name The primary name of the category
* @param string|null $alternativeNames Comma-separated alternative names
* @return string|null The matched KiCad symbol path, or null if no match found
*/
private function findCategoryMapping(array $mappings, string $name, ?string $alternativeNames): ?string
{
// Check primary name against all patterns
foreach ($mappings as $pattern => $kicadSymbol) {
if ($this->matchesPattern($name, $pattern)) {
return $kicadSymbol;
}
}
// Check alternative names against all patterns
if ($alternativeNames !== null && $alternativeNames !== '') {
foreach (explode(',', $alternativeNames) as $altName) {
$altName = trim($altName);
if ($altName === '') {
continue;
}
foreach ($mappings as $pattern => $kicadSymbol) {
if ($this->matchesPattern($altName, $pattern)) {
return $kicadSymbol;
}
}
}
}
return null;
}
/**
* Returns the default mappings for footprints and categories.
* @return array{footprints: array<string, string>, categories: array<string, string>}
* @throws \JsonException
*/
private function getDefaultMappings(): array
{
$path = $this->projectDir . '/' . self::DEFAULT_MAPPING_FILE;
$content = file_get_contents($path);
return json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
}

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parts\Part;
use App\Form\Part\EDA\BatchEdaType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BatchEdaController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
/**
* Compute shared EDA values across all parts. If all parts have the same value for a field, return it.
* @param Part[] $parts
* @return array<string, mixed>
*/
private function getSharedEdaValues(array $parts): array
{
$fields = [
'reference_prefix' => static fn (Part $p) => $p->getEdaInfo()->getReferencePrefix(),
'value' => static fn (Part $p) => $p->getEdaInfo()->getValue(),
'kicad_symbol' => static fn (Part $p) => $p->getEdaInfo()->getKicadSymbol(),
'kicad_footprint' => static fn (Part $p) => $p->getEdaInfo()->getKicadFootprint(),
'visibility' => static fn (Part $p) => $p->getEdaInfo()->getVisibility(),
'exclude_from_bom' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromBom(),
'exclude_from_board' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromBoard(),
'exclude_from_sim' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromSim(),
];
$data = [];
foreach ($fields as $key => $getter) {
$values = array_map($getter, $parts);
$unique = array_unique($values, SORT_REGULAR);
if (count($unique) === 1) {
$data[$key] = $unique[array_key_first($unique)];
}
}
return $data;
}
#[Route('/tools/batch_eda_edit', name: 'batch_eda_edit')]
public function batchEdaEdit(Request $request): Response
{
$this->denyAccessUnlessGranted('@parts.edit');
$ids = $request->query->getString('ids', '');
$redirectUrl = $request->query->getString('_redirect', '');
//Parse part IDs and load parts
$idArray = array_filter(array_map(intval(...), explode(',', $ids)), static fn (int $id): bool => $id > 0);
$parts = $this->entityManager->getRepository(Part::class)->findBy(['id' => $idArray]);
if ($parts === []) {
$this->addFlash('error', 'batch_eda.no_parts_selected');
return $redirectUrl !== '' ? $this->redirect($redirectUrl) : $this->redirectToRoute('parts_show_all');
}
//Pre-populate form with shared values (when all parts have the same value)
$initialData = $this->getSharedEdaValues($parts);
$form = $this->createForm(BatchEdaType::class, $initialData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
foreach ($parts as $part) {
$this->denyAccessUnlessGranted('edit', $part);
$edaInfo = $part->getEdaInfo();
if ($form->get('apply_reference_prefix')->getData()) {
$edaInfo->setReferencePrefix($form->get('reference_prefix')->getData() ?: null);
}
if ($form->get('apply_value')->getData()) {
$edaInfo->setValue($form->get('value')->getData() ?: null);
}
if ($form->get('apply_kicad_symbol')->getData()) {
$edaInfo->setKicadSymbol($form->get('kicad_symbol')->getData() ?: null);
}
if ($form->get('apply_kicad_footprint')->getData()) {
$edaInfo->setKicadFootprint($form->get('kicad_footprint')->getData() ?: null);
}
if ($form->get('apply_visibility')->getData()) {
$edaInfo->setVisibility($form->get('visibility')->getData());
}
if ($form->get('apply_exclude_from_bom')->getData()) {
$edaInfo->setExcludeFromBom($form->get('exclude_from_bom')->getData());
}
if ($form->get('apply_exclude_from_board')->getData()) {
$edaInfo->setExcludeFromBoard($form->get('exclude_from_board')->getData());
}
if ($form->get('apply_exclude_from_sim')->getData()) {
$edaInfo->setExcludeFromSim($form->get('exclude_from_sim')->getData());
}
}
$this->entityManager->flush();
$this->addFlash('success', 'batch_eda.success');
return $redirectUrl !== '' ? $this->redirect($redirectUrl) : $this->redirectToRoute('parts_show_all');
}
return $this->render('parts/batch_eda_edit.html.twig', [
'form' => $form->createView(),
'parts' => $parts,
'redirect_url' => $redirectUrl,
]);
}
}

View file

@ -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->createCacheableJsonResponse($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,31 @@ class KiCadApiController extends AbstractController
}
$this->denyAccessUnlessGranted('@parts.read');
return $this->json($this->kiCADHelper->getCategoryParts($category));
$minimal = $request->query->getBoolean('minimal', false);
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
return $this->createCacheableJsonResponse($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->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 createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
{
$response = new JsonResponse($data);
$response->setEtag(md5(json_encode($data)));
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
$response->isNotModified($request);
return $response;
}
}

View file

@ -115,6 +115,61 @@ class PartDataTableHelper
return implode('<br>', $tmp);
}
/**
* Renders an EDA/KiCad completeness indicator for the given part.
* Shows icons for symbol, footprint, and value status.
*/
public function renderEdaStatus(Part $context): string
{
$edaInfo = $context->getEdaInfo();
$category = $context->getCategory();
$footprint = $context->getFootprint();
// Determine effective values (direct or inherited)
$hasSymbol = $edaInfo->getKicadSymbol() !== null || $category?->getEdaInfo()->getKicadSymbol() !== null;
$hasFootprint = $edaInfo->getKicadFootprint() !== null || $footprint?->getEdaInfo()->getKicadFootprint() !== null;
$hasReference = $edaInfo->getReferencePrefix() !== null || $category?->getEdaInfo()->getReferencePrefix() !== null;
$symbolInherited = $edaInfo->getKicadSymbol() === null && $category?->getEdaInfo()->getKicadSymbol() !== null;
$footprintInherited = $edaInfo->getKicadFootprint() === null && $footprint?->getEdaInfo()->getKicadFootprint() !== null;
$icons = [];
// Symbol status
if ($hasSymbol) {
$title = $this->translator->trans('eda.status.symbol_set');
$class = $symbolInherited ? 'text-info' : 'text-success';
$icons[] = sprintf('<i class="fa-solid fa-microchip fa-fw %s" title="%s"></i>', $class, $title);
}
// Footprint status
if ($hasFootprint) {
$title = $this->translator->trans('eda.status.footprint_set');
$class = $footprintInherited ? 'text-info' : 'text-success';
$icons[] = sprintf('<i class="fa-solid fa-stamp fa-fw %s" title="%s"></i>', $class, $title);
}
// Reference prefix status
if ($hasReference) {
$icons[] = sprintf('<i class="fa-solid fa-font fa-fw text-success" title="%s"></i>',
$this->translator->trans('eda.status.reference_set'));
}
if (empty($icons)) {
return '';
}
// Overall status: all 3 = green check, partial = yellow
$allSet = $hasSymbol && $hasFootprint && $hasReference;
$statusIcon = $allSet
? sprintf('<i class="fa-solid fa-bolt fa-fw text-success" title="%s"></i>', $this->translator->trans('eda.status.complete'))
: sprintf('<i class="fa-solid fa-bolt fa-fw text-warning" title="%s"></i>', $this->translator->trans('eda.status.partial'));
// 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('<a href="%s" data-turbo="false">%s</a>', $editUrl, $statusIcon);
}
public function renderAmount(Part $context): string
{
$amount = $context->getAmountSum();

View file

@ -89,6 +89,10 @@ final class PartsDataTable implements DataTableTypeInterface
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
/*************************************************************************************************************
* When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings!
*************************************************************************************************************/
$this->csh
//Color the table rows depending on the review and favorite status
->add('row_color', RowClassColumn::class, [
@ -228,6 +232,11 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('attachments', PartAttachmentsColumn::class, [
'label' => $this->translator->trans('part.table.attachments'),
])
->add('eda_status', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_status'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
'className' => 'text-center',
]);
//Add a column to list the projects where the part is used, when the user has the permission to see the projects

View file

@ -172,6 +172,13 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
#[Assert\Length(max: 255)]
protected string $group = '';
/**
* @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, nullable: true, options: ['default' => null])]
protected ?bool $eda_visibility = null;
/**
* Mapping is done in subclasses.
*
@ -471,6 +478,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
return static::ALLOWED_ELEMENT_CLASS;
}
public function isEdaVisibility(): ?bool
{
return $this->eda_visibility;
}
/**
* @return $this
*/
public function setEdaVisibility(?bool $eda_visibility): self
{
$this->eda_visibility = $eda_visibility;
return $this;
}
public function getComparableFields(): array
{
return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];

View file

@ -122,6 +122,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $obsolete = false;
/**
* @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, nullable: true, options: ['default' => null])]
protected ?bool $eda_visibility = null;
/**
* @var string The URL to the product on the supplier's website
*/
@ -418,6 +425,21 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this;
}
public function isEdaVisibility(): ?bool
{
return $this->eda_visibility;
}
/**
* @return $this
*/
public function setEdaVisibility(?bool $eda_visibility): self
{
$this->eda_visibility = $eda_visibility;
return $this;
}
public function getName(): string
{
return $this->getSupplierPartNr();

View file

@ -54,7 +54,9 @@ use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\MeasurementUnit;
use App\Form\Type\ExponentialNumberType;
use App\Form\Type\TriStateCheckboxType;
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 +149,14 @@ class ParameterType extends AbstractType
'class' => 'form-control-sm',
],
]);
// 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', TriStateCheckboxType::class, [
'label' => false,
'required' => false,
]);
}
}
public function finishView(FormView $view, FormInterface $form, array $options): void

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Form\Part\EDA;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use function Symfony\Component\Translation\t;
/**
* Form type for batch editing EDA/KiCad fields on multiple parts at once.
* Each field has an "apply" checkbox only checked fields are applied.
*/
class BatchEdaType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('reference_prefix', TextType::class, [
'label' => 'eda_info.reference_prefix',
'required' => false,
'attr' => ['placeholder' => t('eda_info.reference_prefix.placeholder')],
])
->add('apply_reference_prefix', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('value', TextType::class, [
'label' => 'eda_info.value',
'required' => false,
'attr' => ['placeholder' => t('eda_info.value.placeholder')],
])
->add('apply_value', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('kicad_symbol', KicadFieldAutocompleteType::class, [
'label' => 'eda_info.kicad_symbol',
'type' => KicadFieldAutocompleteType::TYPE_SYMBOL,
'required' => false,
'attr' => ['placeholder' => t('eda_info.kicad_symbol.placeholder')],
])
->add('apply_kicad_symbol', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('kicad_footprint', KicadFieldAutocompleteType::class, [
'label' => 'eda_info.kicad_footprint',
'type' => KicadFieldAutocompleteType::TYPE_FOOTPRINT,
'required' => false,
'attr' => ['placeholder' => t('eda_info.kicad_footprint.placeholder')],
])
->add('apply_kicad_footprint', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('visibility', TriStateCheckboxType::class, [
'label' => 'eda_info.visibility',
'required' => false,
])
->add('apply_visibility', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('exclude_from_bom', TriStateCheckboxType::class, [
'label' => 'eda_info.exclude_from_bom',
'required' => false,
])
->add('apply_exclude_from_bom', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('exclude_from_board', TriStateCheckboxType::class, [
'label' => 'eda_info.exclude_from_board',
'required' => false,
])
->add('apply_exclude_from_board', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('exclude_from_sim', TriStateCheckboxType::class, [
'label' => 'eda_info.exclude_from_sim',
'required' => false,
])
->add('apply_exclude_from_sim', CheckboxType::class, [
'label' => 'batch_eda.apply',
'required' => false,
'mapped' => false,
])
->add('submit', SubmitType::class, [
'label' => 'batch_eda.submit',
'attr' => ['class' => 'btn btn-primary'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
]);
}
}

View file

@ -79,6 +79,11 @@ class OrderdetailType extends AbstractType
'label' => 'orderdetails.edit.prices_includes_vat',
]);
$builder->add('eda_visibility', TriStateCheckboxType::class, [
'required' => false,
'label' => 'orderdetails.edit.eda_visibility',
]);
//Add pricedetails after we know the data, so we can set the default currency
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
/** @var Orderdetail $orderdetail */

View file

@ -55,6 +55,15 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
'spn' => 'supplier_part_number',
'supplier_product_number' => 'supplier_part_number',
'storage_location' => 'storelocation',
//EDA/KiCad field aliases
'kicad_symbol' => 'eda_kicad_symbol',
'kicad_footprint' => 'eda_kicad_footprint',
'kicad_reference' => 'eda_reference_prefix',
'kicad_value' => 'eda_value',
'eda_exclude_bom' => 'eda_exclude_from_bom',
'eda_exclude_board' => 'eda_exclude_from_board',
'eda_exclude_sim' => 'eda_exclude_from_sim',
'eda_invisible' => 'eda_visibility',
];
public function __construct(
@ -190,9 +199,45 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
}
}
//Handle EDA/KiCad fields
$this->applyEdaFields($object, $data);
return $object;
}
/**
* Apply EDA/KiCad fields from CSV data to the Part's EDAPartInfo.
*/
private function applyEdaFields(Part $part, array $data): void
{
$edaInfo = $part->getEdaInfo();
if (!empty($data['eda_kicad_symbol'])) {
$edaInfo->setKicadSymbol(trim((string) $data['eda_kicad_symbol']));
}
if (!empty($data['eda_kicad_footprint'])) {
$edaInfo->setKicadFootprint(trim((string) $data['eda_kicad_footprint']));
}
if (!empty($data['eda_reference_prefix'])) {
$edaInfo->setReferencePrefix(trim((string) $data['eda_reference_prefix']));
}
if (!empty($data['eda_value'])) {
$edaInfo->setValue(trim((string) $data['eda_value']));
}
if (isset($data['eda_exclude_from_bom']) && $data['eda_exclude_from_bom'] !== '') {
$edaInfo->setExcludeFromBom(filter_var($data['eda_exclude_from_bom'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($data['eda_exclude_from_board']) && $data['eda_exclude_from_board'] !== '') {
$edaInfo->setExcludeFromBoard(filter_var($data['eda_exclude_from_board'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($data['eda_exclude_from_sim']) && $data['eda_exclude_from_sim'] !== '') {
$edaInfo->setExcludeFromSim(filter_var($data['eda_exclude_from_sim'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($data['eda_visibility']) && $data['eda_visibility'] !== '') {
$edaInfo->setVisibility(filter_var($data['eda_visibility'], FILTER_VALIDATE_BOOLEAN));
}
}
/**
* @return bool[]
*/

View file

@ -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;
@ -43,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,
@ -51,9 +55,10 @@ class KiCadHelper
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator,
KiCadEDASettings $kiCadEDASettings,
private readonly KiCadEDASettings $kiCadEDASettings,
) {
$this->category_depth = $kiCadEDASettings->categoryDepth;
$this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
}
/**
@ -115,11 +120,16 @@ class KiCadHelper
}
//Format the category for KiCAD
// Use the category comment as description if available, otherwise use the Part-DB URL
$description = $category->getComment();
if ($description === null || $description === '') {
$description = $this->entityURLGenerator->listPartsURL($category);
}
$result[] = [
'id' => (string)$category->getId(),
'name' => $category->getFullPath('/'),
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
'description' => $this->entityURLGenerator->listPartsURL($category),
'description' => $description,
];
}
@ -131,11 +141,13 @@ class KiCadHelper
* Returns an array of objects containing all parts for the given category in the format required by KiCAD.
* The result is cached for performance and invalidated on category or part changes.
* @param Category|null $category
* @param bool $minimal If true, only return id and name (faster for symbol chooser listing)
* @return array
*/
public function getCategoryParts(?Category $category): array
public function getCategoryParts(?Category $category, bool $minimal = false): array
{
return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth,
$cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : '');
return $this->kicadCache->get($cacheKey,
function (ItemInterface $item) use ($category) {
$item->tag([
$this->tagGenerator->getElementTypeCacheTag(Category::class),
@ -198,14 +210,22 @@ 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 (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
$result["fields"]["description"] = $this->createField($part->getDescription());
if ($part->getCategory() !== null) {
@ -245,32 +265,7 @@ class KiCadHelper
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
}
// Add supplier information from orderdetails (include obsolete orderdetails)
if ($part->getOrderdetails(false)->count() > 0) {
$supplierCounts = [];
foreach ($part->getOrderdetails(false) as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$supplierName = $orderdetail->getSupplier()->getName();
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
if (!isset($supplierCounts[$supplierName])) {
$supplierCounts[$supplierName] = 0;
}
$supplierCounts[$supplierName]++;
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
$fieldName = $supplierCounts[$supplierName] > 1
? $supplierName . ' ' . $supplierCounts[$supplierName]
: $supplierName;
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
//Add fields for KiCost:
//Add KiCost manufacturer fields (always present, independent of orderdetails)
if ($part->getManufacturer() !== null) {
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
}
@ -278,13 +273,74 @@ class KiCadHelper
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
}
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
if ($part->getOrderdetails(false)->count() > 0) {
foreach ($part->getOrderdetails(false) as $orderdetail) {
// Add supplier information from orderdetails (include obsolete orderdetails)
// If any orderdetail has 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) {
$hasExplicitEdaVisibility = false;
foreach ($allOrderdetails as $od) {
if ($od->isEdaVisibility() !== null) {
$hasExplicitEdaVisibility = true;
break;
}
}
$supplierCounts = [];
foreach ($allOrderdetails as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
// When explicit flags exist, filter by resolved visibility
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->kiCadEDASettings->defaultOrderdetailsVisibility;
if ($hasExplicitEdaVisibility && !$resolvedVisibility) {
continue;
}
$supplierName = $orderdetail->getSupplier()->getName() . ' SPN';
if (!isset($supplierCounts[$supplierName])) {
$supplierCounts[$supplierName] = 0;
}
$supplierCounts[$supplierName]++;
// Create field name with sequential number if more than one from same supplier
$fieldName = $supplierCounts[$supplierName] > 1
? $supplierName . ' ' . $supplierCounts[$supplierName]
: $supplierName;
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
//Also add a KiCost-compatible field (supplier_name# = SPN)
$kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
$result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
//Add stock quantity and storage locations (only count non-expired lots with known quantity)
$totalStock = 0;
$locations = [];
foreach ($part->getPartLots() as $lot) {
$isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
if ($isAvailable) {
$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)));
}
//Add parameters marked for EDA export (explicit true, or system default when null)
foreach ($part->getParameters() as $parameter) {
$paramVisibility = $parameter->isEdaVisibility() ?? $this->kiCadEDASettings->defaultParameterVisibility;
if ($paramVisibility && $parameter->getName() !== '') {
$fieldName = $parameter->getName();
//Don't overwrite hardcoded fields
if (!isset($result['fields'][$fieldName])) {
$result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue());
}
}
}
@ -344,7 +400,7 @@ class KiCadHelper
//If the user set a visibility, then use it
if ($eda_info->getVisibility() !== null) {
return $part->getEdaInfo()->getVisibility();
return $eda_info->getVisibility();
}
//If the part has a category, then use the category visibility if possible
@ -395,4 +451,64 @@ 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 (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 = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
}
if ($extension === '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
);
}
}

View file

@ -396,10 +396,14 @@ class BOMImporter
}
}
// Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
// Create unique key for this entry.
// When linked to a Part-DB part, use the part ID as key (merges footprint variants).
// Otherwise, use name (which includes package) to avoid merging unrelated components.
$entry_key = $part !== null
? 'part:' . $part->getID()
: 'name:' . $name;
// Check if we already have an entry with the same name and part
// Check if we already have an entry with the same key
if (isset($entries_by_key[$entry_key])) {
// Merge with existing entry
$existing_entry = $entries_by_key[$entry_key];
@ -413,14 +417,22 @@ class BOMImporter
$existing_quantity = $existing_entry->getQuantity();
$existing_entry->setQuantity($existing_quantity + $quantity);
// Track footprint variants in comment when merging entries with different packages
$currentPackage = trim($mapped_entry['Package'] ?? '');
if ($currentPackage !== '' && !str_contains($existing_entry->getComment(), $currentPackage)) {
$comment = $existing_entry->getComment();
$existing_entry->setComment($comment . ', Footprint variant: ' . $currentPackage);
}
$this->logger->info('Merged duplicate BOM entry', [
'name' => $name,
'part_id' => $part ? $part->getID() : null,
'part_id' => $part?->getID(),
'original_quantity' => $existing_quantity,
'added_quantity' => $quantity,
'new_quantity' => $existing_quantity + $quantity,
'original_mountnames' => $existing_mountnames,
'added_mountnames' => $designator,
'package' => $currentPackage,
]);
continue; // Skip creating new entry

View file

@ -127,6 +127,15 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
);
}
if ($action === 'batch_edit_eda') {
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
return new RedirectResponse(
$this->urlGenerator->generate('batch_eda_edit', [
'ids' => $ids,
'_redirect' => $redirect_url
])
);
}
//Iterate over the parts and apply the action to it:
foreach ($selected_parts as $part) {

View file

@ -51,6 +51,9 @@ enum PartTableColumns : string implements TranslatableInterface
case GTIN = "gtin";
case TAGS = "tags";
case ATTACHMENTS = "attachments";
case EDA_STATUS = "eda_status";
case EDIT = "edit";
public function trans(TranslatorInterface $translator, ?string $locale = null): string

View file

@ -43,4 +43,23 @@ 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")
)]
public ?bool $datasheetAsPdf = true;
#[SettingsParameter(
label: new TM("settings.misc.kicad_eda.default_parameter_visibility"),
description: new TM("settings.misc.kicad_eda.default_parameter_visibility.help"),
)]
public bool $defaultParameterVisibility = false;
#[SettingsParameter(
label: new TM("settings.misc.kicad_eda.default_orderdetails_visibility"),
description: new TM("settings.misc.kicad_eda.default_orderdetails_visibility.help"),
)]
public bool $defaultOrderdetailsVisibility = false;
}

View file

@ -62,6 +62,9 @@
<option {% if not is_granted('@projects.read') %}disabled{% endif %} value="add_to_project" data-url="{{ path('select_project')}}">{% trans %}part_list.action.projects.add_to_project{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.group.eda{% endtrans %}">
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="batch_edit_eda" data-turbo="false">{% trans %}part_list.action.batch_edit_eda{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.action.delete{% endtrans %}">
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
</optgroup>

View file

@ -0,0 +1,88 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}batch_eda.title{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fas fa-bolt"></i> {% trans %}batch_eda.title{% endtrans %}
{% endblock %}
{% block card_content %}
<div class="mb-3">
<p>{% trans with {'%count%': parts|length} %}batch_eda.description{% endtrans %}</p>
<details>
<summary>{% trans %}batch_eda.show_parts{% endtrans %}</summary>
<ul class="list-unstyled ms-3 mt-1">
{% for part in parts %}
<li><a href="{{ path('part_edit', {id: part.id}) }}">{{ part.name }}</a></li>
{% endfor %}
</ul>
</details>
</div>
{{ form_start(form) }}
<p class="text-muted small">{% trans %}batch_eda.apply_hint{% endtrans %}</p>
<table class="table table-sm">
<thead>
<tr>
<th style="width: 30px;">{% trans %}batch_eda.apply{% endtrans %}</th>
<th>{% trans %}batch_eda.field{% endtrans %}</th>
<th>{% trans %}batch_eda.value{% endtrans %}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_reference_prefix) }}</td>
<td class="align-middle">{{ form_label(form.reference_prefix) }}</td>
<td>{{ form_widget(form.reference_prefix, {'attr': {'class': 'form-control-sm'}}) }}{{ form_errors(form.reference_prefix) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_value) }}</td>
<td class="align-middle">{{ form_label(form.value) }}</td>
<td>{{ form_widget(form.value, {'attr': {'class': 'form-control-sm'}}) }}{{ form_errors(form.value) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_kicad_symbol) }}</td>
<td class="align-middle">{{ form_label(form.kicad_symbol) }}</td>
<td>{{ form_widget(form.kicad_symbol) }}{{ form_errors(form.kicad_symbol) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_kicad_footprint) }}</td>
<td class="align-middle">{{ form_label(form.kicad_footprint) }}</td>
<td>{{ form_widget(form.kicad_footprint) }}{{ form_errors(form.kicad_footprint) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_visibility) }}</td>
<td class="align-middle">{{ form_label(form.visibility) }}</td>
<td>{{ form_widget(form.visibility) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_exclude_from_bom) }}</td>
<td class="align-middle">{{ form_label(form.exclude_from_bom) }}</td>
<td>{{ form_widget(form.exclude_from_bom) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_exclude_from_board) }}</td>
<td class="align-middle">{{ form_label(form.exclude_from_board) }}</td>
<td>{{ form_widget(form.exclude_from_board) }}</td>
</tr>
<tr>
<td class="text-center align-middle">{{ form_widget(form.apply_exclude_from_sim) }}</td>
<td class="align-middle">{{ form_label(form.exclude_from_sim) }}</td>
<td>{{ form_widget(form.exclude_from_sim) }}</td>
</tr>
</tbody>
</table>
<div class="d-flex justify-content-between">
{% if redirect_url %}
<a href="{{ redirect_url }}" class="btn btn-secondary">{% trans %}batch_eda.cancel{% endtrans %}</a>
{% else %}
<a href="{{ path('parts_show_all') }}" class="btn btn-secondary">{% trans %}batch_eda.cancel{% endtrans %}</a>
{% endif %}
{{ form_widget(form.submit) }}
</div>
{{ form_end(form) }}
{% endblock %}

View file

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

View file

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

View file

@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\PopulateKicadCommand;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class PopulateKicadCommandTest extends KernelTestCase
{
private CommandTester $commandTester;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
$application = new Application(self::$kernel);
$command = $application->find('partdb:kicad:populate');
$this->commandTester = new CommandTester($command);
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
public function testListOption(): void
{
$this->commandTester->execute(['--list' => true]);
$output = $this->commandTester->getDisplay();
// Should show footprints and categories tables
$this->assertStringContainsString('Current Footprint KiCad Values', $output);
$this->assertStringContainsString('Current Category KiCad Values', $output);
$this->assertStringContainsString('ID', $output);
$this->assertStringContainsString('Name', $output);
$this->assertEquals(0, $this->commandTester->getStatusCode());
}
public function testDryRunDoesNotModifyDatabase(): void
{
// Create a test footprint without KiCad value
$footprint = new Footprint();
$footprint->setName('SOT-23');
$this->entityManager->persist($footprint);
$this->entityManager->flush();
$footprintId = $footprint->getId();
// Run in dry-run mode
$this->commandTester->execute(['--dry-run' => true, '--footprints' => true]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('DRY RUN MODE', $output);
$this->assertStringContainsString('SOT-23', $output);
// Clear entity manager to force reload from DB
$this->entityManager->clear();
// Verify footprint was NOT updated in the database
$reloadedFootprint = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertNull($reloadedFootprint->getEdaInfo()->getKicadFootprint());
// Cleanup
$this->entityManager->remove($reloadedFootprint);
$this->entityManager->flush();
}
public function testFootprintMappingUpdatesCorrectly(): void
{
// Create test footprints
$footprint1 = new Footprint();
$footprint1->setName('SOT-23');
$footprint2 = new Footprint();
$footprint2->setName('0805');
$footprint3 = new Footprint();
$footprint3->setName('DIP-8');
$this->entityManager->persist($footprint1);
$this->entityManager->persist($footprint2);
$this->entityManager->persist($footprint3);
$this->entityManager->flush();
$ids = [$footprint1->getId(), $footprint2->getId(), $footprint3->getId()];
// Run the command
$this->commandTester->execute(['--footprints' => true]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(0, $this->commandTester->getStatusCode());
// Clear and reload
$this->entityManager->clear();
// Verify mappings were applied
$reloaded1 = $this->entityManager->find(Footprint::class, $ids[0]);
$this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded1->getEdaInfo()->getKicadFootprint());
$reloaded2 = $this->entityManager->find(Footprint::class, $ids[1]);
$this->assertEquals('Resistor_SMD:R_0805_2012Metric', $reloaded2->getEdaInfo()->getKicadFootprint());
$reloaded3 = $this->entityManager->find(Footprint::class, $ids[2]);
$this->assertEquals('Package_DIP:DIP-8_W7.62mm', $reloaded3->getEdaInfo()->getKicadFootprint());
// Cleanup
$this->entityManager->remove($reloaded1);
$this->entityManager->remove($reloaded2);
$this->entityManager->remove($reloaded3);
$this->entityManager->flush();
}
public function testSkipsExistingValuesWithoutForce(): void
{
// Create footprint with existing value
$footprint = new Footprint();
$footprint->setName('SOT-23');
$footprint->getEdaInfo()->setKicadFootprint('Custom:MyFootprint');
$this->entityManager->persist($footprint);
$this->entityManager->flush();
$footprintId = $footprint->getId();
// Run without --force
$this->commandTester->execute(['--footprints' => true]);
$this->entityManager->clear();
// Should keep original value
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertEquals('Custom:MyFootprint', $reloaded->getEdaInfo()->getKicadFootprint());
// Cleanup
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
}
public function testForceOptionOverwritesExistingValues(): void
{
// Create footprint with existing value
$footprint = new Footprint();
$footprint->setName('SOT-23');
$footprint->getEdaInfo()->setKicadFootprint('Custom:MyFootprint');
$this->entityManager->persist($footprint);
$this->entityManager->flush();
$footprintId = $footprint->getId();
// Run with --force
$this->commandTester->execute(['--footprints' => true, '--force' => true]);
$this->entityManager->clear();
// Should overwrite with mapped value
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded->getEdaInfo()->getKicadFootprint());
// Cleanup
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
}
public function testCategoryMappingUpdatesCorrectly(): void
{
// Create test categories
$category1 = new Category();
$category1->setName('Resistors');
$category2 = new Category();
$category2->setName('LED Indicators');
$category3 = new Category();
$category3->setName('Zener Diodes');
$this->entityManager->persist($category1);
$this->entityManager->persist($category2);
$this->entityManager->persist($category3);
$this->entityManager->flush();
$ids = [$category1->getId(), $category2->getId(), $category3->getId()];
// Run the command
$this->commandTester->execute(['--categories' => true]);
$this->assertEquals(0, $this->commandTester->getStatusCode());
// Clear and reload
$this->entityManager->clear();
// Verify mappings were applied (using pattern matching)
$reloaded1 = $this->entityManager->find(Category::class, $ids[0]);
$this->assertEquals('Device:R', $reloaded1->getEdaInfo()->getKicadSymbol());
$reloaded2 = $this->entityManager->find(Category::class, $ids[1]);
$this->assertEquals('Device:LED', $reloaded2->getEdaInfo()->getKicadSymbol());
$reloaded3 = $this->entityManager->find(Category::class, $ids[2]);
$this->assertEquals('Device:D_Zener', $reloaded3->getEdaInfo()->getKicadSymbol());
// Cleanup
$this->entityManager->remove($reloaded1);
$this->entityManager->remove($reloaded2);
$this->entityManager->remove($reloaded3);
$this->entityManager->flush();
}
public function testUnmappedFootprintsAreListed(): void
{
// Create footprint with no mapping
$footprint = new Footprint();
$footprint->setName('CustomPackage-XYZ');
$this->entityManager->persist($footprint);
$this->entityManager->flush();
$footprintId = $footprint->getId();
// Run the command
$this->commandTester->execute(['--footprints' => true]);
$output = $this->commandTester->getDisplay();
// Should list the unmapped footprint
$this->assertStringContainsString('No mapping found', $output);
$this->assertStringContainsString('CustomPackage-XYZ', $output);
// Cleanup
$this->entityManager->clear();
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
}
public function testMappingFileOverridesDefaults(): void
{
// Create a footprint that has a built-in mapping (SOT-23 -> Package_TO_SOT_SMD:SOT-23)
$footprint = new Footprint();
$footprint->setName('SOT-23');
$this->entityManager->persist($footprint);
$this->entityManager->flush();
$footprintId = $footprint->getId();
// Create a temporary JSON mapping file that overrides SOT-23
$mappingFile = sys_get_temp_dir() . '/partdb_test_mappings_' . uniqid() . '.json';
file_put_contents($mappingFile, json_encode([
'footprints' => [
'SOT-23' => 'Custom_Library:Custom_SOT-23',
],
]));
try {
// Run with mapping file
$this->commandTester->execute(['--footprints' => true, '--mapping-file' => $mappingFile]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(0, $this->commandTester->getStatusCode());
$this->assertStringContainsString('custom footprint mappings', $output);
$this->entityManager->clear();
// Should use the custom mapping, not the built-in one
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertEquals('Custom_Library:Custom_SOT-23', $reloaded->getEdaInfo()->getKicadFootprint());
// Cleanup
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
} finally {
@unlink($mappingFile);
}
}
public function testMappingFileInvalidJsonReturnsFailure(): void
{
$mappingFile = sys_get_temp_dir() . '/partdb_test_invalid_' . uniqid() . '.json';
file_put_contents($mappingFile, 'not valid json{{{');
try {
$this->commandTester->execute(['--mapping-file' => $mappingFile]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(1, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Invalid JSON', $output);
} finally {
@unlink($mappingFile);
}
}
public function testMappingFileNotFoundReturnsFailure(): void
{
$this->commandTester->execute(['--mapping-file' => '/nonexistent/path/mappings.json']);
$this->assertEquals(1, $this->commandTester->getStatusCode());
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Mapping file not found', $output);
}
public function testFootprintAlternativeNameMatching(): void
{
// Create a footprint with a primary name that has no mapping,
// but an alternative name that does
$footprint = new Footprint();
$footprint->setName('MyCustomSOT23');
$footprint->setAlternativeNames('SOT-23, SOT23-3L');
$this->entityManager->persist($footprint);
$this->entityManager->flush();
$footprintId = $footprint->getId();
$this->commandTester->execute(['--footprints' => true]);
$this->entityManager->clear();
// Should match via alternative name "SOT-23"
$reloaded = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertEquals('Package_TO_SOT_SMD:SOT-23', $reloaded->getEdaInfo()->getKicadFootprint());
// Cleanup
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
}
public function testCategoryAlternativeNameMatching(): void
{
// Create a category with a primary name that has no mapping,
// but an alternative name that matches a pattern
$category = new Category();
$category->setName('SMD Components');
$category->setAlternativeNames('Resistor SMD, Chip Resistors');
$this->entityManager->persist($category);
$this->entityManager->flush();
$categoryId = $category->getId();
$this->commandTester->execute(['--categories' => true]);
$this->entityManager->clear();
// Should match via alternative name "Resistor SMD" matching pattern "Resistor"
$reloaded = $this->entityManager->find(Category::class, $categoryId);
$this->assertEquals('Device:R', $reloaded->getEdaInfo()->getKicadSymbol());
// Cleanup
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
}
public function testBothFootprintsAndCategoriesUpdatedByDefault(): void
{
// Create one of each
$footprint = new Footprint();
$footprint->setName('TO-220');
$this->entityManager->persist($footprint);
$category = new Category();
$category->setName('Capacitors');
$this->entityManager->persist($category);
$this->entityManager->flush();
$footprintId = $footprint->getId();
$categoryId = $category->getId();
// Run without specific options (should do both)
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Updating Footprint Entities', $output);
$this->assertStringContainsString('Updating Category Entities', $output);
$this->entityManager->clear();
// Both should be updated
$reloadedFootprint = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertEquals('Package_TO_SOT_THT:TO-220-3_Vertical', $reloadedFootprint->getEdaInfo()->getKicadFootprint());
$reloadedCategory = $this->entityManager->find(Category::class, $categoryId);
$this->assertEquals('Device:C', $reloadedCategory->getEdaInfo()->getKicadSymbol());
// Cleanup
$this->entityManager->remove($reloadedFootprint);
$this->entityManager->remove($reloadedCategory);
$this->entityManager->flush();
}
public function testMappingFileWithBothFootprintsAndCategories(): void
{
$footprint = new Footprint();
$footprint->setName('CustomPkg');
$this->entityManager->persist($footprint);
$category = new Category();
$category->setName('CustomType');
$this->entityManager->persist($category);
$this->entityManager->flush();
$footprintId = $footprint->getId();
$categoryId = $category->getId();
$mappingFile = sys_get_temp_dir() . '/partdb_test_both_' . uniqid() . '.json';
file_put_contents($mappingFile, json_encode([
'footprints' => [
'CustomPkg' => 'Custom:Footprint',
],
'categories' => [
'CustomType' => 'Custom:Symbol',
],
]));
try {
$this->commandTester->execute(['--mapping-file' => $mappingFile]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(0, $this->commandTester->getStatusCode());
$this->assertStringContainsString('custom footprint mappings', $output);
$this->assertStringContainsString('custom category mappings', $output);
$this->entityManager->clear();
$reloadedFp = $this->entityManager->find(Footprint::class, $footprintId);
$this->assertEquals('Custom:Footprint', $reloadedFp->getEdaInfo()->getKicadFootprint());
$reloadedCat = $this->entityManager->find(Category::class, $categoryId);
$this->assertEquals('Custom:Symbol', $reloadedCat->getEdaInfo()->getKicadSymbol());
// Cleanup
$this->entityManager->remove($reloadedFp);
$this->entityManager->remove($reloadedCat);
$this->entityManager->flush();
} finally {
@unlink($mappingFile);
}
}
public function testMappingFileWithOnlyCategoriesSection(): void
{
$category = new Category();
$category->setName('OnlyCatType');
$this->entityManager->persist($category);
$this->entityManager->flush();
$categoryId = $category->getId();
$mappingFile = sys_get_temp_dir() . '/partdb_test_catonly_' . uniqid() . '.json';
file_put_contents($mappingFile, json_encode([
'categories' => [
'OnlyCatType' => 'Custom:CatSymbol',
],
]));
try {
$this->commandTester->execute(['--categories' => true, '--mapping-file' => $mappingFile]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(0, $this->commandTester->getStatusCode());
$this->assertStringContainsString('custom category mappings', $output);
// Should NOT mention footprint mappings since they weren't in the file
$this->assertStringNotContainsString('custom footprint mappings', $output);
$this->entityManager->clear();
$reloaded = $this->entityManager->find(Category::class, $categoryId);
$this->assertEquals('Custom:CatSymbol', $reloaded->getEdaInfo()->getKicadSymbol());
$this->entityManager->remove($reloaded);
$this->entityManager->flush();
} finally {
@unlink($mappingFile);
}
}
}

View file

@ -0,0 +1,171 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 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');
$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');
$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');
$crawler = $client->request('GET', '/en/tools/batch_eda_edit', ['ids' => '1,2']);
self::assertResponseIsSuccessful();
$form = $crawler->selectButton('batch_eda[submit]')->form();
$form['batch_eda[apply_reference_prefix]'] = true;
$form['batch_eda[reference_prefix]'] = 'R';
$client->submit($form);
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();
}
}

View file

@ -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',
),
),
);
@ -177,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',
@ -198,27 +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/2/info',
'visible' => 'False',
),
'description' =>
@ -231,14 +245,44 @@ 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' =>
array (
'value' => '0',
'visible' => 'False',
),
),
@ -247,4 +291,31 @@ final class KiCadApiControllerTest extends WebTestCase
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);
}
}

View file

@ -136,4 +136,44 @@ final class PartNormalizerTest extends WebTestCase
$this->assertEqualsWithDelta(1.0, $priceDetail->getPriceRelatedQuantity(), PHP_FLOAT_EPSILON);
$this->assertEqualsWithDelta(1.0, $priceDetail->getMinDiscountQuantity(), PHP_FLOAT_EPSILON);
}
public function testDenormalizeEdaFields(): void
{
$input = [
'name' => 'EDA Test Part',
'kicad_symbol' => 'Device:R',
'kicad_footprint' => 'Resistor_SMD:R_0805_2012Metric',
'kicad_reference' => 'R',
'kicad_value' => '10k',
'eda_exclude_bom' => 'true',
'eda_exclude_board' => 'false',
];
$part = $this->service->denormalize($input, Part::class, 'json', ['groups' => ['import'], 'partdb_import' => true]);
$this->assertInstanceOf(Part::class, $part);
$this->assertSame('EDA Test Part', $part->getName());
$edaInfo = $part->getEdaInfo();
$this->assertSame('Device:R', $edaInfo->getKicadSymbol());
$this->assertSame('Resistor_SMD:R_0805_2012Metric', $edaInfo->getKicadFootprint());
$this->assertSame('R', $edaInfo->getReferencePrefix());
$this->assertSame('10k', $edaInfo->getValue());
$this->assertTrue($edaInfo->getExcludeFromBom());
$this->assertFalse($edaInfo->getExcludeFromBoard());
}
public function testDenormalizeEdaFieldsEmptyValuesIgnored(): void
{
$input = [
'name' => 'Part Without EDA',
'kicad_symbol' => '',
'kicad_footprint' => '',
];
$part = $this->service->denormalize($input, Part::class, 'json', ['groups' => ['import'], 'partdb_import' => true]);
$edaInfo = $part->getEdaInfo();
$this->assertNull($edaInfo->getKicadSymbol());
$this->assertNull($edaInfo->getKicadFootprint());
}
}

View file

@ -0,0 +1,604 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\EDA;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Services\EDA\KiCadHelper;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
#[Group('DB')]
final 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']);
}
/**
* Test that a parameter with eda_visibility=true appears in the KiCad fields.
*/
public function testParameterWithEdaVisibilityAppearsInFields(): void
{
$category = $this->em->find(Category::class, 1);
$part = new Part();
$part->setName('Part with Exported Parameter');
$part->setCategory($category);
$param = new PartParameter();
$param->setName('Voltage Rating');
$param->setValueTypical(3.3);
$param->setUnit('V');
$param->setEdaVisibility(true);
$part->addParameter($param);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
self::assertArrayHasKey('Voltage Rating', $result['fields']);
self::assertSame('3.3 V', $result['fields']['Voltage Rating']['value']);
}
/**
* Test that a parameter with eda_visibility=false does NOT appear in the KiCad fields.
*/
public function testParameterWithoutEdaVisibilityDoesNotAppear(): void
{
$category = $this->em->find(Category::class, 1);
$part = new Part();
$part->setName('Part with Non-exported Parameter');
$part->setCategory($category);
$param = new PartParameter();
$param->setName('Internal Note');
$param->setValueText('for testing only');
$param->setEdaVisibility(false);
$part->addParameter($param);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
self::assertArrayNotHasKey('Internal Note', $result['fields']);
}
/**
* Test that a parameter with eda_visibility=null (system default) does NOT appear in the KiCad fields.
*/
public function testParameterWithNullEdaVisibilityDoesNotAppear(): void
{
$category = $this->em->find(Category::class, 1);
$part = new Part();
$part->setName('Part with Default Parameter');
$part->setCategory($category);
$param = new PartParameter();
$param->setName('Default Param');
$param->setValueText('some value');
// eda_visibility is null by default
$part->addParameter($param);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
self::assertArrayNotHasKey('Default Param', $result['fields']);
}
/**
* Test that an exported parameter named "description" does NOT overwrite the hardcoded description field.
*/
public function testExportedParameterDoesNotOverwriteHardcodedField(): void
{
$category = $this->em->find(Category::class, 1);
$part = new Part();
$part->setName('Part with Conflicting Parameter');
$part->setDescription('The real description');
$part->setCategory($category);
$param = new PartParameter();
$param->setName('description');
$param->setValueText('should not overwrite');
$param->setEdaVisibility(true);
$part->addParameter($param);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
// The hardcoded description should win
self::assertSame('The real description', $result['fields']['description']['value']);
}
/**
* Test that orderdetails without explicit eda_visibility are all exported (backward compat).
*/
public function testOrderdetailsExportedWhenNoEdaVisibilitySet(): void
{
$category = $this->em->find(Category::class, 1);
$supplier = new Supplier();
$supplier->setName('TestSupplier');
$this->em->persist($supplier);
$part = new Part();
$part->setName('Part with Supplier');
$part->setCategory($category);
$od = new Orderdetail();
$od->setSupplier($supplier);
$od->setSupplierpartnr('TS-001');
// eda_visibility is null (default)
$part->addOrderdetail($od);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
// Should export since no explicit flags are set (backward compat)
self::assertArrayHasKey('TestSupplier SPN', $result['fields']);
self::assertSame('TS-001', $result['fields']['TestSupplier SPN']['value']);
// KiCost field should also be present
self::assertArrayHasKey('testsupplier#', $result['fields']);
self::assertSame('TS-001', $result['fields']['testsupplier#']['value']);
}
/**
* Test that only orderdetails with eda_visibility=true are exported when explicit flags exist.
*/
public function testOrderdetailsFilteredByExplicitEdaVisibility(): void
{
$category = $this->em->find(Category::class, 1);
$supplier1 = new Supplier();
$supplier1->setName('VisibleSupplier');
$this->em->persist($supplier1);
$supplier2 = new Supplier();
$supplier2->setName('HiddenSupplier');
$this->em->persist($supplier2);
$part = new Part();
$part->setName('Part with Mixed Visibility');
$part->setCategory($category);
$od1 = new Orderdetail();
$od1->setSupplier($supplier1);
$od1->setSupplierpartnr('VIS-001');
$od1->setEdaVisibility(true);
$part->addOrderdetail($od1);
$od2 = new Orderdetail();
$od2->setSupplier($supplier2);
$od2->setSupplierpartnr('HID-001');
$od2->setEdaVisibility(false);
$part->addOrderdetail($od2);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
// Visible supplier should be exported
self::assertArrayHasKey('VisibleSupplier SPN', $result['fields']);
self::assertSame('VIS-001', $result['fields']['VisibleSupplier SPN']['value']);
// Hidden supplier should NOT be exported
self::assertArrayNotHasKey('HiddenSupplier SPN', $result['fields']);
}
/**
* Test that manufacturer fields (manf, manf#) are always exported.
*/
public function testManufacturerFieldsExported(): void
{
$category = $this->em->find(Category::class, 1);
$manufacturer = new Manufacturer();
$manufacturer->setName('Acme Corp');
$this->em->persist($manufacturer);
$part = new Part();
$part->setName('Acme Widget');
$part->setCategory($category);
$part->setManufacturer($manufacturer);
$part->setManufacturerProductNumber('ACM-1234');
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
self::assertArrayHasKey('manf', $result['fields']);
self::assertSame('Acme Corp', $result['fields']['manf']['value']);
self::assertArrayHasKey('manf#', $result['fields']);
self::assertSame('ACM-1234', $result['fields']['manf#']['value']);
self::assertArrayHasKey('Manufacturer', $result['fields']);
self::assertArrayHasKey('MPN', $result['fields']);
}
/**
* Test that a parameter with empty name is not exported even with eda_visibility=true.
*/
public function testParameterWithEmptyNameIsSkipped(): void
{
$category = $this->em->find(Category::class, 1);
$part = new Part();
$part->setName('Part with Empty Param Name');
$part->setCategory($category);
$param = new PartParameter();
$param->setName('');
$param->setValueText('some value');
$param->setEdaVisibility(true);
$part->addParameter($param);
$this->em->persist($part);
$this->em->flush();
$result = $this->helper->getKiCADPart($part);
// Empty-named parameter should not appear
self::assertArrayNotHasKey('', $result['fields']);
}
}

View file

@ -642,6 +642,12 @@ Sub elements will be moved upwards.</target>
<target>Group</target>
</segment>
</unit>
<unit id="8rz303Z" name="specifications.eda_visibility.help">
<segment state="translated">
<source>specifications.eda_visibility.help</source>
<target>Export this parameter as an EDA field</target>
</segment>
</unit>
<unit id="XclPxI9" name="specification.create">
<segment state="translated">
<source>specification.create</source>
@ -2924,6 +2930,42 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
<target>Attachments</target>
</segment>
</unit>
<unit id="f3Dggp6" name="part.table.eda_status">
<segment state="translated">
<source>part.table.eda_status</source>
<target>EDA</target>
</segment>
</unit>
<unit id="Q_myBuD" name="eda.status.symbol_set">
<segment state="translated">
<source>eda.status.symbol_set</source>
<target>KiCad symbol set</target>
</segment>
</unit>
<unit id="QGLfvit" name="eda.status.footprint_set">
<segment state="translated">
<source>eda.status.footprint_set</source>
<target>KiCad footprint set</target>
</segment>
</unit>
<unit id="hkze9M." name="eda.status.reference_set">
<segment state="translated">
<source>eda.status.reference_set</source>
<target>Reference prefix set</target>
</segment>
</unit>
<unit id="OTXbAfL" name="eda.status.complete">
<segment state="translated">
<source>eda.status.complete</source>
<target>EDA fields complete (symbol, footprint, reference)</target>
</segment>
</unit>
<unit id="z9E5RB." name="eda.status.partial">
<segment state="translated">
<source>eda.status.partial</source>
<target>EDA fields partially set</target>
</segment>
</unit>
<unit id="bMkafCp" name="flash.login_successful">
<segment state="translated">
<source>flash.login_successful</source>
@ -3266,6 +3308,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
<target>No longer available</target>
</segment>
</unit>
<unit id="6H0WQWq" name="orderdetails.edit.eda_visibility">
<segment state="translated">
<source>orderdetails.edit.eda_visibility</source>
<target>Visible in EDA</target>
</segment>
</unit>
<unit id="ZsO5AKM" name="orderdetails.edit.supplierpartnr.placeholder">
<segment state="translated">
<source>orderdetails.edit.supplierpartnr.placeholder</source>
@ -9969,6 +10017,18 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>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 &gt; 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.</target>
</segment>
</unit>
<unit id="X5.rQdO" name="settings.misc.kicad_eda.datasheet_link">
<segment state="translated">
<source>settings.misc.kicad_eda.datasheet_link</source>
<target>Datasheet field links to PDF</target>
</segment>
</unit>
<unit id="Fm1QTCs" name="settings.misc.kicad_eda.datasheet_link.help">
<segment state="translated">
<source>settings.misc.kicad_eda.datasheet_link.help</source>
<target>When enabled, the datasheet field in KiCad will link to the actual PDF file (if found). When disabled, it will link to the Part-DB page instead. The Part-DB page link is always available as a separate "Part-DB URL" field.</target>
</segment>
</unit>
<unit id="VwvmcWE" name="settings.behavior.sidebar">
<segment state="translated">
<source>settings.behavior.sidebar</source>
@ -10953,6 +11013,84 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Bulk Info Provider Import</target>
</segment>
</unit>
<unit id="VtS1yT7" name="part_list.action.group.eda">
<segment state="translated">
<source>part_list.action.group.eda</source>
<target>EDA / KiCad</target>
</segment>
</unit>
<unit id="swU1Rp2" name="part_list.action.batch_edit_eda">
<segment state="translated">
<source>part_list.action.batch_edit_eda</source>
<target>Batch Edit EDA Fields</target>
</segment>
</unit>
<unit id="ZaS_Hg5" name="batch_eda.title">
<segment state="translated">
<source>batch_eda.title</source>
<target>Batch Edit EDA Fields</target>
</segment>
</unit>
<unit id="k2FDo7A" name="batch_eda.description">
<segment state="translated">
<source>batch_eda.description</source>
<target>Edit EDA/KiCad fields for %count% selected parts. Check the "Apply" box next to each field you want to change.</target>
</segment>
</unit>
<unit id="WVHbic3" name="batch_eda.show_parts">
<segment state="translated">
<source>batch_eda.show_parts</source>
<target>Show selected parts</target>
</segment>
</unit>
<unit id="ubQd6G4" name="batch_eda.apply_hint">
<segment state="translated">
<source>batch_eda.apply_hint</source>
<target>Only fields with the "Apply" checkbox checked will be changed. Unchecked fields are left unchanged.</target>
</segment>
</unit>
<unit id="w.5FGYL" name="batch_eda.apply">
<segment state="translated">
<source>batch_eda.apply</source>
<target>Apply</target>
</segment>
</unit>
<unit id="9EmHp5C" name="batch_eda.field">
<segment state="translated">
<source>batch_eda.field</source>
<target>Field</target>
</segment>
</unit>
<unit id="xHaCnEQ" name="batch_eda.value">
<segment state="translated">
<source>batch_eda.value</source>
<target>Value</target>
</segment>
</unit>
<unit id="PLqIBvC" name="batch_eda.submit">
<segment state="translated">
<source>batch_eda.submit</source>
<target>Apply to Selected Parts</target>
</segment>
</unit>
<unit id="5nO7Fpq" name="batch_eda.cancel">
<segment state="translated">
<source>batch_eda.cancel</source>
<target>Cancel</target>
</segment>
</unit>
<unit id="vhlPBNU" name="batch_eda.success">
<segment state="translated">
<source>batch_eda.success</source>
<target>EDA fields updated successfully.</target>
</segment>
</unit>
<unit id="2fMo760" name="batch_eda.no_parts_selected">
<segment state="translated">
<source>batch_eda.no_parts_selected</source>
<target>No parts were selected for batch editing.</target>
</segment>
</unit>
<unit id="yzpXFkB" name="info_providers.bulk_import.step1.spn_recommendation">
<segment state="translated">
<source>info_providers.bulk_import.step1.spn_recommendation</source>
@ -12551,6 +12689,28 @@ Buerklin-API Authentication server:
<target>Last stocktake</target>
</segment>
</unit>
<unit id="s1pgReC" name="settings.misc.kicad_eda.default_parameter_visibility">
<segment>
<source>settings.misc.kicad_eda.default_parameter_visibility</source>
<target>Default EDA visibility of parameters</target>
</segment>
</unit>
<unit id="Z78QunV" name="settings.misc.kicad_eda.default_parameter_visibility.help">
<segment>
<source>settings.misc.kicad_eda.default_parameter_visibility.help</source>
<target>EDA visibility for all [part] parameters who does not have an explicit visibility set. When enabled all parameters will be visible in the EDA software by default.</target>
</segment>
</unit>
<unit id="J6pYnaC" name="settings.misc.kicad_eda.default_orderdetails_visibility">
<segment>
<source>settings.misc.kicad_eda.default_orderdetails_visibility</source>
<target>Default EDA visibility of purchase infos</target>
</segment>
</unit>
<unit id="Hiye4C." name="settings.misc.kicad_eda.default_orderdetails_visibility.help">
<segment>
<source>settings.misc.kicad_eda.default_orderdetails_visibility.help</source>
<target>EDA visibility for all purchase infos who does not have an explicit visibility set. When enabled all purchase infos will be visible in the EDA software by default.</target>
<unit id="aEgd0if" name="label_scanner.open">
<segment>
<source>label_scanner.open</source>