Compare commits

...

74 Commits

Author SHA1 Message Date
Jan Böhmer
ebcda8b75a Added documentation to our twig class variables 2026-03-07 16:13:18 +01:00
copilot-swe-agent[bot]
17e83a9da5 Rename Twig globals: col_label, col_input, offset_label
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-03-07 15:10:32 +00:00
Jan Böhmer
815b2b6c7d Fixed margin of delete button on admin forms 2026-03-07 15:57:38 +01:00
Jan Böhmer
3aa7480fe4 Fixed remaining places where offsets where used 2026-03-07 15:54:56 +01:00
copilot-swe-agent[bot]
b508d57094 Rename form column Twig globals to shorter names: label_col, input_col, offset_col
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-03-07 13:11:16 +00:00
copilot-swe-agent[bot]
2769772877 Make form column layout configurable with global Twig variables
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-03-07 11:47:34 +00:00
copilot-swe-agent[bot]
aed1c4dc09 Initial plan 2026-03-07 11:34:39 +00: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
137 changed files with 14691 additions and 4386 deletions

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.1
2.8.1

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

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

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

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

@@ -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(() => {

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\EDA;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
@@ -43,6 +44,9 @@ class KiCadHelper
/** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
private readonly int $category_depth;
/** @var bool Whether to resolve actual datasheet PDF URLs (true) or use Part-DB page links (false) */
private readonly bool $datasheetAsPdf;
public function __construct(
private readonly NodesListBuilder $nodesListBuilder,
private readonly TagAwareCacheInterface $kicadCache,
@@ -51,9 +55,10 @@ class KiCadHelper
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator,
KiCadEDASettings $kiCadEDASettings,
private readonly KiCadEDASettings $kiCadEDASettings,
) {
$this->category_depth = $kiCadEDASettings->categoryDepth;
$this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
}
/**
@@ -115,11 +120,16 @@ class KiCadHelper
}
//Format the category for KiCAD
// Use the category comment as description if available, otherwise use the Part-DB URL
$description = $category->getComment();
if ($description === null || $description === '') {
$description = $this->entityURLGenerator->listPartsURL($category);
}
$result[] = [
'id' => (string)$category->getId(),
'name' => $category->getFullPath('/'),
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
'description' => $this->entityURLGenerator->listPartsURL($category),
'description' => $description,
];
}
@@ -131,11 +141,13 @@ class KiCadHelper
* Returns an array of objects containing all parts for the given category in the format required by KiCAD.
* The result is cached for performance and invalidated on category or part changes.
* @param Category|null $category
* @param bool $minimal If true, only return id and name (faster for symbol chooser listing)
* @return array
*/
public function getCategoryParts(?Category $category): array
public function getCategoryParts(?Category $category, bool $minimal = false): array
{
return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth,
$cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : '');
return $this->kicadCache->get($cacheKey,
function (ItemInterface $item) use ($category) {
$item->tag([
$this->tagGenerator->getElementTypeCacheTag(Category::class),
@@ -198,14 +210,22 @@ class KiCadHelper
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
$result["fields"]["keywords"] = $this->createField($part->getTags());
//Use the part info page as datasheet link. It must be an absolute URL.
$result["fields"]["datasheet"] = $this->createField(
$this->urlGenerator->generate(
'part_info',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_URL)
//Use the part info page as Part-DB link. It must be an absolute URL.
$partUrl = $this->urlGenerator->generate(
'part_info',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);
//Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link)
if ($this->datasheetAsPdf) {
$datasheetUrl = $this->findDatasheetUrl($part);
$result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
} else {
$result["fields"]["datasheet"] = $this->createField($partUrl);
}
$result["fields"]["Part-DB URL"] = $this->createField($partUrl);
//Add basic fields
$result["fields"]["description"] = $this->createField($part->getDescription());
if ($part->getCategory() !== null) {
@@ -245,32 +265,7 @@ class KiCadHelper
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
}
// Add supplier information from orderdetails (include obsolete orderdetails)
if ($part->getOrderdetails(false)->count() > 0) {
$supplierCounts = [];
foreach ($part->getOrderdetails(false) as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$supplierName = $orderdetail->getSupplier()->getName();
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
if (!isset($supplierCounts[$supplierName])) {
$supplierCounts[$supplierName] = 0;
}
$supplierCounts[$supplierName]++;
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
$fieldName = $supplierCounts[$supplierName] > 1
? $supplierName . ' ' . $supplierCounts[$supplierName]
: $supplierName;
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
//Add fields for KiCost:
//Add KiCost manufacturer fields (always present, independent of orderdetails)
if ($part->getManufacturer() !== null) {
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
}
@@ -278,13 +273,74 @@ class KiCadHelper
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
}
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
if ($part->getOrderdetails(false)->count() > 0) {
foreach ($part->getOrderdetails(false) as $orderdetail) {
// Add supplier information from orderdetails (include obsolete orderdetails)
// If any orderdetail has eda_visibility explicitly set to true, only export those;
// otherwise export all (backward compat when no flags are set)
$allOrderdetails = $part->getOrderdetails(false);
if ($allOrderdetails->count() > 0) {
$hasExplicitEdaVisibility = false;
foreach ($allOrderdetails as $od) {
if ($od->isEdaVisibility() !== null) {
$hasExplicitEdaVisibility = true;
break;
}
}
$supplierCounts = [];
foreach ($allOrderdetails as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
// When explicit flags exist, filter by resolved visibility
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->kiCadEDASettings->defaultOrderdetailsVisibility;
if ($hasExplicitEdaVisibility && !$resolvedVisibility) {
continue;
}
$supplierName = $orderdetail->getSupplier()->getName() . ' SPN';
if (!isset($supplierCounts[$supplierName])) {
$supplierCounts[$supplierName] = 0;
}
$supplierCounts[$supplierName]++;
// Create field name with sequential number if more than one from same supplier
$fieldName = $supplierCounts[$supplierName] > 1
? $supplierName . ' ' . $supplierCounts[$supplierName]
: $supplierName;
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
//Also add a KiCost-compatible field (supplier_name# = SPN)
$kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
$result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
//Add stock quantity and storage locations (only count non-expired lots with known quantity)
$totalStock = 0;
$locations = [];
foreach ($part->getPartLots() as $lot) {
$isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
if ($isAvailable) {
$totalStock += $lot->getAmount();
if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
$locations[] = $lot->getStorageLocation()->getName();
}
}
}
$result['fields']['Stock'] = $this->createField($totalStock);
if ($locations !== []) {
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
}
//Add parameters marked for EDA export (explicit true, or system default when null)
foreach ($part->getParameters() as $parameter) {
$paramVisibility = $parameter->isEdaVisibility() ?? $this->kiCadEDASettings->defaultParameterVisibility;
if ($paramVisibility && $parameter->getName() !== '') {
$fieldName = $parameter->getName();
//Don't overwrite hardcoded fields
if (!isset($result['fields'][$fieldName])) {
$result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue());
}
}
}
@@ -344,7 +400,7 @@ class KiCadHelper
//If the user set a visibility, then use it
if ($eda_info->getVisibility() !== null) {
return $part->getEdaInfo()->getVisibility();
return $eda_info->getVisibility();
}
//If the part has a category, then use the category visibility if possible
@@ -395,4 +451,64 @@ class KiCadHelper
'visible' => $this->boolToKicadBool($visible),
];
}
}
/**
* Finds the URL to the actual datasheet file for the given part.
* Searches attachments by type name, attachment name, and file extension.
* @return string|null The datasheet URL, or null if no datasheet was found.
*/
private function findDatasheetUrl(Part $part): ?string
{
$firstPdf = null;
foreach ($part->getAttachments() as $attachment) {
//Check if the attachment type name contains "datasheet"
$typeName = $attachment->getAttachmentType()?->getName() ?? '';
if (str_contains(mb_strtolower($typeName), 'datasheet')) {
return $this->getAttachmentUrl($attachment);
}
//Check if the attachment name contains "datasheet"
$name = mb_strtolower($attachment->getName());
if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
return $this->getAttachmentUrl($attachment);
}
//Track first PDF as fallback (check internal extension or external URL path)
if ($firstPdf === null) {
$extension = $attachment->getExtension();
if ($extension === null && $attachment->hasExternal()) {
$urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
$extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
}
if ($extension === 'pdf') {
$firstPdf = $attachment;
}
}
}
//Use first PDF attachment as fallback
if ($firstPdf !== null) {
return $this->getAttachmentUrl($firstPdf);
}
return null;
}
/**
* Returns an absolute URL for viewing the given attachment.
* Prefers the external URL (direct link) over the internal view route.
*/
private function getAttachmentUrl(Attachment $attachment): string
{
if ($attachment->hasExternal()) {
return $attachment->getExternalPath();
}
return $this->urlGenerator->generate(
'attachment_view',
['id' => $attachment->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -420,7 +420,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);
@@ -489,7 +489,7 @@ 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);

View File

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

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(name: "keybindings", label: new TM("settings.behavior.keybindings"))]
#[SettingsIcon('fa-keyboard')]
class KeybindingsSettings
{
/**
* Whether to enable special character keybindings (Alt+key) in text input fields
* @var bool
*/
#[SettingsParameter(
label: new TM("settings.behavior.keybindings.enable_special_characters"),
description: new TM("settings.behavior.keybindings.enable_special_characters.help"),
envVar: "bool:KEYBINDINGS_SPECIAL_CHARS_ENABLED",
envVarMode: EnvVarMode::OVERWRITE
)]
public bool $enableSpecialCharacters = true;
}

View File

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

View File

@@ -0,0 +1,96 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\InfoProviderSystem;
use App\Form\Type\APIKeyType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(label: new TM("settings.ips.canopy"))]
#[SettingsIcon("fa-plug")]
class CanopySettings
{
public const ALLOWED_DOMAINS = [
"amazon.de" => "DE",
"amazon.com" => "US",
"amazon.co.uk" => "UK",
"amazon.fr" => "FR",
"amazon.it" => "IT",
"amazon.es" => "ES",
"amazon.ca" => "CA",
"amazon.com.au" => "AU",
"amazon.com.br" => "BR",
"amazon.com.mx" => "MX",
"amazon.in" => "IN",
"amazon.co.jp" => "JP",
"amazon.nl" => "NL",
"amazon.pl" => "PL",
"amazon.sa" => "SA",
"amazon.sg" => "SG",
"amazon.se" => "SE",
"amazon.com.tr" => "TR",
"amazon.ae" => "AE",
"amazon.com.be" => "BE",
"amazon.com.cn" => "CN",
];
use SettingsTrait;
#[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"),
formType: APIKeyType::class,
formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $apiKey = null;
/**
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
*/
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
public string $domain = "DE";
/**
* @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request
*/
#[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))]
public bool $alwaysGetDetails = false;
/**
* Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE)
* @return string
*/
public function getRealDomain(): string
{
$domain = array_search($this->domain, self::ALLOWED_DOMAINS, true);
if ($domain === false) {
throw new \InvalidArgumentException("Invalid domain selected");
}
return $domain;
}
}

View File

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

View File

@@ -43,4 +43,23 @@ class KiCadEDASettings
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
#[Assert\Range(min: -1)]
public int $categoryDepth = 0;
}
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
description: new TM("settings.misc.kicad_eda.datasheet_link.help")
)]
public ?bool $datasheetAsPdf = true;
#[SettingsParameter(
label: new TM("settings.misc.kicad_eda.default_parameter_visibility"),
description: new TM("settings.misc.kicad_eda.default_parameter_visibility.help"),
)]
public bool $defaultParameterVisibility = false;
#[SettingsParameter(
label: new TM("settings.misc.kicad_eda.default_orderdetails_visibility"),
description: new TM("settings.misc.kicad_eda.default_orderdetails_visibility.help"),
)]
public bool $defaultOrderdetailsVisibility = false;
}

