Compare commits

...

112 commits

Author SHA1 Message Date
Jan Böhmer
cb669ad4ec Fixed phpstan issues
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-05-06 00:08:14 +02:00
Jan Böhmer
2e8ab8190a Bumped to version 2.11.1 2026-05-05 23:53:03 +02:00
Jan Böhmer
98c978ff1b Improved RandomizeUseragentHttpClient by not using old user agent strings, but different modernn profiles where also other headers match the user agent 2026-05-05 23:52:14 +02:00
Jan Böhmer
38779740ec AIWebProvider: Make URLs absolute before passing them to the LLM
This ensures that the URLs are valid afterwards, because the LLM does not know the base tag
2026-05-05 23:19:56 +02:00
Jan Böhmer
9c6f9a25c5 Use new watchtower image in configuation example
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
Fixes issue #1363
2026-05-05 22:41:38 +02:00
Jan Böhmer
28fc2a5a2c Bump version to 2.11.0
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-05-04 23:19:29 +02:00
Jan Böhmer
71fbbddbbe
New Crowdin updates (#1361)
* New translations messages.en.xlf (English)

[ci skip]

* New translations messages.en.xlf (German)

[ci skip]

* New translations messages.en.xlf (English)

[ci skip]

* New translations messages.en.xlf (German)

[ci skip]
2026-05-04 23:18:58 +02:00
Jan Böhmer
b50617bd10 Added table length of 250 and 500 2026-05-04 22:51:59 +02:00
Jan Böhmer
6045b50af2 Keep part table length selection and do not clear it at page reload
Fixes issue #1350
2026-05-04 22:51:13 +02:00
Jan Böhmer
3ef4a83f4a Do not change EDA info of original category or footprint when cloning
Fixes #1341. We have to clone the eda info on duplicaion
2026-05-04 22:42:18 +02:00
Jan Böhmer
2a6f6f4ed5 Fixed error that made editing users impossible 2026-05-04 22:37:11 +02:00
Jan Böhmer
19d138632a Properly reset the sequences for postgres and database platform conversion
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
This fixes issue #1362
2026-05-04 22:28:39 +02:00
kernchen-brc
83074a2403
Add 'Add stock' button to part stock info page (#1352)
* Add 'Add stock' button to part stock info page

* Use outline-success style for new stock button on part info page

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-05-04 22:12:30 +02:00
Jan Böhmer
1d1e3008aa Updated dependencies 2026-05-04 22:03:37 +02:00
Sebastian Almberg
ce2b7d11a9
Add Quick Apply and batch update to bulk info provider import (#1316)
* Add Quick Apply and Apply All buttons to bulk info provider import

Adds the ability to apply provider search results to parts directly
from the bulk import step 2 page without navigating to individual
part edit forms. Includes per-result Quick Apply buttons and an
Apply All button for batch operations.

* Add navigation buttons and completion banner to bulk import step2

Adds Back to Jobs / Back to Parts buttons at the top of the page
and a success banner when the job is completed, so users aren't
stuck on the page after applying all parts.

* Highlight top search result and remove skip reason prompt

- Highlight the recommended/top priority result row with table-success class
- Add "Top" badge to the recommended Quick Apply button
- Use outline style for non-top Quick Apply buttons to differentiate
- Remove the annoying "reason for skipping" prompt popup

* Fix 500 error when field mapping has null field or no search results

- Skip field mappings with null/empty field values in convertFieldMappingsToDto
- Return empty DTO instead of throwing when no search results found
- Remove unnecessary try/catch workaround in researchPart

* Fix PHPStan error: remove redundant null check on BulkSearchResponseDTO

* Improve bulk import UI: split active/history jobs, fix text visibility, add match highlighting

- Split manage page into Active Jobs and History sections
- Fix source keyword text color (remove text-muted for better visibility)
- Add exact match indicators: green check badge when name or MPN matches
- Add translation keys for new UI elements

* Fix spinning icon, text visibility, auto-priority, and SPN match highlighting

- Replace spinning icon with static icon on Active Jobs header
- Match highlighting now checks source keyword against name, MPN, AND provider ID (SPN)
- Show green "Match" badge in source field column when any field matches 100%
- Auto-increment priority when adding new field mapping rows
- Fix text-muted visibility issues on table-success background

* Fix broken images and improve match highlighting consistency

- Hide broken external provider images with onerror fallback
- Make source keyword text green when any match is detected
- All matched fields (name, MPN, SPN, or any source keyword) show green text

* Fix TypeError in LCSCProvider when keyword is numeric string

PHP auto-casts numeric string array keys to int. When a search keyword
is a pure number (e.g., a part number like "12345"), the foreach loop
passes an int to processSearchResponse() which expects string. Cast
keyword to string explicitly.

* Clean up stale pending jobs and add job ID to display

- Auto-delete pending jobs with 0 results (from failed searches/500 errors)
- Show job ID (#N) in manage page and step2 to distinguish identical jobs
- Move timestamp to subtitle line on manage page for cleaner layout

* Fix tests to match updated bulk search behavior (no more RuntimeException)

The bulk search service now returns empty response DTOs instead of
throwing RuntimeException when no results are found. Updated tests
to use assertFalse(hasAnyResults()) instead of catching exceptions.

* Add comprehensive test coverage for bulk import controller

Covers Quick Apply, Apply All, delete, stop, mark completed/skipped/pending,
manage page active/history split, stale job cleanup, research endpoints,
and various error paths. Increases patch coverage significantly.

* Fix duplicate test method names in bulk import tests

* Fix last duplicate test method name (testQuickApplyWithNoSearchResults)

* Fixed translation key in translation messages

* Moved table rendering logic into macro

* fixed visual glitch with button success outline

* Use native httpfoundation method to convert json to an array

* Show a more user friendly error message, when

* Allow to automatically create new manufacturers within quick apply

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-05-04 21:56:18 +02:00
Jan Böhmer
0ddf4f903e
Update KiCad symbols and footprints lists (#1348)
* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-04 21:01:34 +02:00
Wieland Schopohl
673d5b5e83
Fix sort order after column reorder on page reload (#1346)
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
When columns are reordered via colReorder and the page is reloaded,
the saved sort state uses visual column indices. These were sent
directly as initial_order to the server, which interprets them as
original indices — causing the wrong column to be sorted.

Use the saved colReorder mapping to translate visual indices back to
original indices before sending initial_order in the _init request.
2026-05-03 23:16:02 +02:00
Sebastian Almberg
d346708150
Add Docker update support via Watchtower integration (#1330)
* Add Docker update support via Watchtower integration

Add web-based Docker container updates using Watchtower HTTP API.
When configured with WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN
environment variables, administrators can trigger container updates
from the Update Manager page.

Features:
- WatchtowerClient service for Watchtower HTTP API communication
- Docker update progress page with animated Docker whale logo
- Real-time step tracking: Trigger, Pull, Stop, Restart, Health Check, Verify
- CSP-compatible progress bar using CSS classes
- Translated UI strings via Stimulus values
- Health endpoint polling to detect container restart
- Watchtower setup documentation for Docker installations
- WatchtowerClient made nullable for non-Docker installations
- Unit tests for WatchtowerClient

* Fixed translation message IDs

* Switch Watchtower docs to maintained nicholas-fedor fork

The original containrrr/watchtower is no longer maintained (last release
Nov 2023). Point users to the drop-in compatible active fork and add an
info note explaining why. No code changes — the HTTP API is identical,
so WatchtowerClient works against either image.

* Fixed exception when github is not reachable

* Only show version string in health endpoint, when user has permissions

* Do not expose watchtower API port in example docker-compose file

* Show if updates, backup restore and backup download are allowed in update manager page

* Report 'not authorized' for version in health endpoint if user lacks permission

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-05-03 23:00:31 +02:00
Jan Böhmer
91bf8371ad Show hint of google/gemini-2.5-flash-lite in placeholder
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-05-03 16:54:06 +02:00
Jan Böhmer
3c9866e90d Improved AI extractor
It now gives better results and use less tokens
2026-05-03 16:50:46 +02:00
Jan Böhmer
fcd598286a Fixed webpack build
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-05-03 01:34:14 +02:00
Jan Böhmer
c09fc7d483
New Crowdin updates (#1358)
* New translations validators.en.xlf (Portuguese, Brazilian)

[ci skip]

* New translations security.en.xlf (Portuguese, Brazilian)

[ci skip]

* New translations frontend.en.xlf (Portuguese, Brazilian)

[ci skip]

* New translations messages.en.xlf (English)

[ci skip]

* New translations validators.en.xlf (Portuguese, Brazilian)

[ci skip]

* New translations security.en.xlf (Portuguese, Brazilian)

[ci skip]

* New translations frontend.en.xlf (Portuguese, Brazilian)

[ci skip]

* Remove pt variants as they are actually pt-BR
2026-05-03 01:24:24 +02:00
Jan Böhmer
54cb43d235 Updated yarn dependencies 2026-05-03 01:08:14 +02:00
Jan Böhmer
b7cfdc3100 Bumped ckeditor to v48 and removed ckeditor-dev-utils, as they are no longer needed
This removes also many transitive dependencies
2026-05-03 01:05:41 +02:00
Jan Böhmer
45ed095509 Updated composer dependencies 2026-05-03 00:49:26 +02:00
Jan Böhmer
801e23e63b Merge branch 'ai_provider' 2026-05-03 00:38:17 +02:00
Jan Böhmer
a15a5efdce Added documentation about AI features
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-05-03 00:35:49 +02:00
Jan Böhmer
21bad81262 Fixed phpstan issues 2026-05-03 00:18:38 +02:00
Jan Böhmer
db86b8c330 Accept all models for openrouter ai provider 2026-05-03 00:08:00 +02:00
Jan Böhmer
9c317db260 Do not translate domain canopy domain settings choices
This removes clutter from the translation panel
2026-05-02 23:51:34 +02:00
Jan Böhmer
e437bb0b7b Improved translations of AI related stuff in settings 2026-05-02 23:49:07 +02:00
Jan Böhmer
889aa08b4e Added URL delegation feature to AI provider and added option to skip that delegation 2026-05-02 23:42:26 +02:00
Jan Böhmer
aac5b8e0be Allow to select which method should be used to in "Create from URL feature" 2026-05-02 23:23:20 +02:00
Jan Böhmer
a2b9ee764d Added tests for AIPlatformRegistry 2026-05-02 22:12:36 +02:00
Jan Böhmer
e77b67445c Added cache to AIWebProvider 2026-05-02 22:08:25 +02:00
Jan Böhmer
fe4dc1f1e4 Allow to set no_cache options via info provider UI
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-05-02 01:40:08 +02:00
Jan Böhmer
4137bde194 Allow to pass options to circumvent caching of info provider results / force fresh
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-05-01 20:57:41 +02:00
Jan Böhmer
f13413a104 Fixed exception when github is not reachable 2026-05-01 20:21:58 +02:00
Jan Böhmer
e576ded86b Updated composer dependencies and use upstream version for micrometa packag 2026-05-01 20:16:58 +02:00
Jan Böhmer
4cbb167e5c Fixed errors 2026-05-01 20:11:56 +02:00
Jan Böhmer
4f67f21b33 Allow to pass options to info providers
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-04-27 22:37:05 +02:00
Jan Böhmer
cf34de6772 Allow to pass additional instructions to the AI model
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-26 23:24:51 +02:00
Jan Böhmer
5edcc60d41 Randomize UserAgent and prevent access to private networks for AI extractor 2026-04-26 23:18:09 +02:00
Jan Böhmer
ad096aa6ff Improved parameter extraction & extraction of other infos 2026-04-26 23:15:29 +02:00
Jan Böhmer
0ca5a41298 Added option for translating AI extracted output 2026-04-26 22:11:27 +02:00
Jan Böhmer
7117926584 Fixed error when notes are not defined 2026-04-26 21:32:19 +02:00
Jan Böhmer
4a45b5d5a9 Improved markdown conversion and add ability to extract notes 2026-04-26 21:31:07 +02:00
Jan Böhmer
4dbd92ac4d Use markdown as input for the LLM and add extracted microdata separatley 2026-04-26 19:36:03 +02:00
Jan Böhmer
af98fc1079 Added translations to AI settings
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-26 15:48:17 +02:00
Jan Böhmer
368dd14785 Allow to add selectors to ai model selects and only show models supporting structured output for AI extractor 2026-04-26 15:39:52 +02:00
Jan Böhmer
9d389309fc Added an custom form type for model selection with autocompletition 2026-04-26 15:19:52 +02:00
Jan Böhmer
67cb6fb8a2 AcceptAllModels return CompletitionsModels as fallback 2026-04-26 01:27:25 +02:00
Jan Böhmer
25ced0d660 Added workaround to make lmstudio accept all models 2026-04-26 01:19:25 +02:00
Jan Böhmer
18bf07b19f Added an AI platform selector for settings 2026-04-26 01:10:00 +02:00
Jan Böhmer
c9d2044949 Fixed structured output response format 2026-04-26 00:40:33 +02:00
Jan Böhmer
2631ff4bee Introduced subsystem to configure AI providers and allow services to select them dynamiclly 2026-04-25 23:29:22 +02:00
Jan Böhmer
c0017d29a7 Refactored and cleaned up AIInfoExtractor 2026-04-25 22:21:06 +02:00
Jan Böhmer
9cf16248e6 Use symfony AI platform for AI provider 2026-04-23 23:26:23 +02:00
Rahul Singh
90d327fdaa Added AI Assisted Information Provider 2026-04-22 22:22:25 +02:00
Jan Böhmer
6330b71bfb Updated dependencies
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-04-21 23:40:32 +02:00
Jan Böhmer
a82d515034
New Crowdin updates (#1325)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
* New translations messages.en.xlf (Danish)

* New translations validators.en.xlf (Danish)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (French)

* New translations validators.en.xlf (French)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)
2026-04-15 23:38:40 +02:00
Jan Böhmer
6a30b41688 Bumped version to 2.10.0 2026-04-15 23:27:30 +02:00
Jan Böhmer
ec05f9d8ab Fixed phpstan issues 2026-04-15 23:27:10 +02:00
Jan Böhmer
1c3dfa26bb Updated dependencies 2026-04-15 23:06:40 +02:00
Jan Böhmer
766665f9e5 Use big E for si value formatting output 2026-04-15 22:57:02 +02:00
Wieland Schopohl
29db029d69
Add SI-prefix-aware sorting column for parts tableFeature/si value sort (#1344)
* Add SI-prefix-aware sorting column for the parts table

Adds an optional "Name (SI)" column that parses numeric values with SI
prefixes (p, n, u/µ, m, k/K, M, G, T) from part names and sorts by the
resulting physical value. This is useful for electronic components where
alphabetical sorting produces wrong results — e.g. 100nF, 10pF, 1uF
should sort as 10pF < 100nF < 1uF.

Implementation:
- New SiValueSort DQL function with platform-specific SQL generation
  for PostgreSQL (POSIX regex), MySQL/MariaDB (REGEXP_SUBSTR), and
  SQLite (PHP callback registered via the existing middleware).
- The regex is start-anchored: only names beginning with a number are
  matched. Part numbers like "MCP2515" or "Crystal 20MHz" are ignored.
- When SI sort is active, NATSORT is appended as a secondary sort so
  that non-matching parts fall back to natural string ordering instead
  of appearing in arbitrary order.
- The column is opt-in (not in default columns) and displays the parsed
  float value, or an empty cell for non-matching names.

* Rename SI column from "Name (SI)" to "SI Value"

The column now shows the parsed numeric value rather than the part name,
so the label should reflect that.

* Support comma as decimal separator in SI value parsing

Part names using European decimal notation (e.g. "4,7 kΩ", "2,2uF")
were parsed incorrectly because the regex only recognized dots. Now
commas are normalized to dots before parsing, matching the existing
pattern used elsewhere in the codebase (PartNormalizer, price providers).
2026-04-15 22:56:34 +02:00
Jan Böhmer
146e85f84c
Update KiCad symbols and footprints lists (#1333)
* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

* Update KiCad symbols and footprints lists

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 22:13:54 +02:00
Niklas
c17cf5e83c
Add price columns to project BOM table and build price summary (#1345)
* Add unit price and extended price columns to project BOM table

Adds two optional columns to the project BOM datatable (hidden by
default, toggleable via column visibility):

- **Price**: unit price for the BOM entry in the base currency,
  looked up via PricedetailHelper. For parts whose BOM quantity falls
  below the minimum order amount the minimum order amount is used for
  the price tier lookup so that a price is always returned.
- **Extended Price**: unit price multiplied by the BOM quantity.

Prices are rendered via MoneyFormatter (locale-aware, with currency
symbol). Both columns round up to 2 decimal places to avoid displaying
0.00 for very small prices.

* Add translation key for project.bom.ext_price

Adds the English translation "Extended Price" for the new BOM extended
price column. Other languages are marked needs-translation and will be
picked up by Crowdin.

* Add build price summary to project info tab

Displays the total BOM price for N builds on the project info page,
using the existing price-tier logic from PricedetailHelper. The user
can adjust the number of builds via a small form; the unit price is
also shown when N > 1.

New backend:
- ProjectBuildHelper gains calculateTotalBuildPrice(),
  calculateUnitBuildPrice(), roundedTotalBuildPrice(), and
  roundedUnitBuildPrice() — bulk-order quantities are factored in so
  that price tiers apply correctly across N builds.
- ProjectController::info() now reads ?n= and passes number_of_builds
  to the template.

Template (_info.html.twig):
- Adds price badge (hidden when no pricing data is available).
- Adds number-of-builds form that reloads the info page.

* Add tests for build price calculation in ProjectBuildHelper

Covers calculateTotalBuildPrice(), calculateUnitBuildPrice(),
roundedTotalBuildPrice(), and the private getBomEntryUnitPrice()
helper. Scenarios tested: empty project, no pricing data, non-part BOM
entries with manual prices, part entries with pricedetails, mixed
entries, rounding-up of sub-cent prices, and minimum order amount
floor for price tier lookup.

* Deduplicate BOM entry price logic into ProjectBuildHelper

The private getBomEntryUnitPrice() in ProjectBomEntriesDataTable was
identical to the one in ProjectBuildHelper. Replaced it with a new
public getEntryUnitPrice() on ProjectBuildHelper (returns BigDecimal,
never null) and delegate to it from the DataTable.

This eliminates the duplicate code and brings the DataTable lines under
the existing ProjectBuildHelper test coverage. Added three tests for
getEntryUnitPrice() covering the no-pricing, non-part, and part cases.

* Added type hint to service

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
Jan Böhmer
5b86d6f652 Require full authentication for the system settings, as some of the settings are quite critical
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-15 00:04:52 +02:00
DanTrackpaw
58a34e3628
Add custom KiCad autocomplete list settings (#1342)
* Add admin editor for KiCad autocomplete lists

* Add custom KiCad autocomplete list settings

* Ignore the footprints_custom.txt and symbols_custom.txt in git and create them on the fly if needed

Otherwise it breaks the update mechanism

* Added comments

* Include kicad custom files in config backup command

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 00:01:00 +02:00
Jan Böhmer
35dcb298e7 Updated dependencies
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-13 22:26:46 +02:00
Marc
0140c9a7b9
Fix #1305: Enable BOM sorting on part fields (Storage location, Manufacturing status) and fix BOM table query/pagination issues (#1338)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
* Fix identation

* Allow ordering of column Storage Locations in BOM fix-#1152

* Fix "[Semantical Error] line 0, col 274 near 'storageLocations.name))': Error: 'storageLocations' is not defined." when trying to sort by column Storage Locations

* Try to fix "Iterate with fetch join in class App\Entity\Parts\PartLot using association part not allowed." when opening BOM

* Revert "Try to fix "Iterate with fetch join in class App\Entity\Parts\PartLot using association part not allowed." when opening BOM"

This reverts commit 5c5c7cece1.

* Try to fix "Iterate with fetch join in class App\Entity\Parts\PartLot using association part not allowed." when opening BOM 2nd try

* Remove alias to fix: Unknown named parameter $alias

* Reformat code to allow easier diff between ProjectBomEntriesDataTable.php and PartsDataTable.php

* Try if 'data' es really needed as it is not used in PartDataTable.php

* Use TwoStepORMAdapter to enable sorting based on other columns like storage location, manufacturing status

* Add readonly hint to projectBom query

---------

Co-authored-by: root <root@part-db.fritz.box>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-06 15:15:15 +02:00
Albert Koczy
d25ac2622e
Fix creating parts from TME if the SPN contains percent signs (#1337)
* Fix creating TME parts with percent signs in SPN

The SPN ends up in the URL, which later causes validation errors n the
form. Solved by encoding the percent sign.

* Add TME provider unit tests.
2026-04-06 14:42:54 +02:00
Jan Böhmer
cee7e2a077 Fixed phpstan issues
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-04-05 23:55:07 +02:00
Jan Böhmer
2a6e5435e1 Use bookworm version of dunglas frankenphp base image to fix docker building 2026-04-05 23:51:39 +02:00
Jan Böhmer
05b1965957 Use truncatate purging during load fixtures to fix compatibility for postgres 2026-04-05 23:45:09 +02:00
dependabot[bot]
57ef3e06a7
Bump codecov/codecov-action from 5 to 6 (#1334)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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-04-05 23:14:50 +02:00
Jan Böhmer
7d8a7ab471 Allow to disable the NoPrivateNetwork protection for attachment downloads via an env variable 2026-04-05 23:14:18 +02:00
Jan Böhmer
ad35ae6e9e Decorate hte attachment download and generic web provider with the NoPrivateNetworkHttpClient
This is for security hardening to prevent SSRF attacks
2026-04-05 23:07:24 +02:00
Jan Böhmer
f12f808b34 Upgraded dependencies 2026-04-05 22:47:25 +02:00
Jan Böhmer
0080aa9f25 Stay on Ckeditor 47 as with 48 seems something to break
47 is the LTS version anyway
2026-04-05 22:44:09 +02:00
Jan Böhmer
dc522d4795 Updated webpack-encore package 2026-04-03 22:08:08 +02:00
Jan Böhmer
f07eabd85a Updated documentation about node requirements 2026-04-03 21:58:02 +02:00
Jan Böhmer
70454e3a6d Require node 22 and bumped ckeditor dependencies 2026-04-03 21:55:11 +02:00
Jan Böhmer
8b3bebca7b Updated dependencies 2026-04-03 21:45:02 +02:00
Jan Böhmer
4d296d8f3a Merge remote-tracking branch 'origin/master' 2026-04-03 21:32:27 +02:00
Tobias Klausmann
96da2b9f1f
.gitignore: add public/.well-known directory (#1335)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
RFC 5785 defines /.well-known/ as the prefix for "well-known locations"
in URIs. This prefix is used for example by Certbot/Let's Encrypt to
request SSL certificates. PartDB doesn't need to care about this
directory, but also should not see its existence as the git tree being
"dirty".
2026-04-03 20:15:03 +02:00
Jan Böhmer
f9a8818e69 Updated dependencies 2026-03-30 20:09:16 +02:00
Jan Böhmer
52df554b29 Added more warnings about sudo -E to docker docs
Related to issue #1319
2026-03-30 20:03:25 +02:00
Albert Koczy
991daf0ead
Implement parsing of TME QR codes (#1324)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
* Implement parsing of TME QR codes

They are present on parts purchased on tme.eu. It's based on the LCSC
parser. Some older codes I found are in upper-case so I handle those
too.

* Removed unused method

* Fixed translation message keys

* Try to find TME part via SPN

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-29 14:53:31 +02:00
Jan Böhmer
34a84bce8f Updated depedencies
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-03-27 23:27:03 +01:00
Marc
4206b702ff
Made EIGP114 parsing less strict (#1321)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
* Enhance barcode format checking in isFormat06Code

Updated isFormat06Code method to handle additional barcode formats for compatibility with older Mouser parts and Eyoyo barcode scanners that don't omit the record separator character

* Added tests

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-24 21:33:41 +01:00
Jan Böhmer
abf0ba5301 Updated dependencies 2026-03-24 20:43:50 +01:00
Jan Böhmer
9ce215c8f9 Added tests for custom doctrine functions
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-03-22 21:50:15 +01:00
Jan Böhmer
753ecee849 Merge remote-tracking branch 'origin/master'
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-03-15 22:09:22 +01:00
Jan Böhmer
8f6ed74d93 Bumped version to 2.9.1 2026-03-15 22:09:10 +01:00
Jan Böhmer
17f11c02f3
New Crowdin updates (#1301)
* New translations validators.en.xlf (Chinese Simplified)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)
2026-03-15 22:08:38 +01:00
Jan Böhmer
a070ebb2ce Fixed 500 error with displaying part prices, when a user has a currency preference different of base currency, and there is no conversion rate known for it
This fixes issue #1317
2026-03-15 22:02:10 +01:00
Jan Böhmer
44bb132de1 Merge remote-tracking branch 'origin/master' 2026-03-15 21:47:21 +01:00
Jan Böhmer
95f3fc66c2 Do not throw an 500 error, if mapping is not possible
This fixes issue #1298
2026-03-15 21:47:15 +01:00
Jan Böhmer
74e5102943 Automatically detect the delimiter of generic BOM imports
The detectFields does this anyway, so use that guessed value further on
2026-03-15 21:35:38 +01:00
swdee
60c5e24c94
Bug fix: Remove fallback from LCSC barcode part resolver (#1302)
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-03-15 18:57:54 +01:00
Jan Böhmer
de371877b9 Make GenericWebProvider more forgiving with URLs and accept the "fixed" strings traefik provides as security measure
This fixes issue #1296
2026-03-15 18:55:16 +01:00
Jan Böhmer
baeef1228a updated dependencies 2026-03-15 15:06:24 +01:00
Jan Böhmer
45da6dacff
Update KiCad symbols and footprints lists (#1303)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-11 11:28:23 +01:00
Moritz Wörmann
c4d8192e76
add data-tube=false to SAML auth button (#1308) 2026-03-11 11:28:09 +01:00
dependabot[bot]
dca0cb8a16
Bump docker/setup-buildx-action from 3 to 4 (#1309)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  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-11 11:15:36 +01:00
dependabot[bot]
3abc0d8b38
Bump docker/build-push-action from 6 to 7 (#1311)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  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-11 11:15:20 +01:00
dependabot[bot]
9ea3ead246
Bump docker/login-action from 3 to 4 (#1310)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  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-11 11:12:27 +01:00
dependabot[bot]
1de440d71e
Bump docker/metadata-action from 5 to 6 (#1312)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  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-03-11 11:08:29 +01:00
dependabot[bot]
5243f90dd8
Bump web-auth/webauthn-symfony-bundle from 5.2.3 to 5.2.4 (#1313)
Bumps [web-auth/webauthn-symfony-bundle](https://github.com/web-auth/webauthn-symfony-bundle) from 5.2.3 to 5.2.4.
- [Commits](https://github.com/web-auth/webauthn-symfony-bundle/compare/5.2.3...5.2.4)

---
updated-dependencies:
- dependency-name: web-auth/webauthn-symfony-bundle
  dependency-version: 5.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 11:07:56 +01:00
Jan Böhmer
343c078b7d fixed intendation of force visibility checkbox in category admin
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Has been cancelled
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
Docker Image Build / merge (push) Has been cancelled
Docker Image Build (FrankenPHP) / merge (push) Has been cancelled
2026-03-07 23:58:32 +01:00
172 changed files with 22777 additions and 7889 deletions

18
.env
View file

@ -76,6 +76,12 @@ DISABLE_BACKUP_RESTORE=1
# When enabled, users must confirm their password before downloading. # When enabled, users must confirm their password before downloading.
DISABLE_BACKUP_DOWNLOAD=1 DISABLE_BACKUP_DOWNLOAD=1
# Watchtower integration for Docker-based updates.
# Set these to enable one-click updates via the Update Manager UI.
# See https://containrrr.dev/watchtower/ for Watchtower setup.
WATCHTOWER_API_URL=
WATCHTOWER_API_TOKEN=
################################################################################### ###################################################################################
# SAML Single sign on-settings # SAML Single sign on-settings
################################################################################### ###################################################################################
@ -121,6 +127,10 @@ SAML_SP_PRIVATE_KEY="MIIE..."
# In demo mode things it is not possible for a user to change his password and his settings. # In demo mode things it is not possible for a user to change his password and his settings.
DEMO_MODE=0 DEMO_MODE=0
# When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file.
# This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0
# Change this to true, if no url rewriting (like mod_rewrite for Apache) is available # Change this to true, if no url rewriting (like mod_rewrite for Apache) is available
# In that case all URL contains the index.php front controller in URL # In that case all URL contains the index.php front controller in URL
NO_URL_REWRITE_AVAILABLE=0 NO_URL_REWRITE_AVAILABLE=0
@ -151,3 +161,11 @@ APP_ENV=prod
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849 APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
APP_SHARE_DIR=var/share APP_SHARE_DIR=var/share
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> symfony/ai-generic-platform ###
# GENERIC_BASE_URL=https://api.example.com/v1
###< symfony/ai-generic-platform ###
###> symfony/ai-open-router-platform ###
OPENROUTER_API_KEY=
###< symfony/ai-open-router-platform ###

View file

@ -67,7 +67,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: '22'
- name: Install yarn dependencies - name: Install yarn dependencies
run: yarn install run: yarn install

View file

@ -36,7 +36,7 @@ jobs:
- -
name: Docker meta name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
# list of Docker images to use as base name for tags # list of Docker images to use as base name for tags
images: | images: |
@ -66,11 +66,11 @@ jobs:
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -78,7 +78,7 @@ jobs:
- -
name: Build and push by digest name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
@ -121,12 +121,12 @@ jobs:
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- -
name: Docker meta name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
jbtronics/part-db1 jbtronics/part-db1
@ -142,7 +142,7 @@ jobs:
- -
name: Login to DockerHub name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}

View file

@ -36,7 +36,7 @@ jobs:
- -
name: Docker meta name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
# list of Docker images to use as base name for tags # list of Docker images to use as base name for tags
images: | images: |
@ -66,11 +66,11 @@ jobs:
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -78,7 +78,7 @@ jobs:
- -
name: Build and push by digest name: Build and push by digest
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: Dockerfile-frankenphp file: Dockerfile-frankenphp
@ -122,12 +122,12 @@ jobs:
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- -
name: Docker meta name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
partdborg/part-db partdborg/part-db
@ -143,7 +143,7 @@ jobs:
- -
name: Login to DockerHub name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}

View file

@ -106,7 +106,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: '22'
- name: Install yarn dependencies - name: Install yarn dependencies
run: yarn install run: yarn install
@ -129,7 +129,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v6
with: with:
env_vars: PHP_VERSION,DB_TYPE env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}

13
.gitignore vendored
View file

@ -25,6 +25,10 @@
uploads/* uploads/*
!uploads/.keep !uploads/.keep
# Some people use Certbot or similar tools to make SSL certificates.
# Also see https://www.rfc-editor.org/rfc/rfc5785
public/.well-known/
# Do not keep cache files # Do not keep cache files
.php_cs.cache .php_cs.cache
.phpcs-cache .phpcs-cache
@ -50,4 +54,11 @@ phpstan.neon
###< phpstan/phpstan ### ###< phpstan/phpstan ###
.claude/ .claude/
CLAUDE.md CLAUDE.md
.codex
migrations/.codex
docker-data/
scripts/
db/
docker-compose.yaml

View file

@ -62,7 +62,7 @@ RUN yarn build
RUN yarn cache clean && rm -rf node_modules/ RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage # FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream
ARG TARGETARCH ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \ RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \ --mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \

View file

@ -62,6 +62,7 @@ for the first time.
* Automatic thumbnail generation for pictures * Automatic thumbnail generation for pictures
* Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and * Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and
prices for parts prices for parts
* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction
* API to access Part-DB from other applications/scripts * API to access Part-DB from other applications/scripts
* [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your * [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your
KiCad and see available parts from Part-DB directly inside KiCad. KiCad and see available parts from Part-DB directly inside KiCad.
@ -74,11 +75,11 @@ Part-DB is also used by small companies and universities for managing their inve
## Requirements ## Requirements
* A **web server** (like Apache2 or nginx) that is capable of * A **web server** (like Apache2 or nginx) that is capable of
running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html), running [Symfony 7](https://symfony.com/doc/current/reference/requirements.html),
this includes a minimum PHP version of **PHP 8.2** this includes a minimum PHP version of **PHP 8.2**
* A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite. * A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite.
* Shell access to your server is highly recommended! * Shell access to your server is highly recommended!
* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed. * For building the client-side assets **yarn** and **nodejs** (>= 22.0) is needed.
## Installation ## Installation

View file

@ -1 +1 @@
2.9.0 2.11.1

View file

@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller"
export default class extends Controller { export default class extends Controller {
static targets = ["progressBar", "progressText"] static targets = ["progressBar", "progressText"]
static values = { static values = {
jobId: Number, jobId: Number,
partId: Number, partId: Number,
researchUrl: String, researchUrl: String,
researchAllUrl: String, researchAllUrl: String,
markCompletedUrl: String, markCompletedUrl: String,
markSkippedUrl: String, markSkippedUrl: String,
markPendingUrl: String markPendingUrl: String,
quickApplyUrl: String,
quickApplyAllUrl: String
} }
connect() { connect() {
@ -119,13 +121,11 @@ export default class extends Controller {
async markSkipped(event) { async markSkipped(event) {
const partId = event.currentTarget.dataset.partId const partId = event.currentTarget.dataset.partId
const reason = prompt('Reason for skipping (optional):') || ''
try { try {
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId) const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, { const data = await this.fetchWithErrorHandling(url, {
method: 'POST', method: 'POST'
body: JSON.stringify({ reason })
}) })
if (data.success) { if (data.success) {
@ -321,6 +321,94 @@ export default class extends Controller {
} }
} }
async quickApply(event) {
event.preventDefault()
event.stopPropagation()
const partId = event.currentTarget.dataset.partId
const providerKey = event.currentTarget.dataset.providerKey
const providerId = event.currentTarget.dataset.providerId
const button = event.currentTarget
const originalHtml = button.innerHTML
button.disabled = true
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Applying...'
try {
const url = this.quickApplyUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
method: 'POST',
body: JSON.stringify({ providerKey, providerId })
}, 60000)
if (data.success) {
this.updateProgressDisplay(data)
this.showSuccessMessage(data.message || 'Part updated successfully')
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Quick apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
} catch (error) {
console.error('Error in quick apply:', error)
this.showErrorMessage(error.message || 'Quick apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
}
async quickApplyAll(event) {
event.preventDefault()
event.stopPropagation()
if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) {
return
}
const button = event.currentTarget
const spinner = document.getElementById('quick-apply-all-spinner')
const originalHtml = button.innerHTML
button.disabled = true
if (spinner) {
spinner.style.display = 'inline-block'
}
try {
const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, {
method: 'POST'
}, 300000)
if (data.success) {
this.updateProgressDisplay(data)
let message = data.message || 'Bulk apply completed'
if (data.errors && data.errors.length > 0) {
message += '\nErrors:\n' + data.errors.join('\n')
}
this.showSuccessMessage(message)
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Bulk apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
} catch (error) {
console.error('Error in quick apply all:', error)
this.showErrorMessage(error.message || 'Bulk apply failed')
button.innerHTML = originalHtml
button.disabled = false
} finally {
if (spinner) {
spinner.style.display = 'none'
}
}
}
showSuccessMessage(message) { showSuccessMessage(message) {
this.showToast('success', message) this.showToast('success', message)
} }

View file

@ -0,0 +1,377 @@
/*
* 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';
/**
* Stimulus controller for Docker update progress tracking.
*
* Polls the health check endpoint to detect when the container restarts
* after a Watchtower-triggered update. Drives the step timeline UI
* with timestamps, matching the git update progress style.
*/
export default class extends Controller {
static values = {
healthUrl: String,
previousVersion: { type: String, default: 'unknown' },
pollInterval: { type: Number, default: 5000 },
maxWaitTime: { type: Number, default: 600000 }, // 10 minutes
// Translated UI strings (passed from Twig template)
textPulling: { type: String, default: 'Waiting for Watchtower to pull the new image...' },
textPullingDetail: { type: String, default: 'Watchtower is checking for and downloading the latest Docker image...' },
textRestarting: { type: String, default: 'Container is restarting with the new image...' },
textRestartingDetail: { type: String, default: 'The container is being recreated with the updated image. This may take a moment...' },
textSuccess: { type: String, default: 'Update Complete!' },
textSuccessDetail: { type: String, default: 'Part-DB has been updated successfully via Docker.' },
textTimeout: { type: String, default: 'Update Taking Longer Than Expected' },
textTimeoutDetail: { type: String, default: 'The update may still be in progress. Check your Docker logs for details.' },
textStepPull: { type: String, default: 'Pull Image' },
textStepRestart: { type: String, default: 'Restart Container' },
};
static targets = [
// Header
'headerWhale', 'titleIcon',
'statusText', 'statusSubtext',
'progressBar', 'elapsedTime',
// Alerts
'stepAlert', 'stepName', 'stepMessage',
'successAlert', 'timeoutAlert', 'errorAlert', 'errorMessage', 'warningAlert',
// Step timeline (multi-target arrays)
'stepRow', 'stepIcon', 'stepDetail', 'stepTime',
// Version display
'newVersion', 'previousVersion',
// Actions
'actions',
];
// Step definitions: name -> { index, progress% }
static STEPS = {
trigger: { index: 0, progress: 15 },
pull: { index: 1, progress: 30 },
stop: { index: 2, progress: 50 },
restart: { index: 3, progress: 65 },
health: { index: 4, progress: 80 },
verify: { index: 5, progress: 100 },
};
connect() {
this.serverWentDown = false;
this.serverCameBack = false;
this.startTime = Date.now();
this.timer = null;
this.currentStep = 'pull'; // trigger is already done
this.stepTimestamps = { trigger: this.formatTime(new Date()) };
this.consecutiveSuccessCount = 0;
// Set the trigger step timestamp
this.setStepTimestamp(0, this.stepTimestamps.trigger);
this.poll();
}
disconnect() {
if (this.timer) {
clearTimeout(this.timer);
}
}
createTimeoutSignal(ms) {
if (typeof AbortSignal.timeout === 'function') {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async poll() {
const elapsed = Date.now() - this.startTime;
this.updateElapsedTime(elapsed);
if (elapsed > this.maxWaitTimeValue) {
this.showTimeout();
return;
}
try {
const response = await fetch(this.healthUrlValue, {
cache: 'no-store',
signal: this.createTimeoutSignal(4000),
});
if (response.ok) {
let data;
try {
data = await response.json();
} catch (parseError) {
this.schedulePoll();
return;
}
if (this.serverWentDown) {
// Server came back! Move through health check -> verify
if (!this.serverCameBack) {
this.serverCameBack = true;
this.advanceToStep('health');
}
this.consecutiveSuccessCount++;
// Wait for 2 consecutive successes to confirm stability
if (this.consecutiveSuccessCount >= 2) {
this.showSuccess(data.version);
return;
}
} else {
// Server still up - Watchtower pulling image
this.showPulling();
}
} else if (response.status === 503) {
// Maintenance mode or shutting down
this.serverWentDown = true;
this.consecutiveSuccessCount = 0;
this.advanceToStep('stop');
} else {
if (this.serverWentDown) {
this.showRestarting();
} else {
this.showPulling();
}
}
} catch (e) {
// Connection refused = container is down
if (!this.serverWentDown) {
this.serverWentDown = true;
this.advanceToStep('stop');
}
this.consecutiveSuccessCount = 0;
this.showRestarting();
}
this.schedulePoll();
}
schedulePoll() {
this.timer = setTimeout(() => this.poll(), this.pollIntervalValue);
}
/**
* Advance the step timeline to a specific step.
* Marks all previous steps as complete with timestamps.
*/
advanceToStep(stepName) {
const steps = this.constructor.STEPS;
const targetIndex = steps[stepName]?.index;
if (targetIndex === undefined) return;
const stepNames = Object.keys(steps);
const now = this.formatTime(new Date());
for (let i = 0; i < stepNames.length; i++) {
const name = stepNames[i];
if (i < targetIndex) {
// Completed step
this.markStepComplete(i, this.stepTimestamps[name] || now);
if (!this.stepTimestamps[name]) {
this.stepTimestamps[name] = now;
}
} else if (i === targetIndex) {
// Current active step
this.markStepActive(i);
this.stepTimestamps[name] = now;
this.setStepTimestamp(i, now);
this.currentStep = name;
}
// Steps after targetIndex remain pending (no change needed)
}
// Update progress bar
this.updateProgressBar(steps[stepName].progress);
}
showPulling() {
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textPullingValue;
}
if (this.hasStepNameTarget) {
this.stepNameTarget.textContent = this.textStepPullValue;
}
if (this.hasStepMessageTarget) {
this.stepMessageTarget.textContent = this.textPullingDetailValue;
}
this.updateProgressBar(30);
}
showRestarting() {
// Advance to restart step if we haven't already
if (this.currentStep !== 'restart' && this.currentStep !== 'health' && this.currentStep !== 'verify') {
this.advanceToStep('restart');
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textRestartingValue;
}
if (this.hasStepNameTarget) {
this.stepNameTarget.textContent = this.textStepRestartValue;
}
if (this.hasStepMessageTarget) {
this.stepMessageTarget.textContent = this.textRestartingDetailValue;
}
}
showSuccess(newVersion) {
// Advance all steps to complete
const steps = this.constructor.STEPS;
const stepNames = Object.keys(steps);
const now = this.formatTime(new Date());
for (let i = 0; i < stepNames.length; i++) {
const name = stepNames[i];
this.markStepComplete(i, this.stepTimestamps[name] || now);
}
this.updateProgressBar(100);
// Update whale animation
if (this.hasHeaderWhaleTarget) {
this.headerWhaleTarget.classList.add('success');
}
if (this.hasTitleIconTarget) {
this.titleIconTarget.className = 'fas fa-check-circle text-success';
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textSuccessValue;
}
if (this.hasStatusSubtextTarget) {
this.statusSubtextTarget.textContent = this.textSuccessDetailValue;
}
// Hide step alert, show success alert
this.toggleTarget('stepAlert', false);
this.toggleTarget('successAlert', true);
this.toggleTarget('warningAlert', false);
this.toggleTarget('actions', true);
if (this.hasNewVersionTarget) {
this.newVersionTarget.textContent = newVersion || 'latest';
}
if (this.hasPreviousVersionTarget) {
this.previousVersionTarget.textContent = this.previousVersionValue;
}
}
showTimeout() {
this.updateProgressBar(0);
if (this.hasHeaderWhaleTarget) {
this.headerWhaleTarget.classList.add('timeout');
}
if (this.hasTitleIconTarget) {
this.titleIconTarget.className = 'fas fa-exclamation-triangle text-warning';
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textTimeoutValue;
}
if (this.hasStatusSubtextTarget) {
this.statusSubtextTarget.textContent = this.textTimeoutDetailValue;
}
this.toggleTarget('stepAlert', false);
this.toggleTarget('timeoutAlert', true);
this.toggleTarget('warningAlert', false);
this.toggleTarget('actions', true);
}
// --- Step timeline helpers ---
markStepComplete(index, timestamp) {
if (this.stepIconTargets[index]) {
this.stepIconTargets[index].className = 'fas fa-check-circle text-success me-3';
}
if (this.stepRowTargets[index]) {
this.stepRowTargets[index].classList.remove('text-muted');
}
if (timestamp) {
this.setStepTimestamp(index, timestamp);
}
}
markStepActive(index) {
if (this.stepIconTargets[index]) {
this.stepIconTargets[index].className = 'fas fa-spinner fa-spin text-primary me-3';
}
if (this.stepRowTargets[index]) {
this.stepRowTargets[index].classList.remove('text-muted');
}
}
setStepTimestamp(index, time) {
if (this.stepTimeTargets[index]) {
this.stepTimeTargets[index].textContent = time;
}
}
// --- UI helpers ---
toggleTarget(name, show) {
const hasMethod = 'has' + name.charAt(0).toUpperCase() + name.slice(1) + 'Target';
if (this[hasMethod]) {
this[name + 'Target'].classList.toggle('d-none', !show);
}
}
updateProgressBar(percent) {
if (this.hasProgressBarTarget) {
const bar = this.progressBarTarget;
// Remove all width classes
bar.classList.remove('progress-w-0', 'progress-w-15', 'progress-w-30', 'progress-w-50', 'progress-w-65', 'progress-w-80', 'progress-w-100');
bar.classList.add('progress-w-' + percent);
bar.textContent = percent + '%';
bar.setAttribute('aria-valuenow', percent);
bar.classList.remove('bg-success', 'bg-danger', 'progress-bar-striped', 'progress-bar-animated');
if (percent === 100) {
bar.classList.add('bg-success');
} else if (percent === 0) {
bar.classList.add('bg-danger');
} else {
bar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
updateElapsedTime(elapsed) {
if (this.hasElapsedTimeTarget) {
const seconds = Math.floor(elapsed / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
this.elapsedTimeTarget.textContent = minutes > 0
? `${minutes}m ${remainingSeconds}s`
: `${remainingSeconds}s`;
}
}
formatTime(date) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
}

View file

@ -0,0 +1,152 @@
/*
* 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";
import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
export default class extends Controller {
_tomSelect;
_platformSelector;
connect() {
let dropdownParent = "body";
if (this.element.closest('.modal')) {
dropdownParent = null
}
//Try to find the platform selector
const platformSelector = document.querySelector("select[data-platform-selector-label='" + this.element.dataset.platformSelector + "']");
//Clear tomselect options, if the platform selector changes
if (platformSelector) {
this.platformSelector = platformSelector;
platformSelector.addEventListener('change', () => {
//Force reload of options by clearing the cache and options of TomSelect and triggering a search with an empty string
this._tomSelect.clearOptions();
this._tomSelect.clearCache();
this._tomSelect.load('');
});
}
let settings = {
persistent: false,
create: true,
maxItems: 1,
preload: 'focus',
createOnBlur: true,
selectOnTab: true,
clearAfterSelect: true,
shouldLoad: ((query) => true),
maxOptions: null,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: dropdownParent,
render: {
item: (data, escape) => {
return '<span>' + escape(data.label) + '</span>';
},
option: (data, escape) => {
if (data.image) {
return "<div class='row m-0'><div class='col-2 pl-0 pr-1'><img class='typeahead-image' src='" + data.image + "'/></div><div class='col-10'>" + data.label + "</div></div>"
}
return '<div>' + escape(data.label) + '</div>';
}
},
plugins: {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
"restore_on_backspace": {}
}
};
if(this.element.dataset.urlTemplate) {
const base_url = this.element.dataset.urlTemplate;
settings.searchField = "label";
settings.sortField = "label";
settings.valueField = "label";
settings.load = (query, callback) => {
if (!this.platformSelector) {
console.error("Platform selector not found for AI model autocomplete");
callback();
return;
}
//Platform is the selected option
const platform = this.platformSelector.value;
if (!platform) {
callback();
return;
}
const self = this;
//Only fetch each platform once
if(self.platformLoaded === platform) {
callback();
}
const url = base_url.replace('__PLATFORM__', encodeURIComponent(platform));
fetch(url)
.then(response => response.json())
.then(json => {
self.platformLoaded = platform;
var data = [];
for (const name in json) {
data.push({
"label": name,
"capabilities": json[name].capabilities,
});
}
callback(data);
}).catch(()=>{
callback();
});
};
}
this._tomSelect = new TomSelect(this.element, settings);
}
disconnect() {
super.disconnect();
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}

View file

@ -29,7 +29,7 @@ import "ckeditor5/ckeditor5.css";;
import "../../css/components/ckeditor.css"; import "../../css/components/ckeditor.css";
const translationContext = require.context( const translationContext = require.context(
'ckeditor5/translations', 'ckeditor5-translations', //Alias defined in webpack.config.js
false, false,
//Only load the translation files we will really need //Only load the translation files we will really need
/(de|it|fr|ru|ja|cs|da|zh|pl|hu)\.js$/ /(de|it|fr|ru|ja|cs|da|zh|pl|hu)\.js$/

View file

@ -83,8 +83,6 @@ export default class extends Controller {
if (data) { if (data) {
//Do not save the start value (current page), as we want to always start at the first page on a page reload //Do not save the start value (current page), as we want to always start at the first page on a page reload
delete data.start; delete data.start;
//Reset the data length to the default value by deleting the length property
delete data.length;
} }
return data; return data;
@ -113,8 +111,16 @@ export default class extends Controller {
return null; return null;
} }
//The saved order index is visual (post-reorder). If colReorder state
//exists, map it back to the original column index so the server sorts
//the correct column. colReorder[visualIndex] == originalIndex.
let columnIndex = order[0];
if (saved_state.colReorder) {
columnIndex = saved_state.colReorder[columnIndex];
}
return { return {
column: order[0], column: columnIndex,
dir: order[1] dir: order[1]
} }
}); });

View file

@ -70,6 +70,13 @@ export default class extends Controller {
newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this)) newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
} }
// Auto-increment priority based on existing mappings
const nextPriority = this.getNextPriority()
const priorityInput = newRow.querySelector('input[name*="[priority]"]')
if (priorityInput) {
priorityInput.value = nextPriority
}
this.updateFieldOptions() this.updateFieldOptions()
this.updateAddButtonState() this.updateAddButtonState()
} }
@ -119,6 +126,18 @@ export default class extends Controller {
} }
} }
getNextPriority() {
const priorityInputs = this.tbodyTarget.querySelectorAll('input[name*="[priority]"]')
let maxPriority = 0
priorityInputs.forEach(input => {
const val = parseInt(input.value, 10)
if (!isNaN(val) && val > maxPriority) {
maxPriority = val
}
})
return Math.min(maxPriority + 1, 10)
}
handleFormSubmit(event) { handleFormSubmit(event) {
if (this.hasSubmitButtonTarget) { if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = true this.submitButtonTarget.disabled = true

View file

@ -33,6 +33,7 @@
"jbtronics/dompdf-font-loader-bundle": "^1.0.0", "jbtronics/dompdf-font-loader-bundle": "^1.0.0",
"jbtronics/settings-bundle": "^3.0.0", "jbtronics/settings-bundle": "^3.0.0",
"jfcherng/php-diff": "^6.14", "jfcherng/php-diff": "^6.14",
"jkphl/micrometa": "^v3.4.0",
"knpuniversity/oauth2-client-bundle": "^2.15", "knpuniversity/oauth2-client-bundle": "^2.15",
"league/commonmark": "^2.7", "league/commonmark": "^2.7",
"league/csv": "^9.8.0", "league/csv": "^9.8.0",
@ -56,6 +57,9 @@
"scheb/2fa-trusted-device": "^v7.11.0", "scheb/2fa-trusted-device": "^v7.11.0",
"shivas/versioning-bundle": "^4.0", "shivas/versioning-bundle": "^4.0",
"spatie/db-dumper": "^3.3.1", "spatie/db-dumper": "^3.3.1",
"symfony/ai-bundle": "^0.8.0",
"symfony/ai-lm-studio-platform": "^0.8.0",
"symfony/ai-open-router-platform": "^0.8.0",
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
"symfony/asset": "7.4.*", "symfony/asset": "7.4.*",
"symfony/console": "7.4.*", "symfony/console": "7.4.*",

3038
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -33,4 +33,5 @@ return [
Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true], Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true],
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true], Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
]; ];

27
config/packages/ai.yaml Normal file
View file

@ -0,0 +1,27 @@
ai:
platform:
# Inference Platform configuration
# see https://github.com/symfony/ai/tree/main/src/platform#platform-bridges
# openai:
# api_key: '%env(OPENAI_API_KEY)%'
agent:
# Agent configuration
# see https://symfony.com/doc/current/ai/bundles/ai-bundle.html
# default:
# platform: 'ai.platform.openai'
# model: 'gpt-5-mini'
# prompt: |
# You are a pirate and you write funny.
# tools:
# - 'Symfony\AI\Agent\Bridge\Clock\Clock'
store:
# Store configuration
# chromadb:
# default:
# client: 'client.service.id'
# collection: 'my_collection'

View file

@ -0,0 +1,5 @@
ai:
platform:
generic:
default:
base_url: '%env(GENERIC_BASE_URL)%'

View file

@ -0,0 +1,4 @@
ai:
platform:
lmstudio:
host_url: '%env(string:settings:ai_lmstudio:hostURL)%'

View file

@ -0,0 +1,4 @@
ai:
platform:
openrouter:
api_key: '%env(string:settings:ai_openrouter:apiKey)%'

View file

@ -8,7 +8,7 @@ datatables:
# Set options, as documented at https://datatables.net/reference/option/ # Set options, as documented at https://datatables.net/reference/option/
options: options:
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]] # We add the "All" option, when part tables are generated lengthMenu : [[10, 25, 50, 100, 250, 500], [10, 25, 50, 100, 250, 500]] # We add the "All" option, when part tables are generated
#pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default #pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default
pageLength: 50 #TODO pageLength: 50 #TODO
dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>> dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>>

View file

@ -56,6 +56,7 @@ doctrine:
natsort: App\Doctrine\Functions\Natsort natsort: App\Doctrine\Functions\Natsort
array_position: App\Doctrine\Functions\ArrayPosition array_position: App\Doctrine\Functions\ArrayPosition
ilike: App\Doctrine\Functions\ILike ilike: App\Doctrine\Functions\ILike
si_value_sort: App\Doctrine\Functions\SiValueSort
when@test: when@test:
doctrine: doctrine:

View file

@ -105,6 +105,8 @@ parameters:
env(DATABASE_EMULATE_NATURAL_SORT): 0 env(DATABASE_EMULATE_NATURAL_SORT): 0
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
###################################################################################################################### ######################################################################################################################
# Bulk Info Provider Import Configuration # Bulk Info Provider Import Configuration
###################################################################################################################### ######################################################################################################################

File diff suppressed because it is too large Load diff

View file

@ -86,6 +86,7 @@ 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 * `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 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. locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
* `ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK` (default `0`): When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file. This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser. * `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, 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. if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.

View file

@ -47,6 +47,7 @@ It is installed on a web server and so can be accessed with any browser without
* Easy migration from an existing PartKeepr instance (see [here]({%link partkeepr_migration.md %})) * Easy migration from an existing PartKeepr instance (see [here]({%link partkeepr_migration.md %}))
* Use cloud providers (like Octopart, Digikey, Farnell, Mouser, or TME) to automatically get part information, datasheets, and * Use cloud providers (like Octopart, Digikey, Farnell, Mouser, or TME) to automatically get part information, datasheets, and
prices for parts (see [here]({% link usage/information_provider_system.md %})) prices for parts (see [here]({% link usage/information_provider_system.md %}))
* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction
* API to access Part-DB from other applications/scripts * API to access Part-DB from other applications/scripts
* [Integration with KiCad]({%link usage/eda_integration.md %}): Use Part-DB as the central datasource for your * [Integration with KiCad]({%link usage/eda_integration.md %}): Use Part-DB as the central datasource for your
KiCad and see available parts from Part-DB directly inside KiCad. KiCad and see available parts from Part-DB directly inside KiCad.

View file

@ -95,6 +95,11 @@ services:
docker-compose up -d docker-compose up -d
``` ```
{: .warning }
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
6. Create the initial database with 6. Create the initial database with
```bash ```bash
@ -219,6 +224,52 @@ docker-compose up -d
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
``` ```
### Automatic updates via Watchtower (Web UI)
Part-DB supports triggering Docker container updates directly from the web interface using [Watchtower](https://github.com/nicholas-fedor/watchtower).
When configured, administrators can check for and apply updates from the **System > Update Manager** page.
{: .info }
> The original `containrrr/watchtower` project is no longer maintained (last release November 2023). These docs use the actively maintained community fork at [`nicholas-fedor/watchtower`](https://github.com/nicholas-fedor/watchtower), which is drop-in compatible with the original HTTP API.
To enable this feature, add a Watchtower service to your `docker-compose.yaml` and configure the connection:
```yaml
services:
partdb:
container_name: partdb
image: jbtronics/part-db1:latest
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
# ... your existing environment variables ...
# Watchtower integration for web-based updates
- WATCHTOWER_API_URL=http://watchtower:8080
- WATCHTOWER_API_TOKEN=your-secret-token
# ... your existing ports/volumes ...
watchtower:
image: ghcr.io/nicholas-fedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_HTTP_API_UPDATE=true
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
```
{: .important }
> Replace `your-secret-token` with a strong, unique token. The same token must be set in both the Part-DB (`WATCHTOWER_API_TOKEN`) and Watchtower (`WATCHTOWER_HTTP_API_TOKEN`) environment variables.
{: .info }
> `WATCHTOWER_LABEL_ENABLE=true` ensures Watchtower only manages containers with the `com.centurylinklabs.watchtower.enable=true` label, preventing it from updating other containers on the same host.
Once configured, the Update Manager page will show the Watchtower connection status and provide an **Update via Watchtower** button when a new version is available. Clicking it triggers Watchtower to pull the latest image and recreate the Part-DB container automatically.
## Direct use of docker image ## Direct use of docker image
You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure

View file

@ -18,7 +18,7 @@ fulfilled by the official Part-DB docker image.*
Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still
running PHP 8.1, you will have to upgrade your PHP version first. running PHP 8.1, you will have to upgrade your PHP version first.
The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too. The minimum required version of node.js is now 22.0 or newer, so if you are using 18.0, you will have to upgrade it too.
Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to
easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which
@ -60,6 +60,8 @@ The `php bin/console partdb:backup` command can help you with this.
If you want to change them, you must migrate them to the settings interface as described below. If you want to change them, you must migrate them to the settings interface as described below.
### Docker installation ### Docker installation
**When running the console commands from inside a docker container's shell as root, be sure to use `sudo -E` to preserve the environment variables, so that they are correctly passed to the command.**
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables. 1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables.
2. Stop the existing Part-DB container with `docker compose down` 2. Stop the existing Part-DB container with `docker compose down`
3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag). 3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag).

27
docs/usage/ai.md Normal file
View file

@ -0,0 +1,27 @@
---
layout: default
title: AI features
nav_order: 6
parent: Usage
---
# AI features
Part-DB can utilize large language Models (LLMs) to provide AI-powered features that can assist you in managing your parts and projects.
For now this is mostly the ability to extract part information from websites without any structured data.
## AI platforms
Part-DB is platform agnostic and can work with different AI platforms, both locally and in the cloud. They can be configured in the "AI" tab in the system settings.
Currently, the following platforms are supported:
### OpenRouter
[OpenRouter](https://openrouter.ai/) is a platform that provides access to various LLMs, including models from OpenAI, Anthropic, and more.
You can use OpenRouter to connect to different LLMs and use them for Part-DB's AI features.
You need to supply an API key for OpenRouter to use it as an AI platform in Part-DB.
### LMStudio
[LMStudio](https://lmstudio.ai/) is a local LLM hosting solution that allows you to run LLMs on your own hardware. You can use LMStudio to host your own LLM and connect it to Part-DB for AI features.
Currently only LMStudio without any authentication is supported. Supply your LMStudio instance URL (including the port) to use it as an AI platform in Part-DB.

View file

@ -67,6 +67,7 @@ You can define this on a per-part basis using the KiCad symbol and KiCad footpri
For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew. For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew.
If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value. If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value.
If you want to keep custom suggestions across updates, open the server settings page and use the "Autocomplete settings" page. There you can edit `public/kicad/footprints_custom.txt` and `public/kicad/symbols_custom.txt` and enable the "Use custom autocomplete lists" option to use those files instead of the autogenerated defaults.
### Parts and category visibility ### Parts and category visibility

View file

@ -111,6 +111,19 @@ may have privacy and security implications.
Following env configuration options are available: Following env configuration options are available:
* `PROVIDER_GENERIC_WEB_ENABLED`: Set this to `1` to enable the Generic Web URL Provider (optional, default: `0`) * `PROVIDER_GENERIC_WEB_ENABLED`: Set this to `1` to enable the Generic Web URL Provider (optional, default: `0`)
### AI Web Extractor
The AI web extractor provider can extract part information from any webpage using AI-based techniques. It is designed to handle unstructured data and can extract relevant information even from websites that do not use structured data formats like Schema.org.
This provider can be particularly useful for extracting information from websites that have complex layouts or do not follow standard e-commerce practices.
It also potentially extracts more detailed information than the Generic Web URL Provider, as it is not limited to the fields defined in the Schema.org format.
To use the AI Web Extractor, you need to setup an AI platform, in the AI settings tab, and chose a model, which support structured output.
For many use cases a small and cheap model like `google/gemini-2.5-flash-lite` will be sufficient, coming down to costs like 0.001$ per request.
For more complex websites, or if you wanna use the LLM for translation purposes too, you should consider a more powerful model.
You can add some additional instructions for the model, which gets added to the system prompt, to tweak the output of the model.
The provider will download the HTML of the given URL, convert it to markdown and send it to the LLM toghether with structured data extracted from the webpage via conventional methods.
### Octopart ### Octopart
The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information. The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information.

View file

@ -9,16 +9,16 @@
"@symfony/stimulus-bridge": "^4.0.0", "@symfony/stimulus-bridge": "^4.0.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^5.1.0", "@symfony/webpack-encore": "^6.0.0",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"core-js": "^3.38.0", "core-js": "^3.38.0",
"intl-messageformat": "^10.2.5", "intl-messageformat": "^10.5.11",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"popper.js": "^1.14.7", "popper.js": "^1.14.7",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.14.1",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-bundle-analyzer": "^5.1.1", "webpack-bundle-analyzer": "^5.1.1",
"webpack-cli": "^5.1.0", "webpack-cli": "^6.0.0",
"webpack-notifier": "^1.15.0" "webpack-notifier": "^1.15.0"
}, },
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@ -30,14 +30,12 @@
"build": "encore production --progress" "build": "encore production --progress"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=22.0.0"
}, },
"dependencies": { "dependencies": {
"@algolia/autocomplete-js": "^1.17.0", "@algolia/autocomplete-js": "^1.17.0",
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0", "@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
"@algolia/autocomplete-theme-classic": "^1.17.0", "@algolia/autocomplete-theme-classic": "^1.17.0",
"@ckeditor/ckeditor5-dev-translations": "^43.0.1",
"@ckeditor/ckeditor5-dev-utils": "^43.0.1",
"@jbtronics/bs-treeview": "^1.0.1", "@jbtronics/bs-treeview": "^1.0.1",
"@part-db/html5-qrcode": "^4.0.0", "@part-db/html5-qrcode": "^4.0.0",
"@zxcvbn-ts/core": "^3.0.2", "@zxcvbn-ts/core": "^3.0.2",
@ -51,7 +49,7 @@
"bootbox": "^6.0.0", "bootbox": "^6.0.0",
"bootswatch": "^5.1.3", "bootswatch": "^5.1.3",
"bs-custom-file-input": "^1.3.4", "bs-custom-file-input": "^1.3.4",
"ckeditor5": "^47.0.0", "ckeditor5": "^48.0.0",
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"compression-webpack-plugin": "^11.1.0", "compression-webpack-plugin": "^11.1.0",
"datatables.net": "^2.0.0", "datatables.net": "^2.0.0",
@ -69,11 +67,11 @@
"marked": "^17.0.1", "marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.1", "marked-gfm-heading-id": "^4.1.1",
"marked-mangle": "^1.0.1", "marked-mangle": "^1.0.1",
"pdfmake": "^0.2.2", "pdfmake": "^0.3.7",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
"tom-select": "^2.1.0", "tom-select": "^2.1.0",
"ts-loader": "^9.2.6", "ts-loader": "^9.2.6",
"typescript": "^5.7.2" "typescript": "^6.0.2"
}, },
"resolutions": { "resolutions": {
"jquery": "^3.5.1" "jquery": "^3.5.1"

View file

@ -59,6 +59,9 @@ parameters:
- '#expects .*PartParameter, .*AbstractParameter given.#' - '#expects .*PartParameter, .*AbstractParameter given.#'
- '#Part::getParameters\(\) should return .*AbstractParameter#' - '#Part::getParameters\(\) should return .*AbstractParameter#'
# Fix some weird issue with how covariance with collections is solved
- '#Method App\\Entity\\Base\\AbstractStructuralDBElement::getParameters\(\) should return Doctrine\\Common\\Collections\\Collection<int, App\\Entity\\Parameters\\AbstractParameter> but returns#'
# Ignore doctrine type mapping mismatch # Ignore doctrine type mapping mismatch
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#' - '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
@ -70,3 +73,6 @@ parameters:
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#' - message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
-
identifier: nullCoalesce.property

3
public/kicad/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# They are user generated and should not be tracked by git
footprints_custom.txt
symbols_custom.txt

View file

@ -1,4 +1,4 @@
# Generated on Tue Mar 3 14:26:21 UTC 2026 # Generated on Mon May 4 05:40:05 UTC 2026
# This file contains all footprints available in the offical KiCAD library # This file contains all footprints available in the offical KiCAD library
Audio_Module:Reverb_BTDR-1H Audio_Module:Reverb_BTDR-1H
Audio_Module:Reverb_BTDR-1V Audio_Module:Reverb_BTDR-1V
@ -8366,6 +8366,7 @@ Converter_DCDC:Converter_DCDC_TRACO_TMR-1SM_SMD
Converter_DCDC:Converter_DCDC_TRACO_TMR10-24xxWIR_48xxWIR_72xxWIR_THT Converter_DCDC:Converter_DCDC_TRACO_TMR10-24xxWIR_48xxWIR_72xxWIR_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR2-xxxxWI_THT Converter_DCDC:Converter_DCDC_TRACO_TMR2-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR4-xxxxWI_THT Converter_DCDC:Converter_DCDC_TRACO_TMR4-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR8-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMU3-05xx_12xx_THT Converter_DCDC:Converter_DCDC_TRACO_TMU3-05xx_12xx_THT
Converter_DCDC:Converter_DCDC_TRACO_TMU3-24xx_THT Converter_DCDC:Converter_DCDC_TRACO_TMU3-24xx_THT
Converter_DCDC:Converter_DCDC_TRACO_TMV-051xD_121xD_Dual_THT Converter_DCDC:Converter_DCDC_TRACO_TMV-051xD_121xD_Dual_THT
@ -11978,6 +11979,8 @@ Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm_ThermalVias Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm_ThermalVias
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm_ThermalVias Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm_ThermalVias
Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm
Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm_ThermalVias
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm_ThermalVias Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm_ThermalVias
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.5x5.06mm Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.5x5.06mm
@ -12028,6 +12031,8 @@ Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm_ThermalVias
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias
Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm

View file

@ -1,4 +1,4 @@
# Generated on Tue Mar 3 14:27:05 UTC 2026 # Generated on Mon May 4 05:40:43 UTC 2026
# This file contains all symbols available in the offical KiCAD library # This file contains all symbols available in the offical KiCAD library
4xxx:14528 4xxx:14528
4xxx:14529 4xxx:14529
@ -899,6 +899,7 @@ Amplifier_Buffer:BUF634AxD
Amplifier_Buffer:BUF634AxDDA Amplifier_Buffer:BUF634AxDDA
Amplifier_Buffer:BUF634AxDRB Amplifier_Buffer:BUF634AxDRB
Amplifier_Buffer:BUF634U Amplifier_Buffer:BUF634U
Amplifier_Buffer:BUF802
Amplifier_Buffer:EL2001CN Amplifier_Buffer:EL2001CN
Amplifier_Buffer:LH0002H Amplifier_Buffer:LH0002H
Amplifier_Buffer:LM6321H Amplifier_Buffer:LM6321H
@ -1667,7 +1668,6 @@ Analog_ADC:CA3300
Analog_ADC:HX711 Analog_ADC:HX711
Analog_ADC:ICL7106CPL Analog_ADC:ICL7106CPL
Analog_ADC:ICL7107CPL Analog_ADC:ICL7107CPL
Analog_ADC:INA234AxYBJ
Analog_ADC:LTC1406CGN Analog_ADC:LTC1406CGN
Analog_ADC:LTC1406IGN Analog_ADC:LTC1406IGN
Analog_ADC:LTC1594CS Analog_ADC:LTC1594CS
@ -2198,6 +2198,7 @@ Audio:WM8731SEDS
Audio:YM2149 Audio:YM2149
Audio:YM2612 Audio:YM2612
Audio:YM3438 Audio:YM3438
Auxiliary_Items:Generic_Outline
Auxiliary_Items:Jumper_Shunt Auxiliary_Items:Jumper_Shunt
Auxiliary_Items:MountingScrew Auxiliary_Items:MountingScrew
Battery_Management:ADP5063 Battery_Management:ADP5063
@ -2254,6 +2255,11 @@ Battery_Management:BQ76200PW
Battery_Management:BQ76920PW Battery_Management:BQ76920PW
Battery_Management:BQ76930DBT Battery_Management:BQ76930DBT
Battery_Management:BQ76940DBT Battery_Management:BQ76940DBT
Battery_Management:BQ7695201PFBR
Battery_Management:BQ7695202PFBR
Battery_Management:BQ7695203PFBR
Battery_Management:BQ7695204PFBR
Battery_Management:BQ76952PFBR
Battery_Management:BQ78350DBT Battery_Management:BQ78350DBT
Battery_Management:BQ78350DBT-R1 Battery_Management:BQ78350DBT-R1
Battery_Management:CN3063 Battery_Management:CN3063
@ -2763,6 +2769,8 @@ Connector:DIN41612_02x32_AC
Connector:DIN41612_02x32_AE Connector:DIN41612_02x32_AE
Connector:DIN41612_02x32_ZB Connector:DIN41612_02x32_ZB
Connector:DIN41612_03x32_C_Split Connector:DIN41612_03x32_C_Split
Connector:DP_Sink
Connector:DP_Source
Connector:DVI-D_Dual_Link Connector:DVI-D_Dual_Link
Connector:DVI-I_Dual_Link Connector:DVI-I_Dual_Link
Connector:ExpressCard Connector:ExpressCard
@ -2901,6 +2909,7 @@ Connector:TestPoint_Alt
Connector:TestPoint_Flag Connector:TestPoint_Flag
Connector:TestPoint_Probe Connector:TestPoint_Probe
Connector:TestPoint_Small Connector:TestPoint_Small
Connector:TestPoint_Square
Connector:UEXT_Host Connector:UEXT_Host
Connector:UEXT_Slave Connector:UEXT_Slave
Connector:USB3_A Connector:USB3_A
@ -7772,6 +7781,7 @@ FPGA_Lattice:ICE40HX1K-TQ144
FPGA_Lattice:ICE40HX4K-BG121 FPGA_Lattice:ICE40HX4K-BG121
FPGA_Lattice:ICE40HX4K-TQ144 FPGA_Lattice:ICE40HX4K-TQ144
FPGA_Lattice:ICE40HX8K-BG121 FPGA_Lattice:ICE40HX8K-BG121
FPGA_Lattice:ICE40LP384-SG32
FPGA_Lattice:ICE40UL1K-SWG16 FPGA_Lattice:ICE40UL1K-SWG16
FPGA_Lattice:ICE40UP5K-SG48ITR FPGA_Lattice:ICE40UP5K-SG48ITR
FPGA_Lattice:ICE5LP1K-SG48 FPGA_Lattice:ICE5LP1K-SG48
@ -8835,6 +8845,7 @@ Interface_USB:CH343G
Interface_USB:CH343P Interface_USB:CH343P
Interface_USB:CH344Q Interface_USB:CH344Q
Interface_USB:CH9102F Interface_USB:CH9102F
Interface_USB:CP2102C-Axx-xQFN24
Interface_USB:CP2102N-Axx-xQFN20 Interface_USB:CP2102N-Axx-xQFN20
Interface_USB:CP2102N-Axx-xQFN24 Interface_USB:CP2102N-Axx-xQFN24
Interface_USB:CP2102N-Axx-xQFN28 Interface_USB:CP2102N-Axx-xQFN28
@ -15731,6 +15742,7 @@ Power_Management:RT9742AGJ5F
Power_Management:RT9742ANGJ5F Power_Management:RT9742ANGJ5F
Power_Management:RT9742BGJ5F Power_Management:RT9742BGJ5F
Power_Management:RT9742BNGJ5F Power_Management:RT9742BNGJ5F
Power_Management:RT9742SNGV
Power_Management:SN6505ADBV Power_Management:SN6505ADBV
Power_Management:SN6505BDBV Power_Management:SN6505BDBV
Power_Management:SN6507DGQ Power_Management:SN6507DGQ
@ -18692,6 +18704,7 @@ Regulator_Linear:TPS7A0530PDBZ
Regulator_Linear:TPS7A0531PDBV Regulator_Linear:TPS7A0531PDBV
Regulator_Linear:TPS7A0533PDBV Regulator_Linear:TPS7A0533PDBV
Regulator_Linear:TPS7A0533PDBZ Regulator_Linear:TPS7A0533PDBZ
Regulator_Linear:TPS7A20xxxDBV
Regulator_Linear:TPS7A20xxxDQN Regulator_Linear:TPS7A20xxxDQN
Regulator_Linear:TPS7A3301RGW Regulator_Linear:TPS7A3301RGW
Regulator_Linear:TPS7A39 Regulator_Linear:TPS7A39
@ -20301,7 +20314,6 @@ Sensor:BME280
Sensor:BME680 Sensor:BME680
Sensor:CHT11 Sensor:CHT11
Sensor:DHT11 Sensor:DHT11
Sensor:INA260
Sensor:LTC2990 Sensor:LTC2990
Sensor:MAX30102 Sensor:MAX30102
Sensor:Nuclear-Radiation_Detector Sensor:Nuclear-Radiation_Detector
@ -20588,9 +20600,12 @@ Sensor_Energy:INA219BxD
Sensor_Energy:INA219BxDCN Sensor_Energy:INA219BxDCN
Sensor_Energy:INA226 Sensor_Energy:INA226
Sensor_Energy:INA228 Sensor_Energy:INA228
Sensor_Energy:INA229
Sensor_Energy:INA233 Sensor_Energy:INA233
Sensor_Energy:INA234AxYBJ
Sensor_Energy:INA237 Sensor_Energy:INA237
Sensor_Energy:INA238 Sensor_Energy:INA238
Sensor_Energy:INA260
Sensor_Energy:LTC4151xMS Sensor_Energy:LTC4151xMS
Sensor_Energy:MCP39F521 Sensor_Energy:MCP39F521
Sensor_Energy:PAC1931x-xJ6CX Sensor_Energy:PAC1931x-xJ6CX
@ -20872,6 +20887,7 @@ Sensor_Proximity:BPR-105
Sensor_Proximity:BPR-105F Sensor_Proximity:BPR-105F
Sensor_Proximity:BPR-205 Sensor_Proximity:BPR-205
Sensor_Proximity:CNY70 Sensor_Proximity:CNY70
Sensor_Proximity:FDC1004DGS
Sensor_Proximity:GP2S700HCP Sensor_Proximity:GP2S700HCP
Sensor_Proximity:ITR1201SR10AR Sensor_Proximity:ITR1201SR10AR
Sensor_Proximity:ITR8307 Sensor_Proximity:ITR8307
@ -21791,6 +21807,7 @@ Transistor_BJT:Q_NPN_Darlington_ECBC
Transistor_BJT:Q_NPN_EBC Transistor_BJT:Q_NPN_EBC
Transistor_BJT:Q_NPN_ECB Transistor_BJT:Q_NPN_ECB
Transistor_BJT:Q_NPN_ECBC Transistor_BJT:Q_NPN_ECBC
Transistor_BJT:Q_PNP_ACAB
Transistor_BJT:Q_PNP_BCE Transistor_BJT:Q_PNP_BCE
Transistor_BJT:Q_PNP_BCEC Transistor_BJT:Q_PNP_BCEC
Transistor_BJT:Q_PNP_BEC Transistor_BJT:Q_PNP_BEC
@ -22324,6 +22341,7 @@ Transistor_FET:PSMN5R2-60YL
Transistor_FET:QM6006D Transistor_FET:QM6006D
Transistor_FET:QM6015D Transistor_FET:QM6015D
Transistor_FET:Q_Dual_NMOS_G1S2G2D2S1D1 Transistor_FET:Q_Dual_NMOS_G1S2G2D2S1D1
Transistor_FET:Q_Dual_NMOS_PMOS_G1S2G2D2S1D1
Transistor_FET:Q_Dual_NMOS_S1G1D2S2G2D1 Transistor_FET:Q_Dual_NMOS_S1G1D2S2G2D1
Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D1 Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D1
Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D2D1D1 Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D2D1D1

View file

@ -201,6 +201,10 @@ class BackupCommand extends Command
$config_dir = $this->project_dir.'/config'; $config_dir = $this->project_dir.'/config';
$zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml'); $zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml');
$zip->addFile($config_dir.'/banner.md', 'config/banner.md'); $zip->addFile($config_dir.'/banner.md', 'config/banner.md');
//Add kicad custom footprints and symbols files
$zip->addFile($this->project_dir . '/public/kicad/footprints_custom.txt', 'public/kicad/footprints_custom.txt');
$zip->addFile($this->project_dir . '/public/kicad/symbols_custom.txt', 'public/kicad/symbols_custom.txt');
} }
protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void

View file

@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command
} }
$factory = new ResetAutoIncrementPurgerFactory(); $factory = new ResetAutoIncrementPurgerFactory();
$purger = $factory->createForEntityManager(null, $this->entityManager);
//Use truncate purging to fix compatibility with postgresql
$purger = $factory->createForEntityManager(null, $this->entityManager, purgeWithTruncate: true);
$purger->purge(); $purger->purge();
//Afterwards run the load fixtures command as normal, but with the --append option //Afterwards run the load fixtures command as normal, but with the --append option
$new_input = new ArrayInput([ $new_input = new ArrayInput([
'command' => 'doctrine:fixtures:load', 'command' => 'doctrine:fixtures:load',
'--purge-with-truncate' => true,
'--append' => true, '--append' => true,
]); ]);
@ -70,4 +73,4 @@ class LoadFixturesCommand extends Command
return $returnCode ?? Command::FAILURE; return $returnCode ?? Command::FAILURE;
} }
} }

View file

@ -229,24 +229,37 @@ class DBPlatformConvertCommand extends Command
if ($platform instanceof PostgreSQLPlatform) { if ($platform instanceof PostgreSQLPlatform) {
$connection->executeStatement( $connection->executeStatement(
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences //See https://github.com/Part-DB/Part-DB-server/issues/1362
<<<SQL <<<SQL
SELECT 'SELECT SETVAL(' || DO $$
quote_literal(quote_ident(PGT.schemaname) || '.' || quote_ident(S.relname)) || DECLARE
', COALESCE(MAX(' ||quote_ident(C.attname)|| '), 1) ) FROM ' || rec RECORD;
quote_ident(PGT.schemaname)|| '.'||quote_ident(T.relname)|| ';' max_id BIGINT;
FROM pg_class AS S, seq TEXT;
pg_depend AS D, BEGIN
pg_class AS T, FOR rec IN
pg_attribute AS C, SELECT c.table_name
pg_tables AS PGT FROM information_schema.columns c
WHERE S.relkind = 'S' JOIN pg_tables t
AND S.oid = D.objid ON t.tablename = c.table_name AND t.schemaname = 'public'
AND D.refobjid = T.oid WHERE c.column_name = 'id'
AND D.refobjid = C.attrelid AND c.table_schema = 'public'
AND D.refobjsubid = C.attnum LOOP
AND T.relname = PGT.tablename BEGIN
ORDER BY S.relname; seq := pg_get_serial_sequence(rec.table_name, 'id');
IF seq IS NOT NULL THEN
EXECUTE format('SELECT MAX(id) FROM %I', rec.table_name) INTO max_id;
IF max_id IS NOT NULL THEN
PERFORM setval(seq, max_id);
RAISE NOTICE 'Reset: %.id → %', rec.table_name, max_id;
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Skipped %: %', rec.table_name, SQLERRM;
END;
END LOOP;
END;
$$;
SQL); SQL);
} }
} }

View file

@ -34,6 +34,7 @@ use App\Entity\Base\PartsContainingRepositoryInterface;
use App\Entity\LabelSystem\LabelProcessMode; use App\Entity\LabelSystem\LabelProcessMode;
use App\Entity\LabelSystem\LabelProfile; use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AbstractParameter;
use App\Entity\UserSystem\User;
use App\Exceptions\AttachmentDownloadException; use App\Exceptions\AttachmentDownloadException;
use App\Exceptions\TwigModeException; use App\Exceptions\TwigModeException;
use App\Form\AdminPages\ImportType; use App\Form\AdminPages\ImportType;
@ -196,7 +197,9 @@ abstract class BaseAdminController extends AbstractController
$this->commentHelper->setMessage($form['log_comment']->getData()); $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. //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); if (!$entity instanceof User) { //Users entities does not have a simple edit permission, so we skip the check for them
$this->denyAccessUnlessGranted('edit', $entity);
}
$em->persist($entity); $em->persist($entity);
$em->flush(); $em->flush();
$this->addFlash('success', 'entity.edit_flash'); $this->addFlash('success', 'entity.edit_flash');

View file

@ -29,11 +29,14 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use App\Form\InfoProviderSystem\GlobalFieldMappingType; use App\Form\InfoProviderSystem\GlobalFieldMappingType;
use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\BulkInfoProviderService; use App\Services\InfoProviderSystem\BulkInfoProviderService;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMInvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -66,6 +69,10 @@ class BulkInfoProviderImportController extends AbstractController
{ {
$dtos = []; $dtos = [];
foreach ($fieldMappings as $mapping) { foreach ($fieldMappings as $mapping) {
// Skip entries where field is null/empty (e.g. user added a row but didn't select a field)
if (empty($mapping['field'])) {
continue;
}
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1); $dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
} }
return $dtos; return $dtos;
@ -276,8 +283,8 @@ class BulkInfoProviderImportController extends AbstractController
$updatedJobs = true; $updatedJobs = true;
} }
// Mark jobs with no results for deletion (failed searches) // Mark jobs with no results for deletion (failed searches or stale pending)
if ($job->getResultCount() === 0 && $job->isInProgress()) { if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) {
$jobsToDelete[] = $job; $jobsToDelete[] = $job;
} }
} }
@ -297,9 +304,23 @@ class BulkInfoProviderImportController extends AbstractController
} }
} }
// Refetch after cleanup and split into active vs finished
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
->findBy([], ['createdAt' => 'DESC']);
$activeJobs = [];
$finishedJobs = [];
foreach ($allJobs as $job) {
if ($job->isCompleted() || $job->isFailed() || $job->isStopped()) {
$finishedJobs[] = $job;
} else {
$activeJobs[] = $job;
}
}
return $this->render('info_providers/bulk_import/manage.html.twig', [ return $this->render('info_providers/bulk_import/manage.html.twig', [
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class) 'active_jobs' => $activeJobs,
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup 'finished_jobs' => $finishedJobs,
]); ]);
} }
@ -470,22 +491,13 @@ class BulkInfoProviderImportController extends AbstractController
$fieldMappingDtos = $job->getFieldMappings(); $fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails(); $prefetchDetails = $job->isPrefetchDetails();
try { $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
} catch (\Exception $searchException) {
// Handle "no search results found" as a normal case, not an error
if (str_contains($searchException->getMessage(), 'No search results found')) {
$searchResultsDto = null;
} else {
throw $searchException;
}
}
// Update the job's search results for this specific part efficiently // Update the job's search results for this specific part efficiently
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null); $this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
// Prefetch details if requested // Prefetch details if requested
if ($prefetchDetails && $searchResultsDto !== null) { if ($prefetchDetails) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto); $this->bulkService->prefetchDetailsForResults($searchResultsDto);
} }
@ -515,6 +527,191 @@ class BulkInfoProviderImportController extends AbstractController
} }
} }
#[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])]
public function quickApply(
int $jobId,
int $partId,
Request $request,
PartInfoRetriever $infoRetriever,
PartMerger $partMerger
): JsonResponse {
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
/** @var Part $part */
$part = $this->entityManager->getRepository(Part::class)->find($partId);
if (!$part) {
return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
}
$this->denyAccessUnlessGranted('edit', $part);
// Get provider key/id from request body, or fall back to top search result
$body = $request->toArray();
$providerKey = $body['providerKey'] ?? null;
$providerId = $body['providerId'] ?? null;
if (!$providerKey || !$providerId) {
$searchResults = $job->getSearchResults($this->entityManager);
foreach ($searchResults->partResults as $partResult) {
if ($partResult->part->getId() === $partId) {
$sorted = $partResult->getResultsSortedByPriority();
if (!empty($sorted)) {
$providerKey = $sorted[0]->searchResult->provider_key;
$providerId = $sorted[0]->searchResult->provider_id;
}
break;
}
}
}
if (!$providerKey || !$providerId) {
return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]);
}
try {
$dto = $infoRetriever->getDetails($providerKey, $providerId);
$providerPart = $infoRetriever->dtoToPart($dto);
$partMerger->merge($part, $providerPart);
//Persist part manufacturer and supplier if they are new, to avoid issues with detached entities during merge
//Do not footprints here, as it might pollute the database with unwanted formatting footprints from the provider,
$this->entityManager->persist($part->getManufacturer());
foreach ($part->getOrderdetails() as $orderdetail) {
$this->entityManager->persist($orderdetail->getSupplier());
}
try {
$this->entityManager->flush();
} catch (ORMInvalidArgumentException $exception) {
if (str_contains($exception->getMessage(), 'not configured to cascade persist operations')) {
throw new \RuntimeException('Failed to persist merged part, as it would create new datastructures! Review the provider data by yourself.');
}
throw $exception; // Re-throw if it's a different ORM error
}
$job->markPartAsCompleted($partId);
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'message' => sprintf('Applied provider data to "%s"', $part->getName()),
'part_id' => $partId,
'provider_key' => $providerKey,
'provider_id' => $providerId,
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted(),
]);
} catch (\Exception $e) {
$this->logger->error($e);
return $this->createErrorResponse(
'Quick apply failed: ' . $e->getMessage(),
500,
['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()]
);
}
}
#[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])]
public function quickApplyAll(
int $jobId,
PartInfoRetriever $infoRetriever,
PartMerger $partMerger
): JsonResponse {
set_time_limit(600);
$job = $this->validateJobAccess($jobId);
if (!$job) {
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
}
$searchResults = $job->getSearchResults($this->entityManager);
$applied = 0;
$failed = 0;
$noResults = 0;
$errors = [];
foreach ($job->getJobParts() as $jobPart) {
if ($jobPart->isCompleted() || $jobPart->isSkipped()) {
continue;
}
$part = $jobPart->getPart();
if (!$this->isGranted('edit', $part)) {
$errors[] = sprintf('No edit permission for "%s"', $part->getName());
$failed++;
continue;
}
// Find top search result for this part
$providerKey = null;
$providerId = null;
foreach ($searchResults->partResults as $partResult) {
if ($partResult->part->getId() === $part->getId()) {
$sorted = $partResult->getResultsSortedByPriority();
if (!empty($sorted)) {
$providerKey = $sorted[0]->searchResult->provider_key;
$providerId = $sorted[0]->searchResult->provider_id;
}
break;
}
}
if (!$providerKey || !$providerId) {
$noResults++;
continue;
}
try {
$dto = $infoRetriever->getDetails($providerKey, $providerId);
$providerPart = $infoRetriever->dtoToPart($dto);
$partMerger->merge($part, $providerPart);
$this->entityManager->flush();
$job->markPartAsCompleted($part->getId());
$applied++;
} catch (\Exception $e) {
$this->logger->error('Quick apply failed for part', [
'part_id' => $part->getId(),
'part_name' => $part->getName(),
'error' => $e->getMessage(),
]);
$errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage());
$failed++;
}
}
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
$job->markAsCompleted();
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'applied' => $applied,
'failed' => $failed,
'no_results' => $noResults,
'errors' => $errors,
'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults),
'progress' => $job->getProgressPercentage(),
'completed_count' => $job->getCompletedPartsCount(),
'total_count' => $job->getPartCount(),
'job_completed' => $job->isCompleted(),
]);
}
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])] #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
public function researchAllParts(int $jobId): JsonResponse public function researchAllParts(int $jobId): JsonResponse
{ {

View file

@ -26,11 +26,14 @@ namespace App\Controller;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Exceptions\OAuthReconnectRequiredException; use App\Exceptions\OAuthReconnectRequiredException;
use App\Form\InfoProviderSystem\FromURLFormType;
use App\Form\InfoProviderSystem\PartSearchType; use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder; use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\InfoProviderSystem\Providers\GenericWebProvider; use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use App\Settings\AppSettings; use App\Settings\AppSettings;
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings; use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -172,10 +175,15 @@ class InfoProviderController extends AbstractController
$keyword = $form->get('keyword')->getData(); $keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData(); $providers = $form->get('providers')->getData();
$no_cache_search = $form->get('no_cache_search')->getData();
$no_cache_details = $form->get('no_cache_details')->getData();
$dtos = []; $dtos = [];
try { try {
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); $dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers, options: [
InfoProviderInterface::OPTION_NO_CACHE => $no_cache_search
]);
} catch (ClientException $e) { } catch (ClientException $e) {
$this->addFlash('error', t('info_providers.search.error.client_exception')); $this->addFlash('error', t('info_providers.search.error.client_exception'));
$this->addFlash('error',$e->getMessage()); $this->addFlash('error',$e->getMessage());
@ -207,40 +215,41 @@ class InfoProviderController extends AbstractController
return $this->render('info_providers/search/part_search.html.twig', [ return $this->render('info_providers/search/part_search.html.twig', [
'form' => $form, 'form' => $form,
'results' => $results, 'results' => $results,
'update_target' => $update_target 'update_target' => $update_target,
'no_cache_details' => $no_cache_details ?? false,
]); ]);
} }
#[Route('/from_url', name: 'info_providers_from_url')] #[Route('/from_url', name: 'info_providers_from_url')]
public function fromURL(Request $request, GenericWebProvider $provider): Response public function fromURL(Request $request, GenericWebProvider $provider, CreateFromUrlHelper $fromUrlHelper): Response
{ {
$this->denyAccessUnlessGranted('@info_providers.create_parts'); $this->denyAccessUnlessGranted('@info_providers.create_parts');
if (!$provider->isActive()) { if (!$fromUrlHelper->canCreateFromUrl()) {
$this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings."); $this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings.");
return $this->redirectToRoute('info_providers_list'); return $this->redirectToRoute('info_providers_list');
} }
$formBuilder = $this->createFormBuilder(); $form = $this->createForm(FromURLFormType::class);
$formBuilder->add('url', UrlType::class, [
'label' => 'info_providers.from_url.url.label',
'required' => true,
]);
$formBuilder->add('submit', SubmitType::class, [
'label' => 'info_providers.search.submit',
]);
$form = $formBuilder->getForm();
$form->handleRequest($request); $form->handleRequest($request);
$partDetail = null; $partDetail = null;
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
//Try to retrieve the part detail from the given URL //Try to retrieve the part detail from the given URL
$url = $form->get('url')->getData(); $url = $form->get('url')->getData();
$method = $form->get('method')->getData();
$no_cache = $form->get('no_cache')->getData();
$skip_delegation = $form->get('skip_delegation')->getData();
try { try {
//It's okay if we use the cached results here, as its just for convenience
$searchResult = $this->infoRetriever->searchByKeyword( $searchResult = $this->infoRetriever->searchByKeyword(
keyword: $url, keyword: $url,
providers: [$provider] providers: [$method],
options: [
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
]
); );
if (count($searchResult) === 0) { if (count($searchResult) === 0) {
@ -251,6 +260,8 @@ class InfoProviderController extends AbstractController
return $this->redirectToRoute('info_providers_create_part', [ return $this->redirectToRoute('info_providers_create_part', [
'providerKey' => $searchResult->provider_key, 'providerKey' => $searchResult->provider_key,
'providerId' => $searchResult->provider_id, 'providerId' => $searchResult->provider_id,
'no_cache' => $no_cache ? 1 : null,
'skip_delegation' => $skip_delegation ? 1 : null,
]); ]);
} }
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {

View file

@ -0,0 +1,88 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Controller;
use App\Form\Settings\KicadListEditorType;
use App\Settings\MiscSettings\KiCadEDASettings;
use App\Services\EDA\KicadListFileManager;
use Jbtronics\SettingsBundle\Exception\SettingsNotValidException;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use function Symfony\Component\Translation\t;
final class KicadListEditorController extends AbstractController
{
public function __construct(
private readonly SettingsManagerInterface $settingsManager,
) {
}
#[Route('/settings/misc/kicad-lists', name: 'settings_kicad_lists')]
public function __invoke(Request $request, KicadListFileManager $fileManager): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@config.change_system_settings');
/** @var KiCadEDASettings $settings */
$settings = $this->settingsManager->createTemporaryCopy(KiCadEDASettings::class);
$form = $this->createForm(KicadListEditorType::class, [
'useCustomList' => $settings->useCustomList,
'customFootprints' => $fileManager->getCustomFootprintsContent(),
'customSymbols' => $fileManager->getCustomSymbolsContent(),
], [
'default_footprints' => $fileManager->getFootprintsContent(),
'default_symbols' => $fileManager->getSymbolsContent(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
try {
$fileManager->saveCustom($data['customFootprints'], $data['customSymbols']);
$settings->useCustomList = (bool) $data['useCustomList'];
$this->settingsManager->mergeTemporaryCopy($settings);
$this->settingsManager->save($settings);
$this->addFlash('success', t('settings.flash.saved'));
return $this->redirectToRoute('settings_kicad_lists');
} catch (RuntimeException|SettingsNotValidException $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
if ($form->isSubmitted() && !$form->isValid()) {
$this->addFlash('error', t('settings.flash.invalid'));
}
return $this->render('settings/kicad_list_editor.html.twig', [
'form' => $form,
]);
}
}

View file

@ -36,10 +36,12 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\Project;
use App\Exceptions\AttachmentDownloadException; use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType; use App\Form\Part\PartBaseType;
use App\Form\Part\PartLotType;
use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator; use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use App\Services\LogSystem\EventCommentHelper; use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\HistoryHelper; use App\Services\LogSystem\HistoryHelper;
use App\Services\LogSystem\TimeTravel; use App\Services\LogSystem\TimeTravel;
@ -127,6 +129,17 @@ final class PartController extends AbstractController
$table = null; $table = null;
} }
// Build the add-lot form for the INFO page modal (only when not in time-travel mode)
$addLotForm = null;
if ($timeTravel_timestamp === null && $this->isGranted('edit', $part)) {
$newLot = new PartLot();
$newLot->setPart($part);
$addLotForm = $this->createForm(PartLotType::class, $newLot, [
'measurement_unit' => $part->getPartUnit(),
'action' => $this->generateUrl('part_lot_add', ['id' => $part->getID()]),
]);
}
return $this->render( return $this->render(
'parts/info/show_part_info.html.twig', 'parts/info/show_part_info.html.twig',
[ [
@ -139,10 +152,39 @@ final class PartController extends AbstractController
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [], 'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper, 'withdraw_add_helper' => $withdrawAddHelper,
'highlightLotId' => $request->query->getInt('highlightLot', 0), 'highlightLotId' => $request->query->getInt('highlightLot', 0),
'add_lot_form' => $addLotForm,
] ]
); );
} }
#[Route(path: '/{id}/add_lot', name: 'part_lot_add', methods: ['POST'])]
public function addLot(Part $part, Request $request, EntityManagerInterface $em): Response
{
$this->denyAccessUnlessGranted('edit', $part);
$newLot = new PartLot();
$newLot->setPart($part);
$form = $this->createForm(PartLotType::class, $newLot, [
'measurement_unit' => $part->getPartUnit(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($newLot);
$em->flush();
$this->addFlash('success', 'part.edited_flash');
return $this->redirectToRoute('part_info', [
'id' => $part->getID(),
'highlightLot' => $newLot->getID(),
]);
}
$this->addFlash('error', 'part.created_flash.invalid');
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}
#[Route(path: '/{id}/edit', name: 'part_edit')] #[Route(path: '/{id}/edit', name: 'part_edit')]
public function edit(Part $part, Request $request): Response public function edit(Part $part, Request $request): Response
{ {
@ -283,7 +325,14 @@ final class PartController extends AbstractController
{ {
$this->denyAccessUnlessGranted('@info_providers.create_parts'); $this->denyAccessUnlessGranted('@info_providers.create_parts');
$dto = $infoRetriever->getDetails($providerKey, $providerId); //Force info providers to not use cache, when retrieving part details for creating a new part, because otherwise we might end up with outdated information
$no_cache = $request->query->getBoolean('no_cache', false);
$skip_delegation = $request->query->getBoolean('skip_delegation', false);
$dto = $infoRetriever->getDetails($providerKey, $providerId, [
InfoProviderInterface::OPTION_NO_CACHE => $no_cache,
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
]);
$new_part = $infoRetriever->dtoToPart($dto); $new_part = $infoRetriever->dtoToPart($dto);
if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) { if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) {
@ -342,10 +391,13 @@ final class PartController extends AbstractController
$this->denyAccessUnlessGranted('edit', $part); $this->denyAccessUnlessGranted('edit', $part);
$this->denyAccessUnlessGranted('@info_providers.create_parts'); $this->denyAccessUnlessGranted('@info_providers.create_parts');
//Force info providers to not use cache, when retrieving part details for creating a new part, because otherwise we might end up with outdated information
$no_cache = $request->query->getBoolean('no_cache', false);
//Save the old name of the target part for the template //Save the old name of the target part for the template
$old_name = $part->getName(); $old_name = $part->getName();
$dto = $infoRetriever->getDetails($providerKey, $providerId); $dto = $infoRetriever->getDetails($providerKey, $providerId, [InfoProviderInterface::OPTION_NO_CACHE => $no_cache]);
$provider_part = $infoRetriever->dtoToPart($dto); $provider_part = $infoRetriever->dtoToPart($dto);
$part = $partMerger->merge($part, $provider_part); $part = $partMerger->merge($part, $provider_part);

View file

@ -69,10 +69,13 @@ class ProjectController extends AbstractController
return $table->getResponse(); return $table->getResponse();
} }
$number_of_builds = max(1, $request->query->getInt('n', 1));
return $this->render('projects/info/info.html.twig', [ return $this->render('projects/info/info.html.twig', [
'buildHelper' => $buildHelper, 'buildHelper' => $buildHelper,
'datatable' => $table, 'datatable' => $table,
'project' => $project, 'project' => $project,
'number_of_builds' => $number_of_builds,
]); ]);
} }
@ -240,7 +243,8 @@ class ProjectController extends AbstractController
} }
// Detect fields and get suggestions // Detect fields and get suggestions
$detected_fields = $BOMImporter->detectFields($file_content); $detected_delimiter = $BOMImporter->detectDelimiter($file_content);
$detected_fields = $BOMImporter->detectFields($file_content, $detected_delimiter);
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields); $suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
// Create mapping of original field names to sanitized field names for template // Create mapping of original field names to sanitized field names for template
@ -257,7 +261,7 @@ class ProjectController extends AbstractController
$builder->add('delimiter', ChoiceType::class, [ $builder->add('delimiter', ChoiceType::class, [
'label' => 'project.bom_import.delimiter', 'label' => 'project.bom_import.delimiter',
'required' => true, 'required' => true,
'data' => ',', 'data' => $detected_delimiter,
'choices' => [ 'choices' => [
'project.bom_import.delimiter.comma' => ',', 'project.bom_import.delimiter.comma' => ',',
'project.bom_import.delimiter.semicolon' => ';', 'project.bom_import.delimiter.semicolon' => ';',

View file

@ -44,6 +44,7 @@ class SettingsController extends AbstractController
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
{ {
$this->denyAccessUnlessGranted('@config.change_system_settings'); $this->denyAccessUnlessGranted('@config.change_system_settings');
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
//Create a clone of the settings object //Create a clone of the settings object
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class); $settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);

View file

@ -22,38 +22,43 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Parameters\AbstractParameter;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category; use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Footprint;
use App\Entity\Parameters\AttachmentTypeParameter; use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter; use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parameters\FootprintParameter; use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter; use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\MeasurementUnitParameter; use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter; use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parameters\StorageLocationParameter; use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter; use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Currency;
use App\Repository\ParameterRepository; use App\Repository\ParameterRepository;
use App\Services\AI\AIPlatformRegistry;
use App\Services\AI\AIPlatforms;
use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\Attachments\PartPreviewGenerator; use App\Services\Attachments\PartPreviewGenerator;
use App\Services\Tools\TagFinder; use App\Services\Tools\TagFinder;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\AI\Platform\Capability;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Asset\Packages; use Symfony\Component\Asset\Packages;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Serializer;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/** /**
* In this controller the endpoints for the typeaheads are collected. * In this controller the endpoints for the typeaheads are collected.
@ -121,9 +126,12 @@ class TypeaheadController extends AbstractController
} }
#[Route(path: '/parts/search/{query}', name: 'typeahead_parts')] #[Route(path: '/parts/search/{query}', name: 'typeahead_parts')]
public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator, public function parts(
AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse EntityManagerInterface $entityManager,
{ PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
): JsonResponse {
$this->denyAccessUnlessGranted('@parts.read'); $this->denyAccessUnlessGranted('@parts.read');
$repo = $entityManager->getRepository(Part::class); $repo = $entityManager->getRepository(Part::class);
@ -134,7 +142,7 @@ class TypeaheadController extends AbstractController
foreach ($parts as $part) { foreach ($parts as $part) {
//Determine the picture to show: //Determine the picture to show:
$preview_attachment = $previewGenerator->getTablePreviewAttachment($part); $preview_attachment = $previewGenerator->getTablePreviewAttachment($part);
if($preview_attachment instanceof Attachment) { if ($preview_attachment instanceof Attachment) {
$preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
} else { } else {
$preview_url = ''; $preview_url = '';
@ -148,7 +156,7 @@ class TypeaheadController extends AbstractController
'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '', 'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), 'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'image' => $preview_url, 'image' => $preview_url,
]; ];
} }
return new JsonResponse($data); return new JsonResponse($data);
@ -219,8 +227,36 @@ class TypeaheadController extends AbstractController
$partRepository = $entityManager->getRepository(Part::class); $partRepository = $entityManager->getRepository(Part::class);
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits); $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description,
$this->ipnSuggestSettings->suggestPartDigits);
return new JsonResponse($ipnSuggestions); return new JsonResponse($ipnSuggestions);
} }
#[Route(path: '/ai/{platform}/models', name: 'typeahead_ai_models', requirements: ['platform' => '.+'])]
public function aiModels(
AIPlatforms $platform,
Request $request,
AIPlatformRegistry $platformRegistry,
CacheInterface $cache,
): JsonResponse {
$this->denyAccessUnlessGranted('@config.change_system_settings');
$capability_filter = $request->query->getEnum('capability', Capability::class);
$models = $cache->get('ai_models_'.$platform->value.'_'.($capability_filter->value ?? 'all'),
function (ItemInterface $item) use ($platformRegistry, $platform, $capability_filter) {
$item->expiresAfter(3600); //Cache for 1 hour
if ($capability_filter === null) {
return $platformRegistry->getPlatform($platform)->getModelCatalog()->getModels();
}
//Otherwise filter the models by the capability
return array_filter($platformRegistry->getPlatform($platform)->getModelCatalog()->getModels(),
static fn(array $model) => in_array($capability_filter, $model['capabilities'], true)
);
});
return new JsonResponse($models);
}
} }

View file

@ -28,6 +28,7 @@ use App\Services\System\BackupManager;
use App\Services\System\InstallationTypeDetector; use App\Services\System\InstallationTypeDetector;
use App\Services\System\UpdateChecker; use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor; use App\Services\System\UpdateExecutor;
use App\Services\System\WatchtowerClient;
use Shivas\VersioningBundle\Service\VersionManagerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -56,6 +57,7 @@ class UpdateManagerController extends AbstractController
private readonly BackupManager $backupManager, private readonly BackupManager $backupManager,
private readonly InstallationTypeDetector $installationTypeDetector, private readonly InstallationTypeDetector $installationTypeDetector,
private readonly UserPasswordHasherInterface $passwordHasher, private readonly UserPasswordHasherInterface $passwordHasher,
private readonly WatchtowerClient $watchtowerClient,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')] #[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false, private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')] #[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
@ -504,4 +506,100 @@ class UpdateManagerController extends AbstractController
return $this->redirectToRoute('admin_update_manager'); return $this->redirectToRoute('admin_update_manager');
} }
/**
* Start a Docker update via Watchtower.
*/
#[Route('/start-docker', name: 'admin_update_manager_start_docker', methods: ['POST'])]
public function startDockerUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_start_docker', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token');
return $this->redirectToRoute('admin_update_manager');
}
// Check if Watchtower is configured and available
if (!$this->watchtowerClient->isConfigured()) {
$this->addFlash('error', 'Watchtower is not configured. Please set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
return $this->redirectToRoute('admin_update_manager');
}
if (!$this->watchtowerClient->isAvailable()) {
$this->addFlash('error', 'Watchtower is not reachable. Please check that the Watchtower container is running and accessible.');
return $this->redirectToRoute('admin_update_manager');
}
// Create backup if requested
$createBackup = $request->request->getBoolean('backup', true);
if ($createBackup) {
try {
$this->backupManager->createBackup();
} catch (\Throwable $e) {
$this->addFlash('error', 'Failed to create backup before update: ' . $e->getMessage());
return $this->redirectToRoute('admin_update_manager');
}
}
// Trigger Watchtower update
$success = $this->watchtowerClient->triggerUpdate();
if (!$success) {
$this->addFlash('error', 'Failed to trigger Watchtower update. Check the logs for details.');
return $this->redirectToRoute('admin_update_manager');
}
$currentVersion = $this->versionManager->getVersion()->toString();
// Redirect to Docker progress page
return $this->redirectToRoute('admin_update_manager_docker_progress', [
'previous_version' => $currentVersion,
]);
}
/**
* Docker update progress page.
* This page contains client-side JavaScript that polls until the container restarts.
*/
#[Route('/progress/docker', name: 'admin_update_manager_docker_progress', methods: ['GET'])]
public function dockerProgress(Request $request): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$previousVersion = $request->query->get('previous_version', 'unknown');
return $this->render('admin/update_manager/docker_progress.html.twig', [
'previous_version' => $previousVersion,
]);
}
/**
* Lightweight health check endpoint used by Docker update progress page.
* Returns current version so the client-side JS can detect when the container restarts with a new version.
*
* Intentionally unauthenticated: after a Docker container restart, the user's session may not survive
* (depends on session storage backend). The version string is non-sensitive public information.
* This endpoint is also whitelisted in MaintenanceModeSubscriber.
*/
#[Route('/health', name: 'admin_update_manager_health', methods: ['GET'])]
public function healthCheck(): JsonResponse
{
//Only show version if user is logged in and has permission
$response = [
'status' => 'ok',
];
if ($this->isGranted('@system.show_updates')) {
$response['version'] = $this->versionManager->getVersion()->toString();
} else {
$response['version'] = "not authorized";
}
return $this->json($response);
}
} }

View file

@ -38,6 +38,7 @@ use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter; use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Helpers\ColumnSortHelper; use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper; use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Functions\SiValueSort;
use App\Doctrine\Helpers\FieldHelper; use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
@ -59,7 +60,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface final class PartsDataTable implements DataTableTypeInterface
{ {
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]; public const LENGTH_MENU = [[10, 25, 50, 100, 250, 500, -1], [10, 25, 50, 100, 250, 500, "All"]];
public function __construct( public function __construct(
private readonly EntityURLGenerator $urlGenerator, private readonly EntityURLGenerator $urlGenerator,
@ -118,6 +119,18 @@ final class PartsDataTable implements DataTableTypeInterface
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
'orderField' => 'NATSORT(part.name)' 'orderField' => 'NATSORT(part.name)'
]) ])
->add('si_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.si_value'),
'render' => function ($value, Part $context): string {
$siValue = SiValueSort::sqliteSiValue($context->getName());
if ($siValue !== null) {
//Output it as scientific number with a big E
return htmlspecialchars(sprintf('%G', $siValue));
}
return '';
},
'orderField' => 'SI_VALUE_SORT(part.name)',
])
->add('id', TextColumn::class, [ ->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'), 'label' => $this->translator->trans('part.table.id'),
]) ])
@ -484,6 +497,19 @@ final class PartsDataTable implements DataTableTypeInterface
//$builder->addGroupBy('_bulkImportJob'); //$builder->addGroupBy('_bulkImportJob');
} }
//When sorting by SI value, add NATSORT as a secondary sort so that parts without
//an SI-prefixed value fall back to natural string ordering seamlessly.
$orderByParts = $builder->getDQLPart('orderBy');
foreach ($orderByParts as $orderBy) {
foreach ($orderBy->getParts() as $part) {
if (str_contains($part, 'SI_VALUE_SORT')) {
$direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC';
$builder->addOrderBy('NATSORT(part.name)', $direction);
break 2;
}
}
}
return $builder; return $builder;
} }

View file

@ -1,8 +1,5 @@
<?php <?php
/**
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * 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 - 2022 Jan Böhmer (https://github.com/jbtronics)
@ -20,23 +17,31 @@ declare(strict_types=1);
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
declare(strict_types=1);
namespace App\DataTables; namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn; use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn; use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\LocaleDateTimeColumn; use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn; use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper; use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment; use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\Part;
use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ElementTypeNameGenerator; use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator; use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter; use App\Services\Formatters\AmountFormatter;
use App\Services\Formatters\MoneyFormatter;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Brick\Math\RoundingMode;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface; use Omines\DataTablesBundle\DataTableTypeInterface;
@ -44,9 +49,14 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface class ProjectBomEntriesDataTable implements DataTableTypeInterface
{ {
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, public function __construct(
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter) protected EntityURLGenerator $entityURLGenerator,
{ protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
protected PartDataTableHelper $partDataTableHelper,
protected ProjectBuildHelper $projectBuildHelper,
protected MoneyFormatter $moneyFormatter,
) {
} }
@ -62,7 +72,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return ''; return '';
} }
return $this->partDataTableHelper->renderPicture($context->getPart()); return $this->partDataTableHelper->renderPicture($context->getPart());
}, }
]) ])
->add('id', TextColumn::class, [ ->add('id', TextColumn::class, [
@ -133,23 +143,24 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('category', EntityColumn::class, [ ->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'), 'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category', 'property' => 'part.category',
'orderField' => 'NATSORT(category.name)', 'orderField' => 'NATSORT(category.name)'
]) ])
->add('footprint', EntityColumn::class, [ ->add('footprint', EntityColumn::class, [
'property' => 'part.footprint', 'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'), 'label' => $this->translator->trans('part.table.footprint'),
'orderField' => 'NATSORT(footprint.name)', 'orderField' => 'NATSORT(footprint.name)'
]) ])
->add('manufacturer', EntityColumn::class, [ ->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer', 'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'), 'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(manufacturer.name)', 'orderField' => 'NATSORT(manufacturer.name)'
]) ])
->add('manufacturing_status', EnumColumn::class, [ ->add('manufacturing_status', EnumColumn::class, [
'label' => $this->translator->trans('part.table.manufacturingStatus'), 'label' => $this->translator->trans('part.table.manufacturingStatus'),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(), 'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'orderField' => 'part.manufacturing_status',
'class' => ManufacturingStatus::class, 'class' => ManufacturingStatus::class,
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string { 'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
if ($status === null) { if ($status === null) {
@ -183,8 +194,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return ''; return '';
} }
]) ])
->add('storageLocations', TextColumn::class, [ ->add('storelocation', TextColumn::class, [
'label' => 'part.table.storeLocations', 'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'visible' => false, 'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) { 'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart() !== null) { if ($context->getPart() !== null) {
@ -194,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return ''; return '';
} }
]) ])
->add('price', TextColumn::class, [
'label' => 'project.bom.price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
},
])
->add('ext_price', TextColumn::class, [
'label' => 'project.bom.ext_price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format(
$price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
null,
2,
true
);
},
])
->add('addedDate', LocaleDateTimeColumn::class, [ ->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'), 'label' => $this->translator->trans('part.table.addedDate'),
@ -207,11 +241,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING); $dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
$dataTable->createAdapter(ORMAdapter::class, [ $dataTable->createAdapter(TwoStepORMAdapter::class, [
'entity' => Attachment::class, 'entity' => ProjectBOMEntry::class,
'query' => function (QueryBuilder $builder) use ($options): void { 'hydrate' => AbstractQuery::HYDRATE_OBJECT,
$this->getQuery($builder, $options); 'filter_query' => function (QueryBuilder $builder) use ($options): void {
$this->getFilterQuery($builder, $options);
}, },
'detail_query' => $this->getDetailQuery(...),
'criteria' => [ 'criteria' => [
function (QueryBuilder $builder) use ($options): void { function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options); $this->buildCriteria($builder, $options);
@ -221,20 +257,71 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
]); ]);
} }
private function getQuery(QueryBuilder $builder, array $options): void private function getFilterQuery(QueryBuilder $builder, array $options): void
{ {
$builder->select('bom_entry') $builder
->addSelect('part') ->select('bom_entry.id')
->from(ProjectBOMEntry::class, 'bom_entry') ->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part') ->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category') ->leftJoin('part.category', 'category')
->leftJoin('part.partLots', '_partLots')
->leftJoin('_partLots.storage_location', '_storelocations')
->leftJoin('part.footprint', 'footprint') ->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer') ->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->where('bom_entry.project = :project') ->where('bom_entry.project = :project')
->setParameter('project', $options['project']) ->setParameter('project', $options['project'])
->addGroupBy('bom_entry')
->addGroupBy('part')
->addGroupBy('category')
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
; ;
} }
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
{
$ids = array_map(static fn (array $row) => $row['id'], $filter_results);
if ($ids === []) {
$ids = [-1];
}
$builder
->select('bom_entry')
->addSelect('part')
->addSelect('category')
->addSelect('partLots')
->addSelect('storelocations')
->addSelect('footprint')
->addSelect('manufacturer')
->addSelect('partCustomState')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.partLots', 'partLots')
->leftJoin('partLots.storage_location', 'storelocations')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->where('bom_entry.id IN (:ids)')
->setParameter('ids', $ids)
->addGroupBy('bom_entry')
->addGroupBy('part')
->addGroupBy('partLots')
->addGroupBy('category')
->addGroupBy('storelocations')
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
FieldHelper::addOrderByFieldParam($builder, 'bom_entry.id', 'ids');
}
private function buildCriteria(QueryBuilder $builder, array $options): void private function buildCriteria(QueryBuilder $builder, array $options): void
{ {

View file

@ -0,0 +1,196 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Doctrine\Functions;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
/**
* Custom DQL function that extracts the first numeric value with an optional SI prefix
* from a string and returns the scaled numeric value for sorting.
*
* Usage: SI_VALUE_SORT(part.name)
*
* This enables sorting parts by their physical value. For example, capacitors
* named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF.
*
* Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6),
* m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12).
*
* Only matches numbers at the very beginning of the string (ignoring leading whitespace).
* Names like "Crystal 20MHz" will NOT match since the number is not at the start.
* Names without a recognizable numeric+prefix pattern return NULL and sort last.
*/
class SiValueSort extends FunctionNode
{
private ?Node $field = null;
/**
* SI prefix multipliers. Used by the SQLite PHP callback.
*/
private const SI_MULTIPLIERS = [
'p' => 1e-12,
'n' => 1e-9,
'u' => 1e-6,
'µ' => 1e-6,
'm' => 1e-3,
'k' => 1e3,
'K' => 1e3,
'M' => 1e6,
'G' => 1e9,
'T' => 1e12,
];
public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->field = $parser->ArithmeticExpression();
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
public function getSql(SqlWalker $sqlWalker): string
{
assert($this->field !== null, 'Field is not set');
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
$rawField = $this->field->dispatch($sqlWalker);
// Normalize comma decimal separator to dot for SQL platforms (European locale support)
$fieldSql = "REPLACE({$rawField}, ',', '.')";
if ($platform instanceof PostgreSQLPlatform) {
return $this->getPostgreSQLSql($fieldSql);
}
if ($platform instanceof AbstractMySQLPlatform) {
return $this->getMySQLSql($fieldSql);
}
// SQLite: comma normalization is handled in the PHP callback
$fieldSql = $rawField;
if ($platform instanceof SQLitePlatform) {
return "SI_VALUE({$fieldSql})";
}
// Fallback: return NULL (no SI sorting available)
return 'NULL';
}
/**
* PostgreSQL implementation using substring() with POSIX regex.
*/
private function getPostgreSQLSql(string $field): string
{
// Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace)
$numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)";
// Extract the SI prefix character
$prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')";
return $this->buildCaseExpression($numericPart, $prefixPart);
}
/**
* MySQL/MariaDB implementation using REGEXP_SUBSTR.
*/
private function getMySQLSql(string $field): string
{
// Extract the numeric part, anchored at start (with optional leading whitespace)
$numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))";
// Extract the prefix: get the full number+prefix match anchored at start, then take the last char
$fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')";
$prefixPart = "RIGHT({$fullMatch}, 1)";
return $this->buildCaseExpression($numericPart, $prefixPart);
}
/**
* Build a CASE expression that maps an SI prefix character to a multiplier
* and multiplies it with the numeric value.
*
* @param string $numericExpr SQL expression that evaluates to the numeric part
* @param string $prefixExpr SQL expression that evaluates to the SI prefix character
* @return string SQL CASE expression
*/
private function buildCaseExpression(string $numericExpr, string $prefixExpr): string
{
return "(CASE" .
" WHEN {$numericExpr} IS NULL THEN NULL" .
" WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" .
" WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" .
" WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" .
" WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" .
" WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" .
" WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" .
" WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" .
" WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" .
" WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" .
" WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" .
" ELSE {$numericExpr} * 1" .
" END)";
}
/**
* PHP callback for SQLite's SI_VALUE function.
* Extracts the first numeric value with an optional SI prefix and returns the scaled value.
*
* @param string|null $value The input string
* @return float|null The scaled numeric value, or null if no number found
*/
public static function sqliteSiValue(?string $value): ?float
{
if ($value === null) {
return null;
}
// Normalize comma decimal separator to dot (European locale support)
$value = str_replace(',', '.', $value);
// Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix
if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) {
return null;
}
$number = (float) $matches[1];
$prefix = $matches[2] ?? '';
if ($prefix === '') {
return $number;
}
$multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0; //@phpstan-ignore-line - fallback to 1.0 if prefix is not recognized (should not happen due to regex)
return $number * $multiplier;
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Doctrine\Middleware; namespace App\Doctrine\Middleware;
use App\Doctrine\Functions\SiValueSort;
use App\Exceptions\InvalidRegexException; use App\Exceptions\InvalidRegexException;
use Doctrine\DBAL\Driver\Connection; use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware; use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
@ -51,6 +52,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
//Create a new collation for natural sorting //Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...)); $native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
} }
} }

View file

@ -208,6 +208,15 @@ class Category extends AbstractPartsContainingDBElement
$this->eda_info = new EDACategoryInfo(); $this->eda_info = new EDACategoryInfo();
} }
public function __clone()
{
if ($this->id) {
//Clone EDA info to prevent changes to the original EDA info when changing the cloned category
$this->eda_info = clone $this->eda_info;
}
parent::__clone();
}
public function getPartnameHint(): string public function getPartnameHint(): string
{ {
return $this->partname_hint; return $this->partname_hint;

View file

@ -152,6 +152,15 @@ class Footprint extends AbstractPartsContainingDBElement
$this->eda_info = new EDAFootprintInfo(); $this->eda_info = new EDAFootprintInfo();
} }
public function __clone()
{
if ($this->id) {
//Clone EDA info to prevent changes to the original EDA info when changing the cloned category
$this->eda_info = clone $this->eda_info;
}
parent::__clone();
}
/**************************************** /****************************************
* Getters * Getters
****************************************/ ****************************************/

View file

@ -62,8 +62,8 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
return; return;
} }
//Allow to view the progress page //Allow to view the progress page and health check endpoint
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) { if (preg_match('#^/[a-z]{2}(?:_[A-Z]{2})?/system/update-manager/(progress|health)#', $event->getRequest()->getPathInfo())) {
return; return;
} }

View file

@ -0,0 +1,87 @@
<?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\InfoProviderSystem;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
class FromURLFormType extends AbstractType
{
public function __construct(private readonly ProviderRegistry $providerRegistry)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('url', UrlType::class, [
'label' => 'info_providers.from_url.url.label',
'required' => true,
]);
$builder->add('method', ChoiceType::class, [
'expanded' => true,
'data' => 'generic_web', //Default value
'label' => 'info_providers.from_url.method',
'choices' => [
'info_providers.from_url.method.generic_web' => 'generic_web',
'info_providers.from_url.method.ai_web' => 'ai_web',
],
'choice_attr' => function ($choice, $key, $value) {
//Disable all providers that are not active
$provider = $this->providerRegistry->getProviderByKey($value);
if (!$provider->isActive()) {
return ['disabled' => 'disabled'];
}
return [];
},
//Render the choices as inline radio buttons
'label_attr' => [
'class' => 'radio-inline',
],
]);
$builder->add('no_cache', CheckboxType::class, [
'label' => 'info_providers.from_url.no_cache',
'required' => false,
]);
$builder->add('skip_delegation', CheckboxType::class, [
'label' => 'info_providers.from_url.skip_delegation',
'required' => false,
]);
$builder->add('submit', SubmitType::class, [
'label' => 'info_providers.search.submit',
]);
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Form\InfoProviderSystem; namespace App\Form\InfoProviderSystem;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SearchType; use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
@ -40,8 +41,17 @@ class PartSearchType extends AbstractType
'help' => 'info_providers.search.providers.help', 'help' => 'info_providers.search.providers.help',
]); ]);
$builder->add('no_cache_search', CheckboxType::class, [
'label' => 'info_providers.no_cache_search',
'required' => false,
]);
$builder->add('no_cache_details', CheckboxType::class, [
'label' => 'info_providers.no_cache_details',
'required' => false,
]);
$builder->add('submit', SubmitType::class, [ $builder->add('submit', SubmitType::class, [
'label' => 'info_providers.search.submit' 'label' => 'info_providers.search.submit'
]); ]);
} }
} }

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Form\Part\EDA; namespace App\Form\Part\EDA;
use App\Form\Type\StaticFileAutocompleteType; use App\Form\Type\StaticFileAutocompleteType;
use App\Settings\MiscSettings\KiCadEDASettings;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -39,6 +40,13 @@ class KicadFieldAutocompleteType extends AbstractType
//Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies //Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies
public const FOOTPRINT_PATH = 'kicad/footprints.txt'; public const FOOTPRINT_PATH = 'kicad/footprints.txt';
public const SYMBOL_PATH = 'kicad/symbols.txt'; public const SYMBOL_PATH = 'kicad/symbols.txt';
public const CUSTOM_FOOTPRINT_PATH = 'kicad/footprints_custom.txt';
public const CUSTOM_SYMBOL_PATH = 'kicad/symbols_custom.txt';
public function __construct(
private readonly KiCadEDASettings $kiCadEDASettings,
) {
}
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
@ -47,8 +55,8 @@ class KicadFieldAutocompleteType extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'file' => fn(Options $options) => match ($options['type']) { 'file' => fn(Options $options) => match ($options['type']) {
self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH, self::TYPE_FOOTPRINT => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_FOOTPRINT_PATH : self::FOOTPRINT_PATH,
self::TYPE_SYMBOL => self::SYMBOL_PATH, self::TYPE_SYMBOL => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_SYMBOL_PATH : self::SYMBOL_PATH,
default => throw new \InvalidArgumentException('Invalid type'), default => throw new \InvalidArgumentException('Invalid type'),
} }
]); ]);
@ -58,4 +66,4 @@ class KicadFieldAutocompleteType extends AbstractType
{ {
return StaticFileAutocompleteType::class; return StaticFileAutocompleteType::class;
} }
} }

