From 3ffb5e827852ec1d9f274cd4ee6a9fa8b9ae0b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 16 Feb 2026 22:05:49 +0100 Subject: [PATCH 1/7] Implemented Amazon info provider using canopy --- .../Providers/CanopyProvider.php | 177 ++++++++++++++++++ .../InfoProviderSystem/CanopySettings.php | 46 +++++ 2 files changed, 223 insertions(+) create mode 100644 src/Services/InfoProviderSystem/Providers/CanopyProvider.php create mode 100644 src/Settings/InfoProviderSystem/CanopySettings.php diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php new file mode 100644 index 00000000..e6ca3961 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -0,0 +1,177 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Settings\InfoProviderSystem\BuerklinSettings; +use App\Settings\InfoProviderSystem\CanopySettings; +use Symfony\Component\DependencyInjection\Attribute\When; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Use canopy API to retrieve infos from amazon + */ +class CanopyProvider implements InfoProviderInterface +{ + + public const BASE_URL = "https://rest.canopyapi.co/api"; + public const SEARCH_API_URL = self::BASE_URL . "/amazon/search"; + public const DETAIL_API_URL = self::BASE_URL . "/amazon/product"; + + public const DISTRIBUTOR_NAME = 'Amazon'; + + public function __construct(private readonly CanopySettings $settings, private readonly HttpClientInterface $httpClient) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'Amazon (Canopy)', + 'description' => 'Retrieves part infos from Amazon using the Canopy API', + 'url' => 'https://canopyapi.co', + 'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider', + 'settings_class' => CanopySettings::class + ]; + } + + public function getProviderKey(): string + { + return 'canopy'; + } + + public function isActive(): bool + { + return $this->settings->apiKey !== null; + } + + private function productPageFromASIN(string $asin): string + { + return "https://www.amazon.{$this->settings->domain}/dp/{$asin}"; + } + + public function searchByKeyword(string $keyword): array + { + $response = $this->httpClient->request('GET', self::SEARCH_API_URL, [ + 'query' => [ + 'domain' => $this->settings->domain, + 'searchTerm' => $keyword, + ], + 'headers' => [ + 'API-KEY' => $this->settings->apiKey, + ] + ]); + + $data = $response->toArray(); + $results = $data['data']['amazonProductSearchResults']['productResults']['results'] ?? []; + + $out = []; + foreach ($results as $result) { + $out[] = new SearchResultDTO( + provider_key: $this->getProviderKey(), + provider_id: $result['asin'], + name: $result["title"], + description: "", + preview_image_url: $result["mainImageUrl"] ?? null, + provider_url: $this->productPageFromASIN($result['asin']), + ); + } + + return $out; + } + + private function categoriesToCategory(array $categories): ?string + { + if (count($categories) === 0) { + return null; + } + + return implode(" -> ", array_map(static fn($cat) => $cat['name'], $categories)); + } + + private function feauturesBulletsToNotes(array $featureBullets): string + { + $notes = ""; + return $notes; + } + + private function priceToPurchaseInfo(array $price, string $asin): PurchaseInfoDTO + { + $priceDto = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true); + + return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: [$priceDto], product_url: $this->productPageFromASIN($asin)); + } + + public function getDetails(string $id): PartDetailDTO + { + //Check that the id is a valid ASIN (10 characters, letters and numbers) + if (!preg_match('/^[A-Z0-9]{10}$/', $id)) { + throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)"); + } + + $response = $this->httpClient->request('GET', self::DETAIL_API_URL, [ + 'query' => [ + 'asin' => $id, + 'domain' => $this->settings->domain, + ], + 'headers' => [ + 'API-KEY' => $this->settings->apiKey, + ], + ]); + + $product = $response->toArray()['data']['amazonProduct']; + + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['asin'], + name: $product['title'], + description: '', + category: $this->categoriesToCategory($product['categories']), + manufacturer: $product['brand'] ?? null, + preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null, + provider_url: $this->productPageFromASIN($product['asin']), + notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []), + vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])] + ); + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::PRICE, + ]; + } +} diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php new file mode 100644 index 00000000..bc40bff1 --- /dev/null +++ b/src/Settings/InfoProviderSystem/CanopySettings.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.canopy"), description: new TM("settings.ips.canopy.help"))] +#[SettingsIcon("fa-plug")] +class CanopySettings +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.canopy.apiKey"), + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + public string $domain = "de"; +} From aa9436a19b706d9a25594e4e5a9f3c0e33141771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 22:09:23 +0100 Subject: [PATCH 2/7] Fixed conrad provider if part does not have manuals --- src/Services/InfoProviderSystem/Providers/ConradProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 3086b7d8..39de1e23 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr public function productMediaToDatasheets(array $productMedia): array { $files = []; - foreach ($productMedia['manuals'] as $manual) { + foreach ($productMedia['manuals'] ?? [] as $manual) { //Filter out unwanted languages if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) { continue; From 258289482b1ad937e95b17b1b04293d875093cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 22:12:50 +0100 Subject: [PATCH 3/7] Increase debug detail expiration time to 10s to avoid double retrieval in one request --- src/Services/InfoProviderSystem/PartInfoRetriever.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 5cc23f05..db1895e7 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -95,7 +95,7 @@ final class PartInfoRetriever $escaped_keyword = hash('xxh3', $keyword); return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) { //Set the expiration time - $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1); + $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10); return $provider->searchByKeyword($keyword); }); @@ -122,7 +122,7 @@ final class PartInfoRetriever $escaped_part_id = hash('xxh3', $part_id); return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) { //Set the expiration time - $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1); + $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10); return $provider->getDetails($part_id); }); From 87919eb445dd5bcbc4a8d9ddc09e750472842c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 22:29:44 +0100 Subject: [PATCH 4/7] Allow to cache amazon search results to reduce API calls --- .../Providers/CanopyProvider.php | 60 +++++++++++++++++-- .../InfoProviderSystem/CanopySettings.php | 5 ++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php index e6ca3961..f7683084 100644 --- a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\BuerklinSettings; use App\Settings\InfoProviderSystem\CanopySettings; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\DependencyInjection\Attribute\When; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -45,7 +46,8 @@ class CanopyProvider implements InfoProviderInterface public const DISTRIBUTOR_NAME = 'Amazon'; - public function __construct(private readonly CanopySettings $settings, private readonly HttpClientInterface $httpClient) + public function __construct(private readonly CanopySettings $settings, + private readonly HttpClientInterface $httpClient, private readonly CacheItemPoolInterface $partInfoCache) { } @@ -76,6 +78,39 @@ class CanopyProvider implements InfoProviderInterface return "https://www.amazon.{$this->settings->domain}/dp/{$asin}"; } + /** + * Saves the given part to the cache. + * Everytime this function is called, the cache is overwritten. + * @param PartDetailDTO $part + * @return void + */ + private function saveToCache(PartDetailDTO $part): void + { + $key = 'canopy_part_'.$part->provider_id; + + $item = $this->partInfoCache->getItem($key); + $item->set($part); + $item->expiresAfter(3600 * 24); //Cache for 1 day + $this->partInfoCache->save($item); + } + + /** + * Retrieves a from the cache, or null if it was not cached yet. + * @param string $id + * @return PartDetailDTO|null + */ + private function getFromCache(string $id): ?PartDetailDTO + { + $key = 'canopy_part_'.$id; + + $item = $this->partInfoCache->getItem($key); + if ($item->isHit()) { + return $item->get(); + } + + return null; + } + public function searchByKeyword(string $keyword): array { $response = $this->httpClient->request('GET', self::SEARCH_API_URL, [ @@ -93,14 +128,20 @@ class CanopyProvider implements InfoProviderInterface $out = []; foreach ($results as $result) { - $out[] = new SearchResultDTO( + + + $dto = new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $result['asin'], name: $result["title"], description: "", preview_image_url: $result["mainImageUrl"] ?? null, provider_url: $this->productPageFromASIN($result['asin']), + vendor_infos: [$this->priceToPurchaseInfo($result['price'], $result['asin'])] ); + + $out[] = $dto; + $this->saveToCache($dto); } return $out; @@ -125,11 +166,15 @@ class CanopyProvider implements InfoProviderInterface return $notes; } - private function priceToPurchaseInfo(array $price, string $asin): PurchaseInfoDTO + private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO { - $priceDto = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true); + $priceDtos = []; + if ($price !== null) { + $priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true); + } - return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: [$priceDto], product_url: $this->productPageFromASIN($asin)); + + return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin)); } public function getDetails(string $id): PartDetailDTO @@ -139,6 +184,11 @@ class CanopyProvider implements InfoProviderInterface throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)"); } + //Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details + if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) { + return $cached; + } + $response = $this->httpClient->request('GET', self::DETAIL_API_URL, [ 'query' => [ 'asin' => $id, diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php index bc40bff1..f6a0494b 100644 --- a/src/Settings/InfoProviderSystem/CanopySettings.php +++ b/src/Settings/InfoProviderSystem/CanopySettings.php @@ -43,4 +43,9 @@ class CanopySettings public ?string $apiKey = null; public string $domain = "de"; + + /** + * @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request + */ + public bool $alwaysGetDetails = false; } From 0b9b2cbf58f79f2ec4c978567ccce5de33a0fe38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 23:16:39 +0100 Subject: [PATCH 5/7] Allow to read amazon labels for part retrieval and creation --- src/Form/LabelSystem/ScanDialogType.php | 7 +-- src/Repository/PartRepository.php | 24 ++++++++++ .../AmazonBarcodeScanResult.php | 46 +++++++++++++++++++ .../BarcodeScanner/BarcodeScanHelper.php | 9 ++++ .../BarcodeScanResultHandler.php | 14 +++++- .../BarcodeScanner/BarcodeSourceType.php | 6 ++- translations/messages.en.xlf | 6 +++ 7 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index d9c1de0e..cd1440c6 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -72,12 +72,7 @@ class ScanDialogType extends AbstractType 'placeholder' => 'scan_dialog.mode.auto', 'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) { null => 'scan_dialog.mode.auto', - BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal', - BarcodeSourceType::IPN => 'scan_dialog.mode.ipn', - BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', - BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', - BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', - BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc', + default => 'scan_dialog.mode.' . $enum->value, }, ]); diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 49342301..d5ccd3a6 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -454,4 +454,28 @@ class PartRepository extends NamedDBElementRepository return $qb->getQuery()->getOneOrNullResult(); } + /** + * Finds a part based on the provided SPN (Supplier Part Number), with an option for case sensitivity. + * If no part is found with the given SPN, null is returned. + * @param string $spn + * @param bool $caseInsensitive + * @return Part|null + */ + public function getPartBySPN(string $spn, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + $qb->leftJoin('part.orderdetails', 'o'); + + if ($caseInsensitive) { + $qb->where("LOWER(o.supplierpartnr) = LOWER(:spn)"); + } else { + $qb->where("o.supplierpartnr = :spn"); + } + + $qb->setParameter('spn', $spn); + + return $qb->getQuery()->getOneOrNullResult(); + } } diff --git a/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php new file mode 100644 index 00000000..fb756043 --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\LabelSystem\BarcodeScanner; + +final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface +{ + public function __construct(public string $asin) { + if (!self::isAmazonBarcode($asin)) { + throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)"); + } + } + + public static function isAmazonBarcode(string $input): bool + { + //Amazon barcodes are 10 alphanumeric characters + return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1; + } + + public function getDecodedForInfoMode(): array + { + return [ + 'ASIN' => $this->asin, + ]; + } +} diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index b2363ec8..0bee33a1 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -101,6 +101,10 @@ final class BarcodeScanHelper return $this->parseLCSCBarcode($input); } + if ($type === BarcodeSourceType::AMAZON) { + return new AmazonBarcodeScanResult($input); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -135,6 +139,11 @@ final class BarcodeScanHelper return $this->parseLCSCBarcode($input); } + //Try amazon barcode + if (AmazonBarcodeScanResult::isAmazonBarcode($input)) { + return new AmazonBarcodeScanResult($input); + } + throw new InvalidArgumentException('Unknown barcode'); } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 372e976e..e24c7077 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -144,7 +144,12 @@ final readonly class BarcodeScanResultHandler return $this->resolvePartFromLCSC($barcodeScan); } - throw new \InvalidArgumentException("Barcode does not support resolving to a local entity: ".get_class($barcodeScan)); + if ($barcodeScan instanceof AmazonBarcodeScanResult) { + return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin) + ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin); + } + + return null; } /** @@ -258,6 +263,13 @@ final readonly class BarcodeScanResultHandler return $this->getCreationInfoForEIGP114($scanResult); } + if ($scanResult instanceof AmazonBarcodeScanResult) { + return [ + 'providerKey' => 'canopy', + 'providerId' => $scanResult->asin, + ]; + } + return null; } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index 13ab4bf3..fb6eaa77 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -36,12 +36,12 @@ enum BarcodeSourceType: string /** * This barcode is a user defined barcode defined on a part lot */ - case USER_DEFINED = 'user_defined'; + case USER_DEFINED = 'user'; /** * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ - case EIGP114 = 'eigp114'; + case EIGP114 = 'eigp'; /** * GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part. @@ -50,4 +50,6 @@ enum BarcodeSourceType: string /** For LCSC.com formatted QR codes */ case LCSC = 'lcsc'; + + case AMAZON = 'amazon'; } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 31bc3884..c8c41b92 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12563,5 +12563,11 @@ Buerklin-API Authentication server: Create [part] from barcode + + + scan_dialog.mode.amazon + Amazon barcode + + From 300382f6e33a172e26828f138ba6feafab3ce147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 23:38:56 +0100 Subject: [PATCH 6/7] Make Canopy provider configurable via UI --- .../Providers/CanopyProvider.php | 2 +- .../InfoProviderSystem/CanopySettings.php | 51 +++++++++++++++++-- .../InfoProviderSettings.php | 3 ++ translations/messages.en.xlf | 18 +++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php index f7683084..131db15f 100644 --- a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -75,7 +75,7 @@ class CanopyProvider implements InfoProviderInterface private function productPageFromASIN(string $asin): string { - return "https://www.amazon.{$this->settings->domain}/dp/{$asin}"; + return "https://www.{$this->settings->getRealDomain()}/dp/{$asin}"; } /** diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php index f6a0494b..88e1fcb7 100644 --- a/src/Settings/InfoProviderSystem/CanopySettings.php +++ b/src/Settings/InfoProviderSystem/CanopySettings.php @@ -29,23 +29,68 @@ use Jbtronics\SettingsBundle\Metadata\EnvVarMode; use Jbtronics\SettingsBundle\Settings\Settings; use Jbtronics\SettingsBundle\Settings\SettingsParameter; use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\CountryType; use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; -#[Settings(label: new TM("settings.ips.canopy"), description: new TM("settings.ips.canopy.help"))] +#[Settings(label: new TM("settings.ips.canopy"))] #[SettingsIcon("fa-plug")] class CanopySettings { + public const ALLOWED_DOMAINS = [ + "amazon.de" => "DE", + "amazon.com" => "US", + "amazon.co.uk" => "UK", + "amazon.fr" => "FR", + "amazon.it" => "IT", + "amazon.es" => "ES", + "amazon.ca" => "CA", + "amazon.com.au" => "AU", + "amazon.com.br" => "BR", + "amazon.com.mx" => "MX", + "amazon.in" => "IN", + "amazon.co.jp" => "JP", + "amazon.nl" => "NL", + "amazon.pl" => "PL", + "amazon.sa" => "SA", + "amazon.sg" => "SG", + "amazon.se" => "SE", + "amazon.com.tr" => "TR", + "amazon.ae" => "AE", + "amazon.com.be" => "BE", + "amazon.com.cn" => "CN", + ]; + use SettingsTrait; - #[SettingsParameter(label: new TM("settings.ips.canopy.apiKey"), + #[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"), formType: APIKeyType::class, formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)] public ?string $apiKey = null; - public string $domain = "de"; + /** + * @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant + */ + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])] + public string $domain = "DE"; /** * @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request */ + #[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))] public bool $alwaysGetDetails = false; + + /** + * Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE) + * @return string + */ + public function getRealDomain(): string + { + $domain = array_search($this->domain, self::ALLOWED_DOMAINS); + if ($domain === false) { + throw new \InvalidArgumentException("Invalid domain selected"); + } + return $domain; + } } diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index 3e78233f..248fcedc 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -72,4 +72,7 @@ class InfoProviderSettings #[EmbeddedSettings] public ?ConradSettings $conrad = null; + + #[EmbeddedSettings] + public ?CanopySettings $canopy = null; } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index c8c41b92..f422beb3 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12569,5 +12569,23 @@ Buerklin-API Authentication server: Amazon barcode + + + settings.ips.canopy + Canopy + + + + + settings.ips.canopy.alwaysGetDetails + Always fetch details + + + + + settings.ips.canopy.alwaysGetDetails.help + When selected, more details will be fetched from canopy when creating a part. This causes an additional API request, but gives product bullet points and category info. + + From e283d9ced617b5d7b29ce5eb5574dfe1187b955f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 22 Feb 2026 23:43:36 +0100 Subject: [PATCH 7/7] Added docs for canopy info provider --- docs/usage/information_provider_system.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 6cdb5183..1600d76f 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -303,7 +303,17 @@ That method is not officially supported nor encouraged by Part-DB, and might bre The following env configuration options are available: * `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory) -### Custom provider +### Canopy / Amazon +The Canopy provider uses the [Canopy API](https://www.canopyapi.co/) to search for parts and get shopping information from Amazon. +Canopy is a third-party service that provides access to Amazon product data through their API. Their trial plan offers 100 requests per month for free, +and they also offer paid plans with higher limits. To use the Canopy provider, you need to create an account on the Canopy website and obtain an API key. +Once you have the API key, you can configure the Canopy provider in Part-DB using the web UI or environment variables: + +* `PROVIDER_CANOPY_API_KEY`: The API key you got from Canopy (mandatory) + + + +### Custom providers To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long as it is a valid Symfony service, it will be automatically loaded and can be used.