Compare commits

..

86 Commits
gtin ... v2.8.0

Author SHA1 Message Date
Jan Böhmer
32a666f6c3 Bumped version to 2.8.0 2026-03-01 23:22:38 +01:00
Jan Böhmer
1ee998853f New Crowdin updates (#1265)
* New translations messages.en.xlf (Italian)

* New translations validators.en.xlf (Italian)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (English)
2026-03-01 23:21:02 +01:00
Jan Böhmer
9ae585d2b7 Fixed static analysis issues 2026-03-01 23:18:27 +01:00
Jan Böhmer
8f92615491 Randomize User agents for reichelt and generic web provider
This might helps with #1176
2026-03-01 23:15:06 +01:00
Hannes Rüger
e5dcfad3ff feat(parts table): add eda reference prefix and value columns (#1266)
* feat(parts table): add eda reference prefix and value columns

* Use better labels for column names and made it visible as default column selection

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-01 22:28:01 +01:00
Jan Böhmer
b7cc14fa14 Fixed translations 2026-03-01 22:14:16 +01:00
Sebastian Almberg
b9d940ae33 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>
2026-03-01 22:10:13 +01:00
Jan Böhmer
46617f01a4 Added documentation about the barcode scanner 2026-03-01 18:11:58 +01:00
Jan Böhmer
f097b79103 Navigate only the content frame when submitting the global barcode scan label 2026-03-01 16:56:47 +01:00
Jan Böhmer
a8f9f9832e Correctly dispatch the input event of non-printable char controller from the barcode scan controller 2026-03-01 16:51:06 +01:00
Jan Böhmer
f3dab36bbe Allow to scan labels anywhere on the page 2026-03-01 16:48:29 +01:00
Jan Böhmer
bebd603117 Allow to handle non-printable inputs like from an attached barcode scanner 2026-03-01 14:39:14 +01:00
Jan Böhmer
2660f4ee82 Render non-printable chars in the scan input field 2026-03-01 13:36:52 +01:00
Jan Böhmer
eb2bbdd633 Show label scan input with monospaced font 2026-03-01 13:00:08 +01:00
Jan Böhmer
24966230ea Updated dependencies 2026-03-01 12:53:25 +01:00
Jan Böhmer
477cc1c0bb Update KiCad symbols and footprints lists (#1273)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-01 12:50:34 +01:00
Niklas
1eee2b30fa add option to disable keybindings fixing #1251 (#1254)
* add option to disable keybindings

* add tests for disabling keybindings

* Fixed translation keys

* Added env to env configuration list

* Removed useless tests

The tests are already enforced by type declarations

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-28 22:07:14 +01:00
Jan Böhmer
1650ade338 Use a cryptographically random suffix for attachment file names to make them harder guess 2026-02-24 23:20:09 +01:00
Jan Böhmer
0c83fd4799 Merge branch 'html_sandbox' 2026-02-24 23:08:03 +01:00
Jan Böhmer
4004cf9c88 Added documentation on ATTACHMENT_SHOW_HTML_FILES env 2026-02-24 23:07:41 +01:00
Jan Böhmer
419b46e806 Allow to load external images and styles in the HTML sandbox
That should not cause much security issues, as this is what users can do anyway via attachment creation, and markdown images
2026-02-24 23:05:09 +01:00
Jan Böhmer
dcafc8a1a1 Allow file downloads and modals in HTML sandbox 2026-02-24 22:57:48 +01:00
Jan Böhmer
628f794b37 Improved HTML sandbox page 2026-02-24 22:53:50 +01:00
Jan Böhmer
a1fd3199d6 Render HTML as plain text via attachment_view controller
This makes it consistent with the public paths and ensures all HTML is only rendered in our sandbox
2026-02-24 22:48:18 +01:00
Jan Böhmer
4a5cc454ce Show HTML files in the HTML sandbox if enabled 2026-02-24 22:40:23 +01:00
Jan Böhmer
63dd344c02 Added basic functionality for an HTML sandbox for relative safely rendering HTML attachments
Fixed #1150
2026-02-24 22:27:33 +01:00
Jan Böhmer
a7a1026f9b Throw an exception if canopy does not return a product 2026-02-24 20:30:39 +01:00
Jan Böhmer
a67f106bc6 Fixed tests 2026-02-22 23:50:32 +01:00
Jan Böhmer
430a564592 Merge branch 'amazon_info_provider' 2026-02-22 23:43:54 +01:00
Jan Böhmer
e283d9ced6 Added docs for canopy info provider 2026-02-22 23:43:36 +01:00
Jan Böhmer
300382f6e3 Make Canopy provider configurable via UI 2026-02-22 23:38:56 +01:00
Jan Böhmer
0b9b2cbf58 Allow to read amazon labels for part retrieval and creation 2026-02-22 23:16:39 +01:00
Jan Böhmer
87919eb445 Allow to cache amazon search results to reduce API calls 2026-02-22 22:29:44 +01:00
Jan Böhmer
258289482b Increase debug detail expiration time to 10s to avoid double retrieval in one request 2026-02-22 22:12:50 +01:00
Jan Böhmer
aa9436a19b Fixed conrad provider if part does not have manuals 2026-02-22 22:09:23 +01:00
Jan Böhmer
cee6c0ef11 Added a "create from label scan button to navbar" 2026-02-22 22:03:46 +01:00
Jan Böhmer
c6cbc17c66 Merge branch 'master' into amazon_info_provider 2026-02-22 21:58:36 +01:00
Jan Böhmer
2ba0f2a95d Use turbo-streams for handling updating locale menu in navbar 2026-02-22 21:53:37 +01:00
Jan Böhmer
e2b43ba01f Use native turbo reload mechanism instead of our own global_reload controller 2026-02-22 21:46:55 +01:00
Jan Böhmer
b6d77af91b Removed title_controller as turbo 8 can handle the title changes natively 2026-02-22 21:43:57 +01:00
Jan Böhmer
36e6c9a402 Updated dependencies 2026-02-22 21:31:40 +01:00
Jan Böhmer
f124fa0023 Made BarcodeScanResult classes readonly 2026-02-22 21:28:58 +01:00
swdee
c29605ef23 Label Scanner Enhancements: LCSC barcode, create part, augmented scanning (#1194)
* added handling of LCSC barcode decoding and part loading on Label Scanner

* when a part is scanned and not found, the scanner did not redraw so scanning subsequent parts was not possible without reloading the browser page.  fixed the barcode scanner initialization and shutdown so it redraws properly after part not found

* added redirection to part page on successful scan of lcsc, digikey, and mouser barcodes.   added create part button if part does not exist in database

* added augmented mode to label scanner to use vendor labels for part lookup to see part storage location quickly

* shrink camera height on mobile so augmented information can been viewed onscreen

* handle momentarily bad reads from qrcode library

* removed augmented checkbox and combined functionality into info mode checkbox.  changed barcode scanner to use XHR callback for barcode decoding to avoid problems with form submission and camera caused by page reloaded when part not found.

* fix scanning of part-db barcodes to redirect to storage location or part lots.   made scan result messages conditional for parts or other non-part barcodes

* fix static analysis errors

* added unit tests for meeting code coverage report

* fix @MayNiklas reported bug:  when manually submitting the form (from a barcode scan or manual input) redirect to Create New part screen for the decoded information instead of showing 'Format Unknown' popup error message

* fix @d-buchmann bug:  clear 'scan-augmented-result' field upon rescan of new barcode

* fix @d-buchmann bug: after scanning in Info mode, if Info mode is turned off when scanning a part that did not exist, it now redirects user to create part page

* fix @d-buchmann bug: make barcode decode table 100% width of page

* fix bug with manual form submission where a part does not exist but decodes properly which causes the camera to not redraw on page reload due to unclean shutdown. this is an existing bug in the scanner interface.

steps to produce the issue:
- have camera active
- put in code in Input
- info mode ticked
- click submit button

on page reload the camera does not reactivate

* fixed translation messages

* Use symfony native functions to generate the routes for part creation

* Use native request functions for request param parsing

* Refactored LCSCBarcocdeScanResult to be an value object like the other Barcode results

* Added test for LCSCBarcodeScanResult

* Fixed exception when submitting form for info mode

* Made BarcodeSourceType a backed enum, so that it can be used in Request::getEnum()

* Moved database queries from BarcodeRedirector to PartRepository

* Fixed modeEnum parsing

* Fixed test errors

* Refactored BarcodeRedirector logic to be more universal

* Fixed BarcodeScanResultHandler test

* Refactored BarcodeScanResultHandler to be able to resolve arbitary entities from scans

* Moved barcode to info provider logic from Controller to BarcodeScanResultHandler service

* Improved augmentented info styling and allow to use it with normal form submit too

* Correctly handle nullable infoURL in ScanController

* Replaced the custom controller for fragment replacements with symfony streams

This does not require a complete new endpoint

* Removed data-lookup-url attribute from scan read box

* Removed unused translations

* Added basic info block when an storage location was found for an barcode

* Fixed phpstan issues

* Fixed tests

* Fixed part image for mobile view

* Added more tests for BarcodeScanResultHandler service

* Fixed tests

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-22 21:26:44 +01:00
dependabot[bot]
8ef9dd432f Bump actions/upload-artifact from 4 to 6 (#1253)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 21:35:00 +01:00
dependabot[bot]
d4d1964aea Bump actions/download-artifact from 4 to 7 (#1252)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 21:34:50 +01:00
Jan Böhmer
3ffb5e8278 Implemented Amazon info provider using canopy 2026-02-16 22:05:49 +01:00
Jan Böhmer
70cde4c3a8 Bumped version to 2.7.1 2026-02-16 18:34:20 +01:00
Jan Böhmer
28e6ca52fe New translations messages.en.xlf (German) (#1249) 2026-02-16 18:30:41 +01:00
Jan Böhmer
5b4c1505b7 Fixed visual bug of tags column in parts lot 2026-02-16 18:29:34 +01:00
Jan Böhmer
8ad3c2e612 Allow stocktake date to be empty on part lot form
Fixes issue #1250
2026-02-16 18:25:41 +01:00
Jan Böhmer
d7ed2225b4 Ensure that part tables are correctly sorted on initial load 2026-02-16 15:09:55 +01:00
Jan Böhmer
7d6b84af3d Bumped version to 2.7.0 2026-02-16 13:32:13 +01:00
Copilot
80492a7b68 Use native ARM runners for ARM Docker image builds (#1248)
* Initial plan

* Use ARM runners for ARM Docker image builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix artifact naming and add comments for latest=false flavor

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove trailing commas from tag configuration in merge job

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove duplicate tag entries and clean up configuration

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-02-16 13:15:52 +01:00
Copilot
7069af4054 Updated dockerfiles to not rely on node deb packages, that are not supported for armhf anymore
* Initial plan

* Refactor Dockerfiles to use Node.js multistage builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix node-builder stage with stub translations file

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve stub translations file creation using heredoc

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Build real translations in node-builder stage via cache warmup

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve error handling for cache warmup fallback

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Use native build platform for node-builder stage

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Do not include fallback for case that translations not exist

* Use newer format for dockerfile-frankenphp

* Dockfile added caching to package managers

* Fixed frankenphp build

* Fixed complain about missing symfony runtime

* Use caching for frankenphp dockerfile

* add target arch to dockerfile caches, to avoid problems

* add targetarch arg

* moved targetarch argument to correct position

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-16 12:50:52 +01:00
Jan Böhmer
05a9e4d035 Merge remote-tracking branch 'origin/master' 2026-02-15 22:33:23 +01:00
Jan Böhmer
be808e28bc Updated dependencies 2026-02-15 22:29:16 +01:00
Jan Böhmer
7354b37ef6 New Crowdin updates (#1228)
* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations validators.en.xlf (Polish)

* New translations security.en.xlf (Danish)

* New translations security.en.xlf (Ukrainian)

* New translations security.en.xlf (German)

* New translations security.en.xlf (Hungarian)

* New translations security.en.xlf (Dutch)

* New translations security.en.xlf (Chinese Simplified)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (English)

* New translations security.en.xlf (English)

* New translations frontend.en.xlf (Danish)

* New translations frontend.en.xlf (German)

* New translations frontend.en.xlf (Hungarian)

* New translations frontend.en.xlf (Ukrainian)

* New translations frontend.en.xlf (Chinese Simplified)

* New translations frontend.en.xlf (English)

* New translations messages.en.xlf (German)
2026-02-15 22:24:00 +01:00
Jan Böhmer
6afca44897 Use xxh3 hashes instead of encoding for info provider cache keys 2026-02-15 22:19:44 +01:00
Jan Böhmer
c17cf2baa1 Fixed rendering of tristate checkboxes 2026-02-15 21:49:18 +01:00
Jan Böhmer
c00556829a Focus the first newly created number input for collection_types
Improves PR #1240
2026-02-15 21:43:47 +01:00
Jan Böhmer
f024c4b09e Merge branch 'autofocus-fields' 2026-02-15 21:37:12 +01:00
Jan Böhmer
8e0fcdb73b Added some part datatables optimization 2026-02-15 20:07:38 +01:00
Jan Böhmer
e19929249f Mark parts datatables query as read only for some memory optimizations 2026-02-15 19:30:53 +01:00
Jan Böhmer
f6764170e1 Fixed phpstan issues 2026-02-15 16:16:15 +01:00
Niklas
1641708508 Added API endpoint for generating labels (#1234)
* init API endpoint for generating labels

* Improved API docs for label endpoint

* Improved LabelGenerationProcessor

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-15 16:03:07 +01:00
d-buchmann
97a74815d3 Fix fallback filename (#1238)
Fixes #1231.
Modify tests to account for this case.
2026-02-15 14:41:25 +01:00
Jan Böhmer
7998cdcd71 Added hint about HTML block to twig label documentation 2026-02-15 14:24:31 +01:00
Jan Böhmer
5e9f7a11a3 Catch more errors of twig labels 2026-02-15 14:11:31 +01:00
Jan Böhmer
1c6bf3f472 Allow more useful functions in twig labels 2026-02-15 14:07:50 +01:00
Jan Böhmer
aed2652f1d Added functions to retrieve associated parts of an element within twig labels
This fixes #1239
2026-02-15 13:52:56 +01:00
Jan Böhmer
233c5e8550 Fixed phpunit and phpstan issues 2026-02-15 00:49:12 +01:00
Jan Böhmer
6b83c772cc Moved user twig functions requiring repo access to its own extension service 2026-02-15 00:28:40 +01:00
Jan Böhmer
1996db6a53 Moved remaining twig extensions to new attributes system 2026-02-15 00:23:30 +01:00
Jan Böhmer
f69b0889eb Ran rector to convert some our twig extensions to use #[AsTwigXX] attributes 2026-02-14 23:53:31 +01:00
Jan Böhmer
c8b1320bb9 Updated rector config 2026-02-14 23:50:42 +01:00
Jan Böhmer
e11cb7d5cb Fixed phpunit tests 2026-02-14 23:46:39 +01:00
Jan Böhmer
097041a43a Ran rector 2026-02-14 23:33:40 +01:00
Jan Böhmer
b21d294cf8 Ran rector and made tests final 2026-02-14 23:32:43 +01:00
Jan Böhmer
43d72faf48 Updated label fonts 2026-02-14 22:46:46 +01:00
Jan Böhmer
bc9a93d71f Removed sodium compat, as all supported PHP versions support it natively nowadays 2026-02-14 22:31:53 +01:00
Jan Böhmer
df0ac76394 Updated composer dependencies that required major version changes 2026-02-14 22:24:36 +01:00
Jan Böhmer
66040b687f Updated dependencies 2026-02-14 22:17:05 +01:00
Jan Böhmer
7a83581597 Merge branch 'gtin' 2026-02-14 22:12:39 +01:00
buchmann
47c0d78985 only autofocus if new 2026-02-11 14:26:36 +01:00
buchmann
76f0b05a09 Autofocus for frequently used input fields
Fixes #1157.
- Focus `name` field on new part
- Focus `amount` on add/withdraw modal
- Focus first "number type" input on any newly added collectionType table row... (debatable)

It would be even more favorable if the user could configure if they want to use autofocus and/or for which fields/dialogs it should be enabled.
2026-02-11 14:10:05 +01:00
Marc
41252d8bb9 Implement URLHandlerInfoProviderInterface in BuerklinProvider (#1235)
* Implement URLHandlerInfoProviderInterface in BuerklinProvider

Added URL handling capabilities to BuerklinProvider.

* Refactor ID extraction logic in BuerklinProvider

* Add tests for BuerklinProvider URLHandlerInfoProviderInterface

* Revert "Refactor ID extraction logic in BuerklinProvider"

This reverts commit 5f65176636.

* Exclude 'p' from valid ID return in BuerklinProvider
2026-02-10 15:26:26 +01:00
319 changed files with 16333 additions and 5152 deletions

View File

@@ -12,7 +12,7 @@ opcache.max_accelerated_files = 20000
opcache.memory_consumption = 256
opcache.enable_file_override = 1
memory_limit = 256M
memory_limit = 512M
upload_max_filesize=256M
post_max_size=300M
post_max_size=300M

View File

@@ -1,4 +1,3 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-symfony
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,13 +76,85 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=jbtronics/part-db1,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v6
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
jbtronics/part-db1
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'jbtronics/part-db1@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect jbtronics/part-db1:${{ steps.docker_meta.outputs.version }}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-server
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,14 +76,86 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile-frankenphp
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=partdborg/part-db,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v6
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
partdborg/part-db
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'partdborg/part-db@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect partdborg/part-db:${{ steps.docker_meta.outputs.version }}

View File

@@ -1,15 +1,75 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=debian:bookworm-slim
ARG PHP_VERSION=8.4
ARG NODE_VERSION=22
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# Base stage for PHP
FROM ${BASE_IMAGE} AS base
ARG PHP_VERSION
ARG TARGETARCH
# Install needed dependencies for PHP build
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \
# libpng-dev libjpeg-dev libfreetype6-dev gnupg zip libzip-dev libjpeg62-turbo-dev libonig-dev libxslt-dev libwebp-dev vim \
# && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get -y install \
# Use BuildKit cache mounts for apt in base stage
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
apt-transport-https \
lsb-release \
ca-certificates \
@@ -39,19 +99,10 @@ RUN apt-get update && apt-get -y install \
gpg \
sudo \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* \
# Create workdir and set permissions if directory does not exists
&& mkdir -p /var/www/html \
&& chown -R www-data:www-data /var/www/html \
# delete the "index.html" that installing Apache drops in here
&& rm -rvf /var/www/html/*
# Install node and yarn
RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && apt-get install -y \
nodejs \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* && \
npm install -g yarn
# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@@ -65,14 +116,12 @@ ENV APACHE_ENVVARS=$APACHE_CONFDIR/envvars
# : ${APACHE_RUN_USER:=www-data}
# export APACHE_RUN_USER
# so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \
set -eux; . "$APACHE_ENVVARS"; \
\
# logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" && \
set -eux; . "$APACHE_ENVVARS" && \
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log" && \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR"
# ---
@@ -141,7 +190,6 @@ COPY --chown=www-data:www-data . .
# Setup apache2
RUN a2dissite 000-default.conf && \
a2ensite symfony.conf && \
# Enable php-fpm
a2enmod proxy_fcgi setenvif && \
a2enconf php${PHP_VERSION}-fpm && \
a2enconf docker-php && \
@@ -149,12 +197,13 @@ RUN a2dissite 000-default.conf && \
# Install composer and yarn dependencies for Part-DB
USER www-data
RUN composer install -a --no-dev && \
# Use BuildKit cache for Composer when running as www-data by setting COMPOSER_CACHE_DIR
RUN --mount=type=cache,id=composer-cache,target=/tmp/.composer-cache \
COMPOSER_CACHE_DIR=/tmp/.composer-cache composer install -a --no-dev && \
composer clear-cache
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -166,10 +215,12 @@ USER root
RUN sed -i "s/PHP_VERSION/${PHP_VERSION}/g" ./.docker/partdb-entrypoint.sh
# Copy entrypoint and apache2-foreground to /usr/local/bin and make it executable
RUN install ./.docker/partdb-entrypoint.sh /usr/local/bin && \
install ./.docker/apache2-foreground /usr/local/bin
# Convert CRLF -> LF and install entrypoint scripts with executable mode
RUN sed -i 's/\r$//' ./.docker/partdb-entrypoint.sh ./.docker/apache2-foreground && \
install -m 0755 ./.docker/partdb-entrypoint.sh /usr/local/bin/ && \
install -m 0755 ./.docker/apache2-foreground /usr/local/bin/
ENTRYPOINT ["partdb-entrypoint.sh"]
CMD ["apache2-foreground"]
CMD ["/usr/local/bin/apache2-foreground"]
# https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
STOPSIGNAL SIGWINCH

View File

@@ -1,6 +1,72 @@
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG NODE_VERSION=22
RUN apt-get update && apt-get -y install \
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
curl \
ca-certificates \
mariadb-client \
@@ -13,24 +79,6 @@ RUN apt-get update && apt-get -y install \
zip \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
RUN set -eux; \
# Run NodeSource setup script
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
\
# Install Node.js
apt-get update; \
apt-get install -y --no-install-recommends \
nodejs; \
\
# Cleanup
apt-get -y autoremove; \
apt-get clean autoclean; \
rm -rf /var/lib/apt/lists/*; \
\
# Install Yarn via npm
npm install -g yarn
# Install PHP
RUN set -eux; \
install-php-extensions \
@@ -76,14 +124,11 @@ COPY --link . ./
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -102,8 +147,8 @@ VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
# See https://caddyserver.com/docs/conventions#file-locations for details
ENV XDG_CONFIG_HOME /config
ENV XDG_DATA_HOME /data
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
EXPOSE 80
EXPOSE 443

View File

@@ -1 +1 @@
2.6.0
2.8.0

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

@@ -20,6 +20,10 @@
import { Controller } from '@hotwired/stimulus';
import { Toast } from 'bootstrap';
/**
* The purpose of this controller, is to show all containers.
* They should already be added via turbo-streams, but have to be called for to show them.
*/
export default class extends Controller {
connect() {
//Move all toasts from the page into our toast container and show them
@@ -33,4 +37,4 @@ export default class extends Controller {
const toast = new Toast(this.element);
toast.show();
}
}
}

View File

@@ -81,7 +81,7 @@ export default class extends Controller {
//Afterwards return the newly created row
if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
ret = targetTable.tBodies[0].lastElementChild;
ret = targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr);
ret = targetTable.lastElementChild;
@@ -90,10 +90,20 @@ export default class extends Controller {
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
this.focusNumberInput(ret);
return ret;
}
focusNumberInput(element) {
const fields = element.querySelectorAll("input[type=number]");
//Focus the first available number input field to open the numeric keyboard on mobile devices
if(fields.length > 0) {
fields[0].focus();
}
}
/**
* This action opens a file dialog to select multiple files and then creates a new element for each file, where
* the file is assigned to the input field.

View File

@@ -108,11 +108,19 @@ export default class extends Controller {
const raw_order = saved_state.order;
settings.initial_order = raw_order.map((order) => {
//Skip if direction is empty, as this is the default, otherwise datatables server is confused when the order is sent in the request, but the initial order is set to an empty direction
if (order[1] === '') {
return null;
}
return {
column: order[0],
dir: order[1]
}
});
//Remove null values from the initial_order array
settings.initial_order = settings.initial_order.filter(order => order !== null);
}
let options = {

View File

@@ -0,0 +1,106 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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/>.
*/
import {Controller} from "@hotwired/stimulus";
/**
* Purpose of this controller is to allow users to input non-printable characters like EOT, FS, etc. in a form field and submit them correctly with the form.
* The visible input field encodes non-printable characters via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉, so that they can be displayed in the input field without breaking the form submission.
* The actual value of the field, which is submitted with the form, is stored in a hidden input and contains the non-printable characters in their original form.
*/
export default class extends Controller {
_hiddenInput;
connect() {
this.element.addEventListener("input", this._update.bind(this));
// We use a hidden input to store the actual value of the field, which is submitted with the form.
// The visible input is just for user interaction and can contain non-printable characters, which are not allowed in the hidden input.
this._hiddenInput = document.createElement("input");
this._hiddenInput.type = "hidden";
this._hiddenInput.name = this.element.name;
this.element.removeAttribute("name");
this.element.parentNode.insertBefore(this._hiddenInput, this.element.nextSibling);
this.element.addEventListener("keypress", this._onKeyPress.bind(this));
}
/**
* Ensures that non-printable characters like EOT, FS, etc. gets added to the input value when the user types them
* @param event
* @private
*/
_onKeyPress(event) {
const ALLOWED_INPUT_CODES = [4, 28, 29, 30, 31]; //EOT, FS, GS, RS, US
if (!ALLOWED_INPUT_CODES.includes(event.keyCode)) {
return;
}
event.preventDefault();
const char = String.fromCharCode(event.keyCode);
this.element.value += char;
this._update();
}
_update() {
//Chrome workaround: Remove a leading ∠ character (U+2220) that appears when the user types a non-printable character at the beginning of the input field.
if (this.element.value.startsWith("∠")) {
this.element.value = this.element.value.substring(1);
}
// Remove non-printable characters from the input value and store them in the hidden input
const normalizedValue = this.decodeNonPrintableChars(this.element.value);
this._hiddenInput.value = normalizedValue;
// Encode non-printable characters in the visible input to their Unicode Control picture representation
const encodedValue = this.encodeNonPrintableChars(normalizedValue);
if (encodedValue !== this.element.value) {
this.element.value = encodedValue;
}
}
/**
* Encodes non-printable characters in the given string via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉.
* This allows us to display non-printable characters in the input field without breaking the form submission.
* @param str
*/
encodeNonPrintableChars(str) {
return str.replace(/[\x00-\x1F\x7F]/g, (char) => {
const code = char.charCodeAt(0);
return String.fromCharCode(0x2400 + code);
});
}
/**
* Decodes the Unicode Control picture representation of non-printable characters back to their original form, e.g. ␊ becomes \n and ␉ becomes \t.
* @param str
*/
decodeNonPrintableChars(str) {
return str.replace(/[\u2400-\u241F\u2421]/g, (char) => {
const code = char.charCodeAt(0) - 0x2400;
return String.fromCharCode(code);
});
}
}

View File

@@ -0,0 +1,136 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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/>.
*/
import { Controller } from "@hotwired/stimulus"
/**
* This controller listens for a special non-printable character (SOH / ASCII 1) to be entered anywhere on the page,
* which is then used as a trigger to submit the following characters as a barcode / scan input.
*/
export default class extends Controller {
connect() {
// Optional: Log to confirm global attachment
console.log("Scanner listener active")
this.isCapturing = false
this.buffer = ""
window.addEventListener("keypress", this.handleKeydown.bind(this))
}
initialize() {
this.isCapturing = false
this.buffer = ""
this.timeoutId = null
}
handleKeydown(event) {
// Ignore if the user is typing in a form field
const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) ||
event.target.isContentEditable;
if (isInput) return
// 1. Detect Start of Header (SOH / Ctrl+A)
if (event.key === "\x01" || event.keyCode === 1) {
this.startCapturing(event)
return
}
// 2. Process characters if in capture mode
if (this.isCapturing) {
this.resetTimeout() // Push the expiration back with every keypress
if (event.key === "Enter" || event.keyCode === 13) {
this.finishCapturing(event)
} else if (event.key.length === 1) {
this.buffer += event.key
}
}
}
startCapturing(event) {
this.isCapturing = true
this.buffer = ""
this.resetTimeout()
event.preventDefault()
console.debug("Scan character detected. Capture started...")
}
finishCapturing(event) {
event.preventDefault()
const data = this.buffer;
this.stopCapturing()
this.processCapture(data)
}
stopCapturing() {
this.isCapturing = false
this.buffer = ""
if (this.timeoutId) clearTimeout(this.timeoutId)
console.debug("Capture cleared/finished.")
}
resetTimeout() {
if (this.timeoutId) clearTimeout(this.timeoutId)
this.timeoutId = setTimeout(() => {
if (this.isCapturing) {
console.warn("Capture timed out. Resetting buffer.")
this.stopCapturing()
}
}, 500)
}
processCapture(data) {
if (!data) return
console.debug("Captured scan data: " + data)
const scanInput = document.getElementById("scan_dialog_input");
if (scanInput) { //When we are on the scan dialog page, submit the form there
this._submitScanForm(data);
} else { //Otherwise use our own form (e.g. on the part list page)
this.element.querySelector("input[name='input']").value = data;
this.element.requestSubmit();
}
}
_submitScanForm(data) {
const scanInput = document.getElementById("scan_dialog_input");
if (!scanInput) {
console.error("Scan input field not found!")
return;
}
scanInput.value = data;
scanInput.dispatchEvent(new Event('input', { bubbles: true }));
const form = document.getElementById("scan_dialog_form");
if (!form) {
console.error("Scan form not found!")
return;
}
form.requestSubmit();
}
}

View File

@@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus";
//import * as ZXing from "@zxing/library";
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
/* stimulusFetch: 'lazy' */
export default class extends Controller {
//codeReader = null;
_scanner = null;
_submitting = false;
_lastDecodedText = "";
_onInfoChange = null;
connect() {
console.log('Init Scanner');
// Prevent double init if connect fires twice
if (this._scanner) return;
// clear last decoded barcode when state changes on info box
const info = document.getElementById("scan_dialog_info_mode");
if (info) {
this._onInfoChange = () => {
this._lastDecodedText = "";
};
info.addEventListener("change", this._onInfoChange);
}
const isMobile = window.matchMedia("(max-width: 768px)").matches;
//This function ensures, that the qrbox is 70% of the total viewport
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
@@ -45,30 +59,66 @@ export default class extends Controller {
}
//Try to get the number of cameras. If the number is 0, then the promise will fail, and we show the warning dialog
Html5Qrcode.getCameras().catch((devices) => {
document.getElementById('scanner-warning').classList.remove('d-none');
Html5Qrcode.getCameras().catch(() => {
document.getElementById("scanner-warning")?.classList.remove("d-none");
});
this._scanner = new Html5QrcodeScanner(this.element.id, {
fps: 10,
qrbox: qrboxFunction,
// Key change: shrink preview height on mobile
...(isMobile ? { aspectRatio: 1.0 } : {}),
experimentalFeatures: {
//This option improves reading quality on android chrome
useBarCodeDetectorIfSupported: true
}
useBarCodeDetectorIfSupported: true,
},
}, false);
this._scanner.render(this.onScanSuccess.bind(this));
}
disconnect() {
this._scanner.pause();
this._scanner.clear();
// If we already stopped/cleared before submit, nothing to do.
const scanner = this._scanner;
this._scanner = null;
this._lastDecodedText = "";
// Unbind info-mode change handler (always do this, even if scanner is null)
const info = document.getElementById("scan_dialog_info_mode");
if (info && this._onInfoChange) {
info.removeEventListener("change", this._onInfoChange);
}
this._onInfoChange = null;
if (!scanner) return;
try {
const p = scanner.clear?.();
if (p && typeof p.then === "function") p.catch(() => {});
} catch (_) {
// ignore
}
}
onScanSuccess(decodedText, decodedResult) {
//Put our decoded Text into the input box
document.getElementById('scan_dialog_input').value = decodedText;
onScanSuccess(decodedText) {
if (!decodedText) return;
const normalized = String(decodedText).trim();
if (!normalized) return;
// scan once per barcode
if (normalized === this._lastDecodedText) return;
// Mark as handled immediately (prevents spam even if callback fires repeatedly)
this._lastDecodedText = normalized;
const input = document.getElementById('scan_dialog_input');
input.value = decodedText;
//Trigger nonprintable char input controller to update the hidden input value
input.dispatchEvent(new Event('input', { bubbles: true }));
//Submit form
document.getElementById('scan_dialog_form').requestSubmit();
}

View File

@@ -5,6 +5,7 @@ export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
this.element.addEventListener('shown.bs.modal', event => this._handleModalShown(event));
}
_handleModalOpen(event) {
@@ -61,4 +62,8 @@ export default class extends Controller
amountInput.setAttribute('max', lotAmount);
}
}
_handleModalShown(event) {
this.element.querySelector('input[name="amount"]').focus();
}
}

View File

@@ -1,27 +0,0 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
//If we encounter an element with global reload controller, then reload the whole page
window.location.reload();
}
}

View File

@@ -1,27 +0,0 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
const menu = document.getElementById('locale-select-menu');
menu.innerHTML = this.element.innerHTML;
}
}

View File

@@ -58,6 +58,12 @@
object-fit: contain;
}
@media (max-width: 768px) {
.part-info-image {
max-height: 100px;
}
}
.object-fit-cover {
object-fit: cover;
}

View File

@@ -27,7 +27,12 @@ class RegisterEventHelper {
constructor() {
this.registerTooltips();
this.configureDropdowns();
this.registerSpecialCharInput();
// Only register special character input if enabled in configuration
const keybindingsEnabled = document.body.dataset.keybindingsSpecialCharacters !== 'false';
if (keybindingsEnabled) {
this.registerSpecialCharInput();
}
//Initialize ClipboardJS
this.registerLoadHandler(() => {

View File

@@ -17,7 +17,7 @@
"api-platform/json-api": "^4.0.0",
"api-platform/symfony": "^4.0.0",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.13.1",
"brick/math": "^0.14.8",
"brick/schema": "^0.2.0",
"composer/ca-bundle": "^1.5",
"composer/package-versions-deprecated": "^1.11.99.5",
@@ -28,7 +28,7 @@
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^3.1.2",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"hshn/base64-encoded-file": "^6.0",
"jbtronics/2fa-webauthn": "^3.0.0",
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
"jbtronics/settings-bundle": "^3.0.0",
@@ -45,7 +45,6 @@
"nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1",
"omines/datatables-bundle": "^0.10.0",
"paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0",
"part-db/swap-bundle": "^6.0.0",
"phpoffice/phpspreadsheet": "^5.0.0",
@@ -70,7 +69,7 @@
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^3.1",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
@@ -88,7 +87,7 @@
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^v2.0.1",
"symfony/yaml": "7.4.*",
"symplify/easy-coding-standard": "^12.5.20",
"symplify/easy-coding-standard": "^13.0",
"tecnickcom/tc-lib-barcode": "^2.1.4",
"tiendanube/gtinvalidation": "^1.0",
"twig/cssinliner-extra": "^3.0",
@@ -129,7 +128,7 @@
},
"suggest": {
"ext-bcmath": "Used to improve price calculation performance",
"ext-gmp": "Used to improve price calculation performanice"
"ext-gmp": "Used to improve price calculation performance"
},
"config": {
"preferred-install": {

1203
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,12 +20,14 @@
declare(strict_types=1);
use Symfony\Config\DoctrineConfig;
/**
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
*/
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
return static function(DoctrineConfig $doctrine) {
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
$doctrine->orm()->enableNativeLazyObjects(true);

View File

@@ -208,29 +208,29 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* initial_marking?: list<scalar|Param|null>,
* events_to_dispatch?: list<string|Param>|null,
* places?: list<array{ // Default: []
* name: scalar|Param|null,
* metadata?: list<mixed>,
* name?: scalar|Param|null,
* metadata?: array<string, mixed>,
* }>,
* transitions: list<array{ // Default: []
* name: string|Param,
* transitions?: list<array{ // Default: []
* name?: string|Param,
* guard?: string|Param, // An expression to block the transition.
* from?: list<array{ // Default: []
* place: string|Param,
* place?: string|Param,
* weight?: int|Param, // Default: 1
* }>,
* to?: list<array{ // Default: []
* place: string|Param,
* place?: string|Param,
* weight?: int|Param, // Default: 1
* }>,
* weight?: int|Param, // Default: 1
* metadata?: list<mixed>,
* metadata?: array<string, mixed>,
* }>,
* metadata?: list<mixed>,
* metadata?: array<string, mixed>,
* }>,
* },
* router?: bool|array{ // Router configuration
* enabled?: bool|Param, // Default: false
* resource: scalar|Param|null,
* resource?: scalar|Param|null,
* type?: scalar|Param|null,
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
@@ -360,10 +360,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* mapping?: array{
* paths?: list<scalar|Param|null>,
* },
* default_context?: list<mixed>,
* default_context?: array<string, mixed>,
* named_serializers?: array<string, array{ // Default: []
* name_converter?: scalar|Param|null,
* default_context?: list<mixed>,
* default_context?: array<string, mixed>,
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
* }>,
@@ -427,7 +427,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* messenger?: bool|array{ // Messenger configuration
* enabled?: bool|Param, // Default: false
* routing?: array<string, array{ // Default: []
* routing?: array<string, string|array{ // Default: []
* senders?: list<scalar|Param|null>,
* }>,
* serializer?: array{
@@ -440,7 +440,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* transports?: array<string, string|array{ // Default: []
* dsn?: scalar|Param|null,
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
* options?: list<mixed>,
* options?: array<string, mixed>,
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
* retry_strategy?: string|array{
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
@@ -462,7 +462,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* allow_no_senders?: bool|Param, // Default: true
* },
* middleware?: list<string|array{ // Default: []
* id: scalar|Param|null,
* id?: scalar|Param|null,
* arguments?: list<mixed>,
* }>,
* }>,
@@ -634,7 +634,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
* limiters?: list<scalar|Param|null>,
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
@@ -679,7 +679,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
* routing?: array<string, array{ // Default: []
* service: scalar|Param|null,
* service?: scalar|Param|null,
* secret?: scalar|Param|null, // Default: ""
* }>,
* },
@@ -694,7 +694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* dbal?: array{
* default_connection?: scalar|Param|null,
* types?: array<string, string|array{ // Default: []
* class: scalar|Param|null,
* class?: scalar|Param|null,
* commented?: bool|Param, // Deprecated: The doctrine-bundle type commenting features were removed; the corresponding config parameter was deprecated in 2.0 and will be dropped in 3.0.
* }>,
* driver_schemes?: array<string, scalar|Param|null>,
@@ -910,7 +910,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* datetime_functions?: array<string, scalar|Param|null>,
* },
* filters?: array<string, string|array{ // Default: []
* class: scalar|Param|null,
* class?: scalar|Param|null,
* enabled?: bool|Param, // Default: false
* parameters?: array<string, mixed>,
* }>,
@@ -975,7 +975,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* providers?: list<scalar|Param|null>,
* },
* entity?: array{
* class: scalar|Param|null, // The full entity class name of your user class.
* class?: scalar|Param|null, // The full entity class name of your user class.
* property?: scalar|Param|null, // Default: null
* manager_name?: scalar|Param|null, // Default: null
* },
@@ -986,8 +986,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* ldap?: array{
* service: scalar|Param|null,
* base_dn: scalar|Param|null,
* service?: scalar|Param|null,
* base_dn?: scalar|Param|null,
* search_dn?: scalar|Param|null, // Default: null
* search_password?: scalar|Param|null, // Default: null
* extra_fields?: list<scalar|Param|null>,
@@ -998,11 +998,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* password_attribute?: scalar|Param|null, // Default: null
* },
* saml?: array{
* user_class: scalar|Param|null,
* user_class?: scalar|Param|null,
* default_roles?: list<scalar|Param|null>,
* },
* }>,
* firewalls: array<string, array{ // Default: []
* firewalls?: array<string, array{ // Default: []
* pattern?: scalar|Param|null,
* host?: scalar|Param|null,
* methods?: list<scalar|Param|null>,
@@ -1136,9 +1136,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* failure_path_parameter?: scalar|Param|null, // Default: "_failure_path"
* },
* login_link?: array{
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
* signature_properties: list<scalar|Param|null>,
* signature_properties?: list<scalar|Param|null>,
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
@@ -1240,13 +1240,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* failure_handler?: scalar|Param|null,
* realm?: scalar|Param|null, // Default: null
* token_extractors?: list<scalar|Param|null>,
* token_handler: string|array{
* token_handler?: string|array{
* id?: scalar|Param|null,
* oidc_user_info?: string|array{
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
* discovery?: array{ // Enable the OIDC discovery.
* cache?: array{
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
* },
* },
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
@@ -1254,27 +1254,27 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* oidc?: array{
* discovery?: array{ // Enable the OIDC discovery.
* base_uri: list<scalar|Param|null>,
* base_uri?: list<scalar|Param|null>,
* cache?: array{
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
* },
* },
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
* issuers: list<scalar|Param|null>,
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
* issuers?: list<scalar|Param|null>,
* algorithm?: array<mixed>,
* algorithms: list<scalar|Param|null>,
* algorithms?: list<scalar|Param|null>,
* key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key).
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
* algorithms: list<scalar|Param|null>,
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
* algorithms?: list<scalar|Param|null>,
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
* },
* },
* cas?: array{
* validation_url: scalar|Param|null, // CAS server validation URL
* validation_url?: scalar|Param|null, // CAS server validation URL
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
* },
@@ -1379,7 +1379,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* use_microseconds?: scalar|Param|null, // Default: true
* channels?: list<scalar|Param|null>,
* handlers?: array<string, array{ // Default: []
* type: scalar|Param|null,
* type?: scalar|Param|null,
* id?: scalar|Param|null,
* enabled?: bool|Param, // Default: true
* priority?: scalar|Param|null, // Default: 0
@@ -1387,7 +1387,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* bubble?: bool|Param, // Default: true
* interactive_only?: bool|Param, // Default: false
* app_name?: scalar|Param|null, // Default: null
* fill_extra_context?: bool|Param, // Default: false
* include_stacktraces?: bool|Param, // Default: false
* process_psr_3_messages?: array{
* enabled?: bool|Param|null, // Default: null
@@ -1407,7 +1406,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* activation_strategy?: scalar|Param|null, // Default: null
* stop_buffering?: bool|Param, // Default: true
* passthru_level?: scalar|Param|null, // Default: null
* excluded_404s?: list<scalar|Param|null>,
* excluded_http_codes?: list<array{ // Default: []
* code?: scalar|Param|null,
* urls?: list<scalar|Param|null>,
@@ -1421,9 +1419,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* url?: scalar|Param|null,
* exchange?: scalar|Param|null,
* exchange_name?: scalar|Param|null, // Default: "log"
* room?: scalar|Param|null,
* message_format?: scalar|Param|null, // Default: "text"
* api_version?: scalar|Param|null, // Default: null
* channel?: scalar|Param|null, // Default: null
* bot_name?: scalar|Param|null, // Default: "Monolog"
* use_attachment?: scalar|Param|null, // Default: true
@@ -1432,9 +1427,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* icon_emoji?: scalar|Param|null, // Default: null
* webhook_url?: scalar|Param|null,
* exclude_fields?: list<scalar|Param|null>,
* team?: scalar|Param|null,
* notify?: scalar|Param|null, // Default: false
* nickname?: scalar|Param|null, // Default: "Monolog"
* token?: scalar|Param|null,
* region?: scalar|Param|null,
* source?: scalar|Param|null,
@@ -1452,12 +1444,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* store?: scalar|Param|null, // Default: null
* connection_timeout?: scalar|Param|null,
* persistent?: bool|Param,
* dsn?: scalar|Param|null,
* hub_id?: scalar|Param|null, // Default: null
* client_id?: scalar|Param|null, // Default: null
* auto_log_stacks?: scalar|Param|null, // Default: false
* release?: scalar|Param|null, // Default: null
* environment?: scalar|Param|null, // Default: null
* message_type?: scalar|Param|null, // Default: 0
* parse_mode?: scalar|Param|null, // Default: null
* disable_webpage_preview?: bool|Param|null, // Default: null
@@ -1467,7 +1453,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* topic?: int|Param, // Default: null
* factor?: int|Param, // Default: 1
* tags?: list<scalar|Param|null>,
* console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead.
* console_formatter_options?: mixed, // Default: []
* formatter?: scalar|Param|null,
* nested?: bool|Param, // Default: false
@@ -1478,15 +1463,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* chunk_size?: scalar|Param|null, // Default: 1420
* encoder?: "json"|"compressed_json"|Param,
* },
* mongo?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 27017
* user?: scalar|Param|null,
* pass?: scalar|Param|null,
* database?: scalar|Param|null, // Default: "monolog"
* collection?: scalar|Param|null, // Default: "logs"
* },
* mongodb?: string|array{
* id?: scalar|Param|null, // ID of a MongoDB\Client service
* uri?: scalar|Param|null,
@@ -1526,10 +1502,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* headers?: list<scalar|Param|null>,
* mailer?: scalar|Param|null, // Default: null
* email_prototype?: string|array{
* id: scalar|Param|null,
* id?: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* lazy?: bool|Param, // Default: true
* verbosity_levels?: array{
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
@@ -1556,7 +1531,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* generate_final_entities?: bool|Param, // Default: false
* }
* @psalm-type WebpackEncoreConfig = array{
* output_path: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
* output_path?: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
* crossorigin?: false|"anonymous"|"use-credentials"|Param, // crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials // Default: false
* preload?: bool|Param, // preload all rendered script and link tags automatically via the http2 Link header. // Default: false
* cache?: bool|Param, // Enable caching of the entry point file(s) // Default: false
@@ -1586,27 +1561,27 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cache_prefix?: scalar|Param|null, // Default: "media/cache"
* },
* aws_s3?: array{
* bucket: scalar|Param|null,
* bucket?: scalar|Param|null,
* cache?: scalar|Param|null, // Default: false
* use_psr_cache?: bool|Param, // Default: false
* acl?: scalar|Param|null, // Default: "public-read"
* cache_prefix?: scalar|Param|null, // Default: ""
* client_id?: scalar|Param|null, // Default: null
* client_config: list<mixed>,
* client_config?: list<mixed>,
* get_options?: array<string, scalar|Param|null>,
* put_options?: array<string, scalar|Param|null>,
* proxies?: array<string, scalar|Param|null>,
* },
* flysystem?: array{
* filesystem_service: scalar|Param|null,
* filesystem_service?: scalar|Param|null,
* cache_prefix?: scalar|Param|null, // Default: ""
* root_url: scalar|Param|null,
* root_url?: scalar|Param|null,
* visibility?: "public"|"private"|"noPredefinedVisibility"|Param, // Default: "public"
* },
* }>,
* loaders?: array<string, array{ // Default: []
* stream?: array{
* wrapper: scalar|Param|null,
* wrapper?: scalar|Param|null,
* context?: scalar|Param|null, // Default: null
* },
* filesystem?: array{
@@ -1620,11 +1595,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* },
* flysystem?: array{
* filesystem_service: scalar|Param|null,
* filesystem_service?: scalar|Param|null,
* },
* asset_mapper?: array<mixed>,
* chain?: array{
* loaders: list<scalar|Param|null>,
* loaders?: list<scalar|Param|null>,
* },
* }>,
* driver?: scalar|Param|null, // Default: "gd"
@@ -1771,23 +1746,23 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* providers?: array{
* apilayer_fixer?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* apilayer_currency_data?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* apilayer_exchange_rates_data?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* abstract_api?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* fixer?: array{
* priority?: int|Param, // Default: 0
* access_key: scalar|Param|null,
* access_key?: scalar|Param|null,
* enterprise?: bool|Param, // Default: false
* },
* cryptonator?: array{
@@ -1795,7 +1770,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* exchange_rates_api?: array{
* priority?: int|Param, // Default: 0
* access_key: scalar|Param|null,
* access_key?: scalar|Param|null,
* enterprise?: bool|Param, // Default: false
* },
* webservicex?: array{
@@ -1830,38 +1805,38 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* currency_data_feed?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* currency_layer?: array{
* priority?: int|Param, // Default: 0
* access_key: scalar|Param|null,
* access_key?: scalar|Param|null,
* enterprise?: bool|Param, // Default: false
* },
* forge?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* open_exchange_rates?: array{
* priority?: int|Param, // Default: 0
* app_id: scalar|Param|null,
* app_id?: scalar|Param|null,
* enterprise?: bool|Param, // Default: false
* },
* xignite?: array{
* priority?: int|Param, // Default: 0
* token: scalar|Param|null,
* token?: scalar|Param|null,
* },
* xchangeapi?: array{
* priority?: int|Param, // Default: 0
* api_key: scalar|Param|null,
* api_key?: scalar|Param|null,
* },
* currency_converter?: array{
* priority?: int|Param, // Default: 0
* access_key: scalar|Param|null,
* access_key?: scalar|Param|null,
* enterprise?: bool|Param, // Default: false
* },
* array?: array{
* priority?: int|Param, // Default: 0
* latestRates: mixed,
* latestRates?: mixed,
* historicalRates?: mixed,
* },
* },
@@ -2123,9 +2098,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* counter_checker?: scalar|Param|null, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid"
* top_origin_validator?: scalar|Param|null, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null
* creation_profiles?: array<string, array{ // Default: []
* rp: array{
* rp?: array{
* id?: scalar|Param|null, // Default: null
* name: scalar|Param|null,
* name?: scalar|Param|null,
* icon?: scalar|Param|null, // Deprecated: The child node "icon" at path "webauthn.creation_profiles..rp.icon" is deprecated and has no effect. // Default: null
* },
* challenge_length?: int|Param, // Default: 32
@@ -2149,21 +2124,21 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* metadata?: bool|array{ // Enable the support of the Metadata Statements. Please read the documentation for this feature.
* enabled?: bool|Param, // Default: false
* mds_repository: scalar|Param|null, // The Metadata Statement repository.
* status_report_repository: scalar|Param|null, // The Status Report repository.
* mds_repository?: scalar|Param|null, // The Metadata Statement repository.
* status_report_repository?: scalar|Param|null, // The Status Report repository.
* certificate_chain_checker?: scalar|Param|null, // A Certificate Chain checker. // Default: "Webauthn\\MetadataService\\CertificateChain\\PhpCertificateChainValidator"
* },
* controllers?: bool|array{
* enabled?: bool|Param, // Default: false
* creation?: array<string, array{ // Default: []
* options_method?: scalar|Param|null, // Default: "POST"
* options_path: scalar|Param|null,
* options_path?: scalar|Param|null,
* result_method?: scalar|Param|null, // Default: "POST"
* result_path?: scalar|Param|null, // Default: null
* host?: scalar|Param|null, // Default: null
* profile?: scalar|Param|null, // Default: "default"
* options_builder?: scalar|Param|null, // When set, corresponds to the ID of the Public Key Credential Creation Builder. The profile-based ebuilder is ignored. // Default: null
* user_entity_guesser: scalar|Param|null,
* user_entity_guesser?: scalar|Param|null,
* hide_existing_credentials?: scalar|Param|null, // In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users. // Default: false
* options_storage?: scalar|Param|null, // Deprecated: The child node "options_storage" at path "webauthn.controllers.creation..options_storage" is deprecated. Please use the root option "options_storage" instead. // Service responsible of the options/user entity storage during the ceremony // Default: null
* success_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultSuccessHandler"
@@ -2175,7 +2150,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* request?: array<string, array{ // Default: []
* options_method?: scalar|Param|null, // Default: "POST"
* options_path: scalar|Param|null,
* options_path?: scalar|Param|null,
* result_method?: scalar|Param|null, // Default: "POST"
* result_path?: scalar|Param|null, // Default: null
* host?: scalar|Param|null, // Default: null
@@ -2196,10 +2171,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* baseurl?: scalar|Param|null, // Default: "<request_scheme_and_host>/saml/"
* strict?: bool|Param,
* debug?: bool|Param,
* idp: array{
* entityId: scalar|Param|null,
* singleSignOnService: array{
* url: scalar|Param|null,
* idp?: array{
* entityId?: scalar|Param|null,
* singleSignOnService?: array{
* url?: scalar|Param|null,
* binding?: scalar|Param|null,
* },
* singleLogoutService?: array{
@@ -2270,30 +2245,30 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* contactPerson?: array{
* technical?: array{
* givenName: scalar|Param|null,
* emailAddress: scalar|Param|null,
* givenName?: scalar|Param|null,
* emailAddress?: scalar|Param|null,
* },
* support?: array{
* givenName: scalar|Param|null,
* emailAddress: scalar|Param|null,
* givenName?: scalar|Param|null,
* emailAddress?: scalar|Param|null,
* },
* administrative?: array{
* givenName: scalar|Param|null,
* emailAddress: scalar|Param|null,
* givenName?: scalar|Param|null,
* emailAddress?: scalar|Param|null,
* },
* billing?: array{
* givenName: scalar|Param|null,
* emailAddress: scalar|Param|null,
* givenName?: scalar|Param|null,
* emailAddress?: scalar|Param|null,
* },
* other?: array{
* givenName: scalar|Param|null,
* emailAddress: scalar|Param|null,
* givenName?: scalar|Param|null,
* emailAddress?: scalar|Param|null,
* },
* },
* organization?: list<array{ // Default: []
* name: scalar|Param|null,
* displayname: scalar|Param|null,
* url: scalar|Param|null,
* name?: scalar|Param|null,
* displayname?: scalar|Param|null,
* url?: scalar|Param|null,
* }>,
* }>,
* use_proxy_vars?: bool|Param, // Default: false
@@ -2329,7 +2304,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* auto_install?: bool|Param, // Default: false
* fonts?: list<array{ // Default: []
* normal: scalar|Param|null,
* normal?: scalar|Param|null,
* bold?: scalar|Param|null,
* italic?: scalar|Param|null,
* bold_italic?: scalar|Param|null,
@@ -2480,7 +2455,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: true
* },
* max_query_depth?: int|Param, // Default: 20
* graphql_playground?: array<mixed>,
* graphql_playground?: bool|array{ // Deprecated: The "graphql_playground" configuration is deprecated and will be ignored.
* enabled?: bool|Param, // Default: false
* },
* max_query_complexity?: int|Param, // Default: 500
* nesting_separator?: scalar|Param|null, // The separator to use to filter nested fields. // Default: "_"
* collection?: array{
@@ -2537,7 +2514,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
* tags?: list<array{ // Default: []
* name: scalar|Param|null,
* name?: scalar|Param|null,
* description?: scalar|Param|null, // Default: null
* }>,
* license?: array{
@@ -2829,7 +2806,10 @@ final class App
*/
public static function config(array $config): array
{
return AppReference::config($config);
/** @var ConfigType $config */
$config = AppReference::config($config);
return $config;
}
}

View File

@@ -86,6 +86,9 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser.
Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled,
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.
* `USE_GRAVATAR`: Set to `1` to use [gravatar.com](https://gravatar.com/) images for user avatars (as long as they have
not set their own picture). The users browsers have to download the pictures from a third-party (gravatar) server, so
this might be a privacy risk.
@@ -126,6 +129,8 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the parts description is used to find existing parts with the same
description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list.
* `KEYBINDINGS_SPECIAL_CHARS_ENABLED`: Set this to 0 to disable the special character keybindings (Alt + key) for inserting special characters. This can be useful if
they conflict with your keyboard layout or system shortcuts.
### E-Mail settings (all env only)

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

@@ -303,7 +303,17 @@ That method is not officially supported nor encouraged by Part-DB, and might bre
The following env configuration options are available:
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
### Custom provider
### Canopy / Amazon
The Canopy provider uses the [Canopy API](https://www.canopyapi.co/) to search for parts and get shopping information from Amazon.
Canopy is a third-party service that provides access to Amazon product data through their API. Their trial plan offers 100 requests per month for free,
and they also offer paid plans with higher limits. To use the Canopy provider, you need to create an account on the Canopy website and obtain an API key.
Once you have the API key, you can configure the Canopy provider in Part-DB using the web UI or environment variables:
* `PROVIDER_CANOPY_API_KEY`: The API key you got from Canopy (mandatory)
### Custom providers
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
as it is a valid Symfony service, it will be automatically loaded and can be used.

View File

@@ -8,6 +8,21 @@ parent: Usage
This page lists all the keybindings of Part-DB. Currently, there are only the special character keybindings.
## Disabling keybindings
If you want to disable the special character keybindings (for example, because they conflict with your keyboard layout or system shortcuts), you can do so in two ways:
### Via the System Settings UI (recommended)
1. Navigate to **System Settings** (Tools → System Settings)
2. Go to **Behavior****Keybindings**
3. Uncheck **Enable special character keybindings**
4. Save the settings
### Via Environment Variable
Alternatively, you can set the environment variable `KEYBINDINGS_SPECIAL_CHARS_ENABLED=0` in your `.env.local` file or your server environment configuration.
## Special characters
Using the keybindings below (Alt + key) you can insert special characters into the text fields of Part-DB. This works on

View File

@@ -91,18 +91,20 @@ in [official documentation](https://twig.symfony.com/doc/3.x/).
Twig allows you for much more complex and dynamic label generation. You can use loops, conditions, and functions to create
the label content and you can access almost all data Part-DB offers. The label templates are evaluated in a special sandboxed environment,
where only certain operations are allowed. Only read access to entities is allowed. However as it circumvents Part-DB normal permission system,
where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system,
the twig mode is only available to users with the "Twig mode" permission.
It is useful to use the HTML embed feature of the editor, to have a block where you can write the twig code without worrying about the WYSIWYG editor messing with your code.
The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` (
or `{% raw %}{{ variable.property }}{% endraw %}`):
| Variable name | Description |
|--------------------------------------------|--------------------------------------------------------------------------------------|
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ user }}{% endraw %}` | The current logged in user. Null if you are not logged in |
| `{% raw %}{{ install_title }}{% endraw %}` | The name of the current Part-DB instance (similar to [[INSTALL_NAME]] placeholder). |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated ) |
| `{% raw %}{{ last_page }}{% endraw %}` | The page number of the last element. Equals the number of all pages / element labels |
| `{% raw %}{{ paper_width }}{% endraw %}` | The width of the label paper in mm |
| `{% raw %}{{ paper_height }}{% endraw %}` | The height of the label paper in mm |
@@ -236,12 +238,18 @@ certain data:
#### Functions
| Function name | Description |
|----------------------------------------------|-----------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| Function name | Description |
|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| `associated_parts(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. Only the directly associated parts are returned |
| `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) |
| `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities |
| `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) |
| `type_label(element)` | Get the name of the type of an element (e.g. "Part", "Storage location", etc.) |
| `type_label_p(element)` | Get the name of the type of an element in plural form (e.g. "Parts", "Storage locations", etc.) |
### Filters
@@ -285,5 +293,5 @@ If you want to use a different (more beautiful) font, you can use the [custom fo
feature.
There is the [Noto](https://www.google.com/get/noto/) font family from Google, which supports a lot of languages and is
available in different styles (regular, bold, italic, bold-italic).
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.

51
docs/usage/scanner.md Normal file
View File

@@ -0,0 +1,51 @@
---
title: Barcode Scanner
layout: default
parent: Usage
---
# Barcode scanner
When the user has the correct permission there will be a barcode scanner button in the navbar.
On this page you can either input a barcode code by hand, use an external barcode scanner, or use your devices camera to
scan a barcode.
In info mode (when the "Info" toggle is enabled) you can scan a barcode and Part-DB will parse it and show information
about it.
Without info mode, the barcode will directly redirect you to the corresponding page.
### Barcode matching
When you scan a barcode, Part-DB will try to match it to an existing part, part lot or storage location first.
For Part-DB generated barcodes, it will use the internal ID of a part. Alternatively you can also scan a barcode that contains the part's IPN.
You can set a GTIN/EAN code in the part properties and Part-DB will open the part page when you scan the corresponding GTIN/EAN barcode.
On a part lot you can under "Advanced" set a user barcode, that will redirect you to the part lot page when scanned. This allows to reuse
arbitrary existing barcodes that already exist on the part lots (for example, from the manufacturer) and link them to the part lot in Part-DB.
Part-DB can also parse various distributor barcodes (for example from Digikey and Mouser) and will try to redirect you to the corresponding
part page based on the distributor part number in the barcode.
### Part creation from barcodes
For certain barcodes Part-DB can automatically create a new part, when it cannot find a matching part.
Part-DB will try to retrieve the part information from an information provider and redirects you to the part creation page
with the retrieved information pre-filled.
## Using an external barcode scanner
Part-DB supports the use of external barcode scanners that emulate keyboard input. To use a barcode scanner with Part-DB,
simply connect the scanner to your computer and scan a barcode while the cursor is in a text field in Part-DB.
The scanned barcode will be entered into the text field as if you had typed it on the keyboard.
In scanner fields, it will also try to insert special non-printable characters the scanner send via Alt + key combinations.
This is required for EIGP114 datamatrix codes.
### Automatically redirect on barcode scanning
If you configure your barcode scanner to send a <SOH> (Start of heading, 0x01) non-printable character at the beginning
of the scanned barcode, Part-DB will automatically scan the barcode that comes afterward (and is ended with an enter key)
and redirects you to the corresponding page.
This allows you to quickly scan a barcode from anywhere in Part-DB without the need to first open the scanner page.
If an input field is focused, the barcode will be entered into the field as usual and no redirection will happen.

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');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
return RectorConfig::configure()
->withComposerBased(phpunit: true)
->withComposerBased(phpunit: true, symfony: true)
->withSymfonyContainerPhp(__DIR__ . '/tests/symfony-container.php')
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml')
@@ -36,8 +36,6 @@ return RectorConfig::configure()
PHPUnitSetList::PHPUNIT_90,
PHPUnitSetList::PHPUNIT_110,
PHPUnitSetList::PHPUNIT_CODE_QUALITY,
])
->withRules([
@@ -59,6 +57,9 @@ return RectorConfig::configure()
PreferPHPUnitThisCallRector::class,
//Do not replace 'GET' with class constant,
LiteralGetToRequestClassConstantRector::class,
//Do not move help text of commands to the command class, as we want to keep the help text in the command definition for better readability
\Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector::class
])
//Do not apply rules to Symfony own files
@@ -67,6 +68,7 @@ return RectorConfig::configure()
__DIR__ . '/src/Kernel.php',
__DIR__ . '/config/preload.php',
__DIR__ . '/config/bundles.php',
__DIR__ . '/config/reference.php'
])
;

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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/>.
*/
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\RequestBody;
use ApiPlatform\OpenApi\Model\Response;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\State\LabelGenerationProcessor;
use App\Validator\Constraints\Misc\ValidRange;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for generating PDF labels for parts, part lots, or storage locations.
* This endpoint allows generating labels using saved label profiles.
*/
#[ApiResource(
uriTemplate: '/labels/generate',
description: 'Generate PDF labels for parts, part lots, or storage locations using label profiles.',
operations: [
new Post(
inputFormats: ['json' => ['application/json']],
outputFormats: [],
openapi: new Operation(
responses: [
"200" => new Response(description: "PDF file containing the generated labels"),
],
summary: 'Generate PDF labels',
description: 'Generate PDF labels for one or more elements using a label profile. Returns a PDF file.',
requestBody: new RequestBody(
description: 'Label generation request',
required: true,
),
),
)
],
processor: LabelGenerationProcessor::class,
)]
class LabelGenerationRequest
{
/**
* @var int The ID of the label profile to use for generation
*/
#[Assert\NotBlank(message: 'Profile ID is required')]
#[Assert\Positive(message: 'Profile ID must be a positive integer')]
public int $profileId = 0;
/**
* @var string Comma-separated list of element IDs or ranges (e.g., "1,2,5-10,15")
*/
#[Assert\NotBlank(message: 'Element IDs are required')]
#[ValidRange()]
#[ApiProperty(example: "1,2,5-10,15")]
public string $elementIds = '';
/**
* @var LabelSupportedElement|null Optional: Override the element type. If not provided, uses profile's default.
*/
public ?LabelSupportedElement $elementType = null;
}

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

@@ -30,6 +30,7 @@ use App\Form\Filters\AttachmentFilterType;
use App\Services\Attachments\AttachmentManager;
use App\Services\Trees\NodesListBuilder;
use App\Settings\BehaviorSettings\TableSettings;
use App\Settings\SystemSettings\AttachmentsSettings;
use Omines\DataTablesBundle\DataTableFactory;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -41,31 +42,56 @@ use Symfony\Component\Routing\Attribute\Route;
class AttachmentFileController extends AbstractController
{
public function __construct(private readonly AttachmentManager $helper)
{
}
#[Route(path: '/attachment/{id}/sandbox', name: 'attachment_html_sandbox')]
public function htmlSandbox(Attachment $attachment, AttachmentsSettings $attachmentsSettings): Response
{
//Check if the sandbox is enabled in the settings, as it can be a security risk if used without proper precautions, so it should be opt-in
if (!$attachmentsSettings->showHTMLAttachments) {
throw $this->createAccessDeniedException('The HTML sandbox for attachments is disabled in the settings, as it can be a security risk if used without proper precautions. Please enable it in the settings if you want to use it.');
}
$this->checkPermissions($attachment);
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
$attachmentContent = file_get_contents($file_path);
$response = $this->render('attachments/html_sandbox.html.twig', [
'attachment' => $attachment,
'content' => $attachmentContent,
]);
//Set an CSP that allows to run inline scripts, styles and images from external ressources, but does not allow any connections or others.
//Also set the sandbox CSP directive with only "allow-script" to run basic scripts
$response->headers->set('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' *; img-src data: *; sandbox allow-scripts allow-downloads allow-modals;");
//Forbid to embed the attachment render page in an iframe to prevent clickjacking, as it is not used anywhere else for now
$response->headers->set('X-Frame-Options', 'DENY');
return $response;
}
/**
* Download the selected attachment.
*/
#[Route(path: '/attachment/{id}/download', name: 'attachment_download')]
public function download(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
public function download(Attachment $attachment): BinaryFileResponse
{
$this->denyAccessUnlessGranted('read', $attachment);
$this->checkPermissions($attachment);
if ($attachment->isSecure()) {
$this->denyAccessUnlessGranted('show_private', $attachment);
}
if (!$attachment->hasInternal()) {
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
}
if (!$helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
$response = $this->forbidHTMLContentType($response);
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $attachment->getFilename());
return $response;
}
@@ -74,7 +100,35 @@ class AttachmentFileController extends AbstractController
* View the attachment.
*/
#[Route(path: '/attachment/{id}/view', name: 'attachment_view')]
public function view(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
public function view(Attachment $attachment): BinaryFileResponse
{
$this->checkPermissions($attachment);
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
$response = $this->forbidHTMLContentType($response);
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $attachment->getFilename());
return $response;
}
private function forbidHTMLContentType(BinaryFileResponse $response): BinaryFileResponse
{
$mimeType = $response->getFile()->getMimeType();
if ($mimeType === 'text/html') {
$mimeType = 'text/plain';
}
$response->headers->set('Content-Type', $mimeType);
return $response;
}
private function checkPermissions(Attachment $attachment): void
{
$this->denyAccessUnlessGranted('read', $attachment);
@@ -86,17 +140,9 @@ class AttachmentFileController extends AbstractController
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
}
if (!$helper->isInternalFileExisting($attachment)) {
if (!$this->helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
return $response;
}
#[Route(path: '/attachment/list', name: 'attachment_list')]

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

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\DataTables\LogDataTable;
use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Parts\Category;
@@ -151,7 +152,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
@@ -172,7 +173,7 @@ final class PartController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token');
}
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
throw $this->createNotFoundException('Bulk import job not found');
}
@@ -338,7 +339,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;

View File

@@ -41,11 +41,16 @@ declare(strict_types=1);
namespace App\Controller;
use App\Exceptions\InfoProviderNotActiveException;
use App\Form\LabelSystem\ScanDialogType;
use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use App\Entity\Parts\Part;
use \App\Entity\Parts\StorageLocation;
use Symfony\UX\Turbo\TurboBundle;
/**
* @see \App\Tests\Controller\ScanControllerTest
@@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/scan')]
class ScanController extends AbstractController
{
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer)
{
}
public function __construct(
protected BarcodeScanResultHandler $resultHandler,
protected BarcodeScanHelper $barcodeNormalizer,
) {}
#[Route(path: '', name: 'scan_dialog')]
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
@@ -72,35 +85,86 @@ class ScanController extends AbstractController
$form = $this->createForm(ScanDialogType::class);
$form->handleRequest($request);
// If JS is working, scanning uses /scan/lookup and this action just renders the page.
// This fallback only runs if user submits the form manually or uses ?input=...
if ($input === null && $form->isSubmitted() && $form->isValid()) {
$input = $form['input']->getData();
$mode = $form['mode']->getData();
}
$infoModeData = null;
if ($input !== null) {
if ($input !== null && $input !== '') {
$mode = $form->isSubmitted() ? $form['mode']->getData() : null;
$infoMode = $form->isSubmitted() && $form['info_mode']->getData();
try {
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
//Perform a redirect if the info mode is not enabled
if (!$form['info_mode']->getData()) {
try {
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
$scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
// If not in info mode, mimic “normal scan” behavior: redirect if possible.
if (!$infoMode) {
// Try to get an Info URL if possible
$url = $this->resultHandler->getInfoURL($scan);
if ($url !== null) {
return $this->redirect($url);
}
//Try to get an creation URL if possible (only for vendor codes)
$createUrl = $this->buildCreateUrlForScanResult($scan);
if ($createUrl !== null) {
return $this->redirect($createUrl);
}
//// Otherwise: show “not found” (not “format unknown”)
$this->addFlash('warning', 'scan.qr_not_found');
} else { // Info mode
// Info mode fallback: render page with prefilled result
$decoded = $scan->getDecodedForInfoMode();
//Try to resolve to an entity, to enhance info mode with entity-specific data
$dbEntity = $this->resultHandler->resolveEntity($scan);
$resolvedPart = $this->resultHandler->resolvePart($scan);
$openUrl = $this->resultHandler->getInfoURL($scan);
//If no entity is found, try to create an URL for creating a new part (only for vendor codes)
$createUrl = null;
if ($dbEntity === null) {
$createUrl = $this->buildCreateUrlForScanResult($scan);
}
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [
'decoded' => $decoded,
'entity' => $dbEntity,
'part' => $resolvedPart,
'openUrl' => $openUrl,
'createUrl' => $createUrl,
]);
}
} else { //Otherwise retrieve infoModeData
$infoModeData = $scan_result->getDecodedForInfoMode();
}
} catch (InvalidArgumentException) {
$this->addFlash('error', 'scan.format_unknown');
} catch (\Throwable $e) {
// Keep fallback user-friendly; avoid 500
$this->addFlash('warning', 'scan.format_unknown');
}
}
//When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
//Only send our flash message, so the client can show it without a full page reload
return $this->renderBlock('_turbo_control.html.twig', 'flashes');
}
return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form,
'infoModeData' => $infoModeData,
//Info mode
'decoded' => $decoded ?? null,
'entity' => $dbEntity ?? null,
'part' => $resolvedPart ?? null,
'openUrl' => $openUrl ?? null,
'createUrl' => $createUrl ?? null,
]);
}
@@ -125,11 +189,30 @@ class ScanController extends AbstractController
source_type: BarcodeSourceType::INTERNAL
);
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found"));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
return $this->redirectToRoute('homepage');
}
}
/**
* Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation.
* @param BarcodeScanResultInterface $scanResult
* @return string|null
*/
private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string
{
try {
return $this->resultHandler->getCreationURL($scanResult);
} catch (InfoProviderNotActiveException $e) {
$this->addFlash('error', $e->getMessage());
} catch (\Throwable) {
// Dont break scanning UX if provider lookup fails
$this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.');
}
return null;
}
}

View File

@@ -147,10 +147,7 @@ class SecurityController extends AbstractController
'label' => 'user.settings.pw_confirm.label',
],
'invalid_message' => 'password_must_match',
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
]);
$builder->add('submit', SubmitType::class, [

View File

@@ -295,10 +295,7 @@ class UserSettingsController extends AbstractController
'autocomplete' => 'new-password',
],
],
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('submit', SubmitType::class, [
'label' => 'save',

View File

@@ -160,7 +160,7 @@ class PartSearchFilter implements FilterInterface
if ($search_dbId) {
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
\Doctrine\DBAL\ParameterType::INTEGER);
ParameterType::INTEGER);
}
//Guard condition

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

@@ -47,6 +47,7 @@ use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Column\TextColumn;
@@ -88,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, [
@@ -227,6 +232,21 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('attachments', PartAttachmentsColumn::class, [
'label' => $this->translator->trans('part.table.attachments'),
])
->add('eda_reference', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_reference'),
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getReferencePrefix() ?? ''),
'orderField' => 'NATSORT(part.eda_info.reference_prefix)'
])
->add('eda_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_value'),
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getValue() ?? ''),
'orderField' => 'NATSORT(part.eda_info.value)'
])
->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
@@ -333,6 +353,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('orderdetails')
->addSelect('attachments')
->addSelect('storelocations')
->addSelect('projectBomEntries')
->from(Part::class, 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
@@ -347,6 +368,7 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.partCustomState', 'partCustomState')
->leftJoin('part.parameters', 'parameters')
->leftJoin('part.project_bom_entries', 'projectBomEntries')
->where('part.id IN (:ids)')
->setParameter('ids', $ids)
@@ -364,7 +386,12 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('attachments')
->addGroupBy('partUnit')
->addGroupBy('partCustomState')
->addGroupBy('parameters');
->addGroupBy('parameters')
->addGroupBy('projectBomEntries')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');

View File

@@ -296,6 +296,22 @@ abstract class Attachment extends AbstractNamedDBElement
return in_array(strtolower($extension), static::MODEL_EXTS, true);
}
/**
* Returns true if this is a locally stored HTML file, which can be shown by the sandbox viewer.
* This is the case if we have an internal path with a html extension.
* @return bool
*/
public function isLocalHTMLFile(): bool
{
if($this->hasInternal()){
$extension = pathinfo($this->getFilename(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), ['html', 'htm'], true);
}
return false;
}
/**
* Checks if this attachment has a path to an external file
*

View File

@@ -41,6 +41,12 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\OpenApi\Model\Operation;
use Doctrine\Common\Collections\Criteria;
use App\Entity\Attachments\Attachment;
use App\Repository\LabelProfileRepository;
@@ -58,6 +64,22 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* @extends AttachmentContainingDBElement<LabelAttachment>
*/
#[ApiResource(
operations: [
new Get(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('read', object)",
openapi: new Operation(summary: 'Get a label profile by ID')
),
new GetCollection(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('@labels.create_labels')",
openapi: new Operation(summary: 'List all available label profiles')
),
],
paginationEnabled: false,
)]
#[ApiFilter(SearchFilter::class, properties: ['options.supported_element' => 'exact', 'show_in_dropdown' => 'exact'])]
#[UniqueEntity(['name', 'options.supported_element'])]
#[ORM\Entity(repositoryClass: LabelProfileRepository::class)]
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
@@ -80,20 +102,21 @@ class LabelProfile extends AttachmentContainingDBElement
*/
#[Assert\Valid]
#[ORM\Embedded(class: 'LabelOptions')]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected LabelOptions $options;
/**
* @var string The comment info for this element
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected string $comment = '';
/**
* @var bool determines, if this label profile should be shown in the dropdown quick menu
*/
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected bool $show_in_dropdown = true;
public function __construct()

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

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Exceptions;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
/**
* An exception denoting that a required info provider is not active. This can be used to display a user-friendly error message,
* when a user tries to use an info provider that is not active.
*/
class InfoProviderNotActiveException extends \RuntimeException
{
public function __construct(public readonly string $providerKey, public readonly string $friendlyName)
{
parent::__construct(sprintf('The info provider "%s" (%s) is not active.', $this->friendlyName, $this->providerKey));
}
/**
* Creates an instance of this exception from an info provider instance
* @param InfoProviderInterface $provider
* @return self
*/
public static function fromProvider(InfoProviderInterface $provider): self
{
return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???');
}
}

View File

@@ -42,15 +42,14 @@ declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
use Twig\Error\Error;
class TwigModeException extends RuntimeException
{
private const PROJECT_PATH = __DIR__ . '/../../';
public function __construct(?Error $previous = null)
public function __construct(?\Throwable $previous = null)
{
parent::__construct($previous->getMessage(), 0, $previous);
parent::__construct($previous?->getMessage() ?? "Unknown message", 0, $previous);
}
/**

View File

@@ -71,6 +71,7 @@ class BaseEntityAdminForm extends AbstractType
'label' => 'name.label',
'attr' => [
'placeholder' => 'part.name.placeholder',
'autofocus' => $is_new,
],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);

View File

@@ -122,9 +122,7 @@ class AttachmentFormType extends AbstractType
],
'constraints' => [
//new AllowedFileExtension(),
new File([
'maxSize' => $options['max_file_size'],
]),
new File(maxSize: $options['max_file_size']),
],
]);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
@@ -61,7 +62,7 @@ class FieldToProviderMappingType extends AbstractType
'style' => 'width: 80px;'
],
'constraints' => [
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
new Range(min: 1, max: 10),
],
]);
}

View File

@@ -61,6 +61,8 @@ class ScanDialogType extends AbstractType
'attr' => [
'autofocus' => true,
'id' => 'scan_dialog_input',
'style' => 'font-family: var(--bs-font-monospace)',
'data-controller' => 'elements--nonprintable-char-input',
],
]);
@@ -72,11 +74,7 @@ class ScanDialogType extends AbstractType
'placeholder' => 'scan_dialog.mode.auto',
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
null => 'scan_dialog.mode.auto',
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp',
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
default => 'scan_dialog.mode.' . $enum->value,
},
]);

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

@@ -117,6 +117,7 @@ class PartBaseType extends AbstractType
'label' => 'part.edit.name',
'attr' => [
'placeholder' => 'part.edit.name.placeholder',
'autofocus' => $new_part,
],
])
->add('description', RichTextEditorType::class, [

View File

@@ -117,7 +117,6 @@ class PartLotType extends AbstractType
'widget' => 'single_text',
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
'required' => false,
'empty_data' => null,
]);
}

View File

@@ -177,10 +177,7 @@ class UserAdminForm extends AbstractType
'required' => false,
'mapped' => false,
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('need_pw_change', CheckboxType::class, [

View File

@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
'accept' => 'image/*',
],
'constraints' => [
new File([
'maxSize' => '5M',
]),
new File(maxSize: '5M'),
],
])
->add('aboutMe', RichTextEditorType::class, [

View File

@@ -0,0 +1,100 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Helpers;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us.
* When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent, until we run out of retries.
*/
final class RandomizeUseragentHttpClient implements HttpClientInterface
{
public const USER_AGENTS = [
"Mozilla/5.0 (Windows; U; Windows NT 10.0; Win64; x64) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/52.0.1359.302 Safari/600.6 Edge/15.25690",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6",
"Mozilla/5.0 (Android; Android 4.4.4; E:number:20-23:00 Build/24.0.B.1.34) AppleWebKit/603.18 (KHTML, like Gecko) Chrome/47.0.1559.384 Mobile Safari/600.5",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)",
"Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536",
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_4_2; like Mac OS X) AppleWebKit/537.24 (KHTML, like Gecko) Chrome/51.0.2432.275 Mobile Safari/535.6",
"Mozilla/5.0 (U; Linux i680 ) Gecko/20100101 Firefox/57.5",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 8_8_6; en-US) Gecko/20100101 Firefox/53.9",
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_6_7) AppleWebKit/534.46 (KHTML, like Gecko) Chrome/55.0.3276.345 Safari/535",
"Mozilla/5.0 (Windows; Windows NT 10.5;) AppleWebKit/535.42 (KHTML, like Gecko) Chrome/53.0.1176.353 Safari/534.0 Edge/11.95743",
"Mozilla/5.0 (Linux; Android 5.1.1; MOTO G Build/LPH223) AppleWebKit/600.27 (KHTML, like Gecko) Chrome/47.0.1604.204 Mobile Safari/535.1",
"Mozilla/5.0 (iPod; CPU iPod OS 7_4_8; like Mac OS X) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/50.0.1632.146 Mobile Safari/600.4",
"Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9",
"Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535",
"Mozilla/5.0 (Windows; Windows NT 10.5; x64; en-US) AppleWebKit/603.39 (KHTML, like Gecko) Chrome/52.0.1443.139 Safari/536.6 Edge/13.79436",
"Mozilla/5.0 (Linux; U; Android 5.1; SM-G9350T Build/MMB29M) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/55.0.2552.307 Mobile Safari/600.8",
"Mozilla/5.0 (Android; Android 6.0; SAMSUNG SM-D9350V Build/MDB08L) AppleWebKit/535.30 (KHTML, like Gecko) Chrome/53.0.1345.278 Mobile Safari/537.4",
"Mozilla/5.0 (Windows; Windows NT 10.0;) AppleWebKit/534.44 (KHTML, like Gecko) Chrome/47.0.3503.387 Safari/601",
];
public function __construct(
private readonly HttpClientInterface $client,
private readonly array $userAgents = self::USER_AGENTS,
private readonly int $repeatOnFailure = 1,
) {
}
public function getRandomUserAgent(): string
{
return $this->userAgents[array_rand($this->userAgents)];
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$repeatsLeft = $this->repeatOnFailure;
do {
$modifiedOptions = $options;
if (!isset($modifiedOptions['headers']['User-Agent'])) {
$modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent();
}
$response = $this->client->request($method, $url, $modifiedOptions);
//When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent
if (!in_array($response->getStatusCode(), [403, 429, 503], true)) {
return $response;
}
//Otherwise we try again with a different user agent, until we run out of retries
} while ($repeatsLeft-- > 0);
return $response;
}
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure);
}
}

View File

@@ -389,4 +389,93 @@ class PartRepository extends NamedDBElementRepository
return $baseIpn . '_' . ($maxSuffix + 1);
}
/**
* Finds a part based on the provided info provider key and ID, with an option for case sensitivity.
* If no part is found with the given provider key and ID, null is returned.
* @param string $providerID
* @param string|null $providerKey If null, the provider key will not be included in the search criteria, and only the provider ID will be used for matching.
* @param bool $caseInsensitive If true, the provider ID comparison will be case-insensitive. Default is true.
* @return Part|null
*/
public function getPartByProviderInfo(string $providerID, ?string $providerKey = null, bool $caseInsensitive = true): ?Part
{
$qb = $this->createQueryBuilder('part');
$qb->select('part');
if ($providerKey) {
$qb->where("part.providerReference.provider_key = :providerKey");
$qb->setParameter('providerKey', $providerKey);
}
if ($caseInsensitive) {
$qb->andWhere("LOWER(part.providerReference.provider_id) = LOWER(:providerID)");
} else {
$qb->andWhere("part.providerReference.provider_id = :providerID");
}
$qb->setParameter('providerID', $providerID);
return $qb->getQuery()->getOneOrNullResult();
}
/**
* Finds a part based on the provided MPN (Manufacturer Part Number), with an option for case sensitivity.
* If no part is found with the given MPN, null is returned.
* @param string $mpn
* @param string|null $manufacturerName If provided, the search will also include a match for the manufacturer's name. If null, the manufacturer name will not be included in the search criteria.
* @param bool $caseInsensitive If true, the MPN comparison will be case-insensitive. Default is true (case-insensitive).
* @return Part|null
*/
public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $caseInsensitive = true): ?Part
{
$qb = $this->createQueryBuilder('part');
$qb->select('part');
if ($caseInsensitive) {
$qb->where("LOWER(part.manufacturer_product_number) = LOWER(:mpn)");
} else {
$qb->where("part.manufacturer_product_number = :mpn");
}
if ($manufacturerName !== null) {
$qb->leftJoin('part.manufacturer', 'manufacturer');
if ($caseInsensitive) {
$qb->andWhere("LOWER(manufacturer.name) = LOWER(:manufacturerName)");
} else {
$qb->andWhere("manufacturer.name = :manufacturerName");
}
$qb->setParameter('manufacturerName', $manufacturerName);
}
$qb->setParameter('mpn', $mpn);
return $qb->getQuery()->getOneOrNullResult();
}
/**
* Finds a part based on the provided SPN (Supplier Part Number), with an option for case sensitivity.
* If no part is found with the given SPN, null is returned.
* @param string $spn
* @param bool $caseInsensitive
* @return Part|null
*/
public function getPartBySPN(string $spn, bool $caseInsensitive = true): ?Part
{
$qb = $this->createQueryBuilder('part');
$qb->select('part');
$qb->leftJoin('part.orderdetails', 'o');
if ($caseInsensitive) {
$qb->where("LOWER(o.supplierpartnr) = LOWER(:spn)");
} else {
$qb->where("o.supplierpartnr = :spn");
}
$qb->setParameter('spn', $spn);
return $qb->getQuery()->getOneOrNullResult();
}
}

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

@@ -137,7 +137,10 @@ class AttachmentSubmitHandler
$attachment->getName()
);
return $safeName.'-'.uniqid('', false).'.'.$extension;
// Generate a 12-character URL-safe random string, which should avoid collisions and prevent from guessing file paths.
$random = str_replace(['+', '/', '='], ['0', '1', '2'], base64_encode(random_bytes(9)));
return $safeName.'-'.$random.'.'.$extension;
}
/**

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Attachments;
use App\Settings\SystemSettings\AttachmentsSettings;
use Imagine\Exception\RuntimeException;
use App\Entity\Attachments\Attachment;
use InvalidArgumentException;
@@ -40,7 +41,7 @@ class AttachmentURLGenerator
public function __construct(protected Packages $assets, protected AttachmentPathResolver $pathResolver,
protected UrlGeneratorInterface $urlGenerator, protected AttachmentManager $attachmentHelper,
protected CacheManager $thumbnailManager, protected LoggerInterface $logger)
protected CacheManager $thumbnailManager, protected LoggerInterface $logger, private readonly AttachmentsSettings $attachmentsSettings)
{
//Determine a normalized path to the public folder (assets are relative to this folder)
$this->public_path = $this->pathResolver->parameterToAbsolutePath('public');
@@ -99,6 +100,10 @@ class AttachmentURLGenerator
return null;
}
if ($this->attachmentsSettings->showHTMLAttachments && $attachment->isLocalHTMLFile()) {
return $this->urlGenerator->generate('attachment_html_sandbox', ['id' => $attachment->getID()]);
}
$asset_path = $this->absolutePathToAssetPath($absolute_path);
//If path is not relative to public path or marked as secure, serve it via controller
if (null === $asset_path || $attachment->isSecure()) {

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

@@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
@@ -275,7 +277,7 @@ class BOMImporter
$mapped_entries = []; // Collect all mapped entries for validation
// Fetch suppliers once for efficiency
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
$supplierSPNKeys = [];
$suppliersByName = []; // Map supplier names to supplier objects
foreach ($suppliers as $supplier) {
@@ -371,7 +373,7 @@ class BOMImporter
if ($supplier_spn !== null) {
// Query for orderdetails with matching supplier and SPN
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
$orderdetail = $this->entityManager->getRepository(Orderdetail::class)
->findOneBy([
'supplier' => $supplier,
'supplierpartnr' => $supplier_spn,
@@ -394,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];
@@ -411,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
@@ -535,7 +549,7 @@ class BOMImporter
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
@@ -570,7 +584,7 @@ class BOMImporter
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Helpers\FilenameSanatizer;
@@ -177,7 +178,7 @@ class EntityExporter
$colIndex = 1;
foreach ($columns as $column) {
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$worksheet->setCellValue($cellCoordinate, $column);
$colIndex++;
}
@@ -265,11 +266,14 @@ class EntityExporter
//Sanitize the filename
$filename = FilenameSanatizer::sanitizeFilename($filename);
//Remove percent for fallback
$fallback = str_replace("%", "_", $filename);
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
u($filename)->ascii()->toString(),
$fallback,
);
// Set the content disposition
$response->headers->set('Content-Disposition', $disposition);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\Category;
@@ -419,14 +420,14 @@ class EntityImporter
'worksheet_title' => $worksheet->getTitle()
]);
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
// Read all columns using numeric index
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
$col = Coordinate::stringFromColumnIndex($colIndex);
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\DTOs;
use Doctrine\ORM\Exception\ORMException;
use App\Entity\Parts\Part;
use Doctrine\ORM\EntityManagerInterface;
use Traversable;
@@ -176,7 +177,7 @@ readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate
* @param array $data
* @param EntityManagerInterface $entityManager
* @return BulkSearchResponseDTO
* @throws \Doctrine\ORM\Exception\ORMException
* @throws ORMException
*/
public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO
{

View File

@@ -24,10 +24,15 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem;
use App\Entity\Parts\Part;
use App\Exceptions\InfoProviderNotActiveException;
use App\Exceptions\OAuthReconnectRequiredException;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Psr\Http\Client\ClientExceptionInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -49,6 +54,11 @@ final class PartInfoRetriever
* @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances
* @param string $keyword The keyword to search for
* @return SearchResultDTO[] The search results
* @throws InfoProviderNotActiveException if any of the given providers is not active
* @throws ClientException if any of the providers throws an exception during the search
* @throws \InvalidArgumentException if any of the given providers is not a valid provider key or instance
* @throws TransportException if any of the providers throws an exception during the search
* @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed
*/
public function searchByKeyword(string $keyword, array $providers): array
{
@@ -61,7 +71,7 @@ final class PartInfoRetriever
//Ensure that the provider is active
if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
throw InfoProviderNotActiveException::fromProvider($provider);
}
if (!$provider instanceof InfoProviderInterface) {
@@ -82,10 +92,10 @@ final class PartInfoRetriever
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array
{
//Generate key and escape reserved characters from the provider id
$escaped_keyword = urlencode($keyword);
$escaped_keyword = hash('xxh3', $keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10);
return $provider->searchByKeyword($keyword);
});
@@ -97,6 +107,7 @@ final class PartInfoRetriever
* @param string $provider_key
* @param string $part_id
* @return PartDetailDTO
* @throws InfoProviderNotActiveException if the the given providers is not active
*/
public function getDetails(string $provider_key, string $part_id): PartDetailDTO
{
@@ -104,14 +115,14 @@ final class PartInfoRetriever
//Ensure that the provider is active
if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key $provider_key is not active!");
throw InfoProviderNotActiveException::fromProvider($provider);
}
//Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id);
$escaped_part_id = hash('xxh3', $part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10);
return $provider->getDetails($part_id);
});
@@ -145,4 +156,4 @@ final class PartInfoRetriever
return $this->dto_to_entity_converter->convertPart($details);
}
}
}

View File

@@ -34,7 +34,7 @@ use App\Settings\InfoProviderSystem\BuerklinSettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class BuerklinProvider implements BatchInfoProviderInterface
class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
{
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
@@ -365,7 +365,7 @@ class BuerklinProvider implements BatchInfoProviderInterface
* - prefers 'zoom' format, then 'product' format, then all others
*
* @param array|null $images
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
* @return FileDTO[]
*/
private function getProductImages(?array $images): array
{
@@ -636,4 +636,35 @@ class BuerklinProvider implements BatchInfoProviderInterface
);
}
public function getHandledDomains(): array
{
return ['buerklin.com'];
}
public function getIDFromURL(string $url): ?string
{
//Inputs:
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
//https://www.buerklin.com/de/p/40F1332/
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
//https://www.buerklin.com/en/p/40F1332/
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
$path = parse_url($url, PHP_URL_PATH);
if (!$path) {
return null;
}
// Ensure it's actually a product URL
if (strpos($path, '/p/') === false) {
return null;
}
$id = basename(rtrim($path, '/'));
return $id !== '' && $id !== 'p' ? $id : null;
}
}

