Merge tag 'v2.1.2' into Buerklin-provider

# Conflicts:
#	.docker/symfony.conf
#	VERSION
This commit is contained in:
Marc Kreidler 2025-09-11 11:33:37 +02:00
commit 5b2fc7ef4b
366 changed files with 32347 additions and 19045 deletions

View file

@ -43,6 +43,7 @@ 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\Settings\SystemSettings\LocalizationSettings;
use Doctrine\ORM\EntityManagerInterface;
/**
@ -54,8 +55,11 @@ final class DTOtoEntityConverter
private const TYPE_DATASHEETS_NAME = 'Datasheet';
private const TYPE_IMAGE_NAME = 'Image';
public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency)
private readonly string $base_currency;
public function __construct(private readonly EntityManagerInterface $em, LocalizationSettings $localizationSettings)
{
$this->base_currency = $localizationSettings->baseCurrency;
}
/**

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Services\InfoProviderSystem;
use App\Entity\Parts\Manufacturer;
@ -74,4 +76,4 @@ final class ExistingPartFinder
return $qb->getQuery()->getResult();
}
}
}

View file

@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\OAuth\OAuthTokenManager;
use App\Settings\InfoProviderSystem\DigikeySettings;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class DigikeyProvider implements InfoProviderInterface
@ -55,17 +56,16 @@ class DigikeyProvider implements InfoProviderInterface
];
public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager,
private readonly string $currency, private readonly string $clientId,
private readonly string $language, private readonly string $country)
private readonly DigikeySettings $settings,)
{
//Create the HTTP client with some default options
$this->digikeyClient = $httpClient->withOptions([
"base_uri" => self::BASE_URI,
"headers" => [
"X-DIGIKEY-Client-Id" => $clientId,
"X-DIGIKEY-Locale-Site" => $this->country,
"X-DIGIKEY-Locale-Language" => $this->language,
"X-DIGIKEY-Locale-Currency" => $this->currency,
"X-DIGIKEY-Client-Id" => $this->settings->clientId,
"X-DIGIKEY-Locale-Site" => $this->settings->country,
"X-DIGIKEY-Locale-Language" => $this->settings->language,
"X-DIGIKEY-Locale-Currency" => $this->settings->currency,
"X-DIGIKEY-Customer-Id" => 0,
]
]);
@ -78,7 +78,8 @@ class DigikeyProvider implements InfoProviderInterface
'description' => 'This provider uses the DigiKey API to search for parts.',
'url' => 'https://www.digikey.com/',
'oauth_app_name' => self::OAUTH_APP_NAME,
'disabled_help' => 'Set the PROVIDER_DIGIKEY_CLIENT_ID and PROVIDER_DIGIKEY_SECRET env option and connect OAuth to enable.'
'disabled_help' => 'Set the Client ID and Secret in provider settings and connect OAuth to enable.',
'settings_class' => DigikeySettings::class,
];
}
@ -101,19 +102,22 @@ class DigikeyProvider implements InfoProviderInterface
public function isActive(): bool
{
//The client ID has to be set and a token has to be available (user clicked connect)
return $this->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
{
$request = [
'Keywords' => $keyword,
'RecordCount' => 50,
'RecordStartPosition' => 0,
'ExcludeMarketPlaceProducts' => 'true',
'Limit' => 50,
'Offset' => 0,
'FilterOptionsRequest' => [
'MarketPlaceFilter' => 'ExcludeMarketPlace',
],
];
$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
'json' => $request,
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
@ -124,18 +128,21 @@ class DigikeyProvider implements InfoProviderInterface
$result = [];
$products = $response_array['Products'];
foreach ($products as $product) {
$result[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['DigiKeyPartNumber'],
name: $product['ManufacturerPartNumber'],
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Value'] ?? null,
mpn: $product['ManufacturerPartNumber'],
preview_image_url: $product['PrimaryPhoto'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
provider_url: $product['ProductUrl'],
);
foreach ($product['ProductVariations'] as $variation) {
$result[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $variation['DigiKeyProductNumber'],
name: $product['ManufacturerProductNumber'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Name'] ?? null,
mpn: $product['ManufacturerProductNumber'],
preview_image_url: $product['PhotoUrl'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
provider_url: $product['ProductUrl'],
footprint: $variation['PackageType']['Name'], //Use the footprint field, to show the user the package type (Tape & Reel, etc., as digikey has many different package types)
);
}
}
return $result;
@ -143,62 +150,79 @@ class DigikeyProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO
{
$response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . urlencode($id), [
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
$product = $response->toArray();
$response_array = $response->toArray();
$product = $response_array['Product'];
$footprint = null;
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
$media = $this->mediaToDTOs($product['MediaLinks']);
$media = $this->mediaToDTOs($id);
// Get the price_breaks of the selected variation
$price_breaks = [];
foreach ($product['ProductVariations'] as $variation) {
if ($variation['DigiKeyProductNumber'] == $id) {
$price_breaks = $variation['StandardPricing'] ?? [];
break;
}
}
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['DigiKeyPartNumber'],
name: $product['ManufacturerPartNumber'],
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
provider_id: $id,
name: $product['ManufacturerProductNumber'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Value'] ?? null,
mpn: $product['ManufacturerPartNumber'],
preview_image_url: $product['PrimaryPhoto'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
manufacturer: $product['Manufacturer']['Name'] ?? null,
mpn: $product['ManufacturerProductNumber'],
preview_image_url: $product['PhotoUrl'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
provider_url: $product['ProductUrl'],
footprint: $footprint,
datasheets: $media['datasheets'],
images: $media['images'],
parameters: $parameters,
vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']),
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
);
}
/**
* Converts the product status from the Digikey API to the manufacturing status used in Part-DB
* @param string|null $dk_status
* @param int|null $dk_status
* @return ManufacturingStatus|null
*/
private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus
private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
{
// The V4 can use strings to get the status, but if you have changed the PROVIDER_DIGIKEY_LANGUAGE it will not match.
// Using the Id instead which should be fixed.
//
// The API is not well documented and the ID are not there yet, so were extracted using "trial and error".
// The 'Preliminary' id was not found in several categories so I was unable to extract it. Disabled for now.
return match ($dk_status) {
null => null,
'Active' => ManufacturingStatus::ACTIVE,
'Obsolete' => ManufacturingStatus::DISCONTINUED,
'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
'Not For New Designs' => ManufacturingStatus::NRFND,
'Preliminary' => ManufacturingStatus::ANNOUNCED,
0 => ManufacturingStatus::ACTIVE,
1 => ManufacturingStatus::DISCONTINUED,
2, 4 => ManufacturingStatus::EOL,
7 => ManufacturingStatus::NRFND,
//'Preliminary' => ManufacturingStatus::ANNOUNCED,
default => ManufacturingStatus::NOT_SET,
};
}
private function getCategoryString(array $product): string
{
$category = $product['Category']['Value'];
$sub_category = $product['Family']['Value'];
$category = $product['Category']['Name'];
$sub_category = current($product['Category']['ChildCategories']);
//Replace the ' - ' category separator with ' -> '
$sub_category = str_replace(' - ', ' -> ', $sub_category);
if ($sub_category) {
//Replace the ' - ' category separator with ' -> '
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
}
return $category . ' -> ' . $sub_category;
return $category;
}
/**
@ -215,18 +239,18 @@ class DigikeyProvider implements InfoProviderInterface
foreach ($parameters as $parameter) {
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint"
$footprint_name = $parameter['Value'];
$footprint_name = $parameter['ValueText'];
}
if (in_array(trim((string) $parameter['Value']), ['', '-'], true)) {
if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
continue;
}
//If the parameter was marked as text only, then we do not try to parse it as a numerical value
if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) {
$results[] = new ParameterDTO(name: $parameter['Parameter'], value_text: $parameter['Value']);
$results[] = new ParameterDTO(name: $parameter['ParameterText'], value_text: $parameter['ValueText']);
} else { //Otherwise try to parse it as a numerical value
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
}
}
@ -245,7 +269,7 @@ class DigikeyProvider implements InfoProviderInterface
$prices = [];
foreach ($price_breaks as $price_break) {
$prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->currency);
$prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->settings->currency);
}
return [
@ -254,16 +278,22 @@ class DigikeyProvider implements InfoProviderInterface
}
/**
* @param array $media_links
* @param string $id The Digikey product number, to get the media for
* @return FileDTO[][]
* @phpstan-return array<string, FileDTO[]>
*/
private function mediaToDTOs(array $media_links): array
private function mediaToDTOs(string $id): array
{
$datasheets = [];
$images = [];
foreach ($media_links as $media_link) {
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
$media_array = $response->toArray();
foreach ($media_array['MediaLinks'] as $media_link) {
$file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
switch ($media_link['MediaType']) {

View file

@ -29,6 +29,8 @@ 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\Settings\InfoProviderSystem\Element14Settings;
use Composer\CaBundle\CaBundle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Element14Provider implements InfoProviderInterface
@ -43,9 +45,19 @@ class Element14Provider implements InfoProviderInterface
private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant',
'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode'];
public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id)
{
private readonly HttpClientInterface $element14Client;
public function __construct(HttpClientInterface $element14Client, private readonly Element14Settings $settings)
{
/* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems
* with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866
*
* This is a workaround until the issue is resolved in debian (or never).
* As this only affects this provider, this should have no negative impact and the CA bundle is still secure.
*/
$this->element14Client = $element14Client->withOptions([
'cafile' => CaBundle::getBundledCaBundlePath(),
]);
}
public function getProviderInfo(): array
@ -54,7 +66,8 @@ class Element14Provider implements InfoProviderInterface
'name' => 'Farnell element14',
'description' => 'This provider uses the Farnell element14 API to search for parts.',
'url' => 'https://www.element14.com/',
'disabled_help' => 'Configure the API key in the PROVIDER_ELEMENT14_KEY environment variable to enable.'
'disabled_help' => 'Configure the API key in the provider settings to enable.',
'settings_class' => Element14Settings::class,
];
}
@ -65,7 +78,7 @@ class Element14Provider implements InfoProviderInterface
public function isActive(): bool
{
return $this->api_key !== '';
return $this->settings->apiKey !== null && trim($this->settings->apiKey) !== '';
}
/**
@ -77,11 +90,11 @@ class Element14Provider implements InfoProviderInterface
$response = $this->element14Client->request('GET', self::ENDPOINT_URL, [
'query' => [
'term' => $term,
'storeInfo.id' => $this->store_id,
'storeInfo.id' => $this->settings->storeId,
'resultsSettings.offset' => 0,
'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS,
'resultsSettings.responseGroup' => 'large',
'callInfo.apiKey' => $this->api_key,
'callInfo.apiKey' => $this->settings->apiKey,
'callInfo.responseDataFormat' => 'json',
'versionNumber' => self::API_VERSION_NUMBER,
],
@ -149,7 +162,7 @@ class Element14Provider implements InfoProviderInterface
$locale = 'en_US';
}
return 'https://' . $this->store_id . '/productimages/standard/' . $locale . $image['baseName'];
return 'https://' . $this->settings->storeId . '/productimages/standard/' . $locale . $image['baseName'];
}
/**
@ -184,7 +197,7 @@ class Element14Provider implements InfoProviderInterface
public function getUsedCurrency(): string
{
//Decide based on the shop ID
return match ($this->store_id) {
return match ($this->settings->storeId) {
'bg.farnell.com', 'at.farnell.com', 'si.farnell.com', 'sk.farnell.com', 'ro.farnell.com', 'pt.farnell.com', 'nl.farnell.com', 'be.farnell.com', 'lv.farnell.com', 'lt.farnell.com', 'it.farnell.com', 'fr.farnell.com', 'fi.farnell.com', 'ee.farnell.com', 'es.farnell.com', 'ie.farnell.com', 'cpcireland.farnell.com', 'de.farnell.com' => 'EUR',
'cz.farnell.com' => 'CZK',
'dk.farnell.com' => 'DKK',
@ -211,7 +224,7 @@ class Element14Provider implements InfoProviderInterface
'tw.element14.com' => 'TWD',
'kr.element14.com' => 'KRW',
'vn.element14.com' => 'VND',
default => throw new \RuntimeException('Unknown store ID: ' . $this->store_id)
default => throw new \RuntimeException('Unknown store ID: ' . $this->settings->storeId)
};
}
@ -296,4 +309,4 @@ class Element14Provider implements InfoProviderInterface
ProviderCapabilities::DATASHEET,
];
}
}
}

View file

@ -39,8 +39,9 @@ interface InfoProviderInterface
* - url?: The url of the provider (e.g. "https://www.digikey.com")
* - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it
* - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown
* - settings_class?: The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown
*
* @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string }
* @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string, settings_class?: class-string }
*/
public function getProviderInfo(): array;
@ -78,4 +79,4 @@ interface InfoProviderInterface
* @return ProviderCapabilities[]
*/
public function getCapabilities(): array;
}
}