View file

@ -0,0 +1,72 @@
<?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\Settings;
use Symfony\AI\Platform\Capability;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* An text input with autocomplete for AI models from the given platform.
* The platform is determined by the value of another form field, which is specified by the "platform_selector" option. This allows to filter the available models based on the selected platform.
*/
final class AiModelsType extends AbstractType
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
{
}
public function getParent(): string
{
return TextType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
//The target label of the platform select, which is used to filter the models for the selected platform.
$resolver->setRequired('platform_selector');
$resolver->setAllowedTypes('platform_selector', 'string');
//Only show models, that have the given capability. This is used to only show models that support structured output for the AI extractor settings.
$resolver->setDefault('filter_capability', null);
$resolver->setAllowedTypes('filter_capability', ['null', Capability::class]);
}
public function finishView(FormView $view, FormInterface $form, array $options): void
{
$urlOptions = ['platform' => '__PLATFORM__'];
if ($options['filter_capability'] !== null) {
$urlOptions['capability'] = $options['filter_capability']->value;
}
$view->vars['attr']['data-url-template'] = $this->urlGenerator->generate('typeahead_ai_models', $urlOptions);
$view->vars['attr']['data-controller'] = 'elements--ai-model-autocomplete';
$view->vars['attr']['data-platform-selector'] = $options['platform_selector'];
}
}

