Compare commits

...

91 Commits

Author SHA1 Message Date
github-actions[bot]
7217ea8521 Update KiCad symbols and footprints lists 2026-03-09 04:25:15 +00:00
Jan Böhmer
343c078b7d fixed intendation of force visibility checkbox in category admin 2026-03-07 23:58:32 +01:00
Jan Böhmer
37b98adc6e Bumped version to 2.9.0 2026-03-07 22:57:54 +01:00
Jan Böhmer
4f12fd7390 New Crowdin updates (#1294)
* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (German)

* New translations messages.en.xlf (German)
2026-03-07 22:51:02 +01:00
Jan Böhmer
13b98cc0b1 Fixed tests 2026-03-07 22:47:05 +01:00
Jan Böhmer
7f8f5990a7 Fixed phpstan issues 2026-03-07 22:30:39 +01:00
Jan Böhmer
bcbbb1ecb9 Add a flash notice when automatically creating a part lot from scan 2026-03-07 22:01:50 +01:00
Jan Böhmer
8727d83097 Increase possible length of the vendor barcode column in part lots
This allows us to store full 2D barcodes content there
2026-03-07 21:54:46 +01:00
Jan Böhmer
70919d953a Allow to pass infos from barcodes to creation dialog 2026-03-07 21:48:27 +01:00
Jan Böhmer
a722608ae8 Clear input after option selection in tomselect fields
Fixes issue #1264
2026-03-07 21:22:29 +01:00
Jan Böhmer
12a760d27e Correctly denormalize parent-child relationships in import, when only children not parent fields are given
This fixes issue #1272
2026-03-07 21:08:32 +01:00
Jan Böhmer
b8d1414403 Handle Barcode placeholders before anything else to avoid wrong delegation
Fixes issue #1268
2026-03-07 19:56:14 +01:00
Jan Böhmer
463d7b89f6 Added part description as property to KiCad response, to show it also in Kicad 9.0.5 and 9.06
Fixes #1291
2026-03-07 19:45:09 +01:00
Marc
6e4d252617 Show ManufacturingStatus in BOM (#1289) 2026-03-07 19:35:08 +01:00
Niklas
3ed27f6c0f /api/part_lots: add user_barcode filter (#1280)
* /api/part_lots: add user_barcode filter

* support LIKE filtering for part lot user_barcode
2026-03-07 19:31:47 +01:00
Sebastian Almberg
0d58262e19 Add manual backup creation and delete buttons to Update Manager (#1255)
* Add manual backup creation and delete buttons to Update Manager

- Add "Create Backup" button in the backups tab for on-demand backups
- Add delete buttons (trash icons) for update logs and backups
- New controller routes with CSRF protection and permission checks
- Use data-turbo-confirm for CSP-safe confirmation dialogs
- Add deleteLog() method to UpdateExecutor with filename validation

* Add Docker backup support: download button, SQLite restore fix, decouple from auto-update

- Decouple backup creation/restore UI from can_auto_update so Docker
  and other non-git installations can use backup features
- Add backup download endpoint for saving backups externally
- Fix SQLite restore to use configured DATABASE_URL path instead of
  hardcoded var/app.db (affects Docker and custom SQLite paths)
- Show Docker-specific warning about var/backups/ not being persisted
- Pass is_docker flag to template via InstallationTypeDetector

* Add tests for backup/update manager improvements

- Controller tests: auth, CSRF validation, 404 for missing backups, restore disabled check
- UpdateExecutor: deleteLog validation, non-existent file, successful deletion
- BackupManager: deleteBackup validation for missing/non-zip files

* Fix test failures: add locale prefix to URLs, correct log directory path

* Fix auth test: expect 401 instead of redirect for HTTP Basic auth

* Improve test coverage for update manager controller

Add happy-path tests for backup creation, deletion, download,
and log deletion with valid CSRF tokens. Also test the locked
state blocking backup creation.

* Fix CSRF tests: initialize session before getting tokens

* Fix CSRF tests: extract tokens from rendered page HTML

* Harden backup security: password confirmation, CSRF, env toggle

Address security review feedback from jbtronics:

- Add IS_AUTHENTICATED_FULLY to all sensitive endpoints (create/delete
  backup, delete log, download backup, start update, restore)
- Change backup download from GET to POST with CSRF token
- Require password confirmation before downloading backups (backups
  contain sensitive data like password hashes and secrets)
- Add DISABLE_BACKUP_DOWNLOAD env var (default: disabled) to control
  whether backup downloads are allowed
- Add password confirmation modal with security warning in template
- Add comprehensive tests: auth checks, env var blocking, POST-only
  enforcement, status/progress endpoint auth

* Fix download modal: use per-backup modals for CSP/Turbo compatibility

- Replace shared modal + inline JS with per-backup modals that have
  filename pre-set in hidden fields (no JavaScript needed)
- Add data-turbo="false" to download forms for native browser handling
- Add data-bs-dismiss="modal" to submit button to auto-close modal
- Add hidden username field for Chrome accessibility best practice
- Fix test: GET on POST-only route returns 404 not 405

* Fixed translation keys

* Fixed text justification in download modal

* Hardenened security of deleteLogEndpoint

* Show whether backup, restores and updates are allowed or disabled by sysadmin on update manager

* Added documentation for update manager related env variables

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-07 19:31:00 +01:00
Sebastian Almberg
db8881621c Add OPcache reset step to update and restore processes (#1288)
After cache warmup, create a temporary PHP script in the public
directory and invoke it via HTTP to reset OPcache in the PHP-FPM
context. This prevents stale bytecode from causing 500 errors when
the progress page refreshes after code has been updated.

The reset is also performed after rollback and during restore.
Uses a random token in the filename for security, and the script
self-deletes after execution with a cleanup in the finally block.
2026-03-07 18:10:36 +01:00
Jan Böhmer
ceda91488c Updated dependencies 2026-03-07 16:20:42 +01:00
Copilot
e84bae2807 Make form layout better at wide screens & Make horizontal form column layout configurable via global Twig variables (#1293)
* Initial plan

* Make form column layout configurable with global Twig variables

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

* Rename form column Twig globals to shorter names: label_col, input_col, offset_col

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

* Fixed remaining places where offsets where used

* Fixed margin of delete button on admin forms

* Rename Twig globals: col_label, col_input, offset_label

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

* Added documentation to our twig class variables

---------

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-03-07 16:14:58 +01:00
Jan Böhmer
e8d90487d2 Added "show password" toggle to all password fields 2026-03-07 00:47:31 +01:00
Jan Böhmer
598cf3ed80 Use a symfony form for login form
This allows us to reuse the global form renderings
2026-03-07 00:46:34 +01:00
Jan Böhmer
30e3bc3153 Fixed highlight on url change for tools sidebar tree 2026-03-07 00:26:33 +01:00
Jan Böhmer
f95a58087b Select the respective node in the sidebar treeviews, when navigating Part-DB
When you open a category page from everywhere in Part-DB, the respective node will be opened
2026-03-06 23:23:38 +01:00
Jan Böhmer
83608fffcf Do not scroll up the sidebar when clicking on a treeview 2026-03-06 22:28:42 +01:00
Jan Böhmer
78b1d41cf8 Merge remote-tracking branch 'origin/master' 2026-03-05 00:22:11 +01:00
Jan Böhmer
616c3a6742 Bumped version to 2.8.1 2026-03-05 00:22:08 +01:00
Copilot
d24a50a696 Auto-upload built assets as release attachments on version tag push (#1287)
* Initial plan

* Upload built assets as release attachments on version tag push

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-03-05 00:04:16 +01:00
Jan Böhmer
3480dd146e Do not use customer reference for digikey barcode creation info as it likely contains not the part number
Fixes #1285
2026-03-05 00:02:35 +01:00
Jan Böhmer
dbe49b5f00 Merge remote-tracking branch 'origin/master' 2026-03-04 23:58:47 +01:00
Jan Böhmer
1c28efb12e Updated dependencies 2026-03-04 23:58:41 +01:00
Jan Böhmer
a6ee68d75a Ensure that user has read permission to part and category to prevent IPN info leakage
issue #1283
2026-03-04 23:54:18 +01:00
Jan Böhmer
30ece64423 Update KiCad symbols and footprints lists (#1282)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 23:39:26 +01:00
dependabot[bot]
77ef77961d Bump actions/upload-artifact from 6 to 7 (#1277)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-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-03-04 23:38:48 +01:00
dependabot[bot]
a629949479 Bump actions/download-artifact from 7 to 8 (#1278)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  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-03-04 23:38:32 +01:00
Jan Böhmer
af6ddffa1d Check that user has general access rights to partdb
See #1283
2026-03-04 23:38:06 +01:00
Jan Böhmer
f15979ed11 Run cache:pool:clear --all instead of cache:clear in updater to clear really all cache pools, even app ones 2026-03-04 23:33:10 +01:00
Jan Böhmer
df3262a3f7 Moved cache.settings to cache.system adapter to ensure it is cleared on updating
Fixes #1279
2026-03-04 23:31:16 +01:00
Jan Böhmer
a071701870 Add cache pool clear hint to error pages
Related to #1279
2026-03-04 23:27:06 +01:00
Jan Böhmer
c549665578 Fixed flash messages in admin pages 2026-03-04 23:24:29 +01:00
Jan Böhmer
2137eecddf Check for good measure again, that a user is able to edit an entity in an admin form
issue #1283
2026-03-04 23:06:01 +01:00
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
169 changed files with 16439 additions and 4764 deletions

5
.env
View File

@@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
# Backups contain sensitive data including password hashes and secrets.
# When enabled, users must confirm their password before downloading.
DISABLE_BACKUP_DOWNLOAD=1
###################################################################################
# SAML Single sign on-settings
###################################################################################

View File

@@ -8,6 +8,9 @@ on:
branches:
- '*'
- "!l10n_*" # Dont test localization branches
tags:
- 'v*.*.*'
- 'v*.*.*-**'
pull_request:
branches:
- '*'
@@ -17,6 +20,8 @@ jobs:
assets_artifact_build:
name: Build assets artifact
runs-on: ubuntu-22.04
permissions:
contents: write
env:
APP_ENV: prod
@@ -80,13 +85,20 @@ jobs:
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
- name: Upload assets artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: Only dependencies and built assets
path: /tmp/partdb_assets.zip
- name: Upload full artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: Full Part-DB including dependencies and built assets
path: /tmp/partdb_with_assets.zip
- name: Upload assets as release attachment
if: startsWith(github.ref, 'refs/tags/')
run: |
gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -98,7 +98,7 @@ jobs:
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
@@ -113,7 +113,7 @@ jobs:
steps:
-
name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*

View File

@@ -99,7 +99,7 @@ jobs:
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
@@ -114,7 +114,7 @@ jobs:
steps:
-
name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*

View File

@@ -1 +1 @@
2.7.0
2.9.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

@@ -45,6 +45,7 @@ export default class extends Controller {
maxItems: 1,
createOnBlur: true,
selectOnTab: true,
clearAfterSelect: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: dropdownParent,

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

@@ -23,6 +23,8 @@ export default class extends Controller {
valueField: "id",
labelField: "name",
dropdownParent: dropdownParent,
selectOnTab: true,
clearAfterSelect: true,
preload: "focus",
render: {
item: (data, escape) => {

View File

@@ -49,6 +49,7 @@ export default class extends Controller {
selectOnTab: true,
maxOptions: null,
dropdownParent: dropdownParent,
clearAfterSelect: true,
render: {
item: this.renderItem.bind(this),

View File

@@ -35,6 +35,8 @@ export default class extends Controller {
maxItems: 1000,
allowEmptyOption: true,
dropdownParent: dropdownParent,
selectOnTab: true,
clearAfterSelect: true,
plugins: ['remove_button'],
});
}

View File

@@ -19,6 +19,7 @@
import {Controller} from "@hotwired/stimulus";
import {default as TreeController} from "./tree_controller";
import {EVENT_INITIALIZED} from "@jbtronics/bs-treeview";
export default class extends TreeController {
static targets = [ "tree", 'sourceText' ];
@@ -40,6 +41,8 @@ export default class extends TreeController {
//Check if we have a saved mode
const stored_mode = localStorage.getItem(this._storage_key);
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
//Use stored mode if possible, otherwise use default
if(stored_mode) {
try {
@@ -55,6 +58,39 @@ export default class extends TreeController {
//Register an event listener which checks if the tree needs to be updated
document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this));
//Register an event listener, to check if we end up on a page we can highlight in the tree, if so then higlight it
document.addEventListener('turbo:load', this._onTurboLoad.bind(this));
//On initial page load the tree is not available yet, so do another check after the tree is initialized
this.treeTarget.addEventListener(EVENT_INITIALIZED, (event) => {
this.selectNodeWithURL(document.location)
});
}
_onTurboLoad(event) {
this.selectNodeWithURL(event.detail.url);
}
selectNodeWithURL(url) {
//Get path from url
const path = new URL(url).pathname;
if (!this._tree) {
return;
}
//Unselect all nodes
this._tree.unselectAll({silent: true, ignorePreventUnselect: true});
//Try to find a node with this path as data-path
const nodes = this._tree.findNodes(path, "href");
if (nodes.length !== 1) {
return; //We can only work with exactly one node, if there are multiple nodes with the same path, we cannot know which one to select, so we do nothing
}
const node = nodes[0];
node.setSelected(true, {ignorePreventUnselect: true, silent: true});
this._tree.revealNode(node);
}
doUpdateIfNeeded()

View File

@@ -56,6 +56,7 @@ export default class extends Controller {
searchField: 'text',
orderField: 'text',
dropdownParent: dropdownParent,
clearAfterSelect: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',

View File

@@ -58,6 +58,7 @@ export default class extends Controller {
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
dropdownParent: dropdownParent,
clearAfterSelect: true,
searchField: [
{field: "text", weight : 2},

View File

@@ -49,6 +49,7 @@ export default class extends Controller {
createOnBlur: true,
create: true,
dropdownParent: dropdownParent,
clearAfterSelect: true,
};
if(this.element.dataset.autocomplete) {

View File

@@ -39,6 +39,8 @@ export default class extends Controller {
*/
_tree = null;
_frame = "frame";
connect() {
const treeElement = this.treeTarget;
if (!treeElement) {
@@ -48,6 +50,7 @@ export default class extends Controller {
this._url = this.element.dataset.treeUrl;
this._data = this.element.dataset.treeData;
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
if(this.element.dataset.treeShowTags === "true") {
this._showTags = true;
@@ -99,8 +102,7 @@ export default class extends Controller {
onNodeSelected: (event) => {
const node = event.detail.node;
if (node.href) {
window.Turbo.visit(node.href, {action: "advance"});
this._registerURLWatcher(node);
window.Turbo.visit(node.href, {action: "advance", frame: this._frame});
}
},
}, [BS5Theme, BS53Theme, FAIconTheme]);
@@ -110,41 +112,12 @@ export default class extends Controller {
const treeView = event.detail.treeView;
treeView.revealNode(treeView.getSelected());
//Add the url watcher to all selected nodes
for (const node of treeView.getSelected()) {
this._registerURLWatcher(node);
}
//Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click
treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this));
});
}
_registerURLWatcher(node)
{
//Register a watcher for a location change, which will unselect the node, if the location changes
const desired_url = node.href;
//Ensure that the node is unselected, if the location changes
const unselectNode = () => {
//Parse url so we can properly compare them
const desired = new URL(node.href, window.location.origin);
//We only compare the pathname, because the hash and parameters should not matter
if(window.location.pathname !== desired.pathname) {
//The ignore parameter is important here, otherwise the node will not be unselected
node.setSelected(false, {silent: true, ignorePreventUnselect: true});
//Unregister the watcher
document.removeEventListener('turbo:load', unselectNode);
}
};
//Register the watcher via hotwire turbo
//We must just load to have the new url in window.location
document.addEventListener('turbo:load', unselectNode);
}
_onContextMenu(event)
{
@@ -198,4 +171,4 @@ export default class extends Controller {
return myResolve(this._data);
});
}
}
}

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

@@ -75,6 +75,7 @@ export default class extends Controller
searchField: "name",
//labelField: "name",
valueField: "name",
clearAfterSelect: true,
onItemAdd: this.onItemAdd.bind(this),
render: {
option: (data, escape) => {
@@ -136,4 +137,4 @@ export default class extends Controller
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

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

@@ -177,6 +177,11 @@
"allow-contrib": false,
"require": "7.4.*",
"docker": true
},
"phpstan/extension-installer": {
"ignore" : [
"ekino/phpstan-banned-code"
]
}
}
}

920
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,5 +25,5 @@ framework:
adapter: cache.app
cache.settings:
adapter: cache.app
adapter: cache.system
tags: true

View File

@@ -18,6 +18,11 @@ twig:
saml_enabled: '%partdb.saml.enabled%'
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
# Bootstrap grid classes used for horizontal form layouts
col_label: 'col-sm-3 col-lg-2' # The column classes for form labels
col_input: 'col-sm-9 col-lg-10' # The column classes for form input fields
offset_label: 'offset-sm-3 offset-lg-2' # Offset classes for elements that should be aligned with the input fields (e.g., submit buttons)
when@test:
twig:
strict_variables: 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
@@ -1502,7 +1502,7 @@ 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
* },
* verbosity_levels?: array{
@@ -1531,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
@@ -1561,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{
@@ -1595,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"
@@ -1746,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{
@@ -1770,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{
@@ -1805,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,
* },
* },
@@ -2098,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
@@ -2124,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"
@@ -2150,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
@@ -2171,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{
@@ -2245,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
@@ -2304,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,
@@ -2455,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{
@@ -2512,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{
@@ -2804,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)
@@ -139,6 +144,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
### Update manager settings
* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
backup restore is disabled.
* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
the downloads contain sensitive data like password hashes or secrets.
### Table related settings
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set

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

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

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260307204859 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Increase the length of the vendor_barcode field in part_lots to 1000 characters and update the index accordingly';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode(100))');
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode LONGTEXT DEFAULT NULL');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode)');
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode VARCHAR(255) DEFAULT NULL');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode CLOB DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM __temp__part_lots');
$this->addSql('DROP TABLE __temp__part_lots');
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
$this->addSql('DROP TABLE __temp__part_lots');
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('DROP INDEX part_lots_idx_barcode');
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE TEXT');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('DROP INDEX part_lots_idx_barcode');
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE VARCHAR(255)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
}

86
phpstan.banned_code.neon Normal file
View File

@@ -0,0 +1,86 @@
# Manually configure ekino/phpstan-banned-code to detect usage of echo, eval, die/exit, print, shell execution and a set of functions that should not be used in production code.
parametersSchema:
banned_code: structure([
nodes: listOf(structure([
type: string()
functions: schema(listOf(string()), nullable())
]))
use_from_tests: bool()
non_ignorable: bool()
])
parameters:
banned_code:
nodes:
# enable detection of echo
-
type: Stmt_Echo
functions: null
# enable detection of eval
-
type: Expr_Eval
functions: null
# enable detection of die/exit
-
type: Expr_Exit
functions: null
# enable detection of a set of functions
-
type: Expr_FuncCall
functions:
- dd
- debug_backtrace
- dump
- exec
- passthru
- phpinfo
- print_r
- proc_open
- shell_exec
- system
- var_dump
# enable detection of print statements
-
type: Expr_Print
functions: null
# enable detection of shell execution by backticks
-
type: Expr_ShellExec
functions: null
# enable detection of empty()
#-
# type: Expr_Empty
# functions: null
# enable detection of `use Tests\Foo\Bar` in a non-test file
use_from_tests: true
# when true, errors cannot be excluded
non_ignorable: false
services:
-
class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule
tags:
- phpstan.rules.rule
arguments:
- '%banned_code.nodes%'
-
class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule
tags:
- phpstan.rules.rule
arguments:
- '%banned_code.use_from_tests%'
-
class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder
arguments:
- '%banned_code.non_ignorable%'

View File

@@ -1,3 +1,6 @@
includes:
- phpstan.banned_code.neon
parameters:
level: 5
@@ -6,9 +9,6 @@ parameters:
- src
# - tests
banned_code:
non_ignorable: false # Allow to ignore some banned code
excludePaths:
- src/DataTables/Adapter/*
- src/Configuration/*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -195,6 +195,8 @@ abstract class BaseAdminController extends AbstractController
$this->commentHelper->setMessage($form['log_comment']->getData());
//In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes.
$this->denyAccessUnlessGranted('edit', $entity);
$em->persist($entity);
$em->flush();
$this->addFlash('success', 'entity.edit_flash');

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

@@ -290,6 +290,23 @@ final class PartController extends AbstractController
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
}
$lotAmount = $request->query->get('lotAmount');
$lotName = $request->query->get('lotName');
$lotUserBarcode = $request->query->get('lotUserBarcode');
if ($lotAmount !== null || $lotName !== null || $lotUserBarcode !== null) {
$partLot = new PartLot();
$partLot->setAmount($lotAmount !== null ? (float)$lotAmount : 0);
$partLot->setDescription($lotName !== null ? (string)$lotName : '');
$partLot->setUserBarcode($lotUserBarcode !== null ? (string)$lotUserBarcode : '');
$new_part->addPartLot($partLot);
$this->addFlash('notice', t('part.create_from_info_provider.lot_filled_from_barcode'));
}
return $this->renderPartForm('new', $request, $new_part, [
'info_provider_dto' => $dto,
]);

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

@@ -25,6 +25,7 @@ namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Events\SecurityEvent;
use App\Events\SecurityEvents;
use App\Form\Security\LoginFormType;
use App\Services\UserSystem\PasswordResetManager;
use Doctrine\ORM\EntityManagerInterface;
use Gregwar\CaptchaBundle\Type\CaptchaType;
@@ -61,7 +62,12 @@ class SecurityController extends AbstractController
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
$form = $this->createForm(LoginFormType::class, [
'_username' => $lastUsername,
]);
return $this->render('security/login.html.twig', [
'form' => $form,
'last_username' => $lastUsername,
'error' => $error,
]);

View File

@@ -71,7 +71,10 @@ class TypeaheadController extends AbstractController
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse
{
$query = $request->get('query');
//Ensure that the user can access Part-DB at all
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
$query = $request->query->getString('query');
$array = $finder->find($query);
$result = [];
@@ -205,9 +208,16 @@ class TypeaheadController extends AbstractController
/** @var Category|null $category */
$category = $entityManager->getRepository(Category::class)->find($categoryId);
//Ensure the user has access to both the part and the category
$this->denyAccessUnlessGranted('read', $part);
if ($category !== null) {
$this->denyAccessUnlessGranted('read', $category);
}
$clonedPart = clone $part;
$clonedPart->setCategory($category);
$partRepository = $entityManager->getRepository(Part::class);
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);

View File

@@ -23,16 +23,21 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Services\System\BackupManager;
use App\Services\System\InstallationTypeDetector;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -49,10 +54,14 @@ class UpdateManagerController extends AbstractController
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly UserPasswordHasherInterface $passwordHasher,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
private readonly bool $backupRestoreDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
private readonly bool $backupDownloadDisabled = false,
) {
}
@@ -76,6 +85,16 @@ class UpdateManagerController extends AbstractController
}
}
/**
* Check if backup download is disabled and throw exception if so.
*/
private function denyIfBackupDownloadDisabled(): void
{
if ($this->backupDownloadDisabled) {
throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
}
}
/**
* Main update manager page.
*/
@@ -101,6 +120,8 @@ class UpdateManagerController extends AbstractController
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
'backup_download_disabled' => $this->backupDownloadDisabled,
'is_docker' => $this->installationTypeDetector->isDocker(),
]);
}
@@ -206,6 +227,7 @@ class UpdateManagerController extends AbstractController
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
public function startUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
@@ -314,12 +336,126 @@ class UpdateManagerController extends AbstractController
return $this->json($details);
}
/**
* Create a manual backup.
*/
#[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])]
public function createBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
if ($this->updateExecutor->isLocked()) {
$this->addFlash('error', 'Cannot create backup while an update is in progress.');
return $this->redirectToRoute('admin_update_manager');
}
try {
$this->backupManager->createBackup(null, 'manual');
$this->addFlash('success', 'update_manager.backup.created');
} catch (\Exception $e) {
$this->addFlash('error', 'Backup failed: ' . $e->getMessage());
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Delete a backup file.
*/
#[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])]
public function deleteBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
if ($filename && $this->backupManager->deleteBackup($filename)) {
$this->addFlash('success', 'update_manager.backup.deleted');
} else {
$this->addFlash('error', 'update_manager.backup.delete_error');
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Delete an update log file.
*/
#[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])]
public function deleteLog(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
if ($filename && $this->updateExecutor->deleteLog($filename)) {
$this->addFlash('success', 'update_manager.log.deleted');
} else {
$this->addFlash('error', 'update_manager.log.delete_error');
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Download a backup file.
* Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
*/
#[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
public function downloadBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupDownloadDisabled();
if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
// Verify password
$password = $request->request->get('password', '');
$user = $this->getUser();
if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
$this->addFlash('error', 'update_manager.backup.download.invalid_password');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename', '');
$details = $this->backupManager->getBackupDetails($filename);
if (!$details) {
throw $this->createNotFoundException('Backup not found');
}
$response = new BinaryFileResponse($details['path']);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']);
return $response;
}
/**
* Restore from a backup.
*/
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
public function restore(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();

View File

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

View File

@@ -89,6 +89,10 @@ final class PartsDataTable implements DataTableTypeInterface
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
/*************************************************************************************************************
* When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings!
*************************************************************************************************************/
$this->csh
//Color the table rows depending on the review and favorite status
->add('row_color', RowClassColumn::class, [
@@ -228,6 +232,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

View File

@@ -23,11 +23,13 @@ declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
@@ -145,6 +147,19 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'orderField' => 'NATSORT(manufacturer.name)',
])
->add('manufacturing_status', EnumColumn::class, [
'label' => $this->translator->trans('part.table.manufacturingStatus'),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'class' => ManufacturingStatus::class,
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
if ($status === null) {
return '';
}
return $this->translator->trans($status->toTranslationKey());
},
])
->add('mountnames', TextColumn::class, [
'label' => 'project.bom.mountnames',
'render' => function ($value, ProjectBOMEntry $context) {

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

@@ -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

@@ -66,7 +66,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'part_lots')]
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
#[ORM\Index(name: 'part_lots_idx_barcode', columns: ['vendor_barcode'], options: ['lengths' => [100]])]
#[ValidPartLot]
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
#[ApiResource(
@@ -81,7 +81,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
#[ApiFilter(LikeFilter::class, properties: ["description", "comment", "user_barcode"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
@@ -166,9 +166,8 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
*/
#[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
#[ORM\Column(name: "vendor_barcode", type: Types::TEXT, nullable: true)]
#[Groups(['part_lot:read', 'part_lot:write'])]
#[Length(max: 255)]
protected ?string $user_barcode = null;
/**

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

@@ -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

@@ -121,6 +121,7 @@ class BaseEntityAdminForm extends AbstractType
'label' => 'entity.edit.alternative_names.label',
'help' => 'entity.edit.alternative_names.help',
'empty_data' => null,
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
'attr' => [
'class' => 'tagsinput',
'data-controller' => 'elements--tagsinput',

View File

@@ -45,7 +45,7 @@ final class TogglePasswordTypeExtension extends AbstractTypeExtension
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'toggle' => false,
'toggle' => true,
'hidden_label' => new TranslatableMessage('password_toggle.hide'),
'visible_label' => new TranslatableMessage('password_toggle.show'),
'hidden_icon' => 'Default',

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

View File

@@ -0,0 +1,83 @@
<?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\Form\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use function Symfony\Component\Translation\t;
class LoginFormType extends AbstractType
{
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void
{
$builder
->add('_username', TextType::class, [
'label' => t('login.username.label'),
'attr' => [
'autofocus' => 'autofocus',
'autocomplete' => 'username',
'placeholder' => t('login.username.placeholder'),
]
])
->add('_password', PasswordType::class, [
'label' => t('login.password.label'),
'attr' => [
'autocomplete' => 'current-password',
'placeholder' => t('login.password.placeholder'),
]
])
->add('_remember_me', CheckboxType::class, [
'label' => t('login.rememberme'),
'required' => false,
])
->add('submit', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, [
'label' => t('login.btn'),
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// This ensures CSRF protection is active for the login
'csrf_protection' => true,
'csrf_field_name' => '_csrf_token',
'csrf_token_id' => 'authenticate',
'attr' => [
'data-turbo' => 'false', // Disable Turbo for the login form to ensure proper redirection after login
]
]);
}
public function getBlockPrefix(): string
{
// This removes the "login_form_" prefix from field names
// so that Security can find "_username" directly.
return '';
}
}

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

@@ -42,6 +42,8 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED';
private const PARENT_ELEMENT = 'STRUCTURAL_DENORMALIZER_PARENT_ELEMENT';
private array $object_cache = [];
public function __construct(
@@ -89,37 +91,59 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
$context[self::ALREADY_CALLED][] = $data;
//In the first step, denormalize without children
$context_without_children = $context;
$context_without_children['groups'] = array_filter(
$context_without_children['groups'] ?? [],
static fn($group) => $group !== 'include_children',
);
//Also unset any parent element, to avoid infinite loops. We will set the parent element in the next step, when we denormalize the children
unset($context_without_children[self::PARENT_ELEMENT]);
/** @var AbstractStructuralDBElement $entity */
$entity = $this->denormalizer->denormalize($data, $type, $format, $context_without_children);
/** @var AbstractStructuralDBElement $deserialized_entity */
$deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context);
//Assign the parent element to the denormalized entity, so it can be used in the denormalization of the children (e.g. for path generation)
if (isset($context[self::PARENT_ELEMENT]) && $context[self::PARENT_ELEMENT] instanceof $entity && $entity->getID() === null) {
$entity->setParent($context[self::PARENT_ELEMENT]);
}
//Check if we already have the entity in the database (via path)
/** @var StructuralDBElementRepository<T> $repo */
$repo = $this->entityManager->getRepository($type);
$path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
$path = $entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
$db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
if ($db_elements !== []) {
//We already have the entity in the database, so we can return it
return end($db_elements);
$entity = end($db_elements);
}
//Check if we have created the entity in this request before (so we don't create multiple entities for the same path)
//Entities get saved in the cache by type and path
//We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem
//unless the user data has mixed structure between json data and a string path
//unless the user data has mixed structure between JSON data and a string path
if (isset($this->object_cache[$type][$path])) {
return $this->object_cache[$type][$path];
$entity = $this->object_cache[$type][$path];
} else {
//Save the entity in the cache
$this->object_cache[$type][$path] = $entity;
}
//Save the entity in the cache
$this->object_cache[$type][$path] = $deserialized_entity;
//In the next step we can denormalize the children, and add our children to the entity.
if (in_array('include_children', $context['groups'], true) && isset($data['children']) && is_array($data['children'])) {
foreach ($data['children'] as $child_data) {
$child_entity = $this->denormalize($child_data, $type, $format, array_merge($context, [self::PARENT_ELEMENT => $entity]));
if ($child_entity !== null && !$entity->getChildren()->contains($child_entity)) {
$entity->addChild($child_entity);
}
}
}
//We don't have the entity in the database, so we have to persist it
$this->entityManager->persist($deserialized_entity);
$this->entityManager->persist($entity);
return $deserialized_entity;
return $entity;
}
public function getSupportedTypes(?string $format): array

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),
@@ -190,6 +202,7 @@ class KiCadHelper
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
"description" => $part->getDescription(),
"fields" => []
];
@@ -198,14 +211,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 +266,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 +274,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 +401,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 +452,64 @@ class KiCadHelper
'visible' => $this->boolToKicadBool($visible),
];
}
}
/**
* Finds the URL to the actual datasheet file for the given part.
* Searches attachments by type name, attachment name, and file extension.
* @return string|null The datasheet URL, or null if no datasheet was found.
*/
private function findDatasheetUrl(Part $part): ?string
{
$firstPdf = null;
foreach ($part->getAttachments() as $attachment) {
//Check if the attachment type name contains "datasheet"
$typeName = $attachment->getAttachmentType()?->getName() ?? '';
if (str_contains(mb_strtolower($typeName), 'datasheet')) {
return $this->getAttachmentUrl($attachment);
}
//Check if the attachment name contains "datasheet"
$name = mb_strtolower($attachment->getName());
if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
return $this->getAttachmentUrl($attachment);
}
//Track first PDF as fallback (check internal extension or external URL path)
if ($firstPdf === null) {
$extension = $attachment->getExtension();
if ($extension === null && $attachment->hasExternal()) {
$urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
$extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
}
if ($extension === 'pdf') {
$firstPdf = $attachment;
}
}
}
//Use first PDF attachment as fallback
if ($firstPdf !== null) {
return $this->getAttachmentUrl($firstPdf);
}
return null;
}
/**
* Returns an absolute URL for viewing the given attachment.
* Prefers the external URL (direct link) over the internal view route.
*/
private function getAttachmentUrl(Attachment $attachment): string
{
if ($attachment->hasExternal()) {
return $attachment->getExternalPath();
}
return $this->urlGenerator->generate(
'attachment_view',
['id' => $attachment->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}

View File

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

View File

@@ -219,11 +219,6 @@ class EntityImporter
$entities = [$entities];
}
//The serializer has only set the children attributes. We also have to change the parent value (the real value in DB)
if ($entities[0] instanceof AbstractStructuralDBElement) {
$this->correctParentEntites($entities, null);
}
//Set the parent of the imported elements to the given options
foreach ($entities as $entity) {
if ($entity instanceof AbstractStructuralDBElement) {
@@ -297,6 +292,14 @@ class EntityImporter
return $resolver;
}
private function persistRecursively(AbstractStructuralDBElement $entity): void
{
$this->em->persist($entity);
foreach ($entity->getChildren() as $child) {
$this->persistRecursively($child);
}
}
/**
* This method deserializes the given file and writes the entities to the database (and flush the db).
* The imported elements will be checked (validated) before written to database.
@@ -322,7 +325,11 @@ class EntityImporter
//Iterate over each $entity write it to DB (the invalid entities were already filtered out).
foreach ($entities as $entity) {
$this->em->persist($entity);
if ($entity instanceof AbstractStructuralDBElement) {
$this->persistRecursively($entity);
} else {
$this->em->persist($entity);
}
}
//Save changes to database, when no error happened, or we should continue on error.
@@ -400,7 +407,7 @@ class EntityImporter
*
* @param File $file The Excel file to convert
* @param string $delimiter The CSV delimiter to use
*
*
* @return string The CSV data as string
*/
protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
@@ -421,7 +428,7 @@ class EntityImporter
]);
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
@@ -431,7 +438,7 @@ class EntityImporter
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';
} catch (\Exception $e) {
$this->logger->warning('Error reading cell value', [
'cell' => "{$col}{$row}",
@@ -484,21 +491,4 @@ class EntityImporter
throw $e;
}
}
/**
* This functions corrects the parent setting based on the children value of the parent.
*
* @param iterable $entities the list of entities that should be fixed
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
*/
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
{
foreach ($entities as $entity) {
/** @var AbstractStructuralDBElement $entity */
$entity->setParent($parent);
//Do the same for the children of entity
$this->correctParentEntites($entity->getChildren(), $entity);
}
}
}

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) {
@@ -85,7 +95,7 @@ final class PartInfoRetriever
$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 = 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);
});

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,340 @@
<?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);
}
//So far we can just copy over our provider info array to the URL parameters:
return $this->urlGenerator->generate('info_providers_create_part', $infos);
}
/**
* 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, lotAmount?: float|int, lotName?: string, lotUserBarcode?: 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,
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->orderNumber ?? $scanResult->pickBatchNumber,
'lotUserBarcode' => $scanResult->rawInput,
];
}
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, lotAmount?: float|int, lotName?: string, lotUserBarcode?: 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,
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->customerPO,
'lotUserBarcode' => $scanResult->rawInput,
];
}
return null;
}
// Digi-Key: supplierPartNumber directly
if ($vendor === 'digikey') {
return [
'providerKey' => 'digikey',
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Digikey barcode does not contain required supplier part number'),
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->digikeyInvoiceNumber ?? $scanResult->digikeySalesOrderNumber ?? $scanResult->customerPO,
'lotUserBarcode' => $scanResult->rawInput,
];
}
// Element14: can use supplierPartNumber directly
if ($vendor === 'element14') {
return [
'providerKey' => 'element14',
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Element14 barcode does not contain required supplier part number'),
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->customerPO,
'lotUserBarcode' => $scanResult->rawInput,
];
}
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, public readonly ?string $rawInput = null)
{
//IDs per EIGP 114.2018
$this->shipDate = $data['6D'] ?? null;
@@ -271,6 +271,8 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*/
public static function parseFormat06Code(string $input): self
{
$rawInput = $input;
//Ensure that the input is a valid format06 code
if (!self::isFormat06Code($input)) {
throw new \InvalidArgumentException("The given input is not a valid format06 code");
@@ -306,7 +308,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
$results[$key] = $fieldValue;
}
return new self($results);
return new self($results, $rawInput);
}
public function getDecodedForInfoMode(): array
@@ -329,4 +331,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