View File

@@ -0,0 +1,231 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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\Services\InfoProviderSystem\Providers;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\BuerklinSettings;
use App\Settings\InfoProviderSystem\CanopySettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Use canopy API to retrieve infos from amazon
*/
class CanopyProvider implements InfoProviderInterface
{
public const BASE_URL = "https://rest.canopyapi.co/api";
public const SEARCH_API_URL = self::BASE_URL . "/amazon/search";
public const DETAIL_API_URL = self::BASE_URL . "/amazon/product";
public const DISTRIBUTOR_NAME = 'Amazon';
public function __construct(private readonly CanopySettings $settings,
private readonly HttpClientInterface $httpClient, private readonly CacheItemPoolInterface $partInfoCache)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Amazon (Canopy)',
'description' => 'Retrieves part infos from Amazon using the Canopy API',
'url' => 'https://canopyapi.co',
'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider',
'settings_class' => CanopySettings::class
];
}
public function getProviderKey(): string
{
return 'canopy';
}
public function isActive(): bool
{
return $this->settings->apiKey !== null;
}
private function productPageFromASIN(string $asin): string
{
return "https://www.{$this->settings->getRealDomain()}/dp/{$asin}";
}
/**
* Saves the given part to the cache.
* Everytime this function is called, the cache is overwritten.
* @param PartDetailDTO $part
* @return void
*/
private function saveToCache(PartDetailDTO $part): void
{
$key = 'canopy_part_'.$part->provider_id;
$item = $this->partInfoCache->getItem($key);
$item->set($part);
$item->expiresAfter(3600 * 24); //Cache for 1 day
$this->partInfoCache->save($item);
}
/**
* Retrieves a from the cache, or null if it was not cached yet.
* @param string $id
* @return PartDetailDTO|null
*/
private function getFromCache(string $id): ?PartDetailDTO
{
$key = 'canopy_part_'.$id;
$item = $this->partInfoCache->getItem($key);
if ($item->isHit()) {
return $item->get();
}
return null;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->httpClient->request('GET', self::SEARCH_API_URL, [
'query' => [
'domain' => $this->settings->domain,
'searchTerm' => $keyword,
],
'headers' => [
'API-KEY' => $this->settings->apiKey,
]
]);
$data = $response->toArray();
$results = $data['data']['amazonProductSearchResults']['productResults']['results'] ?? [];
$out = [];
foreach ($results as $result) {
$dto = new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $result['asin'],
name: $result["title"],
description: "",
preview_image_url: $result["mainImageUrl"] ?? null,
provider_url: $this->productPageFromASIN($result['asin']),
vendor_infos: [$this->priceToPurchaseInfo($result['price'], $result['asin'])]
);
$out[] = $dto;
$this->saveToCache($dto);
}
return $out;
}
private function categoriesToCategory(array $categories): ?string
{
if (count($categories) === 0) {
return null;
}
return implode(" -> ", array_map(static fn($cat) => $cat['name'], $categories));
}
private function feauturesBulletsToNotes(array $featureBullets): string
{
$notes = "<ul>";
foreach ($featureBullets as $bullet) {
$notes .= "<li>" . $bullet . "</li>";
}
$notes .= "</ul>";
return $notes;
}
private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO
{
$priceDtos = [];
if ($price !== null) {
$priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true);
}
return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
}
public function getDetails(string $id): PartDetailDTO
{
//Check that the id is a valid ASIN (10 characters, letters and numbers)
if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
}
//Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details
if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) {
return $cached;
}
$response = $this->httpClient->request('GET', self::DETAIL_API_URL, [
'query' => [
'asin' => $id,
'domain' => $this->settings->domain,
],
'headers' => [
'API-KEY' => $this->settings->apiKey,
],
]);
$product = $response->toArray()['data']['amazonProduct'];
if ($product === null) {
throw new \RuntimeException("Product with ASIN $id not found");
}
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['asin'],
name: $product['title'],
description: '',
category: $this->categoriesToCategory($product['categories']),
manufacturer: $product['brand'] ?? null,
preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null,
provider_url: $this->productPageFromASIN($product['asin']),
notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []),
vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])]
);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::PRICE,
];
}
}