View file

@ -0,0 +1,65 @@
<?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\Settings;
use App\Services\AI\AIPlatformRegistry;
use App\Services\AI\AIPlatforms;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Allow to choose an AI platform from the enabled platforms in the system. This is used in the settings to choose the default platform for AI features.
*/
final class AiPlatformChoiceType extends AbstractType
{
public function __construct(private readonly AIPlatformRegistry $platformRegistry)
{
}
public function getParent(): string
{
return EnumType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$choices = array_map(static fn(string $val) => AIPlatforms::from($val), array_keys($this->platformRegistry->getEnabledPlatforms()));
$resolver->setDefaults([
'class' => AIPlatforms::class,
'choices' => $choices,
'required' => false,
'platform_selector_label' => null
]);
}
public function finishView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['attr']['data-platform-selector-label'] = $options['platform_selector_label'] ?? $view->vars['id'].'_label';
}
}

View file

@ -0,0 +1,103 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Settings;
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\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type for editing the custom KiCad footprints and symbols lists.
*/
final class KicadListEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('useCustomList', CheckboxType::class, [
'label' => 'settings.misc.kicad_eda.use_custom_list',
'help' => 'settings.misc.kicad_eda.use_custom_list.help',
'required' => false,
])
->add('customFootprints', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.custom_footprints',
'help' => 'settings.misc.kicad_eda.editor.footprints.help',
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
],
])
->add('defaultFootprints', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.default_footprints',
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
'disabled' => true,
'mapped' => false,
'data' => $options['default_footprints'],
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
'readonly' => 'readonly',
],
])
->add('customSymbols', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.custom_symbols',
'help' => 'settings.misc.kicad_eda.editor.symbols.help',
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
],
])
->add('defaultSymbols', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.default_symbols',
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
'disabled' => true,
'mapped' => false,
'data' => $options['default_symbols'],
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
'readonly' => 'readonly',
],
])
->add('save', SubmitType::class, [
'label' => 'save',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'default_footprints' => '',
'default_symbols' => '',
]);
$resolver->setAllowedTypes('default_footprints', 'string');
$resolver->setAllowedTypes('default_symbols', 'string');
}
}