View file

@ -29,6 +29,7 @@ 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\Settings\InfoProviderSystem\LCSCSettings;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -39,7 +40,7 @@ class LCSCProvider implements InfoProviderInterface
public const DISTRIBUTOR_NAME = 'LCSC';
public function __construct(private readonly HttpClientInterface $lcscClient, private readonly string $currency, private readonly bool $enabled = true)
public function __construct(private readonly HttpClientInterface $lcscClient, private readonly LCSCSettings $settings)
{
}
@ -50,7 +51,8 @@ class LCSCProvider implements InfoProviderInterface
'name' => 'LCSC',
'description' => 'This provider uses the (unofficial) LCSC API to search for parts.',
'url' => 'https://www.lcsc.com/',
'disabled_help' => 'Set PROVIDER_LCSC_ENABLED to 1 (or true) in your environment variable config.'
'disabled_help' => 'Enable this provider in the provider settings.',
'settings_class' => LCSCSettings::class,
];
}
@ -62,7 +64,7 @@ class LCSCProvider implements InfoProviderInterface
// This provider is always active
public function isActive(): bool
{
return $this->enabled;
return $this->settings->enabled;
}
/**
@ -73,7 +75,7 @@ class LCSCProvider implements InfoProviderInterface
{
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->currency)
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'query' => [
'productCode' => $id,
@ -121,11 +123,11 @@ class LCSCProvider implements InfoProviderInterface
*/
private function queryByTerm(string $term): array
{
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->currency)
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'query' => [
'json' => [
'keyword' => $term,
],
]);
@ -163,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
if ($field === null) {
return null;
}
// Replace "range" indicators with mathematical tilde symbols
// so they don't get rendered as strikethrough by Markdown
$field = preg_replace("/~/", "\u{223c}", $field);
return strip_tags($field);
}
@ -195,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
$category = $product['parentCatalogName'] ?? null;
if (isset($product['catalogName'])) {
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
// Replace the / with a -> for better readability
$category = str_replace('/', ' -> ', $category);
}
return new PartDetailDTO(
@ -273,7 +275,7 @@ class LCSCProvider implements InfoProviderInterface
'kr.' => 'DKK',
'₹' => 'INR',
//Fallback to the configured currency
default => $this->currency,
default => $this->settings->currency,
};
}