View File

@@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
public function productMediaToDatasheets(array $productMedia): array
{
$files = [];
foreach ($productMedia['manuals'] as $manual) {
foreach ($productMedia['manuals'] ?? [] as $manual) {
//Filter out unwanted languages
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
continue;

View File

@@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Exceptions\ProviderIDNotSupportedException;
use App\Helpers\RandomizeUseragentHttpClient;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
@@ -54,11 +55,8 @@ class GenericWebProvider implements InfoProviderInterface
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
)
{
$this->httpClient = $httpClient->withOptions(
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions(
[
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
],
'timeout' => 15,
]
);

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Helpers\RandomizeUseragentHttpClient;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
@@ -30,7 +31,6 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\ReicheltSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -39,10 +39,13 @@ class ReicheltProvider implements InfoProviderInterface
public const DISTRIBUTOR_NAME = "Reichelt";
public function __construct(private readonly HttpClientInterface $client,
private readonly HttpClientInterface $client;
public function __construct(HttpClientInterface $client,
private readonly ReicheltSettings $settings,
)
{
$this->client = new RandomizeUseragentHttpClient($client);
}
public function getProviderInfo(): array

View File

@@ -1,7 +1,8 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
* Copyright (C) 2019 - 2026 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
@@ -17,15 +18,29 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Controller } from '@hotwired/stimulus';
declare(strict_types=1);
export default class extends Controller {
connect() {
//If we encounter an element with this, then change the title of our document according to data-title
this.changeTitle(this.element.dataset.title);
namespace App\Services\LabelSystem\BarcodeScanner;
final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface
{
public function __construct(public string $asin) {
if (!self::isAmazonBarcode($asin)) {
throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)");
}
}
changeTitle(title) {
document.title = title;
public static function isAmazonBarcode(string $input): bool
{
//Amazon barcodes are 10 alphanumeric characters
return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1;
}
}
public function getDecodedForInfoMode(): array
{
return [
'ASIN' => $this->asin,
];
}
}

View File

@@ -1,180 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
namespace App\Services\LabelSystem\BarcodeScanner;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
*/
final class BarcodeRedirector
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em)
{
}
/**
* Determines the URL to which the user should be redirected, when scanning a QR code.
*
* @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
* @return string the URL to which should be redirected
*
* @throws EntityNotFoundException
*/
public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string
{
if($barcodeScan instanceof LocalBarcodeScanResult) {
return $this->getURLLocalBarcode($barcodeScan);
}
if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
return $this->getURLVendorBarcode($barcodeScan);
}
if ($barcodeScan instanceof GTINBarcodeScanResult) {
return $this->getURLGTINBarcode($barcodeScan);
}
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
}
private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string
{
switch ($barcodeScan->target_type) {
case LabelSupportedElement::PART:
return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
case LabelSupportedElement::PART_LOT:
//Try to determine the part to the given lot
$lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
if (!$lot instanceof PartLot) {
throw new EntityNotFoundException();
}
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]);
case LabelSupportedElement::STORELOCATION:
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
default:
throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
}
}
/**
* Gets the URL to a part from a scan of a Vendor Barcode
*/
private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string
{
$part = $this->getPartFromVendor($barcodeScan);
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
}
private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string
{
$part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
if (!$part instanceof Part) {
throw new EntityNotFoundException();
}
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
}
/**
* Gets a part from a scan of a Vendor Barcode by filtering for parts
* with the same Info Provider Id or, if that fails, by looking for parts with a
* matching manufacturer product number. Only returns the first matching part.
*/
private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part
{
// first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
// the info provider system or if the part was bought from a different vendor than the data was retrieved
// from.
if($barcodeScan->digikeyPartNumber) {
$qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
//Lower() to be case insensitive
$qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)'));
$qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber);
$results = $qb->getQuery()->getResult();
if ($results) {
return $results[0];
}
}
if(!$barcodeScan->supplierPartNumber){
throw new EntityNotFoundException();
}
//Fallback to the manufacturer part number. This may return false positives, since it is common for
//multiple manufacturers to use the same part number for their version of a common product
//We assume the user is able to realize when this returns the wrong part
//If the barcode specifies the manufacturer we try to use that as well
$mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
$mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)'));
$mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber);
if($barcodeScan->mouserManufacturer){
$manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer");
$manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)"));
$manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer);
$manufacturers = $manufacturerQb->getQuery()->getResult();
if($manufacturers) {
$mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer"));
$mpnQb->setParameter("manufacturer", $manufacturers);
}
}
$results = $mpnQb->getQuery()->getResult();
if($results){
return $results[0];
}
throw new EntityNotFoundException();
}
}

