Merge branch 'master' into SplitAttachmentPaths

This commit is contained in:
Jan Böhmer 2025-02-22 17:27:33 +01:00 committed by GitHub
commit 524900c34a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 5821 additions and 1805 deletions

View file

@ -44,6 +44,8 @@
PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE
PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY
PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA
PassEnv PROVIDER_REICHELT_ENABLED PROVIDER_REICHELT_CURRENCY PROVIDER_REICHELT_COUNTRY PROVIDER_REICHELT_LANGUAGE PROVIDER_REICHELT_INCLUDE_VAT
PassEnv PROVIDER_POLLIN_ENABLED
PassEnv EDA_KICAD_CATEGORY_DEPTH PassEnv EDA_KICAD_CATEGORY_DEPTH
# For most configuration files from conf-available/, which are # For most configuration files from conf-available/, which are

21
.env
View file

@ -216,6 +216,27 @@ PROVIDER_OEMSECRETS_SET_PARAM=1
#If unset or set to any other value, no sorting is performed. #If unset or set to any other value, no sorting is performed.
PROVIDER_OEMSECRETS_SORT_CRITERIA=C PROVIDER_OEMSECRETS_SORT_CRITERIA=C
# Reichelt provider:
# Reichelt.com offers no official API, so this info provider webscrapes the website to extract info
# It could break at any time, use it at your own risk
# We dont require an API key for Reichelt, just set this to 1 to enable Reichelt support
PROVIDER_REICHELT_ENABLED=0
# The country to get prices for
PROVIDER_REICHELT_COUNTRY=DE
# The language to get results in (en, de, fr, nl, pl, it, es)
PROVIDER_REICHELT_LANGUAGE=en
# Include VAT in prices (set to 1 to include VAT, 0 to exclude VAT)
PROVIDER_REICHELT_INCLUDE_VAT=1
# The currency to get prices in (only for countries with countries other than EUR)
PROVIDER_REICHELT_CURRENCY=EUR
# Pollin provider:
# Pollin.de offers no official API, so this info provider webscrapes the website to extract info
# It could break at any time, use it at your own risk
# We dont require an API key for Pollin, just set this to 1 to enable Pollin support
PROVIDER_POLLIN_ENABLED=0
################################################################################## ##################################################################################
# EDA integration related settings # EDA integration related settings
################################################################################## ##################################################################################

View file

@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; 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 { export default class extends Controller {
_tomSelect; _tomSelect;
@ -46,6 +52,12 @@ export default class extends Controller {
} }
return '<div>' + escape(data.label) + '</div>'; return '<div>' + escape(data.label) + '</div>';
} }
},
plugins: {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
"restore_on_backspace": {}
} }
}; };

View file

@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; 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)
/** /**
* This is the frontend controller for StaticFileAutocompleteType form element. * This is the frontend controller for StaticFileAutocompleteType form element.
* Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete. * Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete.
@ -46,7 +52,13 @@ export default class extends Controller {
orderField: 'text', orderField: 'text',
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin //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' delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
plugins: {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
}
}; };
if (this.element.dataset.url) { if (this.element.dataset.url) {

View file

@ -24,6 +24,9 @@ import {Controller} from "@hotwired/stimulus";
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js' import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -37,11 +40,15 @@ export default class extends Controller {
const allowAdd = this.element.getAttribute("data-allow-add") === "true"; const allowAdd = this.element.getAttribute("data-allow-add") === "true";
const addHint = this.element.getAttribute("data-add-hint") ?? ""; const addHint = this.element.getAttribute("data-add-hint") ?? "";
let settings = { let settings = {
allowEmptyOption: true, allowEmptyOption: true,
selectOnTab: true, selectOnTab: true,
maxOptions: null, maxOptions: null,
create: allowAdd ? this.createItem.bind(this) : false, create: allowAdd ? this.createItem.bind(this) : false,
createFilter: this.createFilter.bind(this),
// This three options allow us to paste element names with commas: (see issue #538) // This three options allow us to paste element names with commas: (see issue #538)
maxItems: 1, maxItems: 1,
@ -81,8 +88,17 @@ export default class extends Controller {
//Add callbacks to update validity //Add callbacks to update validity
onInitialize: this.updateValidity.bind(this), onInitialize: this.updateValidity.bind(this),
onChange: this.updateValidity.bind(this), onChange: this.updateValidity.bind(this),
plugins: {
"autoselect_typed": {},
}
}; };
//Add clear button plugin, if an empty option is present
if (this.element.querySelector("option[value='']") !== null) {
settings.plugins["clear_button"] = {};
}
this._tomSelect = new TomSelect(this.element, settings); this._tomSelect = new TomSelect(this.element, settings);
//Do not do a sync here as this breaks the initial rendering of the empty option //Do not do a sync here as this breaks the initial rendering of the empty option
//this._tomSelect.sync(); //this._tomSelect.sync();
@ -113,6 +129,31 @@ export default class extends Controller {
}); });
} }
createFilter(input) {
//Normalize the input (replace spacing around arrows)
if (input.includes("->")) {
const inputs = input.split("->");
inputs.forEach((value, index) => {
inputs[index] = value.trim();
});
input = inputs.join("->");
} else {
input = input.trim();
}
const options = this._tomSelect.options;
//Iterate over all options and check if the input is already present
for (let index in options) {
const option = options[index];
if (option.path === input) {
return false;
}
}
return true;
}
updateValidity() { updateValidity() {
//Mark this input as invalid, if the selected option is disabled //Mark this input as invalid, if the selected option is disabled

View file

@ -23,14 +23,21 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; 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 { export default class extends Controller {
_tomSelect; _tomSelect;
connect() { connect() {
let settings = { let settings = {
plugins: { plugins: {
remove_button:{ remove_button:{},
} 'autoselect_typed': {},
'click_to_edit': {},
}, },
persistent: false, persistent: false,
selectOnTab: true, selectOnTab: true,

View file

@ -22,6 +22,13 @@ import TomSelect from "tom-select";
import katex from "katex"; import katex from "katex";
import "katex/dist/katex.css"; import "katex/dist/katex.css";
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)
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller export default class extends Controller
{ {
@ -53,7 +60,10 @@ export default class extends Controller
connect() { connect() {
const settings = { const settings = {
plugins: { plugins: {
clear_button:{} 'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
}, },
persistent: false, persistent: false,
maxItems: 1, maxItems: 1,

View file

@ -0,0 +1,63 @@
/**
* Autoselect Typed plugin for Tomselect
*
* This plugin allows automatically selecting an option matching the typed text when the Tomselect element goes out of
* focus (is blurred) and/or when the delimiter is typed.
*
* #select_on_blur option
* Tomselect natively supports the "createOnBlur" option. This option picks up any remaining text in the input field
* and uses it to create a new option and selects that option. It does behave a bit strangely though, in that it will
* not select an already existing option when the input is blurred, so if you typed something that matches an option in
* the list and then click outside the box (without pressing enter) the entered text is just removed (unless you have
* allow duplicates on in which case it will create a new option).
* This plugin fixes that, such that Tomselect will first try to select an option matching the remaining uncommitted
* text and only when no matching option is found tries to create a new one (if createOnBlur and create is on)
*
* #select_on_delimiter option
* Normally when typing the delimiter (space by default) Tomselect will try to create a new option (and select it) (if
* create is on), but if the typed text matches an option (and allow duplicates is off) it refuses to react at all until
* you press enter. With this option, the delimiter will also allow selecting an option, not just creating it.
*/
function select_current_input(self){
if(self.isLocked){
return
}
const val = self.inputValue()
//Do nothing if the input is empty
if (!val) {
return
}
if (self.options[val]) {
self.addItem(val)
self.setTextboxValue()
}
}
export default function(plugin_options_) {
const plugin_options = Object.assign({
//Autoselect the typed text when the input element goes out of focus
select_on_blur: true,
//Autoselect the typed text when the delimiter is typed
select_on_delimiter: true,
}, plugin_options_);
const self = this
if(plugin_options.select_on_blur) {
this.hook("before", "onBlur", function () {
select_current_input(self)
})
}
if(plugin_options.select_on_delimiter) {
this.hook("before", "onKeyPress", function (e) {
const character = String.fromCharCode(e.keyCode || e.which);
if (self.settings.mode === 'multi' && character === self.settings.delimiter) {
select_current_input(self)
}
})
}
}