View file

@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType
*/ */
private function getPreferredLocales(): array private function getPreferredLocales(): array
{ {
$fromSettings = $this->localizationSettings->languageMenuEntries ?? []; $fromSettings = $this->localizationSettings->languageMenuEntries;
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam); return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
} }

View file

@ -29,60 +29,137 @@ 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. * HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us.
* It also sets some other headers to make the requests look more like real browser requests.
* 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. * 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 final class RandomizeUseragentHttpClient implements HttpClientInterface
{ {
public const USER_AGENTS = [ private const PROFILES = [
"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", // --- CHROME ON WINDOWS ---
"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", 'chrome_windows' => [
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6", 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
"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", 'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
"Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)", 'Sec-Ch-Ua-Mobile' => '?0',
"Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536", 'Sec-Ch-Ua-Platform' => '"Windows"',
"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", 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
"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", // --- CHROME ON MACOS ---
"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", 'chrome_mac' => [
"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", 'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
"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", 'Sec-Ch-Ua' => '"Google Chrome";v="141", "Chromium";v="141", "Not=A?Brand";v="99"',
"Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9", 'Sec-Ch-Ua-Mobile' => '?0',
"Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535", 'Sec-Ch-Ua-Platform' => '"macOS"',
"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", 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
"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", // --- EDGE ON WINDOWS ---
'edge_windows' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0',
'Sec-Ch-Ua' => '"Microsoft Edge";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
'Sec-Ch-Ua-Mobile' => '?0',
'Sec-Ch-Ua-Platform' => '"Windows"',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
],
// --- FIREFOX ON WINDOWS ---
'firefox_windows' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
// Firefox does not send Sec-Ch-Ua headers by default
],
// --- FIREFOX ON LINUX ---
'firefox_linux' => [
'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.5',
],
// --- SAFARI ON MACOS ---
'safari_mac' => [
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
],
// --- CHROME ON ANDROID (Mobile) ---
'chrome_android' => [
'User-Agent' => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Mobile Safari/537.36',
'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
'Sec-Ch-Ua-Mobile' => '?1',
'Sec-Ch-Ua-Platform' => '"Android"',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
],
// --- SAFARI ON IPHONE (Mobile) ---
'safari_iphone' => [
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
],
]; ];
private const COMMON_HEADERS = [
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language' => 'en-US,en;q=0.9',
'Sec-Fetch-Dest' => 'document',
'Sec-Fetch-Mode' => 'navigate',
'Sec-Fetch-Site' => 'none',
'Sec-Fetch-User' => '?1',
'Upgrade-Insecure-Requests' => '1',
];
private const ENTRY_REFERERS = [
'https://www.google.com/',
'https://www.bing.com/',
'https://duckduckgo.com/',
'https://t.co/', // Twitter/X shortener
'https://www.reddit.com/',
];
private ?string $lastUrl = null;
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly array $userAgents = self::USER_AGENTS,
private readonly int $repeatOnFailure = 1, 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 public function request(string $method, string $url, array $options = []): ResponseInterface
{ {
$repeatsLeft = $this->repeatOnFailure; $repeatsLeft = $this->repeatOnFailure;
do { do {
$modifiedOptions = $options; $profile = self::PROFILES[array_rand(self::PROFILES)];
if (!isset($modifiedOptions['headers']['User-Agent'])) {
$modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent(); // Merge common headers with the specific browser profile
$headers = array_merge(self::COMMON_HEADERS, $profile);
//Add a Referer header if not already set, to make it look more like a real browser request. We use the last URL we visited as the referer, to simulate internal navigation. If we don't have a last URL (first request), we pick a random entry point from common referers.
if (!isset($options['headers']['Referer'])) {
if ($this->lastUrl !== null) {
// If we have a previous URL, use it (Internal Navigation)
$headers['Referer'] = $this->lastUrl;
} else {
// First request? Pick an entry point (External Entry)
$headers['Referer'] = self::ENTRY_REFERERS[array_rand(self::ENTRY_REFERERS)];
}
} }
$response = $this->client->request($method, $url, $modifiedOptions);
// Allow manual overrides from $options
$options['headers'] = array_merge($headers, $options['headers'] ?? []);
$response = $this->client->request($method, $url, $options);
//When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent //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)) { if (!in_array($response->getStatusCode(), [403, 429, 503], true)) {
$this->lastUrl = $url; // Update last visited URL for referer in the next request
return $response; return $response;
} }
//Otherwise we try again with a different user agent, until we run out of retries //Otherwise we try again with a different user agent, until we run out of retries
usleep(5000); // Sleep for 5ms to avoid hammering the server too hard in case of multiple retries
} while ($repeatsLeft-- > 0); } while ($repeatsLeft-- > 0);
return $response; return $response;
@ -95,6 +172,6 @@ final class RandomizeUseragentHttpClient implements HttpClientInterface
public function withOptions(array $options): static public function withOptions(array $options): static
{ {
return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure); return new self($this->client->withOptions($options), $this->repeatOnFailure);
} }
} }