View file

@ -37,6 +37,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Settings\InfoProviderSystem\MouserSettings;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
@ -50,10 +51,7 @@ class MouserProvider implements InfoProviderInterface
public function __construct(
private readonly HttpClientInterface $mouserClient,
private readonly string $api_key,
private readonly string $language,
private readonly string $options,
private readonly int $search_limit
private readonly MouserSettings $settings,
) {
}
@ -63,7 +61,8 @@ class MouserProvider implements InfoProviderInterface
'name' => 'Mouser',
'description' => 'This provider uses the Mouser API to search for parts.',
'url' => 'https://www.mouser.com/',
'disabled_help' => 'Configure the API key in the PROVIDER_MOUSER_KEY environment variable to enable.'
'disabled_help' => 'Configure the API key in the provider settings to enable.',
'settings_class' => MouserSettings::class
];
}
@ -74,7 +73,7 @@ class MouserProvider implements InfoProviderInterface
public function isActive(): bool
{
return $this->api_key !== '';
return $this->settings->apiKey !== '' && $this->settings->apiKey !== null;
}
public function searchByKeyword(string $keyword): array
@ -94,6 +93,7 @@ class MouserProvider implements InfoProviderInterface
From the startingRecord, the number of records specified will be returned up to the end of the recordset.
This is useful for paging through the complete recordset of parts matching keyword.
searchOptions string
Optional.
If not provided, the default is None.
@ -119,15 +119,15 @@ class MouserProvider implements InfoProviderInterface
$response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/keyword", [
'query' => [
'apiKey' => $this->api_key,
'apiKey' => $this->settings->apiKey
],
'json' => [
'SearchByKeywordRequest' => [
'keyword' => $keyword,
'records' => $this->search_limit, //self::NUMBER_OF_RESULTS,
'records' => $this->settings->searchLimit, //self::NUMBER_OF_RESULTS,
'startingRecord' => 0,
'searchOptions' => $this->options,
'searchWithYourSignUpLanguage' => $this->language,
'searchOptions' => $this->settings->searchOption->value,
'searchWithYourSignUpLanguage' => $this->settings->searchWithSignUpLanguage ? 'true' : 'false',
]
],
]);
@ -160,7 +160,7 @@ class MouserProvider implements InfoProviderInterface
$response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/partnumber", [
'query' => [
'apiKey' => $this->api_key,
'apiKey' => $this->settings->apiKey,
],
'json' => [
'SearchByPartRequest' => [
@ -176,11 +176,16 @@ class MouserProvider implements InfoProviderInterface
throw new \RuntimeException('No part found with ID '.$id);
}
//Manually filter out the part with the correct ID
$tmp = array_filter($tmp, fn(PartDetailDTO $part) => $part->provider_id === $id);
if (count($tmp) === 0) {
throw new \RuntimeException('No part found with ID '.$id);
}
if (count($tmp) > 1) {
throw new \RuntimeException('Multiple parts found with ID '.$id . ' ('.count($tmp).' found). This is basically a bug in Mousers API response. See issue #616.');
throw new \RuntimeException('Multiple parts found with ID '.$id);
}
return $tmp[0];
return reset($tmp);
}
public function getCapabilities(): array
@ -341,4 +346,4 @@ class MouserProvider implements InfoProviderInterface
return $tmp;
}
}
}