View File

@@ -92,10 +92,19 @@ final class BarcodeScanHelper
if ($type === BarcodeSourceType::EIGP114) {
return $this->parseEIGP114Barcode($input);
}
if ($type === BarcodeSourceType::GTIN) {
return $this->parseGTINBarcode($input);
}
if ($type === BarcodeSourceType::LCSC) {
return $this->parseLCSCBarcode($input);
}
if ($type === BarcodeSourceType::AMAZON) {
return new AmazonBarcodeScanResult($input);
}
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@@ -125,6 +134,16 @@ final class BarcodeScanHelper
return $this->parseGTINBarcode($input);
}
// Try LCSC barcode
if (LCSCBarcodeScanResult::isLCSCBarcode($input)) {
return $this->parseLCSCBarcode($input);
}
//Try amazon barcode
if (AmazonBarcodeScanResult::isAmazonBarcode($input)) {
return new AmazonBarcodeScanResult($input);
}
throw new InvalidArgumentException('Unknown barcode');
}
@@ -138,6 +157,11 @@ final class BarcodeScanHelper
return EIGP114BarcodeScanResult::parseFormat06Code($input);
}
private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
{
return LCSCBarcodeScanResult::parse($input);
}
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{
$lot_repo = $this->entityManager->getRepository(PartLot::class);

View File

@@ -0,0 +1,327 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
namespace App\Services\LabelSystem\BarcodeScanner;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Exceptions\InfoProviderNotActiveException;
use App\Repository\Parts\PartRepository;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* This class handles the result of a barcode scan and determines further actions, like which URL the user should be redirected to.
*
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
*/
final readonly class BarcodeScanResultHandler
{
public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em, private PartInfoRetriever $infoRetriever,
private ProviderRegistry $providerRegistry)
{
}
/**
* Determines the URL to which the user should be redirected, when scanning a QR code.
*
* @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
* @return string|null the URL to which should be redirected, or null if no suitable URL could be determined for the given barcode scan result
*/
public function getInfoURL(BarcodeScanResultInterface $barcodeScan): ?string
{
//For other barcodes try to resolve the part first and then redirect to the part page
$entity = $this->resolveEntity($barcodeScan);
if ($entity === null) {
return null;
}
if ($entity instanceof Part) {
return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getID()]);
}
if ($entity instanceof PartLot) {
return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getPart()->getID(), 'highlightLot' => $entity->getID()]);
}
if ($entity instanceof StorageLocation) {
return $this->urlGenerator->generate('part_list_store_location', ['id' => $entity->getID()]);
}
//@phpstan-ignore-next-line This should never happen, since resolveEntity should only return Part, PartLot or StorageLocation
throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity));
}
/**
* Returns a URL to create a new part based on this barcode scan result, if possible.
* @param BarcodeScanResultInterface $scanResult
* @return string|null
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
*/
public function getCreationURL(BarcodeScanResultInterface $scanResult): ?string
{
$infos = $this->getCreateInfos($scanResult);
if ($infos === null) {
return null;
}
//Ensure that the provider is active, otherwise we should not generate a creation URL for it
$provider = $this->providerRegistry->getProviderByKey($infos['providerKey']);
if (!$provider->isActive()) {
throw InfoProviderNotActiveException::fromProvider($provider);
}
return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]);
}
/**
* Tries to resolve the given barcode scan result to a local entity. This can be a Part, a PartLot or a StorageLocation, depending on the type of the barcode and the information contained in it.
* Returns null if no matching entity could be found.
* @param BarcodeScanResultInterface $barcodeScan
* @return Part|PartLot|StorageLocation|null
*/
public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null
{
if ($barcodeScan instanceof LocalBarcodeScanResult) {
return $this->resolvePartFromLocal($barcodeScan);
}
if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
return $this->resolvePartFromVendor($barcodeScan);
}
if ($barcodeScan instanceof GTINBarcodeScanResult) {
return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
}
if ($barcodeScan instanceof LCSCBarcodeScanResult) {
return $this->resolvePartFromLCSC($barcodeScan);
}
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
}
return null;
}
/**
* Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode,
* or the barcode doesn't contain information allowing to resolve to a local part.
* @param BarcodeScanResultInterface $barcodeScan
* @return Part|null
* @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function
*/
public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part
{
$entity = $this->resolveEntity($barcodeScan);
if ($entity instanceof Part) {
return $entity;
}
if ($entity instanceof PartLot) {
return $entity->getPart();
}
//Storage locations are not associated with a specific part, so we cannot resolve a part for
//a storage location barcode
return null;
}
private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): Part|PartLot|StorageLocation|null
{
return match ($barcodeScan->target_type) {
LabelSupportedElement::PART => $this->em->find(Part::class, $barcodeScan->target_id),
LabelSupportedElement::PART_LOT => $this->em->find(PartLot::class, $barcodeScan->target_id),
LabelSupportedElement::STORELOCATION => $this->em->find(StorageLocation::class, $barcodeScan->target_id),
};
}
/**
* Gets a part from a scan of a Vendor Barcode by filtering for parts
* with the same Info Provider Id or, if that fails, by looking for parts with a
* matching manufacturer product number. Only returns the first matching part.
*/
private function resolvePartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : ?Part
{
// first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
// the info provider system or if the part was bought from a different vendor than the data was retrieved
// from.
if($barcodeScan->digikeyPartNumber) {
$part = $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->digikeyPartNumber);
if ($part !== null) {
return $part;
}
}
if (!$barcodeScan->supplierPartNumber){
return null;
}
//Fallback to the manufacturer part number. This may return false positives, since it is common for
//multiple manufacturers to use the same part number for their version of a common product
//We assume the user is able to realize when this returns the wrong part
//If the barcode specifies the manufacturer we try to use that as well
return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer);
}
/**
* Resolve LCSC barcode -> Part.
* Strategy:
* 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there
* 2) Fallback to manufacturer_product_number == pm (MPN)
* Returns first match (consistent with EIGP114 logic)
*/
private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part
{
// Try LCSC code (pc) as provider id if available
$pc = $barcodeScan->lcscCode; // e.g. C138033
if ($pc) {
$part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc);
if ($part !== null) {
return $part;
}
}
// Fallback to MPN (pm)
$pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML
if (!$pm) {
return null;
}
return $this->em->getRepository(Part::class)->getPartByMPN($pm);
}
/**
* Tries to extract creation information for a part from the given barcode scan result. This can be used to
* automatically fill in the info provider reference of a part, when creating a new part based on the scan result.
* Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function.
* It is not necessarily checked that the provider is active, or that the result actually exists on the provider side.
* @param BarcodeScanResultInterface $scanResult
* @return array{providerKey: string, providerId: string}|null
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
*/
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
{
// LCSC
if ($scanResult instanceof LCSCBarcodeScanResult) {
return [
'providerKey' => 'lcsc',
'providerId' => $scanResult->lcscCode,
];
}
if ($scanResult instanceof EIGP114BarcodeScanResult) {
return $this->getCreationInfoForEIGP114($scanResult);
}
if ($scanResult instanceof AmazonBarcodeScanResult) {
return [
'providerKey' => 'canopy',
'providerId' => $scanResult->asin,
];
}
return null;
}
/**
* @param EIGP114BarcodeScanResult $scanResult
* @return array{providerKey: string, providerId: string}|null
*/
private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array
{
$vendor = $scanResult->guessBarcodeVendor();
// Mouser: use supplierPartNumber -> search provider -> provider_id
if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null
) {
// Search Mouser using the MPN
$dtos = $this->infoRetriever->searchByKeyword(
keyword: $scanResult->supplierPartNumber,
providers: ["mouser"]
);
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
$best = $dtos[0] ?? null;
if ($best !== null) {
return [
'providerKey' => 'mouser',
'providerId' => $best->provider_id,
];
}
return null;
}
// Digi-Key: can use customerPartNumber or supplierPartNumber directly
if ($vendor === 'digikey') {
return [
'providerKey' => 'digikey',
'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber,
];
}
// Element14: can use supplierPartNumber directly
if ($vendor === 'element14') {
return [
'providerKey' => 'element14',
'providerId' => $scanResult->supplierPartNumber,
];
}
return null;
}
}