View file

@ -0,0 +1,94 @@
<?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\Services\AI;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
use Symfony\AI\Platform\PlatformInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final readonly class AIPlatformRegistry
{
/**
* All registered platforms, indexed by their service tag name (e.g. "openrouter", "lmstudio")
* @var array<string, PlatformInterface> $allPlatforms
*/
private array $allPlatforms;
/**
* All registered platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value)
* @var array<string, PlatformInterface> $enabledPlatforms
*/
private array $enabledPlatforms;
public function __construct(
SettingsManagerInterface $settingsManager,
#[AutowireIterator(tag: 'ai.platform', indexAttribute: 'name')]
iterable $platforms,
) {
$this->allPlatforms = iterator_to_array($platforms);
//Check which platforms are active based on the settings and store them in $activePlatforms
$tmp = [];
foreach (AIPlatforms::cases() as $platform) {
if (isset($this->allPlatforms[$platform->toServiceTagName()])) {
//Check if the platform is active by calling its isActive() on the settings class
$settings = $settingsManager->get($platform->toSettingsClass());
if (!$settings->isAIPlatformEnabled()) {
continue;
}
$tmp[$platform->value] = $this->allPlatforms[$platform->toServiceTagName()];
}
}
$this->enabledPlatforms = $tmp;
}
public function getPlatform(AIPlatforms $platform): PlatformInterface
{
if (!isset($this->enabledPlatforms[$platform->value])) {
throw new \InvalidArgumentException(sprintf('AI platform "%s" is not active or does not exist.', $platform->name));
}
return $this->enabledPlatforms[$platform->value];
}
/**
* Check if the given platform is active (i.e. it is registered and its settings are properly configured)
* @param AIPlatforms $platform
* @return bool
*/
public function isEnabled(AIPlatforms $platform): bool
{
return isset($this->enabledPlatforms[$platform->value]);
}
/**
* Returns an array of all active platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value)
* @return PlatformInterface[]
*/
public function getEnabledPlatforms(): array
{
return $this->enabledPlatforms;
}
}