View file

@ -0,0 +1,93 @@
/**
* click_to_edit plugin for Tomselect
*
* This plugin allows editing (and selecting text in) any selected item by clicking it.
*
* Usually, when the user typed some text and created an item in Tomselect that item cannot be edited anymore. To make
* a change, the item has to be deleted and retyped completely. There is also generally no way to copy text out of a
* tomselect item. The "restore_on_backspace" plugin improves that somewhat, by allowing the user to edit an item after
* pressing backspace. However, it is somewhat confusing to first have to focus the field an then hit backspace in order
* to copy a piece of text. It may also not be immediately obvious for editing.
* This plugin transforms an item into editable text when it is clicked, e.g. when the user tries to place the caret
* within an item or when they try to drag across the text to highlight it.
* It also plays nice with the remove_button plugin which still removes (deselects) an option entirely.
*
* It is recommended to also enable the autoselect_typed plugin when using this plugin. Without it, the text in the
* input field (i.e. the item that was just clicked) is lost when the user clicks outside the field. Also, when the user
* clicks an option (making it text) and then tries to enter another one by entering the delimiter (e.g. space) nothing
* happens until enter is pressed or the text is changed from what it was.
*/
/**
* Return a dom element from either a dom query string, jQuery object, a dom element or html string
* https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518
*
* param query should be {}
*/
const getDom = query => {
if (query.jquery) {
return query[0];
}
if (query instanceof HTMLElement) {
return query;
}
if (isHtmlString(query)) {
var tpl = document.createElement('template');
tpl.innerHTML = query.trim(); // Never return a text node of whitespace as the result
return tpl.content.firstChild;
}
return document.querySelector(query);
};
const isHtmlString = arg => {
if (typeof arg === 'string' && arg.indexOf('<') > -1) {
return true;
}
return false;
};
function plugin(plugin_options_) {
const self = this
const plugin_options = Object.assign({
//If there is unsubmitted text in the input field, should that text be automatically used to select a matching
//element? If this is off, clicking on item1 and then clicking on item2 will result in item1 being deselected
auto_select_before_edit: true,
//If there is unsubmitted text in the input field, should that text be automatically used to create a matching
//element if no matching element was found or auto_select_before_edit is off?
auto_create_before_edit: true,
//customize this function to change which text the item is replaced with when clicking on it
text: option => {
return option[self.settings.labelField];
}
}, plugin_options_);
self.hook('after', 'setupTemplates', () => {
const orig_render_item = self.settings.render.item;
self.settings.render.item = (data, escape) => {
const item = getDom(orig_render_item.call(self, data, escape));
item.addEventListener('click', evt => {
if (self.isLocked) {
return;
}
const val = self.inputValue();
if (self.options[val]) {
self.addItem(val)
} else if (self.settings.create) {
self.createItem();
}
const option = self.options[item.dataset.value]
self.setTextboxValue(plugin_options.text.call(self, option));
self.focus();
self.removeItem(item);
}
);
return item;
}
});
}
export { plugin as default };

View file

@ -54,6 +54,8 @@
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
"symfony/asset": "6.4.*", "symfony/asset": "6.4.*",
"symfony/console": "6.4.*", "symfony/console": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/dom-crawler": "6.4.*",
"symfony/dotenv": "6.4.*", "symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*", "symfony/expression-language": "6.4.*",
"symfony/flex": "^v2.3.1", "symfony/flex": "^v2.3.1",
@ -104,7 +106,6 @@
"rector/rector": "^2.0.4", "rector/rector": "^2.0.4",
"roave/security-advisories": "dev-latest", "roave/security-advisories": "dev-latest",
"symfony/browser-kit": "6.4.*", "symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/debug-bundle": "6.4.*", "symfony/debug-bundle": "6.4.*",
"symfony/maker-bundle": "^1.13", "symfony/maker-bundle": "^1.13",
"symfony/phpunit-bridge": "6.4.*", "symfony/phpunit-bridge": "6.4.*",