@@ -114,10 +114,12 @@ final class BarcodeProvider implements PlaceholderProviderInterface
return '<b>IPN Barcode ERROR!</b>: '.$e->getMessage();
}
}
return null;
}
public static function getDefaultPriority(): int
{
//This provider should be checked before all others, so that nothing is delegated for part lots
return 1000;
}
}

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

@@ -327,14 +327,14 @@ readonly class BackupManager
*/
private function restoreDatabaseFromBackup(string $tempDir): void
{
// Get database connection params from Doctrine
$connection = $this->entityManager->getConnection();
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
// Check for SQL dump (MySQL/PostgreSQL)
$sqlFile = $tempDir . '/database.sql';
if (file_exists($sqlFile)) {
// Import SQL using mysql/psql command directly
// First, get database connection params from Doctrine
$connection = $this->entityManager->getConnection();
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
if ($platform instanceof AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
@@ -403,7 +403,8 @@ readonly class BackupManager
// Check for SQLite database file
$sqliteFile = $tempDir . '/var/app.db';
if (file_exists($sqliteFile)) {
$targetDb = $this->projectDir . '/var/app.db';
// Use the actual configured SQLite path from Doctrine, not a hardcoded path
$targetDb = $params['path'] ?? $this->projectDir . '/var/app.db';
$this->filesystem->copy($sqliteFile, $targetDb, true);
return;
}

View File

@@ -207,6 +207,79 @@ class UpdateExecutor
}
}
/**
* Reset PHP OPcache for the web server process.
*
* OPcache in PHP-FPM is separate from CLI. After updating code files,
* PHP-FPM may still serve stale cached bytecode, causing constructor
* mismatches and 500 errors. This method creates a temporary PHP script
* in the public directory, invokes it via HTTP to reset OPcache in the
* web server context, then removes the script.
*
* @return bool Whether OPcache was successfully reset
*/
private function resetOpcache(): bool
{
$token = bin2hex(random_bytes(16));
$resetScript = $this->project_dir . '/public/_opcache_reset_' . $token . '.php';
try {
// Create a temporary PHP script that resets OPcache
$scriptContent = '<?php '
. 'if (function_exists("opcache_reset")) { opcache_reset(); echo "OK"; } '
. 'else { echo "NO_OPCACHE"; } '
. '@unlink(__FILE__);';
$this->filesystem->dumpFile($resetScript, $scriptContent);
// Try to invoke it via HTTP on localhost
$urls = [
'http://127.0.0.1/_opcache_reset_' . $token . '.php',
'http://localhost/_opcache_reset_' . $token . '.php',
];
$success = false;
foreach ($urls as $url) {
try {
$context = stream_context_create([
'http' => [
'timeout' => 5,
'ignore_errors' => true,
],
]);
$response = @file_get_contents($url, false, $context);
if ($response === 'OK') {
$this->logger->info('OPcache reset via ' . $url);
$success = true;
break;
}
} catch (\Throwable $e) {
// Try next URL
continue;
}
}
if (!$success) {
$this->logger->info('OPcache reset via HTTP not available, trying CLI fallback');
// CLI opcache_reset() only affects CLI, but try anyway
if (function_exists('opcache_reset')) {
opcache_reset();
}
}
return $success;
} catch (\Throwable $e) {
$this->logger->warning('OPcache reset failed: ' . $e->getMessage());
return false;
} finally {
// Ensure the temp script is removed
if (file_exists($resetScript)) {
@unlink($resetScript);
}
}
}
/**
* Validate that we can perform an update.
*
@@ -420,7 +493,7 @@ class UpdateExecutor
// Step 11: Clear cache
$stepStart = microtime(true);
$this->runCommand([
'php', 'bin/console', 'cache:clear',
'php', 'bin/console', 'cache:pool:clear', '--all',
'--env=prod',
'--no-interaction',
], 'Clear cache', 120);
@@ -434,12 +507,20 @@ class UpdateExecutor
], 'Warmup cache', 120);
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 13: Disable maintenance mode
// Step 13: Reset OPcache (if available)
$stepStart = microtime(true);
$opcacheResult = $this->resetOpcache();
$log('opcache_reset', $opcacheResult
? 'Reset PHP OPcache for web server'
: 'OPcache reset skipped (not available or not needed)',
true, microtime(true) - $stepStart);
// Step 14: Disable maintenance mode
$stepStart = microtime(true);
$this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 14: Release lock
// Step 15: Release lock
$stepStart = microtime(true);
$this->releaseLock();
@@ -489,11 +570,14 @@ class UpdateExecutor
// Clear cache after rollback
$this->runCommand([
'php', 'bin/console', 'cache:clear',
'php', 'bin/console', 'cache:pool:clear', '--all',
'--env=prod',
], 'Clear cache after rollback', 120);
$log('rollback_cache', 'Cleared cache after rollback', true);
// Reset OPcache after rollback
$this->resetOpcache();
} catch (\Exception $rollbackError) {
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
}
@@ -602,6 +686,33 @@ class UpdateExecutor
}
/**
* Delete a specific update log file.
*/
public function deleteLog(string $filename): bool
{
// Validate filename pattern for security
if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) {
$this->logger->warning('Attempted to delete invalid log filename: ' . $filename);
return false;
}
$logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename);
if (!file_exists($logPath)) {
return false;
}
try {
$this->filesystem->remove($logPath);
$this->logger->info('Deleted update log: ' . $filename);
return true;
} catch (\Exception $e) {
$this->logger->error('Failed to delete update log: ' . $e->getMessage());
return false;
}
}
/**
* Restore from a backup file with maintenance mode and cache clearing.
*
@@ -682,12 +793,17 @@ class UpdateExecutor
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 6: Disable maintenance mode
// Step 6: Reset OPcache
$stepStart = microtime(true);
$this->resetOpcache();
$log('opcache_reset', 'Reset PHP OPcache', true, microtime(true) - $stepStart);
// Step 7: Disable maintenance mode
$stepStart = microtime(true);
$this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 7: Release lock
// Step 8: Release lock
$this->releaseLock();
$totalDuration = microtime(true) - $startTime;
@@ -817,7 +933,7 @@ class UpdateExecutor
'create_backup' => $createBackup,
'started_at' => (new \DateTime())->format('c'),
'current_step' => 0,
'total_steps' => 14,
'total_steps' => 15,
'step_name' => 'initializing',
'step_message' => 'Starting update process...',
'steps' => [],
@@ -890,7 +1006,7 @@ class UpdateExecutor
bool $createBackup = true,
?callable $onProgress = null
): array {
$totalSteps = 12;
$totalSteps = 13;
$currentStep = 0;
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {

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

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