View file

@ -0,0 +1,33 @@
<?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\Services\AI;
interface AIPlatformSettingsInterface
{
/**
* Returns true, if the AI platform is enabled in the settings and can be used, false otherwise.
* @return bool
*/
public function isAIPlatformEnabled(): bool;
}

View file

@ -0,0 +1,64 @@
<?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\Services\AI;
use App\Settings\AISettings\LMStudioSettings;
use App\Settings\AISettings\OpenRouterSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum AIPlatforms: string implements TranslatableInterface
{
case OPENROUTER = 'openrouter';
case LMSTUDIO = 'lmstudio';
/**
* Returns the name attribute of the service tag for this platform, which is used to register the platform in the AIPlatformRegistry
* @return string
*/
public function toServiceTagName(): string
{
return $this->value;
}
/**
* Returns the class name of the settings class for this platform, which implements AIPlatformSettingsInterface
* @return string
* @phpstan-return class-string<AIPlatformSettingsInterface>
*/
public function toSettingsClass(): string
{
return match ($this) {
self::LMSTUDIO => LMStudioSettings::class,
self::OPENROUTER => OpenRouterSettings::class,
};
}
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
$key = 'settings.ai.' . $this->value;
return $translator->trans($key, locale: $locale);
}
}

View file

@ -0,0 +1,61 @@
<?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\Services\AI;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Exception\ModelNotFoundException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This is a wrapper, to allow accepting all models, even if they are not contained in the decorated ModelCatalogInterface.
* This is a workaround for outdated/incomplete model catalogs provided by AI platforms, which do not contain all available models, or do not update their catalogs frequently enough.
*/
#[AsDecorator('ai.platform.model_catalog.lmstudio')]
#[AsDecorator('ai.platform.model_catalog.openrouter')]
final readonly class AcceptAllModelsCatalog implements ModelCatalogInterface
{
public function __construct(private ModelCatalogInterface $decorated)
{
}
public function getModel(string $modelName): Model
{
//Use the actual values when its available.
try {
return $this->decorated->getModel($modelName);
} catch (ModelNotFoundException $e) {
//If the model is not found, return a generic model with the given name and no capabilities.
return new CompletionsModel($modelName, []);
}
}
public function getModels(): array
{
//Return the actual models catalog here for correct autocompletition
return $this->decorated->getModels();
}
}

View file

@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException;
use App\Settings\SystemSettings\AttachmentsSettings; use App\Settings\SystemSettings\AttachmentsSettings;
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile; use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile; use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use const DIRECTORY_SEPARATOR; use const DIRECTORY_SEPARATOR;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
@ -76,6 +78,8 @@ class AttachmentSubmitHandler
protected FileTypeFilterTools $filterTools, protected FileTypeFilterTools $filterTools,
protected AttachmentsSettings $settings, protected AttachmentsSettings $settings,
protected readonly SVGSanitizer $SVGSanitizer, protected readonly SVGSanitizer $SVGSanitizer,
#[Autowire(env: "bool:ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK")]
private readonly bool $allow_local_network_downloads = false,
) )
{ {
//The mapping used to determine which folder will be used for an attachment type //The mapping used to determine which folder will be used for an attachment type
@ -95,6 +99,10 @@ class AttachmentSubmitHandler
UserAttachment::class => 'user', UserAttachment::class => 'user',
LabelAttachment::class => 'label_profile', LabelAttachment::class => 'label_profile',
]; ];
if (!$this->allow_local_network_downloads) {
$this->httpClient = new NoPrivateNetworkHttpClient($this->httpClient);
}
} }
/** /**
@ -373,6 +381,7 @@ class AttachmentSubmitHandler
], ],
]; ];
$response = $this->httpClient->request('GET', $url, $opts); $response = $this->httpClient->request('GET', $url, $opts);
//Digikey wants TLSv1.3, so try again with that if we get a 403 //Digikey wants TLSv1.3, so try again with that if we get a 403
if ($response->getStatusCode() === 403) { if ($response->getStatusCode() === 403) {
@ -434,8 +443,8 @@ class AttachmentSubmitHandler
$new_path = $this->pathResolver->realPathToPlaceholder($new_path); $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
//Save the path to the attachment //Save the path to the attachment
$attachment->setInternalPath($new_path); $attachment->setInternalPath($new_path);
} catch (TransportExceptionInterface) { } catch (TransportExceptionInterface $exception) {
throw new AttachmentDownloadException('Transport error!'); throw new AttachmentDownloadException('Transport error: '.$exception->getMessage());
} }
return $attachment; return $attachment;

View file

@ -0,0 +1,158 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\EDA;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
/**
* Manages the KiCad footprints and symbols list files, including reading, writing and ensuring their existence.
*/
final class KicadListFileManager implements CacheWarmerInterface
{
private const FOOTPRINTS_PATH = '/public/kicad/footprints.txt';
private const SYMBOLS_PATH = '/public/kicad/symbols.txt';
private const CUSTOM_FOOTPRINTS_PATH = '/public/kicad/footprints_custom.txt';
private const CUSTOM_SYMBOLS_PATH = '/public/kicad/symbols_custom.txt';
private const CUSTOM_TEMPLATE = <<<'EOT'
# Custom KiCad autocomplete entries. One entry per line.
EOT;
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
}
public function getFootprintsContent(): string
{
return $this->readFile(self::FOOTPRINTS_PATH);
}
public function getCustomFootprintsContent(): string
{
//Ensure that the custom file exists, so that the UI can always display it without error.
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
return $this->readFile(self::CUSTOM_FOOTPRINTS_PATH);
}
public function getSymbolsContent(): string
{
return $this->readFile(self::SYMBOLS_PATH);
}
public function getCustomSymbolsContent(): string
{
//Ensure that the custom file exists, so that the UI can always display it without error.
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
return $this->readFile(self::CUSTOM_SYMBOLS_PATH);
}
public function saveCustom(string $footprints, string $symbols): void
{
$this->writeFile(self::CUSTOM_FOOTPRINTS_PATH, $this->normalizeContent($footprints));
$this->writeFile(self::CUSTOM_SYMBOLS_PATH, $this->normalizeContent($symbols));
}
private function readFile(string $path): string
{
$fullPath = $this->projectDir . $path;
if (!is_file($fullPath)) {
return '';
}
$content = file_get_contents($fullPath);
if ($content === false) {
throw new RuntimeException(sprintf('Failed to read KiCad list file "%s".', $fullPath));
}
return $content;
}
private function writeFile(string $path, string $content): void
{
$fullPath = $this->projectDir . $path;
$tmpPath = $fullPath . '.tmp';
if (file_put_contents($tmpPath, $content, LOCK_EX) === false) {
throw new RuntimeException(sprintf('Failed to write KiCad list file "%s".', $fullPath));
}
if (!rename($tmpPath, $fullPath)) {
@unlink($tmpPath);
throw new RuntimeException(sprintf('Failed to replace KiCad list file "%s".', $fullPath));
}
}
private function normalizeContent(string $content): string
{
$normalized = str_replace(["\r\n", "\r"], "\n", $content);
if ($normalized !== '' && !str_ends_with($normalized, "\n")) {
$normalized .= "\n";
}
return $normalized;
}
private function createCustomFileIfNotExists(string $path): void
{
$fullPath = $this->projectDir . $path;
if (!is_file($fullPath)) {
if (file_put_contents($fullPath, self::CUSTOM_TEMPLATE, LOCK_EX) === false) {
throw new RuntimeException(sprintf('Failed to create custom footprints file "%s".', $fullPath));
}
}
}
/**
* Ensures that the custom footprints and symbols files exist, so that the UI can always display them without error.
* @return void
*/
public function createCustomFilesIfNotExist(): void
{
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
}
public function isOptional(): bool
{
return false;
}
/**
* Ensure that the custom footprints and symbols files exist and generate them on cache warmup, so that the frontend
* can always display them without error, even if the user has not yet visited the settings page.
*/
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$this->createCustomFilesIfNotExist();
return [];
}
}

View file

@ -721,26 +721,36 @@ class BOMImporter
return $mapped; return $mapped;
} }
/**
* Try to detect the separator used in the CSV data by analyzing the first line and counting occurrences of common delimiters.
* @param string $data
* @return string
*/
public function detectDelimiter(string $data): string
{
$delimiters = [',', ';', "\t"];
$lines = explode("\n", $data, 2);
$header_line = $lines[0] ?? '';
$delimiter_counts = [];
foreach ($delimiters as $delim) {
$delimiter_counts[$delim] = substr_count($header_line, $delim);
}
// Choose the delimiter with the highest count, default to comma if all are zero
$max_count = max($delimiter_counts);
$delimiter = array_search($max_count, $delimiter_counts, true);
if ($max_count === 0 || $delimiter === false) {
$delimiter = ',';
}
return $delimiter;
}
/** /**
* Detect available fields in CSV data for field mapping UI * Detect available fields in CSV data for field mapping UI
*/ */
public function detectFields(string $data, ?string $delimiter = null): array public function detectFields(string $data, ?string $delimiter = null): array
{ {
if ($delimiter === null) { if ($delimiter === null) {
// Detect delimiter by counting occurrences in the first row (header) $delimiter = $this->detectDelimiter($data);
$delimiters = [',', ';', "\t"];
$lines = explode("\n", $data, 2);
$header_line = $lines[0] ?? '';
$delimiter_counts = [];
foreach ($delimiters as $delim) {
$delimiter_counts[$delim] = substr_count($header_line, $delim);
}
// Choose the delimiter with the highest count, default to comma if all are zero
$max_count = max($delimiter_counts);
$delimiter = array_search($max_count, $delimiter_counts, true);
if ($max_count === 0 || $delimiter === false) {
$delimiter = ',';
}
} }
// Handle potential BOM (Byte Order Mark) at the beginning // Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data); $data = preg_replace('/^\xEF\xBB\xBF/', '', $data);

View file

@ -46,7 +46,6 @@ final class BulkInfoProviderService
} }
$partResults = []; $partResults = [];
$hasAnyResults = false;
// Group providers by batch capability // Group providers by batch capability
$batchProviders = []; $batchProviders = [];
@ -88,7 +87,6 @@ final class BulkInfoProviderService
); );
if (!empty($allResults)) { if (!empty($allResults)) {
$hasAnyResults = true;
$searchResults = $this->formatSearchResults($allResults); $searchResults = $this->formatSearchResults($allResults);
} }
@ -99,10 +97,6 @@ final class BulkInfoProviderService
); );
} }
if (!$hasAnyResults) {
throw new \RuntimeException('No search results found for any of the selected parts');
}
$response = new BulkSearchResponseDTO($partResults); $response = new BulkSearchResponseDTO($partResults);
// Prefetch details if requested // Prefetch details if requested

View file

@ -0,0 +1,109 @@
<?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\Services\InfoProviderSystem;
use App\Entity\UserSystem\User;
use App\Exceptions\ProviderIDNotSupportedException;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class CreateFromUrlHelper
{
public function __construct(private Security $security,
private ProviderRegistry $providerRegistry,
private PartInfoRetriever $infoRetriever,
)
{
}
/**
* Checks if at least one provider can create parts from an URL and the current user is allowed to use it.
* This is used to determine if the "From URL" feature should be shown to the user.
* @return bool
*/
public function canCreateFromUrl(): bool
{
if (!$this->security->isGranted('@info_providers.create_parts')) {
return false;
}
//Check if either the generic web provider or the ai web provider is active
$genericWebProvider = $this->providerRegistry->getProviderByKey('generic_web');
$aiWebProvider = $this->providerRegistry->getProviderByKey('ai_web');
return $genericWebProvider->isActive() || $aiWebProvider->isActive();
}
/**
* Delegates the URL to another provider if possible, otherwise return null
* @param string $url
* @return SearchResultDTO|null
*/
public function delegateToOtherProvider(string $url, InfoProviderInterface $callingInfoProvider): ?SearchResultDTO
{
//Extract domain from url:
$host = parse_url($url, PHP_URL_HOST);
if ($host === false || $host === null) {
return null;
}
$provider = $this->providerRegistry->getProviderHandlingDomain($host);
if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $callingInfoProvider->getProviderKey()) {
try {
$id = $provider->getIDFromURL($url);
if ($id !== null) {
$results = $this->infoRetriever->searchByKeyword($id, [$provider]);
if (count($results) > 0) {
return $results[0];
}
}
return null;
} catch (ProviderIDNotSupportedException $e) {
//Ignore and continue
return null;
}
}
return null;
}
/**
* Delegates the URL to another provider if possible and returns the details, otherwise return null
* @param string $url
* @param InfoProviderInterface $callingInfoProvider
* @return PartDetailDTO|null
*/
public function delegateToOtherProviderDetails(string $url, InfoProviderInterface $callingInfoProvider): ?PartDetailDTO
{
$delegatedResult = $this->delegateToOtherProvider($url, $callingInfoProvider);
if ($delegatedResult !== null) {
return $this->infoRetriever->getDetailsForSearchResult($delegatedResult);
}
return null;
}
}