View file

@ -88,6 +88,8 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Settings\InfoProviderSystem\OEMSecretsSettings;
use App\Settings\InfoProviderSystem\OEMSecretsSortMode;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Psr\Cache\CacheItemPoolInterface;
@ -99,12 +101,7 @@ class OEMSecretsProvider implements InfoProviderInterface
public function __construct(
private readonly HttpClientInterface $oemsecretsClient,
private readonly string $api_key,
private readonly string $country_code,
private readonly string $currency,
private readonly string $zero_price,
private readonly string $set_param,
private readonly string $sort_criteria,
private readonly OEMSecretsSettings $settings,
private readonly CacheItemPoolInterface $partInfoCache
)
{
@ -249,7 +246,8 @@ class OEMSecretsProvider implements InfoProviderInterface
'name' => 'OEMSecrets',
'description' => 'This provider uses the OEMSecrets API to search for parts.',
'url' => 'https://www.oemsecrets.com/',
'disabled_help' => 'Configure the API key in the PROVIDER_OEMSECRETS_KEY environment variable to enable.'
'disabled_help' => 'Configure the API key in the provider settings to enable.',
'settings_class' => OEMSecretsSettings::class
];
}
/**
@ -268,7 +266,7 @@ class OEMSecretsProvider implements InfoProviderInterface
*/
public function isActive(): bool
{
return $this->api_key !== '';
return $this->settings->apiKey !== null && $this->settings->apiKey !== '';
}
@ -288,18 +286,18 @@ class OEMSecretsProvider implements InfoProviderInterface
public function searchByKeyword(string $keyword): array
{
/*
oemsecrets Part Search API 3.0.1
oemsecrets Part Search API 3.0.1
"https://oemsecretsapi.com/partsearch?
searchTerm=BC547
&apiKey=icawpb0bspoo2c6s64uv4vpdfp2vgr7e27bxw0yct2bzh87mpl027x353uelpq2x
&currency=EUR
&countryCode=IT"
&countryCode=IT"
partsearch description:
Use the Part Search API to find distributor data for a full or partial manufacturer
Use the Part Search API to find distributor data for a full or partial manufacturer
part number including part details, pricing, compliance and inventory.
Required Parameter Format Description
searchTerm string Part number you are searching for
apiKey string Your unique API key provided to you by OEMsecrets
@ -307,14 +305,14 @@ class OEMSecretsProvider implements InfoProviderInterface
Additional Parameter Format Description
countryCode string The country you want to output for
currency string / array The currency you want the prices to be displayed as
To display the output for GB and to view prices in USD, add [ countryCode=GB ] and [ currency=USD ]
as seen below:
oemsecretsapi.com/partsearch?apiKey=abcexampleapikey123&searchTerm=bd04&countryCode=GB&currency=USD
To view prices in both USD and GBP add [ currency[]=USD&currency[]=GBP ]
oemsecretsapi.com/partsearch?searchTerm=bd04&apiKey=abcexampleapikey123&currency[]=USD&currency[]=GBP
*/
@ -324,9 +322,9 @@ class OEMSecretsProvider implements InfoProviderInterface
$response = $this->oemsecretsClient->request('GET', self::ENDPOINT_URL, [
'query' => [
'searchTerm' => $keyword,
'apiKey' => $this->api_key,
'currency' => $this->currency,
'countryCode' => $this->country_code,
'apiKey' => $this->settings->apiKey,
'currency' => $this->settings->currency,
'countryCode' => $this->settings->country,
],
]);
@ -533,7 +531,7 @@ class OEMSecretsProvider implements InfoProviderInterface
// Extract prices
$priceDTOs = $this->getPrices($product);
if (empty($priceDTOs) && (int)$this->zero_price === 0) {
if (empty($priceDTOs) && !$this->settings->keepZeroPrices) {
return null; // Skip products without valid prices
}
@ -557,7 +555,7 @@ class OEMSecretsProvider implements InfoProviderInterface
}
$imagesResults[$provider_id] = $this->getImages($product, $imagesResults[$provider_id] ?? []);
if ($this->set_param == 1) {
if ($this->settings->parseParams) {
$parametersResults[$provider_id] = $this->getParameters($product, $parametersResults[$provider_id] ?? []);
} else {
$parametersResults[$provider_id] = [];
@ -582,7 +580,7 @@ class OEMSecretsProvider implements InfoProviderInterface
$regionB = $this->countryCodeToRegionMap[$countryCodeB] ?? '';
// If the map is empty or doesn't contain the key for $this->country_code, assign a placeholder region.
$regionForEnvCountry = $this->countryCodeToRegionMap[$this->country_code] ?? '';
$regionForEnvCountry = $this->countryCodeToRegionMap[$this->settings->country] ?? '';
// Convert to string before comparison to avoid mixed types
$countryCodeA = (string) $countryCodeA;
@ -599,9 +597,9 @@ class OEMSecretsProvider implements InfoProviderInterface
}
// Step 1: country_code from the environment
if ($countryCodeA === $this->country_code && $countryCodeB !== $this->country_code) {
if ($countryCodeA === $this->settings->country && $countryCodeB !== $this->settings->country) {
return -1;
} elseif ($countryCodeA !== $this->country_code && $countryCodeB === $this->country_code) {
} elseif ($countryCodeA !== $this->settings->country && $countryCodeB === $this->settings->country) {
return 1;
}
@ -681,8 +679,8 @@ class OEMSecretsProvider implements InfoProviderInterface
if (is_array($prices)) {
// Step 1: Check if prices exist in the preferred currency
if (isset($prices[$this->currency]) && is_array($prices[$this->currency])) {
$priceDetails = $prices[$this->currency];
if (isset($prices[$this->settings->currency]) && is_array($prices[$this->settings->currency])) {
$priceDetails = $prices[$this->$this->settings->currency];
foreach ($priceDetails as $priceDetail) {
if (
is_array($priceDetail) &&
@ -694,7 +692,7 @@ class OEMSecretsProvider implements InfoProviderInterface
$priceDTOs[] = new PriceDTO(
minimum_discount_amount: (float)$priceDetail['unit_break'],
price: (string)$priceDetail['unit_price'],
currency_iso_code: $this->currency,
currency_iso_code: $this->settings->currency,
includes_tax: false,
price_related_quantity: 1.0
);
@ -1293,7 +1291,7 @@ class OEMSecretsProvider implements InfoProviderInterface
private function sortResultsData(array &$resultsData, string $searchKeyword): void
{
// If the SORT_CRITERIA is not 'C' or 'M', do not sort
if ($this->sort_criteria !== 'C' && $this->sort_criteria !== 'M') {
if ($this->settings->sortMode !== OEMSecretsSortMode::COMPLETENESS && $this->settings->sortMode !== OEMSecretsSortMode::MANUFACTURER) {
return;
}
usort($resultsData, function ($a, $b) use ($searchKeyword) {
@ -1332,9 +1330,9 @@ class OEMSecretsProvider implements InfoProviderInterface
}
// Final sorting: by completeness or manufacturer, if necessary
if ($this->sort_criteria === 'C') {
if ($this->settings->sortMode === OEMSecretsSortMode::COMPLETENESS) {
return $this->compareByCompleteness($a, $b);
} elseif ($this->sort_criteria === 'M') {
} elseif ($this->settings->sortMode === OEMSecretsSortMode::MANUFACTURER) {
return strcasecmp($a->manufacturer, $b->manufacturer);
}
@ -1468,4 +1466,4 @@ class OEMSecretsProvider implements InfoProviderInterface
return $url;
}
}
}

View file

@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\OAuth\OAuthTokenManager;
use App\Settings\InfoProviderSystem\OctopartSettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -114,9 +115,8 @@ class OctopartProvider implements InfoProviderInterface
public function __construct(private readonly HttpClientInterface $httpClient,
private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache,
private readonly string $clientId, private readonly string $secret,
private readonly string $currency, private readonly string $country,
private readonly int $search_limit, private readonly bool $onlyAuthorizedSellers)
private readonly OctopartSettings $settings,
)
{
}
@ -170,7 +170,8 @@ class OctopartProvider implements InfoProviderInterface
'name' => 'Octopart',
'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.',
'url' => 'https://www.octopart.com/',
'disabled_help' => 'Set the PROVIDER_OCTOPART_CLIENT_ID and PROVIDER_OCTOPART_SECRET env option.'
'disabled_help' => 'Set the Client ID and Secret in provider settings.',
'settings_class' => OctopartSettings::class
];
}
@ -183,7 +184,8 @@ class OctopartProvider implements InfoProviderInterface
{
//The client ID has to be set and a token has to be available (user clicked connect)
//return /*!empty($this->clientId) && */ $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
return $this->clientId !== '' && $this->secret !== '';
return $this->settings->clientId !== null && $this->settings->clientId !== ''
&& $this->settings->secret !== null && $this->settings->secret !== '';
}
private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus
@ -337,7 +339,7 @@ class OctopartProvider implements InfoProviderInterface
) {
hits
results {
part
part
%s
}
}
@ -347,10 +349,10 @@ class OctopartProvider implements InfoProviderInterface
$result = $this->makeGraphQLCall($graphQL, [
'keyword' => $keyword,
'limit' => $this->search_limit,
'currency' => $this->currency,
'country' => $this->country,
'authorizedOnly' => $this->onlyAuthorizedSellers,
'limit' => $this->settings->searchLimit,
'currency' => $this->settings->currency,
'country' => $this->settings->country,
'authorizedOnly' => $this->settings->onlyAuthorizedSellers,
]);
$tmp = [];
@ -383,9 +385,9 @@ class OctopartProvider implements InfoProviderInterface
$result = $this->makeGraphQLCall($graphql, [
'ids' => [$id],
'currency' => $this->currency,
'country' => $this->country,
'authorizedOnly' => $this->onlyAuthorizedSellers,
'currency' => $this->settings->currency,
'country' => $this->settings->country,
'authorizedOnly' => $this->settings->onlyAuthorizedSellers,
]);
$tmp = $this->partResultToDTO($result['data']['supParts'][0]);
@ -403,4 +405,4 @@ class OctopartProvider implements InfoProviderInterface
ProviderCapabilities::PRICE,
];
}
}
}

View file

@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\PollinSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -39,8 +40,7 @@ class PollinProvider implements InfoProviderInterface
{
public function __construct(private readonly HttpClientInterface $client,
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
private readonly bool $enabled = true,
private readonly PollinSettings $settings,
)
{
}
@ -49,9 +49,10 @@ class PollinProvider implements InfoProviderInterface
{
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'
'description' => 'Webscraping from pollin.de to get part information',
'url' => 'https://www.pollin.de/',
'disabled_help' => 'Enable the provider in provider settings',
'settings_class' => PollinSettings::class,
];
}
@ -62,7 +63,7 @@ class PollinProvider implements InfoProviderInterface
public function isActive(): bool
{
return $this->enabled;
return $this->settings->enabled;
}
public function searchByKeyword(string $keyword): array
@ -157,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
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')),
//TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),
@ -215,7 +217,7 @@ class PollinProvider implements InfoProviderInterface
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();
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

View file

@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\ReicheltSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -39,16 +40,7 @@ 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",
private readonly ReicheltSettings $settings,
)
{
}
@ -57,9 +49,10 @@ class ReicheltProvider implements InfoProviderInterface
{
return [
'name' => 'Reichelt',
'description' => 'Webscrapping from reichelt.com to get part information',
'description' => 'Webscraping from reichelt.com to get part information',
'url' => 'https://www.reichelt.com/',
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
'disabled_help' => 'Enable provider in provider settings.',
'settings_class' => ReicheltSettings::class,
];
}
@ -70,7 +63,7 @@ class ReicheltProvider implements InfoProviderInterface
public function isActive(): bool
{
return $this->enabled;
return $this->settings->enabled;
}
public function searchByKeyword(string $keyword): array
@ -121,8 +114,8 @@ class ReicheltProvider implements InfoProviderInterface
sprintf(
'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s',
$id,
strtoupper($this->language),
strtoupper($this->country)
strtoupper($this->settings->language),
strtoupper($this->settings->country)
)
);
$json = $response->toArray();
@ -133,8 +126,8 @@ class ReicheltProvider implements InfoProviderInterface
$response = $this->client->request('GET', $productPage, [
'query' => [
'CCTYPE' => $this->includeVAT ? 'private' : 'business',
'currency' => $this->currency,
'CCTYPE' => $this->settings->includeVAT ? 'private' : 'business',
'currency' => $this->settings->currency,
],
]);
$html = $response->getContent();
@ -158,7 +151,7 @@ class ReicheltProvider implements InfoProviderInterface
distributor_name: self::DISTRIBUTOR_NAME,
order_number: $json[0]['article_artnr'],
prices: array_merge(
[new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)]
[new PriceDTO(1.0, $priceString, $currency, $this->settings->includeVAT)]
, $this->parseBatchPrices($dom, $currency)),
product_url: $productPage
);
@ -218,7 +211,7 @@ class ReicheltProvider implements InfoProviderInterface
//Strip any non-numeric characters
$priceString = preg_replace('/[^0-9.]/', '', $priceString);
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT);
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->settings->includeVAT);
});
return $prices;
@ -270,7 +263,7 @@ class ReicheltProvider implements InfoProviderInterface
private function getBaseURL(): string
{
//Without the trailing slash
return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language);
return 'https://www.reichelt.com/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language);
}
public function getCapabilities(): array
@ -282,4 +275,4 @@ class ReicheltProvider implements InfoProviderInterface
ProviderCapabilities::PRICE,
];
}
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Settings\InfoProviderSystem\TMESettings;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
@ -30,15 +31,15 @@ class TMEClient
{
public const BASE_URI = 'https://api.tme.eu';
public function __construct(private readonly HttpClientInterface $tmeClient, private readonly string $token, private readonly string $secret)
public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings)
{
}
public function makeRequest(string $action, array $parameters): ResponseInterface
{
$parameters['Token'] = $this->token;
$parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->secret);
$parameters['Token'] = $this->settings->apiToken;
$parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->settings->apiSecret);
return $this->tmeClient->request('POST', $this->getUrlForAction($action), [
'body' => $parameters,
@ -47,7 +48,7 @@ class TMEClient
public function isUsable(): bool
{
return $this->token !== '' && $this->secret !== '';
return !($this->settings->apiToken === null || $this->settings->apiSecret === null);
}
/**
@ -58,7 +59,7 @@ class TMEClient
public function isUsingPrivateToken(): bool
{
//Private tokens are longer than anonymous ones (50 instead of 45 characters)
return strlen($this->token) > 45;
return strlen($this->settings->apiToken ?? '') > 45;
}
/**
@ -93,4 +94,4 @@ class TMEClient
return $params;
}
}
}

View file

@ -30,24 +30,21 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\TMESettings;
class TMEProvider implements InfoProviderInterface
{
private const VENDOR_NAME = 'TME';
/** @var bool If true, the prices are gross prices. If false, the prices are net prices. */
private readonly bool $get_gross_prices;
public function __construct(private readonly TMEClient $tmeClient, private readonly string $country,
private readonly string $language, private readonly string $currency,
bool $get_gross_prices)
public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings)
{
//If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then
if ($this->tmeClient->isUsingPrivateToken()) {
$this->get_gross_prices = false;
} else {
$this->get_gross_prices = $get_gross_prices;
$this->get_gross_prices = $this->settings->grossPrices;
}
}
@ -57,7 +54,8 @@ class TMEProvider implements InfoProviderInterface
'name' => 'TME',
'description' => 'This provider uses the API of TME (Transfer Multipart).',
'url' => 'https://tme.eu/',
'disabled_help' => 'Configure the PROVIDER_TME_KEY and PROVIDER_TME_SECRET environment variables to use this provider.'
'disabled_help' => 'Configure the API Token and secret in provider settings to use this provider.',
'settings_class' => TMESettings::class
];
}
@ -74,8 +72,8 @@ class TMEProvider implements InfoProviderInterface
public function searchByKeyword(string $keyword): array
{
$response = $this->tmeClient->makeRequest('Products/Search', [
'Country' => $this->country,
'Language' => $this->language,
'Country' => $this->settings->country,
'Language' => $this->settings->language,
'SearchPlain' => $keyword,
]);
@ -104,8 +102,8 @@ class TMEProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO
{
$response = $this->tmeClient->makeRequest('Products/GetProducts', [
'Country' => $this->country,
'Language' => $this->language,
'Country' => $this->settings->country,
'Language' => $this->settings->language,
'SymbolList' => [$id],
]);
@ -149,8 +147,8 @@ class TMEProvider implements InfoProviderInterface
public function getFiles(string $id): array
{
$response = $this->tmeClient->makeRequest('Products/GetProductsFiles', [
'Country' => $this->country,
'Language' => $this->language,
'Country' => $this->settings->country,
'Language' => $this->settings->language,
'SymbolList' => [$id],
]);
@ -191,9 +189,9 @@ class TMEProvider implements InfoProviderInterface
public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO
{
$response = $this->tmeClient->makeRequest('Products/GetPricesAndStocks', [
'Country' => $this->country,
'Language' => $this->language,
'Currency' => $this->currency,
'Country' => $this->settings->country,
'Language' => $this->settings->language,
'Currency' => $this->settings->currency,
'GrossPrices' => $this->get_gross_prices,
'SymbolList' => [$id],
]);
@ -234,8 +232,8 @@ class TMEProvider implements InfoProviderInterface
public function getParameters(string $id, string|null &$footprint_name = null): array
{
$response = $this->tmeClient->makeRequest('Products/GetParameters', [
'Country' => $this->country,
'Language' => $this->language,
'Country' => $this->settings->country,
'Language' => $this->settings->language,
'SymbolList' => [$id],
]);
@ -298,4 +296,4 @@ class TMEProvider implements InfoProviderInterface
ProviderCapabilities::PRICE,
];
}
}
}