View File

@@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
* @return array<string, string|int|float|null>
*/
public function getDecodedForInfoMode(): array;
}
}

View File

@@ -26,25 +26,30 @@ namespace App\Services\LabelSystem\BarcodeScanner;
/**
* This enum represents the different types, where a barcode/QR-code can be generated from
*/
enum BarcodeSourceType
enum BarcodeSourceType: string
{
/** This Barcode was generated using Part-DB internal recommended barcode generator */
case INTERNAL;
case INTERNAL = 'internal';
/** This barcode is containing an internal part number (IPN) */
case IPN;
case IPN = 'ipn';
/**
* This barcode is a user defined barcode defined on a part lot
*/
case USER_DEFINED;
case USER_DEFINED = 'user';
/**
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
case EIGP114;
case EIGP114 = 'eigp';
/**
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
*/
case GTIN;
case GTIN = 'gtin';
/** For LCSC.com formatted QR codes */
case LCSC = 'lcsc';
case AMAZON = 'amazon';
}

View File

@@ -28,40 +28,40 @@ namespace App\Services\LabelSystem\BarcodeScanner;
* Based on PR 811, EIGP 114.2018 (https://www.ecianow.org/assets/docs/GIPC/EIGP-114.2018%20ECIA%20Labeling%20Specification%20for%20Product%20and%20Shipment%20Identification%20in%20the%20Electronics%20Industry%20-%202D%20Barcode.pdf),
* , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097
*/
class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
{
/**
* @var string|null Ship date in format YYYYMMDD
*/
public readonly ?string $shipDate;
public ?string $shipDate;
/**
* @var string|null Customer assigned part number Optional based on
* agreements between Distributor and Supplier
*/
public readonly ?string $customerPartNumber;
public ?string $customerPartNumber;
/**
* @var string|null Supplier assigned part number
*/
public readonly ?string $supplierPartNumber;
public ?string $supplierPartNumber;
/**
* @var int|null Quantity of product
*/
public readonly ?int $quantity;
public ?int $quantity;
/**
* @var string|null Customer assigned purchase order number
*/
public readonly ?string $customerPO;
public ?string $customerPO;
/**
* @var string|null Line item number from PO. Required on Logistic Label when
* used on back of Packing Slip. See Section 4.9
*/
public readonly ?string $customerPOLine;
public ?string $customerPOLine;
/**
* 9D - YYWW (Year and Week of Manufacture). ) If no date code is used
@@ -69,7 +69,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* to indicate the product is Not Traceable by this data field.
* @var string|null
*/
public readonly ?string $dateCode;
public ?string $dateCode;
/**
* 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
@@ -77,7 +77,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* to indicate the product is Not Traceable by this data field.
* @var string|null
*/
public readonly ?string $alternativeDateCode;
public ?string $alternativeDateCode;
/**
* Traceability number assigned to a batch or group of items. If
@@ -86,14 +86,14 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* by this data field.
* @var string|null
*/
public readonly ?string $lotCode;
public ?string $lotCode;
/**
* Country where part was manufactured. Two-letter code from
* ISO 3166 country code list
* @var string|null
*/
public readonly ?string $countryOfOrigin;
public ?string $countryOfOrigin;
/**
* @var string|null Unique alphanumeric number assigned by supplier
@@ -101,85 +101,85 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
* Carton. Always used in conjunction with a mixed logistic label
* with a 5S data identifier for Package ID.
*/
public readonly ?string $packageId1;
public ?string $packageId1;
/**
* @var string|null
* 4S - Package ID for Logistic Carton with like items
*/
public readonly ?string $packageId2;
public ?string $packageId2;
/**
* @var string|null
* 5S - Package ID for Logistic Carton with mixed items
*/
public readonly ?string $packageId3;
public ?string $packageId3;
/**
* @var string|null Unique alphanumeric number assigned by supplier.
*/
public readonly ?string $packingListNumber;
public ?string $packingListNumber;
/**
* @var string|null Ship date in format YYYYMMDD
*/
public readonly ?string $serialNumber;
public ?string $serialNumber;
/**
* @var string|null Code for sorting and classifying LEDs. Use when applicable
*/
public readonly ?string $binCode;
public ?string $binCode;
/**
* @var int|null Sequential carton count in format “#/#” or “# of #”
*/
public readonly ?int $packageCount;
public ?int $packageCount;
/**
* @var string|null Alphanumeric string assigned by the supplier to distinguish
* from one closely-related design variation to another. Use as
* required or when applicable
*/
public readonly ?string $revisionNumber;
public ?string $revisionNumber;
/**
* @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number.
*/
public readonly ?string $digikeyPartNumber;
public ?string $digikeyPartNumber;
/**
* @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.)
*/
public readonly ?string $digikeySalesOrderNumber;
public ?string $digikeySalesOrderNumber;
/**
* @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers
*/
public readonly ?string $digikeyInvoiceNumber;
public ?string $digikeyInvoiceNumber;
/**
* @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type.
*/
public readonly ?string $digikeyLabelType;
public ?string $digikeyLabelType;
/**
* @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907
*/
public readonly ?string $digikeyPartID;
public ?string $digikeyPartID;
/**
* @var string|null Digikey Extension: For internal use of Digikey. Probably not needed
*/
public readonly ?string $digikeyNA;
public ?string $digikeyNA;
/**
* @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore.
*/
public readonly ?string $digikeyPadding;
public ?string $digikeyPadding;
public readonly ?string $mouserPositionInOrder;
public ?string $mouserPositionInOrder;
public readonly ?string $mouserManufacturer;
public ?string $mouserManufacturer;
@@ -187,7 +187,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
*/
public function __construct(public readonly array $data)
public function __construct(public array $data)
{
//IDs per EIGP 114.2018
$this->shipDate = $data['6D'] ?? null;
@@ -329,4 +329,4 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
return $tmp;
}
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Services\LabelSystem\BarcodeScanner;
use InvalidArgumentException;
/**
* This class represents the content of a lcsc.com barcode
* Its data structure is represented by {pbn:...,on:...,pc:...,pm:...,qty:...}
*/
readonly class LCSCBarcodeScanResult implements BarcodeScanResultInterface
{
/** @var string|null (pbn) */
public ?string $pickBatchNumber;
/** @var string|null (on) */
public ?string $orderNumber;
/** @var string|null LCSC Supplier part number (pc) */
public ?string $lcscCode;
/** @var string|null (pm) */
public ?string $mpn;
/** @var int|null (qty) */
public ?int $quantity;
/** @var string|null Country Channel as raw value (CC) */
public ?string $countryChannel;
/**
* @var string|null Warehouse code as raw value (WC)
*/
public ?string $warehouseCode;
/**
* @var string|null Unknown numeric code (pdi)
*/
public ?string $pdi;
/**
* @var string|null Unknown value (hp)
*/
public ?string $hp;
/**
* @param array<string, string> $fields
*/
public function __construct(
public array $fields,
public string $rawInput,
) {
$this->pickBatchNumber = $this->fields['pbn'] ?? null;
$this->orderNumber = $this->fields['on'] ?? null;
$this->lcscCode = $this->fields['pc'] ?? null;
$this->mpn = $this->fields['pm'] ?? null;
$this->quantity = isset($this->fields['qty']) ? (int)$this->fields['qty'] : null;
$this->countryChannel = $this->fields['cc'] ?? null;
$this->warehouseCode = $this->fields['wc'] ?? null;
$this->pdi = $this->fields['pdi'] ?? null;
$this->hp = $this->fields['hp'] ?? null;
}
public function getSourceType(): BarcodeSourceType
{
return BarcodeSourceType::LCSC;
}
/**
* @return array|float[]|int[]|null[]|string[] An array of fields decoded from the barcode
*/
public function getDecodedForInfoMode(): array
{
// Keep it human-friendly
return [
'Barcode type' => 'LCSC',
'MPN (pm)' => $this->mpn ?? '',
'LCSC code (pc)' => $this->lcscCode ?? '',
'Qty' => $this->quantity !== null ? (string) $this->quantity : '',
'Order No (on)' => $this->orderNumber ?? '',
'Pick Batch (pbn)' => $this->pickBatchNumber ?? '',
'Warehouse (wc)' => $this->warehouseCode ?? '',
'Country/Channel (cc)' => $this->countryChannel ?? '',
'PDI (unknown meaning)' => $this->pdi ?? '',
'HP (unknown meaning)' => $this->hp ?? '',
];
}
/**
* Parses the barcode data to see if the input matches the expected format used by lcsc.com
* @param string $input
* @return bool
*/
public static function isLCSCBarcode(string $input): bool
{
$s = trim($input);
// Your example: {pbn:...,on:...,pc:...,pm:...,qty:...}
if (!str_starts_with($s, '{') || !str_ends_with($s, '}')) {
return false;
}
// Must contain at least pm: and pc: (common for LCSC labels)
return (stripos($s, 'pm:') !== false) && (stripos($s, 'pc:') !== false);
}
/**
* Parse the barcode input string into the fields used by lcsc.com
* @param string $input
* @return self
*/
public static function parse(string $input): self
{
$raw = trim($input);
if (!self::isLCSCBarcode($raw)) {
throw new InvalidArgumentException('Not an LCSC barcode');
}
$inner = substr($raw, 1, -1); // remove { }
$fields = [];
// This format is comma-separated pairs, values do not contain commas in your sample.
$pairs = array_filter(
array_map(trim(...), explode(',', $inner)),
static fn(string $s): bool => $s !== ''
);
foreach ($pairs as $pair) {
$pos = strpos($pair, ':');
if ($pos === false) {
continue;
}
$k = trim(substr($pair, 0, $pos));
$v = trim(substr($pair, $pos + 1));
if ($k === '') {
continue;
}
$fields[$k] = $v;
}
if (!isset($fields['pm']) || trim($fields['pm']) === '') {
throw new InvalidArgumentException('LCSC barcode missing pm field');
}
return new self($fields, $raw);
}
}

View File

@@ -29,12 +29,12 @@ use App\Entity\LabelSystem\LabelSupportedElement;
* This class represents the result of a barcode scan of a barcode that uniquely identifies a local entity,
* like an internally generated barcode or a barcode that was added manually to the system by a user
*/
class LocalBarcodeScanResult implements BarcodeScanResultInterface
readonly class LocalBarcodeScanResult implements BarcodeScanResultInterface
{
public function __construct(
public readonly LabelSupportedElement $target_type,
public readonly int $target_id,
public readonly BarcodeSourceType $source_type,
public LabelSupportedElement $target_type,
public int $target_id,
public BarcodeSourceType $source_type,
) {
}
@@ -46,4 +46,4 @@ class LocalBarcodeScanResult implements BarcodeScanResultInterface
'Target ID' => $this->target_id,
];
}
}
}