View File

@@ -58,4 +58,11 @@ class AttachmentsSettings
envVar: "bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT", envVarMode: EnvVarMode::OVERWRITE
)]
public bool $downloadByDefault = false;
}
#[SettingsParameter(
label: new TM("settings.system.attachments.showHTMLAttachments"),
description: new TM("settings.system.attachments.showHTMLAttachments.help"),
envVar: "bool:ATTACHMENT_SHOW_HTML_FILES", envVarMode: EnvVarMode::OVERWRITE
)]
public bool $showHTMLAttachments = false;
}

View File

@@ -23,7 +23,10 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Parts\Part;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\Misc\FAIconGenerator;
use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension;
@@ -31,7 +34,7 @@ use Twig\TwigFunction;
final readonly class AttachmentExtension
{
public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator)
public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator, private PartPreviewGenerator $partPreviewGenerator)
{
}
@@ -44,6 +47,26 @@ final readonly class AttachmentExtension
return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name);
}
/**
* Returns the URL of the thumbnail of the given element. Returns null if no thumbnail is available.
* For parts, a special preview image is generated, for other entities, the master picture is used as preview (if available).
*/
#[AsTwigFunction("entity_thumbnail")]
public function entityThumbnail(AttachmentContainingDBElement $element, string $filter_name = 'thumbnail_sm'): ?string
{
if ($element instanceof Part) {
$preview_attachment = $this->partPreviewGenerator->getTablePreviewAttachment($element);
} else { // For other entities, we just use the master picture as preview, if available
$preview_attachment = $element->getMasterPictureAttachment();
}
if ($preview_attachment === null) {
return null;
}
return $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, $filter_name);
}
/**
* Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available.
* Null is allowed for files withot extension

View File

@@ -60,6 +60,15 @@
</a>
</li>
{% endif %}
{% if is_granted("@tools.label_scanner") %}
<li>
<a class="dropdown-item" href="{{ path('scan_dialog') }}">
<i class="fa-fw fa-solid fa-camera-retro"></i>
{% trans %}parts.create_from_scan.title{% endtrans %}
</a>
</li>
{% endif %}
{% endif %}
{% if is_granted('@parts.import') %}
@@ -159,7 +168,7 @@
<li role="separator" class="dropdown-divider"></li>
<h6 class="dropdown-header">{% trans %}user.language_select{% endtrans %}</h6>
<div id="locale-select-menu">
{# This menu is filled by 'turbo/locale_menu' controller from the _turbo_control.html.twig template, to always have the correct path #}
{# This menu is filled by a turbo-stream in _turbo_contro.html.twig #}
</div>
</ul>
</li>

View File

@@ -1,35 +1,35 @@
{# Insert flashes #}
<div class="toasts-global d-none">
{% for label, messages in app.flashes() %}
{% for message in messages %}
{{ include('_toast.html.twig', {
'label': label,
'message': message
}) }}
{% endfor %}
{% endfor %}
</div>
{# Allow pages to request a fully reload of everything #}
{% if global_reload_needed is defined and global_reload_needed %}
<div {{ stimulus_controller('turbo/global_reload') }}></div>
{% endif %}
{% block flashes %}
{# Insert flashes #}
<turbo-stream action="update" action="morph" target="toast-container">
<template>
{% for label, messages in app.flashes() %}
{% for message in messages %}
{{ include('_toast.html.twig', {
'label': label,
'message': message
}) }}
{% endfor %}
{% endfor %}
</template>
</turbo-stream>
{% endblock %}
{# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #}
<span id="sidebar-last-time-updated" style="display: none;" data-last-update="{{ sidebar_tree_updater.lastTreeUpdate.format("Y-m-d\\TH:i:sP") }}"></span>
{# The title block is already escaped, therefore we dont require any additional escaping here #}
<div class="d-none" data-title="{{ current_page_title|trim|raw }}" {{ stimulus_controller('turbo/title') }}></div>
<turbo-stream action="update" target="locale-select-menu">
<template>
{% set locales = settings_instance('localization').languageMenuEntries %}
{% if locales is empty %}
{% set locales = locale_menu %}
{% endif %}
{% for locale in locales %}
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
{{ locale|language_name }} ({{ locale|upper }})</a>
{% endfor %}
</template>
</turbo-stream>
<div class="d-none" {{ stimulus_controller('turbo/locale_menu') }}>
{% set locales = settings_instance('localization').languageMenuEntries %}
{% if locales is empty %}
{% set locales = locale_menu %}
{% endif %}
{% for locale in locales %}
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
{{ locale|language_name }} ({{ locale|upper }})</a>
{% endfor %}
</div>

View File

@@ -5,7 +5,7 @@
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ entity.id) }}">
<div class="form-group">
<div class=""></div>
<div class="col-sm offset-sm-3 ps-2">
<div class="{{ col_input }} {{ offset_label }} ps-1">
{% set delete_disabled = (not is_granted("delete", entity)) or (entity.group is defined and entity.id == 1) or entity == app.user %}
<div class="btn-group">
<button class="btn btn-danger" {% if delete_disabled %}disabled{% endif %}>{% trans %}entity.delete{% endtrans %}</button>
@@ -20,7 +20,7 @@
</div>
</div>
{% if entity.parent is defined %}
<div class="ms-2 custom-control custom-checkbox custom-control-inline">
<div class="ms-1 custom-control custom-checkbox custom-control-inline">
<input type="checkbox" class="form-check-input" id="recursive" name="delete_recursive">
<label class="form-check-label" for="recursive">{% trans %}entity.delete.recursive{% endtrans %}</label>
</div>

View File

@@ -1,5 +1,5 @@
<div class="row mb-2">
<div class="offset-3 col">
<div class="{{ offset_label }} {{ col_input }}">
<a class="btn btn-info {% if not is_granted('create', entity) %}disabled{% endif %}" href="{{ entity_url(entity, 'clone') }}">{% trans %}entity.duplicate{% endtrans %}</a>
</div>
</div>
</div>

View File

@@ -35,8 +35,8 @@
</div>
<div class="row mt-2">
<div class="offset-sm-3 col-sm">
<div class="{{ offset_label }} col-sm">
<button type="submit" class="btn btn-primary">{% trans %}export.btn{% endtrans %}</button>
</div>
</div>
</form>
</form>

View File

@@ -129,7 +129,7 @@
</div>
<div class="form-group row">
<div class="col-sm-9 offset-sm-3">
<div class="{{ col_input }} {{ offset_label }}">
<div class="btn-group">
{{ form_widget(form.save) }}
<button type="button" class="btn {% if entity.id is not null %}btn-primary{% else %}btn-success{% endif %} dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -186,7 +186,7 @@
<div id="mass_creation" class="tab-pane fade">
<div class="row">
<p class="text-muted offset-sm-3 col-sm-9">{% trans %}mass_creation.help{% endtrans %}</p>
<p class="text-muted {{ offset_label }} {{ col_input }}">{% trans %}mass_creation.help{% endtrans %}</p>
</div>
{{ form(mass_creation_form) }}
</div>
@@ -195,10 +195,7 @@
{% endif %}
</div>
</fieldset>
{# Include turbo control things, so we can still control page title and reloading #}
{% include "_turbo_control.html.twig" %}
</turbo-frame>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -41,13 +41,13 @@
{{ form_row(form.eda_info.reference_prefix) }}
<div class="row">
<div class="col-sm-9 offset-sm-3">
<div class="{{ col_input }} {{ offset_label }}">
{{ form_row(form.eda_info.visibility) }}
</div>
</div>
<div class="row mb-2">
<div class="col-sm-9 offset-sm-3">
<div class="{{ col_input }} {{ offset_label }}">
{{ form_widget(form.eda_info.exclude_from_bom) }}
{{ form_widget(form.eda_info.exclude_from_board) }}
{{ form_widget(form.eda_info.exclude_from_sim) }}
@@ -55,7 +55,7 @@
</div>
<div class="row">
<div class="col-sm-9 offset-sm-3">
<div class="{{ col_input }} {{ offset_label }}">
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
</div>
</div>

View File

@@ -10,10 +10,10 @@
{{ form_row(form.iso_code) }}
{% if entity.isoCode %}
<div class="mt-0 mb-3">
<span class="form-text text-muted offset-3 col-9">
<span class="form-text text-muted {{ offset_label }} {{ col_input }}">
<b>{% trans %}currency.iso_code.caption{% endtrans %}:</b> {{ entity.isoCode }}
</span>
<span class="form-text text-muted offset-3 col-9">
<span class="form-text text-muted {{ offset_label }} {{ col_input }}">
<b>{% trans %}currency.symbol.caption{% endtrans %}:</b> {{ entity.isoCode | currency_symbol }}
</span>
</div>
@@ -21,7 +21,7 @@
{{ form_row(form.exchange_rate) }}
{% if entity.inverseExchangeRate %}
<p class="form-text text-muted offset-3 col-9">
<p class="form-text text-muted {{ offset_label }} {{ col_input }}">
{{ '1'|format_currency(vars.base_currency()) }} = {{ entity.inverseExchangeRate.tofloat | format_currency(entity.isoCode, {fraction_digit: 5}) }}<br>
{{ '1'|format_currency(entity.isoCode) }} = {{ entity.exchangeRate.tofloat | format_currency(vars.base_currency(), {fraction_digit: 5}) }}
</p>

View File

@@ -28,7 +28,7 @@
{% block additional_panes %}
<div class="tab-pane" id="eda">
<div class="row">
<div class="col-sm-9 offset-sm-3">
<div class="{{ col_input }} {{ offset_label }}">
<h6>{% trans %}eda_info.kicad_section.title{% endtrans %}:</h6>
</div>
</div>

View File

@@ -27,7 +27,7 @@
{{ form_row(form.options.supported_element) }}
<div class="mb-2 row">
{{ form_label(form.options.width) }}
<div class="col-sm-9">
<div class="{{ col_input }}">
<div class="input-group">
{{ form_widget(form.options.width) }}

View File

@@ -31,8 +31,8 @@
{{ form_row(form.status) }}
{% if entity.id %}
<div class="mb-2 row">
<label class="col-form-label col-sm-3">{% trans %}project.edit.associated_build_part{% endtrans %}</label>
<div class="col-sm-9">
<label class="col-form-label {{ col_label }}">{% trans %}project.edit.associated_build_part{% endtrans %}</label>
<div class="{{ col_input }}">
{% if entity.buildPart %}
<span class="form-control-static"><a href="{{ entity_url(entity.buildPart) }}">{{ entity.buildPart.name }}</a></span>
{% else %}

View File

@@ -8,7 +8,7 @@
{% block additional_controls %}
{% if entity.id %}
<div class="row form-group">
<div class="offset-sm-3 col-sm-9">
<div class="{{ offset_label }} {{ col_input }}">
{{ dropdown.profile_dropdown('storelocation', entity.id) }}
</div>
</div>

View File

@@ -50,7 +50,7 @@
<div class="tab-pane" id="password">
{% if entity.samlUser %}
<div class="offset-3 mb-3 col-9">
<div class="mb-3 {{ offset_label }} {{ col_input }}">
<span class="badge badge-warning bg-warning"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
</div>
{% endif %}
@@ -60,7 +60,7 @@
{{ form_row(form.disabled) }}
{% if entity.id is not null %}
<div class="offset-3 mb-3">
<div class="{{ offset_label }} {{ col_input }} mb-3">
<hr>
<h6>{% trans %}user.edit.tfa.caption{% endtrans %}</h6>

View File

@@ -44,7 +44,7 @@
{{ form_row(filterForm.discard) }}
<div class="row mb-3">
<div class="col-sm-9 offset-sm-3">
<div class="{{ col_input }} {{ offset_label }}">
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>️⚠️️</text></svg>">
{# The content block is already escaped. so we must not escape it again. #}
<title>{% trans %}attachment.sandbox.title{% endtrans %}: {{ attachment.filename }}</title>
<style>
/* Reset margins and stop the page from scrolling */
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: sans-serif;
}
/* The Flex Container */
.wrapper {
display: flex;
flex-direction: column;
height: 100vh;
}
/* The Warning Header */
.warning-bar {
background-color: #ff4d4d;
color: white;
padding: 10px 20px;
text-align: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 10; /* Keep it above the iframe */
}
/* The Iframe: The 'flex: 1' makes it fill all remaining space */
.content-frame {
flex: 1;
border: none;
width: 100%;
}
</style>
</head>
<body>
{% block body %}
{# We have a fullscreen iframe, with an warning on top #}
<div class="wrapper">
<header>
<header class="warning-bar">
<b>⚠️ {% trans%}attachment.sandbox.warning{% endtrans %}</b>
<br>
<small>
{% trans%}[Attachment]{% endtrans%}: <b>{{ attachment.name }}</b> / <b>{{ attachment.filename ?? "" }}</b> ({% trans%}id.label{% endtrans %}: {{ attachment.id }})
<a href="{{ path("attachment_view", {id: attachment.id}) }}" style="color: white; margin-left: 15px;">{% trans%}attachment.sandbox.as_plain_text{% endtrans %}</a>
<a href="{{ path("homepage") }}" style="color: white; margin-left: 15px;">{% trans%}attachment.sandbox.back_to_partdb{% endtrans %}</a>
</small>
</header>
</header>
<iframe referrerpolicy="no-referrer" class="content-frame"
{# When changing this sandbox, also change the sandbox CSP in the controller #}
sandbox="allow-scripts allow-downloads allow-modals"
srcdoc="{{ content|e('html_attr') }}"
></iframe>
</div>
{% endblock %}
</body>
</html>

View File

@@ -2,7 +2,7 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
{# For the UX translator, just use the language part (before the _. should be 2 chars), otherwise it finds no translations #}
{# For the UX translator, just use the language part (before the _. should be 2 chars), otherwise it finds no translations #}
data-symfony-ux-translator-locale="{{ app.request.locale|u.truncate(2) }}">
<head>
<meta charset="UTF-8">
@@ -26,6 +26,11 @@
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
{# Allow pages to request a fully reload of everything #}
{% if global_reload_needed is defined and global_reload_needed %}
<meta name="turbo-visit-control" content="reload">
{% endif %}
<link rel="shortcut icon" type="image/x-icon" href="{{ asset('favicon.ico') }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('icon/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" href="{{ asset('icon/favicon-32x32.png') }}" sizes="32x32">
@@ -68,7 +73,17 @@
{{ encore_entry_script_tags('webauthn_tfa') }}
{% endblock %}
</head>
<body data-base-url="{{ path('homepage', {'_locale': app.request.locale}) }}" data-locale="{{ app.request.locale|default("en")|slice(0,2) }}">
<body data-base-url="{{ path('homepage', {'_locale': app.request.locale}) }}"
data-locale="{{ app.request.locale|default("en")|slice(0,2) }}"
data-keybindings-special-characters="{{ settings_instance('keybindings').enableSpecialCharacters ? 'true' : 'false' }}">
{# Listen for the special #}
{% if is_granted("@tools.label_scanner") %}
<form class="d-none" {{ stimulus_controller('helpers/scan_special_char') }} action="{{ path("scan_dialog") }}" data-turbo-frame="content">
<input name="input" type="hidden">
</form>
{% endif %}
{% block body %}
<header>
<turbo-frame id="navbar-frame" target="content" data-turbo-action="advance">
@@ -114,13 +129,13 @@
<!-- Back to top button -->
<button id="back-to-top" class="btn btn-primary back-to-top btn-sm" role="button" title="{% trans %}back_to_top{% endtrans %}"
{{ stimulus_controller('common/back_to_top') }} {{ stimulus_action('common/back_to_top', 'backToTop') }}>
{{ stimulus_controller('common/back_to_top') }} {{ stimulus_action('common/back_to_top', 'backToTop') }}>
<i class="fas fa-angle-up fa-fw"></i>
</button>
{# Must be outside of the sidebar or it will be hidden too #}
<button class="btn btn-outline-secondary btn-sm p-0 d-md-block d-none" type="button" id="sidebar-toggle-button" title="{% trans %}sidebar.big.toggle{% endtrans %}"
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
<i class="fas fa-angle-left"></i>
</button>

View File

@@ -17,7 +17,7 @@
<b><i>Can not load frontend assets.</i></b><p>Try following things:</p>
<ul>
<li>Run <kbd>yarn install</kbd> and <kbd>yarn build</kbd> in Part-DB folder.</li>
<li>Run <kbd>php bin/console cache:clear</kbd></li>
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
</ul>
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\InvalidFieldNameException"
or exception.class == "Doctrine\\DBAL\\Exception\\TableNotFoundException"
@@ -26,21 +26,21 @@
<ul>
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema</li>
<li>Run <kbd>php bin/console cache:clear</kbd></li>
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
</ul>
{% elseif exception.class == "Doctrine\\DBAL\\Exception\\DriverException" %}
<b><i>Error while executing database query.</i></b><br>This is maybe caused by an old database schema.<br><p>Try following things:</p>
<ul>
<li>Check if the <code>DATABASE_URL</code> in <code>.env.local</code> (or docker configure) is correct</li>
<li>Run <kbd>php bin/console doctrine:migrations:migrate</kbd> to upgrade database schema (if upgrade is available)</li>
<li>Run <kbd>php bin/console cache:clear</kbd></li>
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd></li>
<li>If this issue persist, create a ticket at <a href="https://github.com/Part-DB/Part-DB-symfony/issues" rel="noopener">GitHub</a>.</li>
</ul>
{% else %}
You could try following things, if this error is unexpected:
<ul>
<li>Check <code>var/log/prod.log</code> (or <code>docker logs</code> when Part-DB is running inside a docker container) for additional informations</li>
<li>Run <kbd>php bin/console cache:clear</kbd> to clear cache</li>
<li>Run <kbd>php bin/console cache:clear</kbd> and <kbd>php bin/console cache:pool:clear --all</kbd> to clear caches</li>
</ul>
{% endif %}
{% endblock %}
{% endblock %}

View File

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

View File

@@ -63,8 +63,8 @@
<div class="">
{{ form_row(form.mountnames) }}
<div class="row mb-2">
<label class="col-form-label col-sm-3">{% trans %}project.bom.price{% endtrans %}</label>
<div class="col-sm-9">
<label class="col-form-label {{ col_label }}">{% trans %}project.bom.price{% endtrans %}</label>
<div class="{{ col_input }}">
<div class="input-group">
{{ form_widget(form.price) }}
{{ form_widget(form.priceCurrency) }}

View File

@@ -17,11 +17,11 @@
{% block form_label_class -%}
col-sm-3
{{ col_label }}
{%- endblock form_label_class %}
{% block form_group_class -%}
col-sm-9
{{ col_input }}
{%- endblock form_group_class %}
{% block si_unit_widget %}

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