522
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -150,9 +150,9 @@ In the `serverVersion` parameter you can specify the version of the PostgreSQL s
The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly. The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly.
If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `unix_socket` parameter. If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `host` parameter.
```shell ```shell
DATABASE_URL="postgresql://db_user:db_password@localhost/db_name?serverVersion=12.19&charset=utf8&unix_socket=/var/run/postgresql/.s.PGSQL.5432" DATABASE_URL="postgresql://db_user@localhost/db_name?serverVersion=16.6&charset=utf8&host=/var/run/postgresql"
``` ```

View file

@ -127,6 +127,9 @@ You must create an organization there and create a "Production app". Most settin
grant access to the "Product Information" API. grant access to the "Product Information" API.
You will get a Client ID and a Client Secret, which you have to put in the Part-DB env configuration (see below). You will get a Client ID and a Client Secret, which you have to put in the Part-DB env configuration (see below).
**Attention**: Currently only the "Product Information V3 (Deprecated)" is supported by Part-DB.
Using "Product Information V4" will not work.
The following env configuration options are available: The following env configuration options are available:
* `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory) * `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory)
@ -232,6 +235,26 @@ The following env configuration options are available:
completeness (prioritizing items with the most detailed information). If set to 'M', it further sorts by manufacturer name. completeness (prioritizing items with the most detailed information). If set to 'M', it further sorts by manufacturer name.
If set to any other value, no sorting is performed. If set to any other value, no sorting is performed.
### Reichelt
The reichelt provider uses webscraping from [reichelt.com](https://reichelt.com/) to get part information.
This is not an official API and could break at any time. So use it at your own risk.
The following env configuration options are available:
* `PROVIDER_REICHELT_ENABLED`: Set this to `1` to enable the Reichelt provider
* `PROVIDER_REICHELT_CURRENCY`: The currency you want to get prices in. Only possible for countries which use Non-EUR (optional, default: `EUR`)
* `PROVIDER_REICHELT_COUNTRY`: The country you want to get the prices for (optional, default: `DE`)
* `PROVIDER_REICHELT_LANGUAGE`: The language you want to get the descriptions in (optional, default: `en`)
* `PROVIDER_REICHELT_INCLUDE_VAT`: If set to `1`, the prices will be gross prices (including tax), otherwise net prices (optional, default: `1`)
### Pollin
The pollin provider uses webscraping from [pollin.de](https://www.pollin.de/) to get part information.
This is not an official API and could break at any time. So use it at your own risk.
The following env configuration options are available:
* `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider
### Custom provider ### Custom provider
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -43,9 +43,9 @@ class AddDocumentedAPIPropertiesJSONSchemaFactory implements SchemaFactoryInterf
string $className, string $className,
string $format = 'json', string $format = 'json',
string $type = Schema::TYPE_OUTPUT, string $type = Schema::TYPE_OUTPUT,
Operation $operation = null, ?Operation $operation = null,
Schema $schema = null, ?Schema $schema = null,
array $serializerContext = null, ?array $serializerContext = null,
bool $forceCollection = false bool $forceCollection = false
): Schema { ): Schema {

View file

@ -37,7 +37,7 @@ class EntityFilter extends AbstractFilter
public function __construct( public function __construct(
ManagerRegistry $managerRegistry, ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper, private readonly EntityFilterHelper $filter_helper,
LoggerInterface $logger = null, ?LoggerInterface $logger = null,
?array $properties = null, ?array $properties = null,
?NameConverterInterface $nameConverter = null ?NameConverterInterface $nameConverter = null
) { ) {
@ -50,7 +50,7 @@ class EntityFilter extends AbstractFilter
QueryBuilder $queryBuilder, QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator, QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass, string $resourceClass,
Operation $operation = null, ?Operation $operation = null,
array $context = [] array $context = []
): void { ): void {
if ( if (

View file

@ -38,7 +38,7 @@ final class LikeFilter extends AbstractFilter
QueryBuilder $queryBuilder, QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator, QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass, string $resourceClass,
Operation $operation = null, ?Operation $operation = null,
array $context = [] array $context = []
): void { ): void {
// Otherwise filter is applied to order and page as well // Otherwise filter is applied to order and page as well

View file

@ -38,7 +38,7 @@ class PartStoragelocationFilter extends AbstractFilter
public function __construct( public function __construct(
ManagerRegistry $managerRegistry, ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper, private readonly EntityFilterHelper $filter_helper,
LoggerInterface $logger = null, ?LoggerInterface $logger = null,
?array $properties = null, ?array $properties = null,
?NameConverterInterface $nameConverter = null ?NameConverterInterface $nameConverter = null
) { ) {
@ -51,7 +51,7 @@ class PartStoragelocationFilter extends AbstractFilter
QueryBuilder $queryBuilder, QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator, QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass, string $resourceClass,
Operation $operation = null, ?Operation $operation = null,
array $context = [] array $context = []
): void { ): void {
//Do not check for mapping here, as we are using a virtual property //Do not check for mapping here, as we are using a virtual property

View file

@ -35,7 +35,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')] #[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')]
class UserEnableCommand extends Command class UserEnableCommand extends Command
{ {
public function __construct(protected EntityManagerInterface $entityManager, string $name = null) public function __construct(protected EntityManagerInterface $entityManager, ?string $name = null)
{ {
parent::__construct($name); parent::__construct($name);
} }

View file

@ -54,7 +54,7 @@ class TwoStepORMAdapter extends ORMAdapter
private \Closure|null $query_modifier = null; private \Closure|null $query_modifier = null;
public function __construct(ManagerRegistry $registry = null) public function __construct(?ManagerRegistry $registry = null)
{ {
parent::__construct($registry); parent::__construct($registry);
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never { $this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {

View file

@ -45,7 +45,7 @@ abstract class AbstractConstraint implements FilterInterface
* @var string The property where this BooleanConstraint should apply to * @var string The property where this BooleanConstraint should apply to
*/ */
protected string $property, protected string $property,
string $identifier = null) ?string $identifier = null)
{ {
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property); $this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
} }

View file

@ -28,7 +28,7 @@ class BooleanConstraint extends AbstractConstraint
{ {
public function __construct( public function __construct(
string $property, string $property,
string $identifier = null, ?string $identifier = null,
/** @var bool|null The value of our constraint */ /** @var bool|null The value of our constraint */
protected ?bool $value = null protected ?bool $value = null
) )

View file

@ -34,7 +34,7 @@ class DateTimeConstraint extends AbstractConstraint
public function __construct( public function __construct(
string $property, string $property,
string $identifier = null, ?string $identifier = null,
/** /**
* The value1 used for comparison (this is the main one used for all mono-value comparisons) * The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/ */

View file

@ -46,7 +46,7 @@ class EntityConstraint extends AbstractConstraint
public function __construct(protected ?NodesListBuilder $nodesListBuilder, public function __construct(protected ?NodesListBuilder $nodesListBuilder,
protected string $class, protected string $class,
string $property, string $property,
string $identifier = null, ?string $identifier = null,
protected ?AbstractDBElement $value = null, protected ?AbstractDBElement $value = null,
protected ?string $operator = null) protected ?string $operator = null)
{ {

View file

@ -31,7 +31,7 @@ class NumberConstraint extends AbstractConstraint
public function __construct( public function __construct(
string $property, string $property,
string $identifier = null, ?string $identifier = null,
/** /**
* The value1 used for comparison (this is the main one used for all mono-value comparisons) * The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/ */

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\QueryBuilder;
class LessThanDesiredConstraint extends BooleanConstraint class LessThanDesiredConstraint extends BooleanConstraint
{ {
public function __construct(string $property = null, string $identifier = null, ?bool $default_value = null) public function __construct(?string $property = null, ?string $identifier = null, ?bool $default_value = null)
{ {
parent::__construct($property ?? '( parent::__construct($property ?? '(
SELECT COALESCE(SUM(ld_partLot.amount), 0.0) SELECT COALESCE(SUM(ld_partLot.amount), 0.0)

View file

@ -30,7 +30,7 @@ class TagsConstraint extends AbstractConstraint
{ {
final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE']; final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
public function __construct(string $property, string $identifier = null, public function __construct(string $property, ?string $identifier = null,
protected ?string $value = null, protected ?string $value = null,
protected ?string $operator = '') protected ?string $operator = '')
{ {

View file

@ -32,7 +32,7 @@ class TextConstraint extends AbstractConstraint
/** /**
* @param string $value * @param string $value
*/ */
public function __construct(string $property, string $identifier = null, /** public function __construct(string $property, ?string $identifier = null, /**
* @var string|null The value to compare to * @var string|null The value to compare to
*/ */
protected ?string $value = null, /** protected ?string $value = null, /**

View file

@ -162,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
* *
* @return string the link to the article * @return string the link to the article
*/ */
public function getAutoProductUrl(string $partnr = null): string public function getAutoProductUrl(?string $partnr = null): string
{ {
if (is_string($partnr)) { if (is_string($partnr)) {
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url); return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);

View file

@ -54,7 +54,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
*/ */
private const DEFAULT_EXPIRATION_TIME = 3600; private const DEFAULT_EXPIRATION_TIME = 3600;
public function __construct(string $name, ?string $refresh_token, ?string $token = null, \DateTimeImmutable $expires_at = null) public function __construct(string $name, ?string $refresh_token, ?string $token = null, ?\DateTimeImmutable $expires_at = null)
{ {
//If token is given, you also have to give the expires_at date //If token is given, you also have to give the expires_at date
if ($token !== null && $expires_at === null) { if ($token !== null && $expires_at === null) {

View file

@ -62,7 +62,7 @@ trait WithPermPresetsTrait
return json_encode($user->getPermissions()); return json_encode($user->getPermissions());
} }
public function setContainer(ContainerInterface $container = null): void public function setContainer(?ContainerInterface $container = null): void
{ {
if ($container !== null) { if ($container !== null) {
$this->container = $container; $this->container = $container;

View file

@ -160,7 +160,7 @@ class LogEntryRepository extends DBElementRepository
* @param int|null $limit * @param int|null $limit
* @param int|null $offset * @param int|null $offset
*/ */
public function getLogsOrderedByTimestamp(string $order = 'DESC', int $limit = null, int $offset = null): array public function getLogsOrderedByTimestamp(string $order = 'DESC', ?int $limit = null, ?int $offset = null): array
{ {
return $this->findBy([], ['timestamp' => $order], $limit, $offset); return $this->findBy([], ['timestamp' => $order], $limit, $offset);
} }

View file

@ -131,7 +131,7 @@ class ApiTokenAuthenticator implements AuthenticatorInterface
/** /**
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3 * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
*/ */
private function getAuthenticateHeader(string $errorDescription = null): string private function getAuthenticateHeader(?string $errorDescription = null): string
{ {
$data = [ $data = [
'realm' => $this->realm, 'realm' => $this->realm,

View file

@ -47,7 +47,7 @@ class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
) { ) {
} }
public function start(Request $request, AuthenticationException $authException = null): Response public function start(Request $request, ?AuthenticationException $authException = null): Response
{ {
//Check if the request is an API request //Check if the request is an API request
if ($this->isJSONRequest($request)) { if ($this->isJSONRequest($request)) {

View file

@ -116,10 +116,10 @@ class SamlUserFactory implements SamlUserFactoryInterface, EventSubscriberInterf
* Maps a list of SAML roles to a local group ID. * Maps a list of SAML roles to a local group ID.
* The first available mapping will be used (so the order of the $map is important, first match wins). * The first available mapping will be used (so the order of the $map is important, first match wins).
* @param array $roles The list of SAML roles * @param array $roles The list of SAML roles
* @param array $map|null The mapping from SAML roles. If null, the global mapping will be used. * @param array|null $map The mapping from SAML roles. If null, the global mapping will be used.
* @return int|null The ID of the local group or null if no mapping was found. * @return int|null The ID of the local group or null if no mapping was found.
*/ */
public function mapSAMLRolesToLocalGroupID(array $roles, array $map = null): ?int public function mapSAMLRolesToLocalGroupID(array $roles, ?array $map = null): ?int
{ {
$map ??= $this->saml_role_mapping; $map ??= $this->saml_role_mapping;

View file

@ -42,7 +42,7 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
{ {
} }
public function normalize(mixed $object, string $format = null, array $context = []): array|null public function normalize(mixed $object, ?string $format = null, array $context = []): array|null
{ {
if (!$object instanceof Attachment) { if (!$object instanceof Attachment) {
throw new \InvalidArgumentException('This normalizer only supports Attachment objects!'); throw new \InvalidArgumentException('This normalizer only supports Attachment objects!');
@ -64,7 +64,7 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
return $data; return $data;
} }
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{ {
// avoid recursion: only call once per object // avoid recursion: only call once per object
if (isset($context[self::ALREADY_CALLED])) { if (isset($context[self::ALREADY_CALLED])) {

View file

@ -33,12 +33,12 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
{ {
public function supportsNormalization($data, string $format = null, array $context = []): bool public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{ {
return $data instanceof BigNumber; return $data instanceof BigNumber;
} }
public function normalize($object, string $format = null, array $context = []): string public function normalize($object, ?string $format = null, array $context = []): string
{ {
if (!$object instanceof BigNumber) { if (!$object instanceof BigNumber) {
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!'); throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
@ -58,7 +58,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
]; ];
} }
public function denormalize(mixed $data, string $type, string $format = null, array $context = []): BigNumber|null public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): BigNumber|null
{ {
if (!is_a($type, BigNumber::class, true)) { if (!is_a($type, BigNumber::class, true)) {
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!'); throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
@ -67,7 +67,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
return $type::of($data); return $type::of($data);
} }
public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{ {
//data must be a string or a number (int, float, etc.) and the type must be BigNumber or BigDecimal //data must be a string or a number (int, float, etc.) and the type must be BigNumber or BigDecimal
return (is_string($data) || is_numeric($data)) && (is_subclass_of($type, BigNumber::class)); return (is_string($data) || is_numeric($data)) && (is_subclass_of($type, BigNumber::class));

View file

@ -63,13 +63,13 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
{ {
} }
public function supportsNormalization($data, string $format = null, array $context = []): bool public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{ {
//We only remove the type field for CSV export //We only remove the type field for CSV export
return !isset($context[self::ALREADY_CALLED]) && $format === 'csv' && $data instanceof Part ; return !isset($context[self::ALREADY_CALLED]) && $format === 'csv' && $data instanceof Part ;
} }
public function normalize($object, string $format = null, array $context = []): array public function normalize($object, ?string $format = null, array $context = []): array
{ {
if (!$object instanceof Part) { if (!$object instanceof Part) {
throw new \InvalidArgumentException('This normalizer only supports Part objects!'); throw new \InvalidArgumentException('This normalizer only supports Part objects!');
@ -117,7 +117,7 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
return $data; return $data;
} }
public function denormalize($data, string $type, string $format = null, array $context = []): ?Part public function denormalize($data, string $type, ?string $format = null, array $context = []): ?Part
{ {
$this->normalizeKeys($data); $this->normalizeKeys($data);

View file

@ -49,7 +49,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
{ {
} }
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
{ {
//Only denormalize if we are doing a file import operation //Only denormalize if we are doing a file import operation
if (!($context['partdb_import'] ?? false)) { if (!($context['partdb_import'] ?? false)) {
@ -78,7 +78,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
* @return AbstractStructuralDBElement|null * @return AbstractStructuralDBElement|null
* @phpstan-return T|null * @phpstan-return T|null
*/ */
public function denormalize($data, string $type, string $format = null, array $context = []): ?AbstractStructuralDBElement public function denormalize($data, string $type, ?string $format = null, array $context = []): ?AbstractStructuralDBElement
{ {
//Do not use API Platform's denormalizer //Do not use API Platform's denormalizer
$context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true; $context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true;

View file

@ -36,7 +36,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
{ {
} }
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
{ {
//Only denormalize if we are doing a file import operation //Only denormalize if we are doing a file import operation
if (!($context['partdb_import'] ?? false)) { if (!($context['partdb_import'] ?? false)) {
@ -51,7 +51,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
* @phpstan-param class-string<T> $type * @phpstan-param class-string<T> $type
* @phpstan-return T|null * @phpstan-return T|null
*/ */
public function denormalize($data, string $type, string $format = null, array $context = []): AbstractStructuralDBElement|null public function denormalize($data, string $type, ?string $format = null, array $context = []): AbstractStructuralDBElement|null
{ {
//Retrieve the repository for the given type //Retrieve the repository for the given type
/** @var StructuralDBElementRepository<T> $repo */ /** @var StructuralDBElementRepository<T> $repo */

View file

@ -38,7 +38,7 @@ class StructuralElementNormalizer implements NormalizerInterface
{ {
} }
public function supportsNormalization($data, string $format = null, array $context = []): bool public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{ {
//Only normalize if we are doing a file export operation //Only normalize if we are doing a file export operation
if (!($context['partdb_export'] ?? false)) { if (!($context['partdb_export'] ?? false)) {
@ -48,7 +48,7 @@ class StructuralElementNormalizer implements NormalizerInterface
return $data instanceof AbstractStructuralDBElement; return $data instanceof AbstractStructuralDBElement;
} }
public function normalize($object, string $format = null, array $context = []): mixed public function normalize($object, ?string $format = null, array $context = []): mixed
{ {
if (!$object instanceof AbstractStructuralDBElement) { if (!$object instanceof AbstractStructuralDBElement) {
throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!'); throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');

View file

@ -357,7 +357,7 @@ class EntityImporter
* @param iterable $entities the list of entities that should be fixed * @param iterable $entities the list of entities that should be fixed
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set * @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
*/ */
protected function correctParentEntites(iterable $entities, AbstractStructuralDBElement $parent = null): void protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
{ {
foreach ($entities as $entity) { foreach ($entities as $entity) {
/** @var AbstractStructuralDBElement $entity */ /** @var AbstractStructuralDBElement $entity */

View file

@ -72,9 +72,9 @@ class ParameterDTO
group: $group); group: $group);
} }
//If the attribute contains "..." or a tilde we assume it is a range //If the attribute contains ".." or "..." or a tilde we assume it is a range
if (preg_match('/(\.{3}|~)/', $value) === 1) { if (preg_match('/(\.{2,3}|~)/', $value) === 1) {
$parts = preg_split('/\s*(\.{3}|~)\s*/', $value); $parts = preg_split('/\s*(\.{2,3}|~)\s*/', $value);
if (count($parts) === 2) { if (count($parts) === 2) {
//Try to extract number and unit from value (allow leading +) //Try to extract number and unit from value (allow leading +)
if ($unit === null || trim($unit) === '') { if ($unit === null || trim($unit) === '') {

View file

@ -27,6 +27,7 @@ use App\Entity\Parts\Part;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
@ -34,10 +35,12 @@ final class PartInfoRetriever
{ {
private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 7; // 7 days private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 4; // 7 days
public function __construct(private readonly ProviderRegistry $provider_registry, public function __construct(private readonly ProviderRegistry $provider_registry,
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache) private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache,
#[Autowire(param: "kernel.debug")]
private readonly bool $debugMode = false)
{ {
} }
@ -56,6 +59,11 @@ final class PartInfoRetriever
$provider = $this->provider_registry->getProviderByKey($provider); $provider = $this->provider_registry->getProviderByKey($provider);
} }
//Ensure that the provider is active
if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
}
if (!$provider instanceof InfoProviderInterface) { if (!$provider instanceof InfoProviderInterface) {
throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!"); throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!");
} }
@ -77,7 +85,7 @@ final class PartInfoRetriever
$escaped_keyword = urlencode($keyword); $escaped_keyword = urlencode($keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) { return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time //Set the expiration time
$item->expiresAfter(self::CACHE_RESULT_EXPIRATION); $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
return $provider->searchByKeyword($keyword); return $provider->searchByKeyword($keyword);
}); });
@ -94,11 +102,16 @@ final class PartInfoRetriever
{ {
$provider = $this->provider_registry->getProviderByKey($provider_key); $provider = $this->provider_registry->getProviderByKey($provider_key);
//Ensure that the provider is active
if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key $provider_key is not active!");
}
//Generate key and escape reserved characters from the provider id //Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id); $escaped_part_id = urlencode($part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) { return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time //Set the expiration time
$item->expiresAfter(self::CACHE_DETAIL_EXPIRATION); $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
return $provider->getDetails($part_id); return $provider->getDetails($part_id);
}); });

View file

@ -76,7 +76,7 @@ class LCSCProvider implements InfoProviderInterface
'Cookie' => new Cookie('currencyCode', $this->currency) 'Cookie' => new Cookie('currencyCode', $this->currency)
], ],
'query' => [ 'query' => [
'productCode' => $id, 'prductCode' => $id,
], ],
]); ]);

View file

@ -1221,7 +1221,7 @@ class OEMSecretsProvider implements InfoProviderInterface
* - 'value_min' => string|null The minimum value in a range, if applicable. * - 'value_min' => string|null The minimum value in a range, if applicable.
* - 'value_max' => string|null The maximum value in a range, if applicable. * - 'value_max' => string|null The maximum value in a range, if applicable.
*/ */
private function customSplitIntoValueAndUnit(string $value1, string $value2 = null): array private function customSplitIntoValueAndUnit(string $value1, ?string $value2 = null): array
{ {
// Separate numbers and units (basic parsing handling) // Separate numbers and units (basic parsing handling)
$unit = null; $unit = null;

View file

@ -0,0 +1,249 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
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;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PollinProvider implements InfoProviderInterface
{
public function __construct(private readonly HttpClientInterface $client,
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
private readonly bool $enabled = true,
)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Pollin',
'description' => 'Webscrapping from pollin.de to get part information',
'url' => 'https://www.reichelt.de/',
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
];
}
public function getProviderKey(): string
{
return 'pollin';
}
public function isActive(): bool
{
return $this->enabled;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [
'search' => $keyword
]
]);
$content = $response->getContent();
//If the response has us redirected to the product page, then just return the single item
if ($response->getInfo('redirect_count') > 0) {
return [$this->parseProductPage($content)];
}
$dom = new Crawler($content);
$results = [];
//Iterate over each div.product-box
$dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
$results[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
name: $node->filter('a.product-name')->text(),
description: '',
preview_image_url: $node->filter('img.product-image')->attr('src'),
manufacturing_status: $this->mapAvailability($node->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $node->filter('a.product-name')->attr('href')
);
});
return $results;
}
private function mapAvailability(string $availabilityURI): ManufacturingStatus
{
return match( $availabilityURI) {
'http://schema.org/InStock' => ManufacturingStatus::ACTIVE,
'http://schema.org/OutOfStock' => ManufacturingStatus::DISCONTINUED,
default => ManufacturingStatus::NOT_SET
};
}
public function getDetails(string $id): PartDetailDTO
{
//Ensure that $id is numeric
if (!is_numeric($id)) {
throw new \InvalidArgumentException("The id must be numeric!");
}
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [
'search' => $id
]
]);
//The response must have us redirected to the product page
if ($response->getInfo('redirect_count') > 0) {
throw new \RuntimeException("Could not resolve the product page for the given id!");
}
$content = $response->getContent();
return $this->parseProductPage($content);
}
private function parseProductPage(string $content): PartDetailDTO
{
$dom = new Crawler($content);
$productPageUrl = $dom->filter('meta[property="product:product_link"]')->attr('content');
$orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
//Calculate the mass
$massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
//Remove the unit
$massStr = str_replace('kg', '', $massStr);
//Convert to float and convert to grams
$mass = (float) $massStr * 1000;
//Parse purchase info
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $orderId,
name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
description: $dom->filter('meta[property="og:description"]')->attr('content'),
category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),
parameters: $this->parseParameters($dom),
vendor_infos: [$purchaseInfo],
mass: $mass,
);
}
private function parseDatasheets(Crawler $dom): array
{
//Iterate over each a element withing div.pol-product-detail-download-files
$datasheets = [];
$dom->filter('div.pol-product-detail-download-files a')->each(function (Crawler $node) use (&$datasheets) {
$datasheets[] = new FileDTO($node->attr('href'), $node->text());
});
return $datasheets;
}
private function parseParameters(Crawler $dom): array
{
$parameters = [];
//Iterate over each tr.properties-row inside table.product-detail-properties-table
$dom->filter('table.product-detail-properties-table tr.properties-row')->each(function (Crawler $node) use (&$parameters) {
$parameters[] = ParameterDTO::parseValueIncludingUnit(
name: rtrim($node->filter('th.properties-label')->text(), ':'),
value: trim($node->filter('td.properties-value')->text())
);
});
return $parameters;
}
private function parseCategory(Crawler $dom): string
{
$category = '';
//Iterate over each li.breadcrumb-item inside ol.breadcrumb
$dom->filter('ol.breadcrumb li.breadcrumb-item')->each(function (Crawler $node) use (&$category) {
//Skip if it has breadcrumb-item-home class
if (str_contains($node->attr('class'), 'breadcrumb-item-home')) {
return;
}
$category .= $node->text() . ' -> ';
});
//Remove the last ' -> '
return substr($category, 0, -4);
}
private function parseNotes(Crawler $dom): string
{
//Concat product highlights and product description
return $dom->filter('div.product-detail-top-features')->html() . '<br><br>' . $dom->filter('div.product-detail-description-text')->html();
}
private function parsePrices(Crawler $dom): array
{
//TODO: Properly handle multiple prices, for now we just look at the price for one piece
//We assume the currency is always the same
$currency = $dom->filter('meta[property="product:price:currency"]')->attr('content');
//If there is meta[property=highPrice] then use this as the price
if ($dom->filter('meta[itemprop="highPrice"]')->count() > 0) {
$price = $dom->filter('meta[itemprop="highPrice"]')->attr('content');
} else {
$price = $dom->filter('meta[property="product:price:amount"]')->attr('content');
}
return [
new PriceDTO(1.0, $price, $currency)
];
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::PRICE,
ProviderCapabilities::DATASHEET
];
}
}

View file

@ -0,0 +1,285 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ReicheltProvider implements InfoProviderInterface
{
public const DISTRIBUTOR_NAME = "Reichelt";
public function __construct(private readonly HttpClientInterface $client,
#[Autowire(env: "bool:PROVIDER_REICHELT_ENABLED")]
private readonly bool $enabled = true,
#[Autowire(env: "PROVIDER_REICHELT_LANGUAGE")]
private readonly string $language = "en",
#[Autowire(env: "PROVIDER_REICHELT_COUNTRY")]
private readonly string $country = "DE",
#[Autowire(env: "PROVIDER_REICHELT_INCLUDE_VAT")]
private readonly bool $includeVAT = false,
#[Autowire(env: "PROVIDER_REICHELT_CURRENCY")]
private readonly string $currency = "EUR",
)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Reichelt',
'description' => 'Webscrapping from reichelt.com to get part information',
'url' => 'https://www.reichelt.com/',
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
];
}
public function getProviderKey(): string
{
return 'reichelt';
}
public function isActive(): bool
{
return $this->enabled;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword));
$html = $response->getContent();
//Parse the HTML and return the results
$dom = new Crawler($html);
//Iterate over all div.al_gallery_article elements
$results = [];
$dom->filter('div.al_gallery_article')->each(function (Crawler $element) use (&$results) {
//Extract product id from data-product attribute
$artId = json_decode($element->attr('data-product'), true, 2, JSON_THROW_ON_ERROR)['artid'];
$productID = $element->filter('meta[itemprop="productID"]')->attr('content');
$name = $element->filter('meta[itemprop="name"]')->attr('content');
$sku = $element->filter('meta[itemprop="sku"]')->attr('content');
//Try to extract a picture URL:
$pictureURL = $element->filter("div.al_artlogo img")->attr('src');
$results[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $artId,
name: $productID,
description: $name,
category: null,
manufacturer: $sku,
preview_image_url: $pictureURL,
provider_url: $element->filter('a.al_artinfo_link')->attr('href')
);
});
return $results;
}
public function getDetails(string $id): PartDetailDTO
{
//Check that the ID is a number
if (!is_numeric($id)) {
throw new \InvalidArgumentException("Invalid ID");
}
//Use this endpoint to resolve the artID to a product page
$response = $this->client->request('GET',
sprintf(
'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s',
$id,
strtoupper($this->language),
strtoupper($this->country)
)
);
$json = $response->toArray();
//Retrieve the product page from the response
$productPage = $this->getBaseURL() . '/shop/product' . $json[0]['article_path'];
$response = $this->client->request('GET', $productPage, [
'query' => [
'CCTYPE' => $this->includeVAT ? 'private' : 'business',
'currency' => $this->currency,
],
]);
$html = $response->getContent();
$dom = new Crawler($html);
//Extract the product notes
$notes = $dom->filter('p[itemprop="description"]')->html();
//Extract datasheets
$datasheets = [];
$dom->filter('div.articleDatasheet a')->each(function (Crawler $element) use (&$datasheets) {
$datasheets[] = new FileDTO($element->attr('href'), $element->filter('span')->text());
});
//Determine price for one unit
$priceString = $dom->filter('meta[itemprop="price"]')->attr('content');
$currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR');
//Create purchase info
$purchaseInfo = new PurchaseInfoDTO(
distributor_name: self::DISTRIBUTOR_NAME,
order_number: $json[0]['article_artnr'],
prices: array_merge(
[new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)]
, $this->parseBatchPrices($dom, $currency)),
product_url: $productPage
);
//Create part object
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $id,
name: $json[0]['article_artnr'],
description: $json[0]['article_besch'],
category: $this->parseCategory($dom),
manufacturer: $json[0]['manufacturer_name'],
mpn: $this->parseMPN($dom),
preview_image_url: $json[0]['article_picture'],
provider_url: $productPage,
notes: $notes,
datasheets: $datasheets,
parameters: $this->parseParameters($dom),
vendor_infos: [$purchaseInfo]
);
}
private function parseMPN(Crawler $dom): string
{
//Find the small element directly after meta[itemprop="url"] element
$element = $dom->filter('meta[itemprop="url"] + small');
//If the text contains GTIN text, take the small element afterwards
if (str_contains($element->text(), 'GTIN')) {
$element = $dom->filter('meta[itemprop="url"] + small + small');
}
//The MPN is contained in the span inside the element
return $element->filter('span')->text();
}
private function parseBatchPrices(Crawler $dom, string $currency): array
{
//Iterate over each a.inline-block element in div.discountValue
$prices = [];
$dom->filter('div.discountValue a.inline-block')->each(function (Crawler $element) use (&$prices, $currency) {
//The minimum amount is the number in the span.block element
$minAmountText = $element->filter('span.block')->text();
//Extract a integer from the text
$matches = [];
if (!preg_match('/\d+/', $minAmountText, $matches)) {
return;
}
$minAmount = (int) $matches[0];
//The price is the text of the p.productPrice element
$priceString = $element->filter('p.productPrice')->text();
//Replace comma with dot
$priceString = str_replace(',', '.', $priceString);
//Strip any non-numeric characters
$priceString = preg_replace('/[^0-9.]/', '', $priceString);
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT);
});
return $prices;
}
private function parseCategory(Crawler $dom): string
{
// Look for ol.breadcrumb and iterate over the li elements
$category = '';
$dom->filter('ol.breadcrumb li.triangle-left')->each(function (Crawler $element) use (&$category) {
//Do not include the .breadcrumb-showmore element
if ($element->attr('id') === 'breadcrumb-showmore') {
return;
}
$category .= $element->text() . ' -> ';
});
//Remove the trailing ' -> '
$category = substr($category, 0, -4);
return $category;
}
/**
* @param Crawler $dom
* @return ParameterDTO[]
*/
private function parseParameters(Crawler $dom): array
{
$parameters = [];
//Iterate over each ul.articleTechnicalData which contains the specifications of each group
$dom->filter('ul.articleTechnicalData')->each(function (Crawler $groupElement) use (&$parameters) {
$groupName = $groupElement->filter('li.articleTechnicalHeadline')->text();
//Iterate over each second li in ul.articleAttribute, which contains the specifications
$groupElement->filter('ul.articleAttribute li:nth-child(2n)')->each(function (Crawler $specElement) use (&$parameters, $groupName) {
$parameters[] = ParameterDTO::parseValueIncludingUnit(
name: $specElement->previousAll()->text(),
value: $specElement->text(),
group: $groupName
);
});
});
return $parameters;
}
private function getBaseURL(): string
{
//Without the trailing slash
return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
];
}
}

View file

@ -43,12 +43,12 @@ class UniqueObjectCollection extends Constraint
* @param array|string $fields the combination of fields that must contain unique values or a set of options * @param array|string $fields the combination of fields that must contain unique values or a set of options
*/ */
public function __construct( public function __construct(
array $options = null, ?array $options = null,
string $message = null, ?string $message = null,
callable $normalizer = null, ?callable $normalizer = null,
array $groups = null, ?array $groups = null,
mixed $payload = null, mixed $payload = null,
array|string $fields = null, array|string|null $fields = null,
public bool $allowNull = true, public bool $allowNull = true,
) { ) {
parent::__construct($options, $groups, $payload); parent::__construct($options, $groups, $payload);

View file

@ -31,8 +31,8 @@ class ValidGoogleAuthCode extends Constraint
* @param TwoFactorInterface|null $user The user to use for the validation process, if null, the current user is used * @param TwoFactorInterface|null $user The user to use for the validation process, if null, the current user is used
*/ */
public function __construct( public function __construct(
array $options = null, ?array $options = null,
array $groups = null, ?array $groups = null,
mixed $payload = null, mixed $payload = null,
public ?TwoFactorInterface $user = null) public ?TwoFactorInterface $user = null)
{ {

View file

@ -70,6 +70,16 @@ class ParameterDTOTest extends TestCase
'test' 'test'
]; ];
//Test ranges
yield [
new ParameterDTO('test', value_min: 1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'),
'test',
'1.0..2.0',
'kg',
'm',
'test'
];
//Test ranges with tilde //Test ranges with tilde
yield [ yield [
new ParameterDTO('test', value_min: -1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'), new ParameterDTO('test', value_min: -1.0, value_max: 2.0, unit: 'kg', symbol: 'm', group: 'test'),

View file

@ -1,17 +1,23 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl"> <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
<file id="security.en"> <file id="security.en">
<unit id="aazoCks" name="user.login_error.user_disabled"> <unit id="GrLNa9P" name="user.login_error.user_disabled">
<segment state="translated"> <segment state="translated">
<source>user.login_error.user_disabled</source> <source>user.login_error.user_disabled</source>
<target>Uw account is gedeactiveerd! Neem contact op met een beheerder indien dit incorrect is.</target> <target>Uw account is gedeactiveerd! Neem contact op met een beheerder indien dit incorrect is.</target>
</segment> </segment>
</unit> </unit>
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml"> <unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
<segment state="translated"> <segment state="translated">
<source>saml.error.cannot_login_local_user_per_saml</source> <source>saml.error.cannot_login_local_user_per_saml</source>
<target>U kunt niet inloggen als lokale gebruiker met SSO! Gebruik uw lokale wachtwoord.</target> <target>U kunt niet inloggen als lokale gebruiker met SSO! Gebruik uw lokale wachtwoord.</target>
</segment> </segment>
</unit> </unit>
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
<segment state="translated">
<source>saml.error.cannot_login_saml_user_locally</source>
<target>U kunt geen lokale authenticatie gebruiken om in te loggen als SAML-gebruiker! Gebruik in plaats daarvan SSO-aanmelding.</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>

1732
yarn.lock

File diff suppressed because it is too large Load diff