View File

@@ -95,7 +95,7 @@ final class LabelHTMLGenerator
'paper_height' => $options->getHeight(),
]
);
} catch (Error $exception) {
} catch (\Throwable $exception) {
throw new TwigModeException($exception);
}
} else {

View File

@@ -70,12 +70,14 @@ use App\Twig\Sandbox\SandboxedLabelExtension;
use App\Twig\TwigCoreExtension;
use InvalidArgumentException;
use Twig\Environment;
use Twig\Extension\AttributeExtension;
use Twig\Extension\SandboxExtension;
use Twig\Extra\Html\HtmlExtension;
use Twig\Extra\Intl\IntlExtension;
use Twig\Extra\Markdown\MarkdownExtension;
use Twig\Extra\String\StringExtension;
use Twig\Loader\ArrayLoader;
use Twig\RuntimeLoader\FactoryRuntimeLoader;
use Twig\Sandbox\SecurityPolicyInterface;
/**
@@ -84,11 +86,11 @@ use Twig\Sandbox\SecurityPolicyInterface;
*/
final class SandboxedTwigFactory
{
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with'];
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'types', 'verbatim', 'with'];
private const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name',
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'first', 'format',
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'find', 'first', 'format',
'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'html_to_markdown', 'join', 'keys',
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'raw', 'number_format',
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'number_format', 'raw',
'reduce', 'replace', 'reverse', 'round', 'slice', 'slug', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title',
'trim', 'u', 'upper', 'url_encode',
@@ -102,16 +104,17 @@ final class SandboxedTwigFactory
];
private const ALLOWED_FUNCTIONS = ['country_names', 'country_timezones', 'currency_names', 'cycle',
'date', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
'template_from_string', 'timezone_names',
'date', 'enum', 'enum_cases', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
'timezone_names',
//Part-DB specific extensions:
//EntityExtension:
'entity_type', 'entity_url',
'entity_type', 'entity_url', 'type_label', 'type_label_plural',
//BarcodeExtension:
'barcode_svg',
//SandboxedLabelExtension
'placeholder',
'associated_parts', 'associated_parts_count', 'associated_parts_r', 'associated_parts_count_r',
];
private const ALLOWED_METHODS = [
@@ -128,7 +131,7 @@ final class SandboxedTwigFactory
'getValueTypical', 'getUnit', 'getValueText', ],
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],
PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation',
'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', 'getVendorBarcode'],
'getPart', 'isInstockUnknown', 'getAmount', 'getOwner', 'getLastStocktakeAt', 'getNeedsRefill', 'getVendorBarcode'],
StorageLocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'],
Supplier::class => ['getShippingCosts', 'getDefaultCurrency'],
Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference',
@@ -139,13 +142,13 @@ final class SandboxedTwigFactory
'getParameters', 'getGroupedParameters',
'isProjectBuildPart', 'getBuiltProject',
'getAssociatedPartsAsOwner', 'getAssociatedPartsAsOther', 'getAssociatedPartsAll',
'getEdaInfo'
'getEdaInfo', 'getGtin'
],
Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'],
Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete',
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl'],
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl', 'getPricesIncludesVAT'],
Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity',
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode'],
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode', 'getIncludesVat'],
InfoProviderReference:: class => ['getProviderKey', 'getProviderId', 'getProviderUrl', 'getLastUpdated', 'isProviderCreated'],
PartAssociation::class => ['getType', 'getComment', 'getOwner', 'getOther', 'getOtherType'],
@@ -186,13 +189,18 @@ final class SandboxedTwigFactory
$twig->addExtension(new StringExtension());
$twig->addExtension(new HtmlExtension());
//Add Part-DB specific extension
$twig->addExtension($this->formatExtension);
$twig->addExtension($this->barcodeExtension);
$twig->addExtension($this->entityExtension);
$twig->addExtension($this->twigCoreExtension);
$twig->addExtension($this->sandboxedLabelExtension);
//Our other extensions are using attributes, we need a bit more work to load them
$dynamicExtensions = [$this->formatExtension, $this->barcodeExtension, $this->entityExtension, $this->twigCoreExtension];
$dynamicExtensionsMap = [];
foreach ($dynamicExtensions as $extension) {
$twig->addExtension(new AttributeExtension($extension::class));
$dynamicExtensionsMap[$extension::class] = static fn () => $extension;
}
$twig->addRuntimeLoader(new FactoryRuntimeLoader($dynamicExtensionsMap));
return $twig;
}