View file

@ -0,0 +1,252 @@
<?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\Services\InfoProviderSystem;
use App\Entity\Parts\ManufacturingStatus;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
/**
* This class allows to convert the JSON data returned by an LLM into the DTOs used by the info provider system later.
*/
final class DTOJsonSchemaConverter
{
/**
* Returns the JSON schema, that defines the expected structure of the JSON data returned by the LLM.
* @return array
*/
public function getJSONSchema(): array
{
return [
'name' => 'clock',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'Product name'],
'description' => ['type' => 'string', 'description' => 'A short description of the product, maybe containing the most important things. Onnly One line.'],
'manufacturer' => ['type' => ['string', 'null'], 'description' => 'Manufacturer name'],
'mpn' => ['type' => ['string', 'null'], 'description' => 'Manufacturer Part Number'],
'category' => ['type' => ['string', 'null'], 'description' => 'Product category, e.g. "Passive components -> Resistors"'],
'manufacturing_status' => ['type' => ['string', 'null'], 'enum' => ['active', 'obsolete', 'nrfnd', 'discontinued', null], 'description' => 'Manufacturing status'],
'footprint' => ['type' => ['string', 'null'], 'description' => 'Package/footprint type, like "SOT-23", "DIP-8", "QFN-32" etc.'],
'mass' => ['type' => ['number', 'null'], 'description' => 'Mass of the product in grams'],
'gtin' => ['type' => ['string', 'null'], 'description' => 'Global Trade Item Number (GTIN) / EAN / UPC code for barcodes'],
'notes' => ['type' => ['string', 'null'], 'description' => 'Optional long description of the part with more details than description. Can be markdown formatted.'],
'parameters' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'symbol' => ['type' => ['string', 'null'], 'description' => 'An optional quantity symbol for the parameter in latex code, like R_1'],
'value_typical' => ['type' => ['number', 'null'], 'description' => 'The typical value of the parameter. For example, for a resistor this could be 100 for a 100 Ohm resistor. Also used if only one numeric value is given. If used an unit should be given'],
'value_min' => ['type' => ['number', 'null'], 'description' => 'If a range is given for the parameter, this is the minimum value. Null if no range is given.'],
'value_max' => ['type' => ['number', 'null'], 'description' => 'If a range is given for the parameter, this is the maximum value. Null if not a range.'],
'value_text' => ['type' => ['string', 'null'], 'description' => 'When a value is not numeric it can be put here as text. Only use if it does not fit in value_min, value_typical or value_max. E.g. "Yes", "Red", etc.'],
'group' => ['type' => ['string', 'null'], 'description' => 'An optional group name for the parameter, e.g. "Electrical parameters", "Mechanical parameters" etc.'],
'unit' => ['type' => ['string', 'null'], 'description' => 'The unit of the parameter values, e.g. kg, Ohm, V, etc.'],
],
'required' => ['name', 'value_typical', 'value_min', 'value_max', 'value_text']
],
],
'datasheets' => [
'description' => 'A list of datasheets, manuals, or other technical documents related to the product. Not images, but actual documents, preferably PDFs.',
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'url' => ['type' => 'string'],
'description' => ['type' => 'string'],
],
'required' => ['url'],
],
],
'images' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'url' => ['type' => 'string'],
'description' => ['type' => 'string'],
],
'required' => ['url'],
],
],
'vendor_infos' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'distributor_name' => ['type' => 'string', 'description' => 'Name of the distributor or vendor. Typically the shop name'],
'order_number' => ['type' => ['string', 'null'], 'description' => 'The order number or SKU used by the distributor. Optional, but can help to find the product on the distributor website.'],
'product_url' => ['type' => 'string'],
'prices_include_vat' => ['type' => ['boolean', 'null'], 'description' => 'Whether the prices include VAT or not. Null if unknown.'],
'prices' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'minimum_quantity' => ['type' => 'integer', 'description' => 'Minimum quantity for this price tier. 1 when no tiered pricing is available.'],
'price' => ['type' => 'number', 'description' => 'Price for the given minimum quantity.'],
'currency' => ['type' => 'string', 'description' => 'Currency ISO code, e.g. USD'],
],
'required' => ['minimum_quantity', 'price', 'currency'],
],
],
],
'required' => ['distributor_name', 'product_url'],
],
],
'manufacturer_product_url' => ['type' => ['string', 'null'], 'description' => 'Manufacturer product page URL'],
],
'required' => ['name', 'description'],
]
];
}
public function jsonToDTO(array $data, string $providerKey, string $providerId, ?string $productUrl = null, string $distributorNameFallback = '???'): PartDetailDTO
{
// Map manufacturing status
$manufacturingStatus = null;
if (!empty($data['manufacturing_status'])) {
$status = strtolower((string) $data['manufacturing_status']);
$manufacturingStatus = match ($status) {
'active' => ManufacturingStatus::ACTIVE,
'obsolete', 'discontinued' => ManufacturingStatus::DISCONTINUED,
'nrfnd', 'not recommended for new designs' => ManufacturingStatus::NRFND,
'eol' => ManufacturingStatus::EOL,
'announced' => ManufacturingStatus::ANNOUNCED,
default => null,
};
}
// Build parameters
$parameters = null;
if (!empty($data['parameters']) && is_array($data['parameters'])) {
$parameters = [];
foreach ($data['parameters'] as $p) {
if (!empty($p['name'])) {
$parameters[] = new ParameterDTO(
name: $p['name'],
value_text: $p['value_text'] ?? null,
value_typ: isset($p['value_typical']) && is_numeric($p['value_typical']) ? (float) $p['value_typical'] : null,
value_min: isset($p['value_min']) && is_numeric($p['value_min']) ? (float) $p['value_min'] : null,
value_max: isset($p['value_max']) && is_numeric($p['value_max']) ? (float) $p['value_max'] : null,
unit: $p['unit'] ?? null,
symbol: $p['symbol'] ?? null,
group: $p['group'] ?? null,
);
}
}
}
// Build datasheets
$datasheets = null;
if (!empty($data['datasheets']) && is_array($data['datasheets'])) {
$datasheets = [];
foreach ($data['datasheets'] as $d) {
if (!empty($d['url'])) {
$datasheets[] = new FileDTO(
url: $d['url'],
name: $d['description'] ?? 'Datasheet'
);
}
}
}
// Build images
$images = null;
if (!empty($data['images']) && is_array($data['images'])) {
$images = [];
foreach ($data['images'] as $i) {
if (!empty($i['url'])) {
$images[] = new FileDTO(
url: $i['url'],
name: $i['description'] ?? 'Image'
);
}
}
}
// Build vendor infos
$vendorInfos = null;
if (!empty($data['vendor_infos']) && is_array($data['vendor_infos'])) {
$vendorInfos = [];
foreach ($data['vendor_infos'] as $v) {
$prices = [];
if (!empty($v['prices']) && is_array($v['prices'])) {
foreach ($v['prices'] as $p) {
$prices[] = new PriceDTO(
minimum_discount_amount: (int) ($p['minimum_quantity'] ?? 1),
price: (string) ($p['price'] ?? 0),
currency_iso_code: $p['currency'] ?? null,
price_related_quantity: 1,
);
}
}
$vendorInfos[] = new PurchaseInfoDTO(
distributor_name: $v['distributor_name'] ?? $distributorNameFallback,
order_number: $v['order_number'] ?? 'Unknown',
prices: $prices,
product_url: $v['product_url'] ?? $productUrl,
prices_include_vat: $v['prices_include_vat'] ?? null,
);
}
}
// Get preview image URL
$previewImageUrl = null;
if (!empty($data['images']) && is_array($data['images']) && !empty($data['images'][0]['url'])) {
$previewImageUrl = $data['images'][0]['url'];
}
return new PartDetailDTO(
provider_key: $providerKey,
provider_id: $providerId,
name: $data['name'] ?? 'Unknown',
description: $data['description'] ?? '',
category: $data['category'] ?? null,
manufacturer: $data['manufacturer'] ?? null,
mpn: $data['mpn'] ?? null,
preview_image_url: $previewImageUrl,
manufacturing_status: $manufacturingStatus,
provider_url: $productUrl,
footprint: $data['footprint'] ?? null,
gtin: $data['gtin'] ?? null,
notes: $data['notes'] ?? null,
datasheets: $datasheets,
images: $images,
parameters: $parameters,
vendor_infos: $vendorInfos,
mass: isset($data['mass']) && is_numeric($data['mass']) ? (float) $data['mass'] : null,
manufacturer_product_url: $data['manufacturer_product_url'] ?? null,
);
}
}

View file

@ -53,6 +53,7 @@ final class PartInfoRetriever
* Search for a keyword in the given providers. The results can be cached * Search for a keyword in the given providers. The results can be cached
* @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances * @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 * @param string $keyword The keyword to search for
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
* @return SearchResultDTO[] The search results * @return SearchResultDTO[] The search results
* @throws InfoProviderNotActiveException if any of the given providers is not active * @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 ClientException if any of the providers throws an exception during the search
@ -60,7 +61,7 @@ final class PartInfoRetriever
* @throws TransportException if any of the providers throws an exception during the search * @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 * @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 public function searchByKeyword(string $keyword, array $providers, array $options = []): array
{ {
$results = []; $results = [];
@ -79,7 +80,7 @@ final class PartInfoRetriever
} }
/** @noinspection SlowArrayOperationsInLoopInspection */ /** @noinspection SlowArrayOperationsInLoopInspection */
$results = array_merge($results, $this->searchInProvider($provider, $keyword)); $results = array_merge($results, $this->searchInProvider($provider, $keyword, $options));
} }
return $results; return $results;
@ -89,15 +90,31 @@ final class PartInfoRetriever
* Search for a keyword in the given provider. The result is cached for 7 days. * Search for a keyword in the given provider. The result is cached for 7 days.
* @return SearchResultDTO[] * @return SearchResultDTO[]
*/ */
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array protected function searchInProvider(InfoProviderInterface $provider, string $keyword, array $options = []): array
{ {
//Generate key and escape reserved characters from the provider id //Generate key and escape reserved characters from the provider id
$escaped_keyword = hash('xxh3', $keyword); $escaped_keyword = hash('xxh3', $keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
$no_cache = $options[InfoProviderInterface::OPTION_NO_CACHE] ?? false;
//Exclude the no_cache option from the options hash, since it should not affect the cache key, as it only determines whether to bypass the cache or not, but does not change the actual search results
$options_without_cache = $options;
unset($options_without_cache[InfoProviderInterface::OPTION_NO_CACHE]);
//Generate a hash for the options, to ensure that different options result in different cache entries
$options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR));
$cache_key = "search_{$provider->getProviderKey()}_{$escaped_keyword}_{$options_hash}";
//If no_cache is set, bypass the cache and get fresh results from the provider
if ($no_cache) {
$this->partInfoCache->delete($cache_key);
}
return $this->partInfoCache->get($cache_key, function (ItemInterface $item) use ($provider, $keyword, $options) {
//Set the expiration time //Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10); $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10);
return $provider->searchByKeyword($keyword); return $provider->searchByKeyword($keyword, $options);
}); });
} }
@ -106,10 +123,11 @@ final class PartInfoRetriever
* The result is cached for 4 days. * The result is cached for 4 days.
* @param string $provider_key * @param string $provider_key
* @param string $part_id * @param string $part_id
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
* @return PartDetailDTO * @return PartDetailDTO
* @throws InfoProviderNotActiveException if the the given providers is not active * @throws InfoProviderNotActiveException if the the given providers is not active
*/ */
public function getDetails(string $provider_key, string $part_id): PartDetailDTO public function getDetails(string $provider_key, string $part_id, array $options = []): PartDetailDTO
{ {
$provider = $this->provider_registry->getProviderByKey($provider_key); $provider = $this->provider_registry->getProviderByKey($provider_key);
@ -118,13 +136,26 @@ final class PartInfoRetriever
throw InfoProviderNotActiveException::fromProvider($provider); throw InfoProviderNotActiveException::fromProvider($provider);
} }
//Exclude the no_cache option from the options hash, since it should not affect the cache key, as it only determines whether to bypass the cache or not, but does not change the actual search results
$options_without_cache = $options;
unset($options_without_cache[InfoProviderInterface::OPTION_NO_CACHE]);
//Generate a hash for the options, to ensure that different options result in different cache entries
$options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR));
//Generate key and escape reserved characters from the provider id //Generate key and escape reserved characters from the provider id
$escaped_part_id = hash('xxh3', $part_id); $escaped_part_id = hash('xxh3', $part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) { $cache_key = "details_{$provider_key}_{$escaped_part_id}_{$options_hash}";
//Delete the cache entry if no_cache is set, to ensure that the next get call will fetch fresh data from the provider, instead of returning stale data from the cache.
if ($options[InfoProviderInterface::OPTION_NO_CACHE] ?? false) {
$this->partInfoCache->delete($cache_key);
}
return $this->partInfoCache->get($cache_key, function (ItemInterface $item) use ($provider, $part_id, $options) {
//Set the expiration time //Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10); $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10);
return $provider->getDetails($part_id); return $provider->getDetails($part_id, $options);
}); });
} }

View file

@ -0,0 +1,312 @@
<?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)
* Copyright (C) 2026 Rahul Singh (https://github.com/rahools)
*
* 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\Exceptions\ProviderIDNotSupportedException;
use App\Helpers\RandomizeUseragentHttpClient;
use App\Services\AI\AIPlatformRegistry;
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
use App\Services\InfoProviderSystem\DTOJsonSchemaConverter;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Settings\InfoProviderSystem\AIExtractorSettings;
use Brick\Schema\SchemaReader;
use Imagine\Image\Format;
use Jkphl\Micrometa;
use League\HTMLToMarkdown\HtmlConverter;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Component\Intl\Languages;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use function Symfony\Component\String\u;
final class AIWebProvider implements InfoProviderInterface
{
use FixAndValidateUrlTrait;
private const DISTRIBUTOR_NAME = 'Website';
private readonly HttpClientInterface $httpClient;
public function __construct(
HttpClientInterface $httpClient,
private readonly AIExtractorSettings $settings,
private readonly AIPlatformRegistry $AIPlatformRegistry,
private readonly DTOJsonSchemaConverter $jsonSchemaConverter,
private readonly CacheItemPoolInterface $partInfoCache,
private readonly CreateFromUrlHelper $createFromUrlHelper,
) {
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
[
'timeout' => 15,
]
);
}
public function getProviderInfo(): array
{
return [
'name' => 'AI Web Extractor',
'description' => 'Extract part info from any URL using LLM',
//'url' => 'https://openrouter.ai',
'disabled_help' => 'Configure AI settings',
'settings_class' => AIExtractorSettings::class,
];
}
public function getProviderKey(): string
{
return 'ai_web';
}
public function isActive(): bool
{
return $this->settings->platform !== null && $this->settings->model !== null && $this->settings->model !== '';
}
public function searchByKeyword(string $keyword, array $options = []): array
{
$url = $this->fixAndValidateURL($keyword);
if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
//Before loading the page, try to delegate to another provider
$delegatedPart = $this->createFromUrlHelper->delegateToOtherProvider($url, $this);
if ($delegatedPart !== null) {
return [$delegatedPart];
}
}
try {
$new_options = $options;
$new_options[self::OPTION_SKIP_DELEGATION] = true; //Skip delegation for the getDetails call to prevent infinite loops
return [
$this->getDetails($keyword, $new_options)
]; } catch (ProviderIDNotSupportedException $e) {
return [];
}
}
public function getDetails(string $id, array $options = []): PartDetailDTO
{
$url = $this->fixAndValidateURL($id);
if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
//Before loading the page, try to delegate to another provider
$delegatedPart = $this->createFromUrlHelper->delegateToOtherProviderDetails($url, $this);
if ($delegatedPart !== null) {
return $delegatedPart;
}
}
//Check if we have a cached result for this URL, to avoid unnecessary LLM calls, which can be slow and costly.
$cacheKey = 'ai_web_'.hash('xxh3', $url);
//If ignore cache option is set, skip cache and fetch fresh data
if ($options[self::OPTION_NO_CACHE] ?? false) {
$this->partInfoCache->deleteItem($cacheKey);
}
//Return cached result if available
$cacheItem = $this->partInfoCache->getItem($cacheKey);
if ($cacheItem->isHit()) {
return $cacheItem->get();
}
// Fetch HTML content
$response = $this->httpClient->request('GET', $url);
$html = $response->getContent();
//Convert html to markdown, to provide a cleaner input to the LLM.
$markdown = $this->htmlToMarkdown($html, $url);
//Truncate markdown to max content length, if needed
$markdown = u($markdown)->truncate($this->settings->maxContentLength, '... [truncated]')->toString();
//Extract structured data using traditional methods, to provide additional context to the LLM. This can help improve accuracy, especially for technical specifications that might be in tables or specific formats.
$structuredData = $this->extractStructuredData($html, $url);
// Call LLM
$llmResponse = $this->callLLM($markdown, $url, $structuredData);
// Build and return PartDetailDTO
$result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, $this->getProviderKey(), $url, $url, self::DISTRIBUTOR_NAME);
// Cache the result for future use, to improve performance and reduce costs.
$cacheItem->set($result);
$cacheItem->expiresAfter(3600 * 2); //Cache for 2 hours, as web content can change frequently, but we still want to benefit from caching for repeated accesses.
$this->partInfoCache->save($cacheItem);
return $result;
}
/**
* Extracts structured data from the HTML using microformats.
* @param string $html
* @param string $url
* @return string JSON encoded structured data
*/
private function extractStructuredData(string $html, string $url): string
{
//Only parse microdata, json-ld and rdfa, as they are the most common formats for structured data on product pages. Links and microformat only create clutter for the LLM
$micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA | Micrometa\Ports\Format::RDFA_LITE);
$items = $micrometa($url, $html);
return json_encode($items->toObject(), JSON_THROW_ON_ERROR);
}
private function htmlToMarkdown(string $html, string $url): string
{
$crawler = new Crawler($html);
//Replace relative URLs with absolute URLs, to ensure that the LLM has full context and can access the links if needed.
$baseUrl = $crawler->getBaseHref() ?? $url;
//Replace all relative links with their absolute counnterparts, to provide more context to the LLM and to ensure that any links included in the markdown are valid and can be accessed if needed.
$crawler->filter('a')->each(function (Crawler $node) use ($baseUrl) {
$href = $node->attr('href');
if ($href) {
$absoluteUrl = UriResolver::resolve($href, $baseUrl);
//@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements.
$node->getNode(0)->setAttribute('href', $absoluteUrl);
}
});
$crawler->filter('img')->each(function (Crawler $node) use ($baseUrl) {
$src = $node->attr('src');
if ($src) {
$absoluteUrl = UriResolver::resolve($src, $baseUrl);
//@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements.
$node->getNode(0)->setAttribute('src', $absoluteUrl);
}
});
//Extract only the main content of the page to avoid overwhelming the LLM with irrelevant information.
$mainContent = $crawler->filter('main, article, #content');
// If we found a specific content area, get its HTML; otherwise, use the whole body.
//Concat the html of all matched nodes, to provide more context to the LLM, especially for pages that use multiple sections for product info.
if ($mainContent->count() > 0) {
$htmlToConvert = '';
foreach ($mainContent as $node) {
$htmlToConvert .= $node->ownerDocument->saveHTML($node);
$htmlToConvert .= "\n\n"; // Add some spacing between sections
}
} else {
//Use the whole body content, as it might contain relevant information, especially for simpler pages that don't have a clear main/content section.
$htmlToConvert = $crawler->outerHtml();
}
//Concert to markdown
$converter = new HtmlConverter([
'strip_tags' => true, // Removes tags that aren't Markdown-compatible (like <div>)
'hard_break' => true, // Preserves line breaks
'remove_nodes' => 'nav footer script style' // Extra safety layer
]);
return $converter->convert($htmlToConvert);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
ProviderCapabilities::PARAMETERS,
];
}
private function callLLM(string $htmlContent, string $url, ?string $structuredData = null): array
{
$input = new MessageBag(
Message::forSystem($this->buildSystemPrompt()),
Message::ofUser("Extract part information from this webpage content:\n\nURL: $url\n\n$htmlContent")
);
if ($structuredData) {
$input->add(Message::ofUser("Following data was extracted using traditional methods, but might be incomplete or inaccurate.
Enrich it with the actual website data:\n\n".$structuredData));
}
try {
$aiPlatform = $this->AIPlatformRegistry->getPlatform($this->settings->platform ?? throw new \RuntimeException('No AI platform selected') );
//'openai/gpt-5-mini'
$result = $aiPlatform->invoke($this->settings->model ?? throw new \RuntimeException('No model selected'), $input, [
'response_format' => [
'type' => 'json_schema',
'json_schema' => $this->jsonSchemaConverter->getJSONSchema(),
]
]);
} catch (\Throwable $e) {
throw new \RuntimeException('LLM invocation failed: '.$e->getMessage(), previous: $e);
}
return $result->getResult()->getContent();
}
private function buildSystemPrompt(): string
{
$tmp = <<<'PROMPT'
You are an expert at extracting electronic component information from web pages. Extract structured data in JSON format, from markdown extracted from a product page.
Focus on the main content of the page, such as product descriptions, specifications, and tables. Ignore navigation menus, footers, and sidebars.
Rules:
- manufacturing_status: Use "active", "obsolete", "nrfnd" (not recommended for new designs), "discontinued", or null
- parameters: Extract technical specs like voltage, current, temperature, etc. and put them into the fields according to the JSON schema. Include units if available.
- prices: Extract pricing tiers with minimum_quantity, price, and currency code
- URLs must be absolute (include https://...)
- If information is not found, use null
- Try to avoid duplicating parameters, if the same parameter is mentioned multiple times, or if it is already used in another field.
- Include only the 1 to 3 most relevant images, such as the main product image or important diagrams. Ignore decorative images, logos, or icons.
- Extract GTIN / EAN if available, as it can be useful for matching parts across different sources, even if the part number is different.
- Include detailed product description into notes field, as it can contain important information that doesn't fit into other fields, such as features, applications, or unique selling points.
PROMPT;
if ($this->settings->outputLanguage === null) {
$tmp .= "\n\nProvide the response in the same language of the webpage.";
} else {
$tmp .= "\n\nThe response must be in ". Languages::getName($this->settings->outputLanguage, 'en') ." language. Translate texts if needed.";
}
if ($this->settings->additionalInstructions) {
$tmp .= "\n\nAdditional instructions:\n" . $this->settings->additionalInstructions;
}
return $tmp;
}
}

View file

@ -34,7 +34,8 @@ interface BatchInfoProviderInterface extends InfoProviderInterface
* Search for multiple keywords in a single batch operation and return the results, ordered by the keywords. * Search for multiple keywords in a single batch operation and return the results, ordered by the keywords.
* This allows for a more efficient search compared to running multiple single searches. * This allows for a more efficient search compared to running multiple single searches.
* @param string[] $keywords * @param string[] $keywords
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
* @return array<string, SearchResultDTO[]> An associative array where the key is the keyword and the value is the search results for that keyword * @return array<string, SearchResultDTO[]> An associative array where the key is the keyword and the value is the search results for that keyword
*/ */
public function searchByKeywordsBatch(array $keywords): array; public function searchByKeywordsBatch(array $keywords, array $options = []): array;
} }

View file

