diff --git a/config/packages/http_client.yaml b/config/packages/http_client.yaml new file mode 100644 index 00000000..9489e92b --- /dev/null +++ b/config/packages/http_client.yaml @@ -0,0 +1,17 @@ +framework: + http_client: + default_options: + headers: + 'User-Agent': 'Part-DB' + + + scoped_clients: + digikey.client: + base_uri: 'https://sandbox-api.digikey.com' + auth_bearer: '%env(PROVIDER_DIGIKEY_TOKEN)%' + headers: + X-DIGIKEY-Client-Id: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' + X-DIGIKEY-Locale-Site: 'DE' + X-DIGIKEY-Locale-Language: 'de' + X-DIGIKEY-Locale-Currency: '%partdb.default_currency%' + X-DIGIKEY-Customer-Id: 0 \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 300497de..6f939198 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,6 +24,9 @@ services: App\Services\LabelSystem\PlaceholderProviders\PlaceholderProviderInterface: tags: ['app.label_placeholder_provider'] + App\Services\InfoProviderSystem\Providers\InfoProviderInterface: + tags: ['app.info_provider'] + # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: @@ -234,6 +237,13 @@ services: $rootNodeExpandedByDefault: '%partdb.sidebar.root_expanded%' $rootNodeEnabled: '%partdb.sidebar.root_node_enable%' + #################################################################################################################### + # Part info provider system + #################################################################################################################### + App\Services\InfoProviderSystem\ProviderRegistry: + arguments: + $providers: !tagged_iterator 'app.info_provider' + #################################################################################################################### # Symfony overrides #################################################################################################################### diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php new file mode 100644 index 00000000..11935f0d --- /dev/null +++ b/src/Controller/InfoProviderController.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; + +#[Route('/tools/info_providers')] +class InfoProviderController extends AbstractController +{ + + #[Route('/providers', name: 'info_providers_list')] + public function listProviders(ProviderRegistry $providerRegistry): Response + { + return $this->render('info_providers/providers_list/providers_list.html.twig', [ + 'active_providers' => $providerRegistry->getActiveProviders(), + 'disabled_providers' => $providerRegistry->getDisabledProviders(), + ]); + } + + #[Route('/search', name: 'info_providers_search')] + public function search(): Response + { + + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php new file mode 100644 index 00000000..5c4cb97f --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -0,0 +1,52 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\ManufacturingStatus; + +class SearchResultDTO +{ + public function __construct( + /** @var string The provider key (e.g. "digikey") */ + public readonly string $provider_key, + /** @var string The ID which identifies the part in the provider system */ + public readonly string $provider_id, + /** @var string The name of the part */ + public readonly string $name, + /** @var string A short description of the part */ + public readonly string $description, + /** @var string|null The manufacturer of the part */ + public readonly ?string $manufacturer = null, + /** @var string|null The manufacturer part number */ + public readonly ?string $mpn = null, + /** @var string|null An URL to a preview image */ + public readonly ?string $preview_image_url = null, + /** @var ManufacturingStatus|null The manufacturing status of the part */ + public readonly ?ManufacturingStatus $manufacturing_status = null, + /** @var string|null A link to the part on the providers page */ + public readonly ?string $provider_url = null, + ) { + + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/ProviderRegistry.php b/src/Services/InfoProviderSystem/ProviderRegistry.php new file mode 100644 index 00000000..2b9ef43b --- /dev/null +++ b/src/Services/InfoProviderSystem/ProviderRegistry.php @@ -0,0 +1,107 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; + +class ProviderRegistry +{ + + /** + * @var InfoProviderInterface[] The info providers index by their keys + * @psalm-var array + */ + private array $providers_by_name = []; + + /** + * @var InfoProviderInterface[] The enabled providers indexed by their keys + * @psalm-var array + */ + private array $providers_active = []; + + /** + * @var InfoProviderInterface[] The disabled providers indexed by their keys + * @psalm-var array + */ + private array $providers_disabled = []; + + /** + * @param iterable $providers + */ + public function __construct(private readonly iterable $providers) + { + foreach ($providers as $provider) { + $key = $provider->getProviderKey(); + + if (isset($this->providers_by_name[$key])) { + throw new \LogicException("Provider with key $key already registered"); + } + + $this->providers_by_name[$key] = $provider; + if ($provider->isActive()) { + $this->providers_active[$key] = $provider; + } else { + $this->providers_disabled[$key] = $provider; + } + } + } + + /** + * Returns an array of all registered providers (enabled and disabled) + * @return InfoProviderInterface[] + */ + public function getProviders(): array + { + return $this->providers_by_name; + } + + /** + * Returns the provider identified by the given key + * @param string $key + * @return InfoProviderInterface + * @throws \InvalidArgumentException If the provider with the given key does not exist + */ + public function getProviderByKey(string $key): InfoProviderInterface + { + return $this->providers_by_name[$key] ?? throw new \InvalidArgumentException("Provider with key $key not found"); + } + + /** + * Returns an array of all active providers + * @return InfoProviderInterface[] + */ + public function getActiveProviders(): array + { + return $this->providers_active; + } + + /** + * Returns an array of all disabled providers + * @return InfoProviderInterface[] + */ + public function getDisabledProviders(): array + { + return $this->providers_disabled; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php new file mode 100644 index 00000000..6c85e36b --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -0,0 +1,121 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class DigikeyProvider implements InfoProviderInterface +{ + + public function __construct(private readonly HttpClientInterface $digikeyClient) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'DigiKey', + 'description' => 'This provider uses the DigiKey API to search for parts.', + 'url' => 'https://www.digikey.com/', + ]; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } + + public function getProviderKey(): string + { + return 'digikey'; + } + + public function isActive(): bool + { + return true; + } + + public function searchByKeyword(string $keyword): array + { + $request = [ + 'Keywords' => $keyword, + 'RecordCount' => 50, + 'RecordStartPosition' => 0, + 'ExcludeMarketPlaceProducts' => 'true', + ]; + + $response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [ + 'json' => $request, + ]); + + $response_array = $response->toArray(); + + + $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['ProductDescription'], + manufacturer: $product['Manufacturer']['Value'] ?? null, + mpn: $product['ManufacturerPartNumber'], + preview_image_url: $product['PrimaryPhoto'] ?? null, + manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']), + provider_url: 'https://digikey.com'.$product['ProductUrl'], + ); + } + + return $result; + } + + /** + * Converts the product status from the Digikey API to the manufacturing status used in Part-DB + * @param string|null $dk_status + * @return ManufacturingStatus|null + */ + private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus + { + return match ($dk_status) { + null => null, + 'Active' => ManufacturingStatus::ACTIVE, + 'Obsolete' => ManufacturingStatus::DISCONTINUED, + 'Discontinued at Digi-Key' => ManufacturingStatus::EOL, + 'Last Time Buy' => ManufacturingStatus::EOL, + 'Not For New Designs' => ManufacturingStatus::NRFND, + 'Preliminary' => ManufacturingStatus::ANNOUNCED, + default => ManufacturingStatus::NOT_SET, + }; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php new file mode 100644 index 00000000..97d8087b --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; + +interface InfoProviderInterface +{ + + /** + * Get information about this provider + * + * @return array An associative array with the following keys (? means optional): + * - name: The (user friendly) name of the provider (e.g. "Digikey"), will be translated + * - description?: A short description of the provider (e.g. "Digikey is a ..."), will be translated + * - logo?: The logo of the provider (e.g. "digikey.png") + * - 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 + * + * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string } + */ + public function getProviderInfo(): array; + + /** + * Returns a unique key for this provider, which will be saved into the database + * and used to identify the provider + * @return string A unique key for this provider (e.g. "digikey") + */ + public function getProviderKey(): string; + + /** + * Checks if this provider is enabled or not (meaning that it can be used for searching) + * @return bool True if the provider is enabled, false otherwise + */ + public function isActive(): bool; + + /** + * Searches for a keyword and returns a list of search results + * @param string $keyword The keyword to search for + * @return SearchResultDTO[] A list of search results + */ + public function searchByKeyword(string $keyword): array; + + /** + * A list of capabilities this provider supports (which kind of data it can provide). + * Not every part have to contain all of these data, but the provider should be able to provide them in general. + * Currently, this list is purely informational and not used in functional checks. + * @return ProviderCapabilities[] + */ + public function getCapabilities(): array; +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php new file mode 100644 index 00000000..fd67cd2c --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +/** + * This enum contains all capabilities (which data it can provide) a provider can have. + */ +enum ProviderCapabilities +{ + /** Basic information about a part, like the name, description, part number, manufacturer etc */ + case BASIC; + + /** Information about the footprint of a part */ + case FOOTPRINT; + + /** Provider can provide a picture for a part */ + case PICTURE; + + /** Provider can provide datasheets for a part */ + case DATASHEET; + + /** Provider can provide prices for a part */ + case PRICE; + + public function getTranslationKey(): string + { + return 'info_providers.capabilities.' . match($this) { + self::BASIC => 'basic', + self::FOOTPRINT => 'footprint', + self::PICTURE => 'picture', + self::DATASHEET => 'datasheet', + self::PRICE => 'price', + }; + } + + public function getFAIconClass(): string + { + return 'fa-solid ' . match($this) { + self::BASIC => 'fa-info-circle', + self::FOOTPRINT => 'fa-microchip', + self::PICTURE => 'fa-image', + self::DATASHEET => 'fa-file-alt', + self::PRICE => 'fa-money-bill-wave', + }; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php new file mode 100644 index 00000000..4147b5b4 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +class TestProvider implements InfoProviderInterface +{ + + public function getProviderInfo(): array + { + return [ + 'name' => 'Test Provider', + 'description' => 'This is a test provider', + //'url' => 'https://example.com', + 'disabled_help' => 'This provider is disabled for testing purposes' + ]; + } + + public function getProviderKey(): string + { + return 'test'; + } + + public function isActive(): bool + { + return false; + } + + public function searchByKeyword(string $keyword): array + { + // TODO: Implement searchByKeyword() method. + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ]; + } +} \ No newline at end of file diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig new file mode 100644 index 00000000..4f8dc3b8 --- /dev/null +++ b/templates/info_providers/providers.macro.html.twig @@ -0,0 +1,52 @@ +{% macro provider_info_table(providers) %} + + + {% for provider in providers %} + {# @var provider \App\Services\InfoProviderSystem\Providers\InfoProviderInterface #} + + + + {% endfor %} + +
+
+
+
+ {% if provider.providerInfo.url is defined and provider.providerInfo.url is not empty %} + {{ provider.providerInfo.name }} + {% else %} + {{ provider.providerInfo.name | trans }} + {% endif %} + +
+
+ {% if provider.providerInfo.description is defined and provider.providerInfo.description is not null %} + {{ provider.providerInfo.description | trans }} + {% endif %} +
+ +
+
+ {% for capability in provider.capabilities %} + {# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #} + + + {{ capability.translationKey|trans }} + + {% endfor %} +
+
+ {% if provider.active == false %} +
+
+ {% trans %}info_providers.providers_list.disabled{% endtrans %} + {% if provider.providerInfo.disabled_help is defined and provider.providerInfo.disabled_help is not empty %} +
+ {{ provider.providerInfo.disabled_help|trans }} + {% endif %} +
+
+ + {% endif %} +
+{% endmacro %} \ No newline at end of file diff --git a/templates/info_providers/providers_list/providers_list.html.twig b/templates/info_providers/providers_list/providers_list.html.twig new file mode 100644 index 00000000..18037bd2 --- /dev/null +++ b/templates/info_providers/providers_list/providers_list.html.twig @@ -0,0 +1,33 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} + +{% block title %}{% trans %}info_providers.providers_list.title{% endtrans %}{% endblock %} + +{% block card_title %} + {% trans %}info_providers.providers_list.title{% endtrans %} +{% endblock %} + +{% block card_content %} + +
+
+ {{ providers_macro.provider_info_table(active_providers) }} +
+
+ {{ providers_macro.provider_info_table(disabled_providers) }} +
+
+ +{% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ffbd71df..be059355 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11435,5 +11435,53 @@ Please note, that you can not impersonate a disabled user. If you try you will g User impersonated + + + info_providers.providers_list.title + Info providers + + + + + info_providers.providers_list.active + Active + + + + + info_providers.providers_list.disabled + Disabled + + + + + info_providers.capabilities.basic + Basic + + + + + info_providers.capabilities.footprint + Footprint + + + + + info_providers.capabilities.picture + Picture + + + + + info_providers.capabilities.datasheet + Datasheets + + + + + info_providers.capabilities.price + Prices + +