View File

@@ -32,7 +32,7 @@ class LogDiffFormatter
* @param $old_data
* @param $new_data
*/
public function formatDiff($old_data, $new_data): string
public function formatDiff(mixed $old_data, mixed $new_data): string
{
if (is_string($old_data) && is_string($new_data)) {
return $this->diffString($old_data, $new_data);

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

@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Services\System;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
@@ -334,7 +336,7 @@ readonly class BackupManager
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
if ($platform instanceof AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
$mysqlCmd = 'mysql';
if (isset($params['host'])) {
@@ -361,7 +363,7 @@ readonly class BackupManager
if (!$process->isSuccessful()) {
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
}
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
} elseif ($platform instanceof PostgreSQLPlatform) {
// Use psql command to import
$psqlCmd = 'psql';
if (isset($params['host'])) {

View File

@@ -41,4 +41,7 @@ class BehaviorSettings
#[EmbeddedSettings]
public ?PartInfoSettings $partInfo = null;
#[EmbeddedSettings]
public ?KeybindingsSettings $keybindings = null;
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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\Settings\BehaviorSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(name: "keybindings", label: new TM("settings.behavior.keybindings"))]
#[SettingsIcon('fa-keyboard')]
class KeybindingsSettings
{
/**
* Whether to enable special character keybindings (Alt+key) in text input fields
* @var bool
*/
#[SettingsParameter(
label: new TM("settings.behavior.keybindings.enable_special_characters"),
description: new TM("settings.behavior.keybindings.enable_special_characters.help"),
envVar: "bool:KEYBINDINGS_SPECIAL_CHARS_ENABLED",
envVarMode: EnvVarMode::OVERWRITE
)]
public bool $enableSpecialCharacters = true;
}

View File

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

View File

@@ -0,0 +1,96 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Settings\InfoProviderSystem;
use App\Form\Type\APIKeyType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(label: new TM("settings.ips.canopy"))]
#[SettingsIcon("fa-plug")]
class CanopySettings
{
public const ALLOWED_DOMAINS = [
"amazon.de" => "DE",
"amazon.com" => "US",
"amazon.co.uk" => "UK",
"amazon.fr" => "FR",
"amazon.it" => "IT",
"amazon.es" => "ES",
"amazon.ca" => "CA",
"amazon.com.au" => "AU",
"amazon.com.br" => "BR",
"amazon.com.mx" => "MX",
"amazon.in" => "IN",
"amazon.co.jp" => "JP",
"amazon.nl" => "NL",
"amazon.pl" => "PL",
"amazon.sa" => "SA",
"amazon.sg" => "SG",
"amazon.se" => "SE",
"amazon.com.tr" => "TR",
"amazon.ae" => "AE",
"amazon.com.be" => "BE",
"amazon.com.cn" => "CN",
];
use SettingsTrait;
#[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"),
formType: APIKeyType::class,
formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $apiKey = null;
/**
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
*/
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
public string $domain = "DE";
/**
* @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request
*/
#[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))]
public bool $alwaysGetDetails = false;
/**
* Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE)
* @return string
*/
public function getRealDomain(): string
{
$domain = array_search($this->domain, self::ALLOWED_DOMAINS, true);
if ($domain === false) {
throw new \InvalidArgumentException("Invalid domain selected");
}
return $domain;
}
}

View File

@@ -72,4 +72,7 @@ class InfoProviderSettings
#[EmbeddedSettings]
public ?ConradSettings $conrad = null;
#[EmbeddedSettings]
public ?CanopySettings $canopy = null;
}

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

Some files were not shown because too many files have changed in this diff Show More