@ -120,7 +120,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
]; ];
} }
private function getProduct(string $code): array private function getProduct(string $code, bool $use_cache = true): array
{ {
$code = strtoupper(trim($code)); $code = strtoupper(trim($code));
if ($code === '') { if ($code === '') {
@ -132,6 +132,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
md5($code . '|' . $this->settings->language . '|' . $this->settings->currency) md5($code . '|' . $this->settings->language . '|' . $this->settings->currency)
); );
if (!$use_cache) {
$this->partInfoCache->deleteItem($cacheKey);
unset($this->productCache[$cacheKey]);
}
if (isset($this->productCache[$cacheKey])) { if (isset($this->productCache[$cacheKey])) {
return $this->productCache[$cacheKey]; return $this->productCache[$cacheKey];
} }
@ -461,9 +466,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
} }
/** /**
* @param string $keyword
* @param array $options
* @return PartDetailDTO[] * @return PartDetailDTO[]
*/ */
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$keyword = strtoupper(trim($keyword)); $keyword = strtoupper(trim($keyword));
if ($keyword === '') { if ($keyword === '') {
@ -486,17 +493,18 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
// Fallback: try direct lookup by code // Fallback: try direct lookup by code
try { try {
$product = $this->getProduct($keyword); $product = $this->getProduct($keyword, use_cache: !($options[self::OPTION_NO_CACHE] ?? false));
return [$this->getPartDetail($product)]; return [$this->getPartDetail($product)];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return []; return [];
} }
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
// Detail endpoint is /products/{code}/ // Detail endpoint is /products/{code}/
$response = $this->getProduct($id); //By default use cache for details, but allow bypassing cache with option (e.g. for refresh)
$response = $this->getProduct($id, use_cache: !($options[self::OPTION_NO_CACHE] ?? false));
return $this->getPartDetail($response); return $this->getPartDetail($response);
} }
@ -588,10 +596,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
} }
/** /**
* @param string[] $keywords * @param array $keywords
* @param array $options
* @return array<string, SearchResultDTO[]> * @return array<string, SearchResultDTO[]>
*/ */
public function searchByKeywordsBatch(array $keywords): array public function searchByKeywordsBatch(array $keywords, array $options = []): array
{ {
/** @var array<string, SearchResultDTO[]> $results */ /** @var array<string, SearchResultDTO[]> $results */
$results = []; $results = [];
@ -643,27 +652,27 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
public function getIDFromURL(string $url): ?string public function getIDFromURL(string $url): ?string
{ {
//Inputs: //Inputs:
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/ //https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
//https://www.buerklin.com/de/p/40F1332/ //https://www.buerklin.com/de/p/40F1332/
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/ //https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
//https://www.buerklin.com/en/p/40F1332/ //https://www.buerklin.com/en/p/40F1332/
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash //The ID is the last part after the manufacturer/category/mpn segment and before the final slash
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work //https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
$path = parse_url($url, PHP_URL_PATH); $path = parse_url($url, PHP_URL_PATH);
if (!$path) { if (!$path) {
return null; return null;
} }
// Ensure it's actually a product URL // Ensure it's actually a product URL
if (strpos($path, '/p/') === false) { if (strpos($path, '/p/') === false) {
return null; return null;
} }
$id = basename(rtrim($path, '/')); $id = basename(rtrim($path, '/'));
return $id !== '' && $id !== 'p' ? $id : null; return $id !== '' && $id !== 'p' ? $id : null;
} }

View file

@ -111,7 +111,7 @@ class CanopyProvider implements InfoProviderInterface
return null; return null;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$response = $this->httpClient->request('GET', self::SEARCH_API_URL, [ $response = $this->httpClient->request('GET', self::SEARCH_API_URL, [
'query' => [ 'query' => [
@ -177,15 +177,17 @@ class CanopyProvider implements InfoProviderInterface
return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin)); return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
//Check that the id is a valid ASIN (10 characters, letters and numbers) //Check that the id is a valid ASIN (10 characters, letters and numbers)
if (!preg_match('/^[A-Z0-9]{10}$/', $id)) { if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)"); throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
} }
$do_not_cache = ($options[self::OPTION_NO_CACHE] ?? false) || $this->settings->alwaysGetDetails;
//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 //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) { if(!$do_not_cache && ($cached = $this->getFromCache($id)) !== null) {
return $cached; return $cached;
} }

View file

@ -88,7 +88,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
return null; return null;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/' $url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage() . $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
@ -279,7 +279,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
); );
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID() $productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
. '/product/' . $id; . '/product/' . $id;

View file

@ -106,7 +106,7 @@ class DigikeyProvider implements InfoProviderInterface
return $this->settings->clientId !== null && $this->settings->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); return $this->settings->clientId !== null && $this->settings->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$request = [ $request = [
'Keywords' => $keyword, 'Keywords' => $keyword,
@ -159,7 +159,7 @@ class DigikeyProvider implements InfoProviderInterface
return $result; return $result;
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
try { try {
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [ $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [

View file

@ -282,12 +282,12 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider
}; };
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
return $this->queryByTerm('any:' . $keyword); return $this->queryByTerm('any:' . $keyword);
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
$tmp = $this->queryByTerm('id:' . $id); $tmp = $this->queryByTerm('id:' . $id);
if (count($tmp) === 0) { if (count($tmp) === 0) {

View file

@ -54,7 +54,7 @@ class EmptyProvider implements InfoProviderInterface
return true; return true;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
return [ return [
@ -69,7 +69,7 @@ class EmptyProvider implements InfoProviderInterface
]; ];
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
throw new \RuntimeException('No part details available'); throw new \RuntimeException('No part details available');
} }

View file

@ -0,0 +1,58 @@
<?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\Services\InfoProviderSystem\Providers;
use App\Exceptions\ProviderIDNotSupportedException;
trait FixAndValidateUrlTrait
{
private function fixAndValidateURL(string $url): string
{
$originalUrl = $url;
//Add scheme if missing
if (!preg_match('/^https?:\/\//', $url)) {
//Remove any leading slashes
$url = ltrim($url, '/');
//If the URL starts with https:/ or http:/, add the missing slash
//Traefik removes the double slash as secruity measure, so we want to be forgiving and add it back if needed
//See https://github.com/Part-DB/Part-DB-server/issues/1296
if (preg_match('/^https?:\/[^\/]/', $url)) {
$url = preg_replace('/^(https?:)\/([^\/])/', '$1//$2', $url);
} else {
$url = 'https://'.$url;
}
}
//If this is not a valid URL with host, domain and path, throw an exception
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
parse_url($url, PHP_URL_HOST) === null ||
parse_url($url, PHP_URL_PATH) === null) {
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl);
}
return $url;
}
}

View file

@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers;
use App\Exceptions\ProviderIDNotSupportedException; use App\Exceptions\ProviderIDNotSupportedException;
use App\Helpers\RandomizeUseragentHttpClient; use App\Helpers\RandomizeUseragentHttpClient;
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO;
@ -42,20 +43,24 @@ use Brick\Schema\Interfaces\Thing;
use Brick\Schema\SchemaReader; use Brick\Schema\SchemaReader;
use Brick\Schema\SchemaTypeList; use Brick\Schema\SchemaTypeList;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class GenericWebProvider implements InfoProviderInterface class GenericWebProvider implements InfoProviderInterface
{ {
use FixAndValidateUrlTrait;
public const DISTRIBUTOR_NAME = 'Website'; public const DISTRIBUTOR_NAME = 'Website';
private readonly HttpClientInterface $httpClient; private readonly HttpClientInterface $httpClient;
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings, public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever, private readonly CreateFromUrlHelper $createFromUrlHelper,
) )
{ {
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions( //Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
[ [
'timeout' => 15, 'timeout' => 15,
] ]
@ -83,19 +88,23 @@ class GenericWebProvider implements InfoProviderInterface
return $this->settings->enabled; return $this->settings->enabled;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$url = $this->fixAndValidateURL($keyword); $url = $this->fixAndValidateURL($keyword);
//Before loading the page, try to delegate to another provider if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
$delegatedPart = $this->delegateToOtherProvider($url); //Before loading the page, try to delegate to another provider
if ($delegatedPart !== null) { $delegatedPart = $this->createFromUrlHelper->delegateToOtherProvider($url, $this);
return [$delegatedPart]; if ($delegatedPart !== null) {
return [$delegatedPart];
}
} }
try { try {
$new_options = $options;
$new_options[self::OPTION_SKIP_DELEGATION] = true; //Skip delegation for the getDetails call to prevent infinite loops
return [ return [
$this->getDetails($keyword, false) //We already tried delegation $this->getDetails($keyword, $new_options)
]; } catch (ProviderIDNotSupportedException $e) { ]; } catch (ProviderIDNotSupportedException $e) {
return []; return [];
} }
@ -272,71 +281,16 @@ class GenericWebProvider implements InfoProviderInterface
return null; return null;
} }
/**
* Delegates the URL to another provider if possible, otherwise return null
* @param string $url
* @return SearchResultDTO|null
*/
private function delegateToOtherProvider(string $url): ?SearchResultDTO
{
//Extract domain from url:
$host = parse_url($url, PHP_URL_HOST);
if ($host === false || $host === null) {
return null;
}
$provider = $this->providerRegistry->getProviderHandlingDomain($host); public function getDetails(string $id, array $options = []): PartDetailDTO
if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $this->getProviderKey()) {
try {
$id = $provider->getIDFromURL($url);
if ($id !== null) {
$results = $this->infoRetriever->searchByKeyword($id, [$provider]);
if (count($results) > 0) {
return $results[0];
}
}
return null;
} catch (ProviderIDNotSupportedException $e) {
//Ignore and continue
return null;
}
}
return null;
}
private function fixAndValidateURL(string $url): string
{
$originalUrl = $url;
//Add scheme if missing
if (!preg_match('/^https?:\/\//', $url)) {
//Remove any leading slashes
$url = ltrim($url, '/');
$url = 'https://'.$url;
}
//If this is not a valid URL with host, domain and path, throw an exception
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
parse_url($url, PHP_URL_HOST) === null ||
parse_url($url, PHP_URL_PATH) === null) {
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl);
}
return $url;
}
public function getDetails(string $id, bool $check_for_delegation = true): PartDetailDTO
{ {
$url = $this->fixAndValidateURL($id); $url = $this->fixAndValidateURL($id);
if ($check_for_delegation) { if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
//Before loading the page, try to delegate to another provider //Before loading the page, try to delegate to another provider
$delegatedPart = $this->delegateToOtherProvider($url); $delegatedPart = $this->createFromUrlHelper->delegateToOtherProviderDetails($url, $this);
if ($delegatedPart !== null) { if ($delegatedPart !== null) {
return $this->infoRetriever->getDetailsForSearchResult($delegatedPart); return $delegatedPart;
} }
} }

View file

@ -28,6 +28,8 @@ use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
interface InfoProviderInterface interface InfoProviderInterface
{ {
public const OPTION_NO_CACHE = 'no_cache'; // if set to true, the provider should not use any cache and retrieve fresh data from the source
public const OPTION_SKIP_DELEGATION = 'skip_delegation'; // if set to true, the provider should not delegate the request to other providers, even if it supports delegation.
/** /**
* Get information about this provider * Get information about this provider
@ -61,16 +63,18 @@ interface InfoProviderInterface
/** /**
* Searches for a keyword and returns a list of search results * Searches for a keyword and returns a list of search results
* @param string $keyword The keyword to search for * @param string $keyword The keyword to search for
* @param array $options An associative array of options for the search, which can be used to pass additional parameters to the provider (e.g. filters, pagination, etc.). The content of this array is provider specific and not defined by the interface
* @return SearchResultDTO[] A list of search results * @return SearchResultDTO[] A list of search results
*/ */
public function searchByKeyword(string $keyword): array; public function searchByKeyword(string $keyword, array $options = []): array;
/** /**
* Returns detailed information about the part with the given id * Returns detailed information about the part with the given id
* @param string $id * @param string $id
* @param array $options An associative array of options for the search, which can be used to pass additional parameters to the provider (e.g. filters, pagination, etc.). The content of this array is provider specific and not defined by the interface
* @return PartDetailDTO * @return PartDetailDTO
*/ */
public function getDetails(string $id): PartDetailDTO; public function getDetails(string $id, array $options = []): PartDetailDTO;
/** /**
* A list of capabilities this provider supports (which kind of data it can provide). * A list of capabilities this provider supports (which kind of data it can provide).

View file

@ -349,17 +349,18 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
return $result; return $result;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
return $this->queryByTerm($keyword, true); // Use lightweight mode for search return $this->queryByTerm($keyword, true); // Use lightweight mode for search
} }
/** /**
* Batch search multiple keywords asynchronously (like JavaScript Promise.all) * Batch search multiple keywords asynchronously (like JavaScript Promise.all)
* @param array $keywords Array of keywords to search * @param array $keywords
* @param array $options
* @return array Results indexed by keyword * @return array Results indexed by keyword
*/ */
public function searchByKeywordsBatch(array $keywords): array public function searchByKeywordsBatch(array $keywords, array $options = []): array
{ {
if (empty($keywords)) { if (empty($keywords)) {
return []; return [];
@ -396,6 +397,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
// Now collect all results (like .then() in JavaScript) // Now collect all results (like .then() in JavaScript)
foreach ($responses as $keyword => $response) { foreach ($responses as $keyword => $response) {
try { try {
$keyword = (string) $keyword;
$arr = $response->toArray(); // This waits for the response $arr = $response->toArray(); // This waits for the response
$results[$keyword] = $this->processSearchResponse($arr, $keyword); $results[$keyword] = $this->processSearchResponse($arr, $keyword);
} catch (\Exception $e) { } catch (\Exception $e) {
@ -428,7 +430,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
return $result; return $result;
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
$tmp = $this->queryByTerm($id, false); $tmp = $this->queryByTerm($id, false);
if (count($tmp) === 0) { if (count($tmp) === 0) {

View file

@ -76,7 +76,7 @@ class MouserProvider implements InfoProviderInterface
return $this->settings->apiKey !== '' && $this->settings->apiKey !== null; return $this->settings->apiKey !== '' && $this->settings->apiKey !== null;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
/* /*
SearchByKeywordRequest description: SearchByKeywordRequest description:
@ -144,7 +144,7 @@ class MouserProvider implements InfoProviderInterface
return $this->responseToDTOArray($response); return $this->responseToDTOArray($response);
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
/* /*
SearchByPartRequest description: SearchByPartRequest description:

View file

@ -278,12 +278,13 @@ class OEMSecretsProvider implements InfoProviderInterface
* and debugging with local JSON files. The results are processed, cached, and then sorted based * and debugging with local JSON files. The results are processed, cached, and then sorted based
* on the keyword and specified criteria. * on the keyword and specified criteria.
* *
* @param string $keyword The part number to search for * @param string $keyword
* @param array $options
* @return array An array of processed product details, sorted by relevance and additional criteria. * @return array An array of processed product details, sorted by relevance and additional criteria.
* *
* @throws \Exception If the JSON file used for debugging is not found or contains errors. * @throws \Exception If the JSON file used for debugging is not found or contains errors.
*/ */
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
/* /*
oemsecrets Part Search API 3.0.1 oemsecrets Part Search API 3.0.1
@ -414,14 +415,20 @@ class OEMSecretsProvider implements InfoProviderInterface
* found in the cache, they are returned. If not, an exception is thrown indicating that * found in the cache, they are returned. If not, an exception is thrown indicating that
* the details could not be found. * the details could not be found.
* *
* @param string $id The unique identifier of the provider or part. * @param string $id
* @param array $options
* @return PartDetailDTO The detailed information about the part. * @return PartDetailDTO The detailed information about the part.
* *
* @throws \Exception If no details are found for the given provider ID. * @throws \Exception If no details are found for the given provider ID.
*/ */
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
$cacheKey = $this->getCacheKey($id); $cacheKey = $this->getCacheKey($id);
if ($options[self::OPTION_NO_CACHE] ?? false) {
$this->partInfoCache->deleteItem($cacheKey);
}
$cacheItem = $this->partInfoCache->getItem($cacheKey); $cacheItem = $this->partInfoCache->getItem($cacheKey);
if ($cacheItem->isHit()) { if ($cacheItem->isHit()) {

View file

@ -326,7 +326,7 @@ class OctopartProvider implements InfoProviderInterface
); );
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$graphQL = sprintf(<<<'GRAPHQL' $graphQL = sprintf(<<<'GRAPHQL'
query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) { query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) {
@ -367,11 +367,13 @@ class OctopartProvider implements InfoProviderInterface
return $tmp; return $tmp;
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
$no_cache = $options[self::OPTION_NO_CACHE] ?? false;
//Check if we have the part cached //Check if we have the part cached
$cached = $this->getFromCache($id); $cached = $this->getFromCache($id);
if ($cached !== null) { if (!$no_cache && $cached !== null) {
return $cached; return $cached;
} }

View file

@ -66,7 +66,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
return $this->settings->enabled; return $this->settings->enabled;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$response = $this->client->request('GET', 'https://www.pollin.de/search', [ $response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [ 'query' => [
@ -110,7 +110,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
}; };
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
//Ensure that $id is numeric //Ensure that $id is numeric
if (!is_numeric($id)) { if (!is_numeric($id)) {

View file

@ -46,6 +46,9 @@ enum ProviderCapabilities
/** Provider can provide GTIN for a part */ /** Provider can provide GTIN for a part */
case GTIN; case GTIN;
/** Provider can provide parameters/specifications for a part */
case PARAMETERS;
/** /**
* Get the order index for displaying capabilities in a stable order. * Get the order index for displaying capabilities in a stable order.
* @return int * @return int
@ -59,6 +62,7 @@ enum ProviderCapabilities
self::PRICE => 4, self::PRICE => 4,
self::FOOTPRINT => 5, self::FOOTPRINT => 5,
self::GTIN => 6, self::GTIN => 6,
self::PARAMETERS => 7,
}; };
} }
@ -71,6 +75,7 @@ enum ProviderCapabilities
self::DATASHEET => 'datasheet', self::DATASHEET => 'datasheet',
self::PRICE => 'price', self::PRICE => 'price',
self::GTIN => 'gtin', self::GTIN => 'gtin',
self::PARAMETERS => 'parameters',
}; };
} }
@ -83,6 +88,7 @@ enum ProviderCapabilities
self::DATASHEET => 'fa-file-alt', self::DATASHEET => 'fa-file-alt',
self::PRICE => 'fa-money-bill-wave', self::PRICE => 'fa-money-bill-wave',
self::GTIN => 'fa-barcode', self::GTIN => 'fa-barcode',
self::PARAMETERS => 'fa-list-ul',
}; };
} }
} }

View file

@ -69,7 +69,7 @@ class ReicheltProvider implements InfoProviderInterface
return $this->settings->enabled; return $this->settings->enabled;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword)); $response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword));
$html = $response->getContent(); $html = $response->getContent();
@ -108,7 +108,7 @@ class ReicheltProvider implements InfoProviderInterface
return $results; return $results;
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
//Check that the ID is a number //Check that the ID is a number
if (!is_numeric($id)) { if (!is_numeric($id)) {

View file

@ -69,7 +69,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
return $this->tmeClient->isUsable(); return $this->tmeClient->isUsable();
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
$response = $this->tmeClient->makeRequest('Products/Search', [ $response = $this->tmeClient->makeRequest('Products/Search', [
'Country' => $this->settings->country, 'Country' => $this->settings->country,
@ -99,7 +99,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
return $result; return $result;
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
$response = $this->tmeClient->makeRequest('Products/GetProducts', [ $response = $this->tmeClient->makeRequest('Products/GetProducts', [
'Country' => $this->settings->country, 'Country' => $this->settings->country,
@ -280,9 +280,13 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
{ {
//If a URL starts with // we assume that it is a relative URL and we add the protocol //If a URL starts with // we assume that it is a relative URL and we add the protocol
if (str_starts_with($url, '//')) { if (str_starts_with($url, '//')) {
return 'https:' . $url; $url = 'https:' . $url;
} }
//Encode bare % signs that are not already part of a valid percent-encoded sequence
//Fixes part numbers with % in them e.g. SMD0603-5K1-1%
$url = preg_replace('/%(?![0-9A-Fa-f]{2})/', '%25', $url);
return $url; return $url;
} }

View file

@ -55,7 +55,7 @@ class TestProvider implements InfoProviderInterface
return true; return true;
} }
public function searchByKeyword(string $keyword): array public function searchByKeyword(string $keyword, array $options = []): array
{ {
return [ return [
new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'), new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'),
@ -72,7 +72,7 @@ class TestProvider implements InfoProviderInterface
]; ];
} }
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id, array $options = []): PartDetailDTO
{ {
return new PartDetailDTO( return new PartDetailDTO(
provider_key: $this->getProviderKey(), provider_key: $this->getProviderKey(),
@ -92,4 +92,4 @@ class TestProvider implements InfoProviderInterface
] ]
); );
} }
} }

View file

@ -105,6 +105,10 @@ final class BarcodeScanHelper
return new AmazonBarcodeScanResult($input); return new AmazonBarcodeScanResult($input);
} }
if ($type === BarcodeSourceType::TME) {
return TMEBarcodeScanResult::parse($input);
}
//Null means auto and we try the different formats //Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input); $result = $this->parseInternalBarcode($input);
@ -144,6 +148,11 @@ final class BarcodeScanHelper
return new AmazonBarcodeScanResult($input); return new AmazonBarcodeScanResult($input);
} }
// Try TME barcode
if (TMEBarcodeScanResult::isTMEBarcode($input)) {
return TMEBarcodeScanResult::parse($input);
}
throw new InvalidArgumentException('Unknown barcode'); throw new InvalidArgumentException('Unknown barcode');
} }
@ -162,6 +171,7 @@ final class BarcodeScanHelper
return LCSCBarcodeScanResult::parse($input); return LCSCBarcodeScanResult::parse($input);
} }
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{ {
$lot_repo = $this->entityManager->getRepository(PartLot::class); $lot_repo = $this->entityManager->getRepository(PartLot::class);

View file

@ -150,6 +150,10 @@ final readonly class BarcodeScanResultHandler
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin); ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
} }
if ($barcodeScan instanceof TMEBarcodeScanResult) {
return $this->resolvePartFromTME($barcodeScan);
}
return null; return null;
} }
@ -217,8 +221,8 @@ final readonly class BarcodeScanResultHandler
* Resolve LCSC barcode -> Part. * Resolve LCSC barcode -> Part.
* Strategy: * Strategy:
* 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there * 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) * Returns first match (consistent with EIGP114 logic)
* 2) Fallback to search across supplier part number (SPN)
*/ */
private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part
{ {
@ -231,16 +235,31 @@ final readonly class BarcodeScanResultHandler
} }
} }
// Fallback to MPN (pm) // fallback to search by SPN
$pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML return $this->em->getRepository(Part::class)->getPartBySPN($pc);
if (!$pm) {
return null;
}
return $this->em->getRepository(Part::class)->getPartByMPN($pm);
} }
private function resolvePartFromTME(TMEBarcodeScanResult $barcodeScan): ?Part
{
$pn = $barcodeScan->tmePartNumber;
if ($pn) {
$part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pn);
if ($part !== null) {
return $part;
}
//Try to find the part by SPN/SKU
$part = $this->em->getRepository(Part::class)->getPartBySPN($pn);
if ($part !== null) {
return $part;
}
}
// Fallback: search by MPN
return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->mpn, $barcodeScan->manufacturer);
}
/** /**
* Tries to extract creation information for a part from the given barcode scan result. This can be used to * 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. * automatically fill in the info provider reference of a part, when creating a new part based on the scan result.
@ -252,6 +271,20 @@ final readonly class BarcodeScanResultHandler
*/ */
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
{ {
// TME
if ($scanResult instanceof TMEBarcodeScanResult) {
if ($scanResult->tmePartNumber === null) {
return null;
}
return [
'providerKey' => 'tme',
'providerId' => $scanResult->tmePartNumber,
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->purchaseOrder,
'lotUserBarcode' => $scanResult->rawInput,
];
}
// LCSC // LCSC
if ($scanResult instanceof LCSCBarcodeScanResult) { if ($scanResult instanceof LCSCBarcodeScanResult) {
return [ return [

View file

@ -52,4 +52,7 @@ enum BarcodeSourceType: string
case LCSC = 'lcsc'; case LCSC = 'lcsc';
case AMAZON = 'amazon'; case AMAZON = 'amazon';
/** For TME (tme.eu) formatted QR codes */
case TME = 'tme';
} }

View file

@ -254,12 +254,16 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*/ */
public static function isFormat06Code(string $input): bool public static function isFormat06Code(string $input): bool
{ {
//Code must begin with [)><RS>06<GS> //Code should begin with [)><RS>06<GS> as per the standard
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){ if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")
return false; // some codes don't contain record separators
&& !str_starts_with($input, "[)>06\u{1D}")
// This is found on old Mouser parts
&& !str_starts_with($input, ">[)>06\u{1D}"))
{
return false;
} }
//Digikey and Mouser don't put a trailer onto the barcode, so we just check for the header
//Digikey does not put a trailer onto the barcode, so we just check for the header
return true; return true;
} }

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