From be35c36c58ece478af73f7685d6bad8515fca8a6 Mon Sep 17 00:00:00 2001 From: Marc Date: Sun, 4 Jan 2026 21:05:47 +0100 Subject: [PATCH] Added info provider for Buerklin (#1151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed Typos and mistranslations in GDPR mode (DSGVO Modus) Fixed Typo enviroment * Create BuerklinProvider based on LCSCProvider * Update GET URLs for Buerklin * Add getToken function analog to Octopart * Remove line break in docs * Remove trailing / in ENDPOINT_URL Use Autowire to use values of environment variables Remove unwanted Code from LCSC-Provider Map json response to DTO variables * Fix variable reference errors ($term → $keyword) Ensure array keys exist before accessing them Optimize API calls to prevent unnecessary requests Improve error handling for better debugging Enhance readability and maintainability of functions * Bumped version v1.16.2 * Update BuerklinProvider.php Change Order of Capabilities * Change order of capabilities in LCSCProvider.php * Change order of capabilities in PollinProvider.php * Try to fix getToken BuerklinProvider.php * Add ip_buerklin_oauth to knpu_oauth2_client.yaml * Update buerklin authorize URL in knpu_oauth2_client.yaml * Update knpu_oauth2_client.yaml * Adapt Buerklin InfoProvider to new Settings mechanism * According to Buerklin API spec it's really 'token' as urlAuthorize endpoint * Rückgabewert ist schon ein Array deshalb kein toArray * Fix API-Access, add image, price and parameter retrieval (Datasheets not yet implemented because it is not available in the API response) * Add Caching of requests, use default query params (language and currency) using a function, Fix Footprint assignment, translate German code comments * Remove DATASHEET from ProviderCapabilities because the Bürklin API doesn't include Datasheet URLs at the moment, more reverse engineering needed * Update BuerklinSettings with existing translatable strings * Improve Buerklin Settings Page * Added Translation strings for Buerklin Info Provider * Improve Buerklin Provider help message * Adapt Buerklin-provider to new settings system * Adapt Buerklin-provider to new settings system: add missing instance of BuerklinSettings * Improve Compliance Parameters parsing * Remove language-dependent RoHs Date code and use shortened ISO format, Add Customs Code without parseValueField * Fix no results for keyword search * Implement BatchInfoProviderInterface for Buerklin Provider * Rename searchBatch to searchByKeywordsBatch to correctly implement BatchInfoProviderInterface * Fix Bulk Info Provider Import for Buerklin * Tranlate comments to English to prepare for Pull-Request * Add phpUnit unit tests for BuerklinProvider * Try fixing PHPStan issues * Remove OAuthTokenManager from BuerklinProviderTest Removed OAuthTokenManager mock from BuerklinProviderTest setup. * Fix Settings must not be instantiated directly * Fix UnitTest for value_typ * https://github.com/Part-DB/Part-DB-server/pull/1151/files/edd5fb3319e9c8c4470476a70b6f398e3d545550#r2622249199 Revert "Change order of capabilities in LCSCProvider.php" This reverts commit dfd6f33e52299377f3537285b2ff0bc77fa6e1af. * https://github.com/Part-DB/Part-DB-server/pull/1151/files/edd5fb3319e9c8c4470476a70b6f398e3d545550#r2622249861 Revert "Change order of capabilities in PollinProvider.php" This reverts commit fc2e7265be3e7d76576ffa9a3630300190b41dd3. * Use language setting for ProductShortURL * Update default language for Buerklin provider to English in documentation * Add suggested improvements from SonarQube * Removed unused use directives * Revert SonarQube proposed change. Having more than one return is acceptable nowadays * Improve getProviderInfo: disable oauth_app_name, add settings_class, improve disabled_help * Implement retrieveROPCToken as proposed in https://github.com/Part-DB/Part-DB-server/pull/1151#discussion_r2622976206 * Add missing ) to retrieveROPCToken * add use OAuthTokenManager and create instance in constructor * Revert the following commits that tried to implement getToken using OAuthTokenManager Revert "add use OAuthTokenManager and create instance in constructor"This reverts commit 2a1e7c9b0974ebd7e082d5a2fa62753d6254a767.Revert "Add missing ) to retrieveROPCToken"This reverts commit 8df5cfc49e774127d906b99802211eb63ad7de04. Revert "Implement retrieveROPCToken as proposed in https://github.com/Part-DB/Part-DB-server/pull/1151#discussion_r2622976206" This reverts commit 66cc732082da07bcc689d278abc7c0bc128140b7. * Remove OAuthTokenManager leftovers * Disable buerklin provider if settings fields are empty * Improved docs * Added TODO comment --------- Co-authored-by: root Co-authored-by: Jan Böhmer --- config/packages/knpu_oauth2_client.yaml | 2 +- docs/usage/information_provider_system.md | 18 + .../Providers/BuerklinProvider.php | 639 ++++++++++++++++++ .../Providers/PollinProvider.php | 2 +- .../InfoProviderSystem/BuerklinSettings.php | 84 +++ .../InfoProviderSettings.php | 3 + .../Providers/BuerklinProviderTest.php | 271 ++++++++ translations/messages.de.xlf | 148 ++-- translations/messages.en.xlf | 117 ++-- 9 files changed, 1170 insertions(+), 114 deletions(-) create mode 100644 src/Services/InfoProviderSystem/Providers/BuerklinProvider.php create mode 100644 src/Settings/InfoProviderSystem/BuerklinSettings.php create mode 100644 tests/Services/InfoProviderSystem/Providers/BuerklinProviderTest.php diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 5e56d5c5..4967684e 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -35,4 +35,4 @@ knpu_oauth2_client: provider_options: urlAuthorize: 'https://identity.nexar.com/connect/authorize' urlAccessToken: 'https://identity.nexar.com/connect/token' - urlResourceOwnerDetails: '' \ No newline at end of file + urlResourceOwnerDetails: '' diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index c6d4c83f..13df7f10 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -260,6 +260,24 @@ This is not an official API and could break at any time. So use it at your own r The following env configuration options are available: * `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider +### Buerklin + +The Buerklin provider uses the [Buerklin API](https://www.buerklin.com/en/services/eprocurement/) to search for parts and get information. +To use it you have to request access to the API. +You will get an e-mail with the client ID and client secret, which you have to put in the Part-DB configuration (see below). + +Please note that the Buerklin API is limited to 100 requests/minute per IP address and +access to the Authentication server is limited to 10 requests/minute per IP address + +The following env configuration options are available: + +* `PROVIDER_BUERKLIN_CLIENT_ID`: The client ID you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_SECRET`: The client secret you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_USERNAME`: The username you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_PASSWORD`: The password you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`). +* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`) + ### Custom provider To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long diff --git a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php new file mode 100644 index 00000000..07125c73 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php @@ -0,0 +1,639 @@ +. + */ + +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 App\Settings\InfoProviderSystem\BuerklinSettings; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class BuerklinProvider implements BatchInfoProviderInterface +{ + + private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin'; + + public const DISTRIBUTOR_NAME = 'Buerklin'; + + private const CACHE_TTL = 600; + /** + * Local in-request cache to avoid hitting the PSR cache repeatedly for the same product. + * @var array + */ + private array $productCache = []; + + public function __construct( + private readonly HttpClientInterface $client, + private readonly CacheItemPoolInterface $partInfoCache, + private readonly BuerklinSettings $settings, + ) { + + } + + /** + * Gets the latest OAuth token for the Buerklin API, or creates a new one if none is available + * TODO: Rework this to use the OAuth token manager system in the database... + * @return string + */ + private function getToken(): string + { + // Cache token to avoid hammering the auth server on every request + $cacheKey = 'buerklin.oauth.token'; + $item = $this->partInfoCache->getItem($cacheKey); + + if ($item->isHit()) { + $token = $item->get(); + if (is_string($token) && $token !== '') { + return $token; + } + } + + // Buerklin OAuth2 password grant (ROPC) + $resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => [ + 'grant_type' => 'password', + 'client_id' => $this->settings->clientId, + 'client_secret' => $this->settings->secret, + 'username' => $this->settings->username, + 'password' => $this->settings->password, + ], + ]); + + $data = $resp->toArray(false); + + if (!isset($data['access_token'])) { + throw new \RuntimeException( + 'Invalid token response from Buerklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false) + ); + } + + $token = (string) $data['access_token']; + + // Cache for (expires_in - 30s) if available + $ttl = 300; + if (isset($data['expires_in']) && is_numeric($data['expires_in'])) { + $ttl = max(60, (int) $data['expires_in'] - 30); + } + + $item->set($token); + $item->expiresAfter($ttl); + $this->partInfoCache->save($item); + + return $token; + } + + private function getDefaultQueryParams(): array + { + return [ + 'curr' => $this->settings->currency ?: 'EUR', + 'language' => $this->settings->language ?: 'en', + ]; + } + + private function getProduct(string $code): array + { + $code = strtoupper(trim($code)); + if ($code === '') { + throw new \InvalidArgumentException('Product code must not be empty.'); + } + + $cacheKey = sprintf( + 'buerklin.product.%s', + md5($code . '|' . $this->settings->language . '|' . $this->settings->currency) + ); + + if (isset($this->productCache[$cacheKey])) { + return $this->productCache[$cacheKey]; + } + + $item = $this->partInfoCache->getItem($cacheKey); + if ($item->isHit() && is_array($cached = $item->get())) { + return $this->productCache[$cacheKey] = $cached; + } + + $product = $this->makeAPICall('/products/' . rawurlencode($code) . '/'); + + $item->set($product); + $item->expiresAfter(self::CACHE_TTL); + $this->partInfoCache->save($item); + + return $this->productCache[$cacheKey] = $product; + } + + private function makeAPICall(string $endpoint, array $queryParams = []): array + { + try { + $response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [ + 'auth_bearer' => $this->getToken(), + 'headers' => ['Accept' => 'application/json'], + 'query' => array_merge($this->getDefaultQueryParams(), $queryParams), + ]); + + return $response->toArray(); + } catch (\Exception $e) { + throw new \RuntimeException("Buerklin API request failed: " . + "Endpoint: " . $endpoint . + "Token: [redacted] " . + "QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " . + "Exception message: " . $e->getMessage()); + } + } + + + public function getProviderInfo(): array + { + return [ + 'name' => 'Buerklin', + 'description' => 'This provider uses the Buerklin API to search for parts.', + 'url' => 'https://www.buerklin.com/', + 'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.', + 'settings_class' => BuerklinSettings::class + ]; + } + + public function getProviderKey(): string + { + return 'buerklin'; + } + + // This provider is considered active if settings are present + public function isActive(): bool + { + // The client credentials and user credentials must be set + return $this->settings->clientId !== null && $this->settings->clientId !== '' + && $this->settings->secret !== null && $this->settings->secret !== '' + && $this->settings->username !== null && $this->settings->username !== '' + && $this->settings->password !== null && $this->settings->password !== ''; + } + + /** + * Sanitizes a field by removing any HTML tags and other unwanted characters + * @param string|null $field + * @return string|null + */ + private function sanitizeField(?string $field): ?string + { + if ($field === null) { + return null; + } + + return strip_tags($field); + } + + /** + * Takes a deserialized JSON object of the product and returns a PartDetailDTO + * @param array $product + * @return PartDetailDTO + */ + private function getPartDetail(array $product): PartDetailDTO + { + // If this is a search-result object, it may not contain prices/features/images -> reload full details. + if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) { + try { + $product = $this->getProduct((string) $product['code']); + } catch (\Throwable $e) { + // If reload fails, keep the partial product data and continue. + } + } + + // Extract images from API response + $productImages = $this->getProductImages($product['images'] ?? null); + + // Set preview image + $preview = $productImages[0]->url ?? null; + + // Extract features (parameters) from classifications[0].features of Buerklin JSON response + $features = $product['classifications'][0]['features'] ?? []; + + // Feature parameters (from classifications->features) + $featureParams = $this->attributesToParameters($features, ''); // leave group empty for normal parameters + + // Compliance parameters (from top-level fields like RoHS/SVHC/…) + $complianceParams = $this->complianceToParameters($product, 'Compliance'); + + // Merge all parameters + $allParams = array_merge($featureParams, $complianceParams); + + // Assign footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehäuse" (de) + $footprint = null; + if (is_array($features)) { + foreach ($features as $feature) { + $name = $feature['name'] ?? null; + if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehäuse') { + $footprint = $feature['featureValues'][0]['value'] ?? null; + break; + } + } + } + + // Prices: prefer volumePrices, fallback to single price + $code = (string) ($product['orderNumber'] ?? $product['code'] ?? ''); + $prices = $product['volumePrices'] ?? null; + + if (!is_array($prices) || count($prices) === 0) { + $pVal = $product['price']['value'] ?? null; + $pCur = $product['price']['currencyIso'] ?? ($this->settings->currency ?: 'EUR'); + + if (is_numeric($pVal)) { + $prices = [ + [ + 'minQuantity' => 1, + 'value' => (float) $pVal, + 'currencyIso' => (string) $pCur, + ] + ]; + } else { + $prices = []; + } + } + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: (string) ($product['code'] ?? $code), + + name: (string) ($product['manufacturerProductId'] ?? $code), + description: $this->sanitizeField($product['description'] ?? null), + + category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)), + manufacturer: $this->sanitizeField($product['manufacturer'] ?? null), + mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null), + + preview_image_url: $preview, + manufacturing_status: null, + + provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)), + footprint: $footprint, + + datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets + images: $productImages, + + parameters: $allParams, + + vendor_infos: $this->pricesToVendorInfo( + sku: $code, + url: $this->getProductShortURL($code), + prices: $prices + ), + + mass: $product['weight'] ?? null, + ); + } + + /** + * Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO + * @param string $sku + * @param string $url + * @param array $prices + * @return array + */ + private function pricesToVendorInfo(string $sku, string $url, array $prices): array + { + $priceDTOs = array_map(function ($price) { + $val = $price['value'] ?? null; + $valStr = is_numeric($val) + ? number_format((float) $val, 6, '.', '') // 6 decimal places, trailing zeros are fine + : (string) $val; + + // Optional: softly trim unnecessary trailing zeros (e.g. 75.550000 -> 75.55) + $valStr = rtrim(rtrim($valStr, '0'), '.'); + + return new PriceDTO( + minimum_discount_amount: (float) ($price['minQuantity'] ?? 1), + price: $valStr, + currency_iso_code: (string) ($price['currencyIso'] ?? $this->settings->currency ?? 'EUR'), + includes_tax: false + ); + }, $prices); + + return [ + new PurchaseInfoDTO( + distributor_name: self::DISTRIBUTOR_NAME, + order_number: $sku, + prices: $priceDTOs, + product_url: $url, + ) + ]; + } + + + /** + * Returns a valid Buerklin product short URL from product code + * @param string $product_code + * @return string + */ + private function getProductShortURL(string $product_code): string + { + return 'https://www.buerklin.com/' . $this->settings->language . '/p/' . $product_code . '/'; + } + + /** + * Returns a deduplicated list of product images as FileDTOs. + * + * - takes only real image arrays (with 'url' field) + * - makes relative URLs absolute + * - deduplicates using URL + * - prefers 'zoom' format, then 'product' format, then all others + * + * @param array|null $images + * @return \App\Services\InfoProviderSystem\DTOs\FileDTO[] + */ + private function getProductImages(?array $images): array + { + if (!is_array($images)) { + return []; + } + + // 1) Only real image entries with URL + $imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url']))); + + // 2) Prefer zoom images + $zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom')); + $chosen = count($zoom) > 0 + ? $zoom + : array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product')); + + // 3) If still none, take all + if (count($chosen) === 0) { + $chosen = $imgs; + } + + // 4) Deduplicate by URL (after making absolute) + $byUrl = []; + foreach ($chosen as $img) { + $url = (string) $img['url']; + + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://www.buerklin.com' . $url; + } + if (!filter_var($url, FILTER_VALIDATE_URL)) { + continue; + } + + $byUrl[$url] = $url; + } + + return array_map( + fn($url) => new FileDTO($url), + array_values($byUrl) + ); + } + + private function attributesToParameters(array $features, ?string $group = null): array + { + $out = []; + + foreach ($features as $f) { + if (!is_array($f)) { + continue; + } + + $name = $f['name'] ?? null; + if (!is_string($name) || trim($name) === '') { + continue; + } + + $vals = []; + foreach (($f['featureValues'] ?? []) as $fv) { + if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') { + $vals[] = trim($fv['value']); + } + } + if (empty($vals)) { + continue; + } + + // Multiple values: join with comma + $value = implode(', ', array_values(array_unique($vals))); + + // Unit/symbol from Buerklin feature + $unit = $f['featureUnit']['symbol'] ?? null; + if (!is_string($unit) || trim($unit) === '') { + $unit = null; + } + + // ParameterDTO parses value field (handles value + unit) + $out[] = ParameterDTO::parseValueField( + name: $name, + value: $value, + unit: $unit, + symbol: null, + group: $group + ); + } + + // Deduplicate by name + $byName = []; + foreach ($out as $p) { + $byName[$p->name] ??= $p; + } + + return array_values($byName); + } + + /** + * @return PartDetailDTO[] + */ + public function searchByKeyword(string $keyword): array + { + $keyword = strtoupper(trim($keyword)); + if ($keyword === '') { + return []; + } + + $response = $this->makeAPICall('/products/search/', [ + 'pageSize' => 50, + 'currentPage' => 0, + 'query' => $keyword, + 'sort' => 'relevance', + ]); + + $products = $response['products'] ?? []; + + // Normal case: products found in search results + if (is_array($products) && !empty($products)) { + return array_map(fn($p) => $this->getPartDetail($p), $products); + } + + // Fallback: try direct lookup by code + try { + $product = $this->getProduct($keyword); + return [$this->getPartDetail($product)]; + } catch (\Throwable $e) { + return []; + } + } + + public function getDetails(string $id): PartDetailDTO + { + // Detail endpoint is /products/{code}/ + $response = $this->getProduct($id); + + return $this->getPartDetail($response); + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + //ProviderCapabilities::DATASHEET, // currently not implemented + ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, + ]; + } + + private function complianceToParameters(array $product, ?string $group = 'Compliance'): array + { + $params = []; + + $add = function (string $name, $value) use (&$params, $group) { + if ($value === null) { + return; + } + + if (is_bool($value)) { + $value = $value ? 'Yes' : 'No'; + } elseif (is_array($value) || is_object($value)) { + // Avoid dumping large or complex structures + return; + } else { + $value = trim((string) $value); + if ($value === '') { + return; + } + } + + $params[] = ParameterDTO::parseValueField( + name: $name, + value: (string) $value, + unit: null, + symbol: null, + group: $group + ); + }; + + $add('RoHS conform', $product['labelRoHS'] ?? null); // "yes"/"no" + + $rawRoHsDate = $product['dateRoHS'] ?? null; + // Try to parse and reformat date to Y-m-d (do not use language-dependent formats) + if (is_string($rawRoHsDate) && $rawRoHsDate !== '') { + try { + $dt = new \DateTimeImmutable($rawRoHsDate); + $formatted = $dt->format('Y-m-d'); + } catch (\Exception $e) { + $formatted = $rawRoHsDate; + } + // Always use the same parameter name (do not use language-dependent names) + $add('RoHS date', $formatted); + } + $add('SVHC free', $product['SVHC'] ?? null); // bool + $add('Hazardous good', $product['hazardousGood'] ?? null); // bool + $add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool + + $add('Country of origin', $product['countryOfOrigin'] ?? null); + // Customs tariff code must always be stored as string, otherwise "85411000" may be stored as "8.5411e+7" + if (isset($product['articleCustomsCode'])) { + // Raw value as string + $codeRaw = (string) $product['articleCustomsCode']; + + // Optionally keep only digits (in case of spaces or other characters) + $code = preg_replace('/\D/', '', $codeRaw) ?? $codeRaw; + $code = trim($code); + + if ($code !== '') { + $params[] = new ParameterDTO( + name: 'Customs code', + value_text: $code, + value_typ: null, + value_min: null, + value_max: null, + unit: null, + symbol: null, + group: $group + ); + } + } + + return $params; + } + + /** + * @param string[] $keywords + * @return array + */ + public function searchByKeywordsBatch(array $keywords): array + { + /** @var array $results */ + $results = []; + + foreach ($keywords as $keyword) { + $keyword = strtoupper(trim((string) $keyword)); + if ($keyword === '') { + continue; + } + + // Reuse existing single search -> returns PartDetailDTO[] + /** @var PartDetailDTO[] $partDetails */ + $partDetails = $this->searchByKeyword($keyword); + + // Convert to SearchResultDTO[] + $results[$keyword] = array_map( + fn(PartDetailDTO $detail) => $this->convertPartDetailToSearchResult($detail), + $partDetails + ); + } + + return $results; + } + + /** + * Converts a PartDetailDTO into a SearchResultDTO for bulk search. + */ + private function convertPartDetailToSearchResult(PartDetailDTO $detail): SearchResultDTO + { + return new SearchResultDTO( + provider_key: $detail->provider_key, + provider_id: $detail->provider_id, + name: $detail->name, + description: $detail->description ?? '', + category: $detail->category ?? null, + manufacturer: $detail->manufacturer ?? null, + mpn: $detail->mpn ?? null, + preview_image_url: $detail->preview_image_url ?? null, + manufacturing_status: $detail->manufacturing_status ?? null, + provider_url: $detail->provider_url ?? null, + footprint: $detail->footprint ?? null, + ); + } + +} diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index b74e0365..2c5d68a3 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -248,4 +248,4 @@ class PollinProvider implements InfoProviderInterface ProviderCapabilities::DATASHEET ]; } -} +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/BuerklinSettings.php b/src/Settings/InfoProviderSystem/BuerklinSettings.php new file mode 100644 index 00000000..c083c07a --- /dev/null +++ b/src/Settings/InfoProviderSystem/BuerklinSettings.php @@ -0,0 +1,84 @@ +. + */ + +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\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.buerklin"), description: new TM("settings.ips.buerklin.help"))] +#[SettingsIcon("fa-plug")] +class BuerklinSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.client_id"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $clientId = null; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.secret"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_SECRET", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $secret = null; + + #[SettingsParameter( + label: new TM("settings.ips.buerklin.username"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_USER", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $username = null; + + #[SettingsParameter( + label: new TM("user.edit.password"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_PASSWORD", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $password = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, + formOptions: ["preferred_choices" => ["EUR"]], + envVar: "PROVIDER_BUERKLIN_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Currency()] + public string $currency = "EUR"; + + #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, + formOptions: ["preferred_choices" => ["en", "de"]], + envVar: "PROVIDER_BUERKLIN_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Language] + public string $language = "en"; +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index e6e258f5..d4679e23 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -63,4 +63,7 @@ class InfoProviderSettings #[EmbeddedSettings] public ?PollinSettings $pollin = null; + + #[EmbeddedSettings] + public ?BuerklinSettings $buerklin = null; } diff --git a/tests/Services/InfoProviderSystem/Providers/BuerklinProviderTest.php b/tests/Services/InfoProviderSystem/Providers/BuerklinProviderTest.php new file mode 100644 index 00000000..3193db89 --- /dev/null +++ b/tests/Services/InfoProviderSystem/Providers/BuerklinProviderTest.php @@ -0,0 +1,271 @@ +httpClient = $this->createMock(HttpClientInterface::class); + + // Cache mock + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->method('isHit')->willReturn(false); + $cacheItem->method('set')->willReturn($cacheItem); + + $this->cache = $this->createMock(CacheItemPoolInterface::class); + $this->cache->method('getItem')->willReturn($cacheItem); + + // IMPORTANT: Settings must not be instantiated directly (SettingsBundle forbids constructor) + $ref = new \ReflectionClass(BuerklinSettings::class); + /** @var BuerklinSettings $settings */ + $settings = $ref->newInstanceWithoutConstructor(); + + $settings->clientId = 'CID'; + $settings->secret = 'SECRET'; + $settings->username = 'USER'; + $settings->password = 'PASS'; + $settings->language = 'en'; + $settings->currency = 'EUR'; + + $this->settings = $settings; + + $this->provider = new BuerklinProvider( + client: $this->httpClient, + partInfoCache: $this->cache, + settings: $this->settings, + ); + } + + private function mockApi(string $expectedUrl, array $jsonResponse): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn($jsonResponse); + + $this->httpClient + ->method('request') + ->with( + 'GET', + $this->callback(fn($url) => str_contains((string) $url, $expectedUrl)), + $this->anything() + ) + ->willReturn($response); + } + + public function testAttributesToParametersParsesUnitsAndValues(): void + { + $method = new \ReflectionMethod(BuerklinProvider::class, 'attributesToParameters'); + $method->setAccessible(true); + + $features = [ + [ + 'name' => 'Zener voltage', + 'featureUnit' => ['symbol' => 'V'], + 'featureValues' => [ + ['value' => '12'] + ] + ], + [ + 'name' => 'Length', + 'featureUnit' => ['symbol' => 'mm'], + 'featureValues' => [ + ['value' => '2.9'] + ] + ], + [ + 'name' => 'Assembly', + 'featureUnit' => [], + 'featureValues' => [ + ['value' => 'SMD'] + ] + ] + ]; + + $params = $method->invoke($this->provider, $features, ''); + + $this->assertCount(3, $params); + + $this->assertSame('Zener voltage', $params[0]->name); + $this->assertNull($params[0]->value_text); + $this->assertSame(12.0, $params[0]->value_typ); + $this->assertNull($params[0]->value_min); + $this->assertNull($params[0]->value_max); + $this->assertSame('V', $params[0]->unit); + + $this->assertSame('Length', $params[1]->name); + $this->assertNull($params[1]->value_text); + $this->assertSame(2.9, $params[1]->value_typ); + $this->assertSame('mm', $params[1]->unit); + + $this->assertSame('Assembly', $params[2]->name); + $this->assertSame('SMD', $params[2]->value_text); + $this->assertNull($params[2]->unit); + } + + public function testComplianceParameters(): void + { + $method = new \ReflectionMethod(BuerklinProvider::class, 'complianceToParameters'); + $method->setAccessible(true); + + $product = [ + 'labelRoHS' => 'Yes', + 'dateRoHS' => '2015-03-31T00:00+0000', + 'SVHC' => true, + 'hazardousGood' => false, + 'hazardousMaterials' => false, + 'countryOfOrigin' => 'China', + 'articleCustomsCode' => '85411000' + ]; + + $params = $method->invoke($this->provider, $product, 'Compliance'); + + $map = []; + foreach ($params as $p) { + $map[$p->name] = $p->value_text; + } + + $this->assertSame('Yes', $map['RoHS conform']); + $this->assertSame('2015-03-31', $map['RoHS date']); + $this->assertSame('Yes', $map['SVHC free']); + $this->assertSame('No', $map['Hazardous good']); + $this->assertSame('No', $map['Hazardous materials']); + $this->assertSame('China', $map['Country of origin']); + $this->assertSame('85411000', $map['Customs code']); + } + + public function testImageSelectionPrefersZoomAndDeduplicates(): void + { + $method = new \ReflectionMethod(BuerklinProvider::class, 'getProductImages'); + $method->setAccessible(true); + + $images = [ + ['format' => 'product', 'url' => '/img/a.webp'], + ['format' => 'zoom', 'url' => '/img/z.webp'], + ['format' => 'zoom', 'url' => '/img/z.webp'], // duplicate + ['format' => 'thumbnail', 'url' => '/img/t.webp'] + ]; + + $results = $method->invoke($this->provider, $images); + + $this->assertCount(1, $results); + $this->assertSame('https://www.buerklin.com/img/z.webp', $results[0]->url); + } + + public function testFootprintExtraction(): void + { + $method = new \ReflectionMethod(BuerklinProvider::class, 'getPartDetail'); + $method->setAccessible(true); + + $product = [ + 'code' => 'TEST1', + 'manufacturerProductId' => 'ABC', + 'description' => 'X', + 'images' => [], + 'classifications' => [ + [ + 'name' => 'Cat', + 'features' => [ + [ + 'name' => 'Enclosure', + 'featureValues' => [['value' => 'SOT-23']] + ] + ] + ] + ], + 'price' => ['value' => 1, 'currencyIso' => 'EUR'] + ]; + + $dto = $method->invoke($this->provider, $product); + $this->assertSame('SOT-23', $dto->footprint); + } + + public function testPriceFormatting(): void + { + $detailPrice = [ + [ + 'minQuantity' => 1, + 'value' => 0.0885, + 'currencyIso' => 'EUR' + ] + ]; + + $method = new \ReflectionMethod(BuerklinProvider::class, 'pricesToVendorInfo'); + $method->setAccessible(true); + + $vendorInfo = $method->invoke($this->provider, 'SKU1', 'https://x', $detailPrice); + + $price = $vendorInfo[0]->prices[0]; + $this->assertSame('0.0885', $price->price); + } + + public function testBatchSearchReturnsSearchResultDTO(): void + { + $mockDetail = new PartDetailDTO( + provider_key: 'buerklin', + provider_id: 'TESTID', + name: 'Zener', + description: 'Desc' + ); + + $provider = $this->getMockBuilder(BuerklinProvider::class) + ->setConstructorArgs([ + $this->httpClient, + $this->cache, + $this->settings + ]) + ->onlyMethods(['searchByKeyword']) + ->getMock(); + + $provider->method('searchByKeyword')->willReturn([$mockDetail]); + + $result = $provider->searchByKeywordsBatch(['ABC']); + + $this->assertArrayHasKey('ABC', $result); + $this->assertIsArray($result['ABC']); + $this->assertCount(1, $result['ABC']); + $this->assertInstanceOf(SearchResultDTO::class, $result['ABC'][0]); + $this->assertSame('Zener', $result['ABC'][0]->name); + } + + public function testConvertPartDetailToSearchResult(): void + { + $detail = new PartDetailDTO( + provider_key: 'buerklin', + provider_id: 'X1', + name: 'PartX', + description: 'D', + preview_image_url: 'https://img' + ); + + $method = new \ReflectionMethod(BuerklinProvider::class, 'convertPartDetailToSearchResult'); + $method->setAccessible(true); + + $dto = $method->invoke($this->provider, $detail); + + $this->assertInstanceOf(SearchResultDTO::class, $dto); + $this->assertSame('X1', $dto->provider_id); + $this->assertSame('PartX', $dto->name); + $this->assertSame('https://img', $dto->preview_image_url); + } +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 933214a0..10c7e7a7 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1,4 +1,4 @@ - + @@ -231,7 +231,7 @@ part.info.timetravel_hint - So sah das Bauteil vor %timestamp% aus. <i>Beachten Sie, dass dieses Feature experimentell ist und die angezeigten Infos daher nicht unbedingt korrekt sind.</i> + Beachten Sie, dass dieses Feature experimentell ist und die angezeigten Infos daher nicht unbedingt korrekt sind.]]> @@ -537,7 +537,7 @@ Maßeinheit - + part_custom_state.caption Benutzerdefinierter Bauteilstatus @@ -715,9 +715,9 @@ user.edit.tfa.disable_tfa_message - Dies wird <b>alle aktiven Zwei-Faktor-Authentifizierungsmethoden des Nutzers deaktivieren</b> und die <b>Backupcodes löschen</b>! <br> -Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müssen und neue Backupcodes ausdrucken müssen! <br><br> -<b>Führen sie dies nur durch, wenn Sie über die Identität des (um Hilfe suchenden) Benutzers absolut sicher sind, da ansonsten eine Kompromittierung des Accounts durch einen Angreifer erfolgen könnte!</b> + alle aktiven Zwei-Faktor-Authentifizierungsmethoden des Nutzers deaktivieren und die Backupcodes löschen!
+Der Benutzer wird alle Zwei-Faktor-Authentifizierungmethoden neu einrichten müssen und neue Backupcodes ausdrucken müssen!

+Führen sie dies nur durch, wenn Sie über die Identität des (um Hilfe suchenden) Benutzers absolut sicher sind, da ansonsten eine Kompromittierung des Accounts durch einen Angreifer erfolgen könnte!]]>
@@ -1424,7 +1424,7 @@ Subelemente werden beim Löschen nach oben verschoben. homepage.github.text - Quellcode, Downloads, Bugreports, ToDo-Liste usw. gibts auf der <a class="link-external" target="_blank" href="%href%">GitHub Projektseite</a> + GitHub Projektseite]]> @@ -1446,7 +1446,7 @@ Subelemente werden beim Löschen nach oben verschoben. homepage.help.text - Hilfe und Tipps finden sie im <a class="link-external" rel="noopener" target="_blank" href="%href%">Wiki</a> der GitHub Seite. + Wiki der GitHub Seite.]]> @@ -1688,7 +1688,7 @@ Subelemente werden beim Löschen nach oben verschoben. email.pw_reset.fallback - Wenn dies nicht funktioniert, rufen Sie <a href="%url%">%url%</a> auf und geben Sie die folgenden Daten ein + %url% auf und geben Sie die folgenden Daten ein]]> @@ -1718,7 +1718,7 @@ Subelemente werden beim Löschen nach oben verschoben. email.pw_reset.valid_unit %date% - Das Reset-Token ist gültig bis <i>%date%</i> + %date%]]> @@ -3591,8 +3591,8 @@ Subelemente werden beim Löschen nach oben verschoben. tfa_google.disable.confirm_message - Wenn Sie die Authenticator App deaktivieren, werden alle Backupcodes gelöscht, daher sie müssen sie evtl. neu ausdrucken.<br> -Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nicht mehr so gut gegen Angreifer geschützt ist! + +Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nicht mehr so gut gegen Angreifer geschützt ist!]]> @@ -3612,7 +3612,7 @@ Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nich tfa_google.step.download - Laden Sie eine Authenticator App herunter (z.B. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>) + Google Authenticator oder FreeOTP Authenticator)]]> @@ -3854,8 +3854,8 @@ Beachten Sie außerdem, dass ihr Account ohne Zwei-Faktor-Authentifizierung nich tfa_trustedDevices.explanation - Bei der Überprüfung des zweiten Faktors, kann der aktuelle Computer als vertrauenswürdig gekennzeichnet werden, daher werden keine Zwei-Faktor-Überprüfungen mehr an diesem Computer benötigt. -Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertrauenswürdig ist, können Sie hier den Status <i>aller </i>Computer zurücksetzen. + aller Computer zurücksetzen.]]> @@ -4813,7 +4813,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Maßeinheit - + part.table.partCustomState Benutzerdefinierter Bauteilstatus @@ -5301,7 +5301,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr label_options.lines_mode.help - Wenn Sie hier Twig auswählen, wird das Contentfeld als Twig-Template interpretiert. Weitere Hilfe gibt es in der <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig Dokumentation</a> und dem <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a>. + Twig Dokumentation und dem Wiki.]]> @@ -5683,7 +5683,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Maßeinheit - + part.edit.partCustomState Benutzerdefinierter Bauteilstatus @@ -5976,7 +5976,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr Maßeinheit - + part_custom_state.label Benutzerdefinierter Bauteilstatus @@ -6225,7 +6225,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr [[Measurement_unit]] - + tree.tools.edit.part_custom_state [[Part_custom_state]] @@ -7149,15 +7149,15 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr mass_creation.lines.placeholder - Element 1 + +Element 1 -> Element 1.1 +Element 1 -> Element 1.2]]> @@ -8372,7 +8372,7 @@ Element 1 -> Element 1.2 Maßeinheiten - + perm.part_custom_states Benutzerdefinierter Bauteilstatus @@ -9303,25 +9303,25 @@ Element 1 -> Element 1.2 filter.parameter_value_constraint.operator.< - Typ. Wert < + filter.parameter_value_constraint.operator.> - Typ. Wert > + ]]> filter.parameter_value_constraint.operator.<= - Typ. Wert <= + filter.parameter_value_constraint.operator.>= - Typ. Wert >= + =]]> @@ -9429,7 +9429,7 @@ Element 1 -> Element 1.2 parts_list.search.searching_for - Suche Teile mit dem Suchbegriff <b>%keyword%</b> + %keyword%]]> @@ -10089,13 +10089,13 @@ Element 1 -> Element 1.2 project.builds.number_of_builds_possible - Sie haben genug Bauteile auf Lager, um <b>%max_builds%</b> Exemplare dieses Projektes zu bauen. + %max_builds% Exemplare dieses Projektes zu bauen.]]> project.builds.check_project_status - Der aktuelle Projektstatus ist <b>"%project_status%"</b>. Sie sollten überprüfen, ob sie das Projekt mit diesem Status wirklich bauen wollen! + "%project_status%". Sie sollten überprüfen, ob sie das Projekt mit diesem Status wirklich bauen wollen!]]> @@ -10209,7 +10209,7 @@ Element 1 -> Element 1.2 entity.select.add_hint - Nutzen Sie -> um verschachtelte Strukturen anzulegen, z.B. "Element 1->Element 1.1" + um verschachtelte Strukturen anzulegen, z.B. "Element 1->Element 1.1"]]> @@ -10233,13 +10233,13 @@ Element 1 -> Element 1.2 homepage.first_steps.introduction - Die Datenbank ist momentan noch leer. Sie möchten möglicherweise die <a href="%url%">Dokumentation</a> lesen oder anfangen, die folgenden Datenstrukturen anzulegen. + Dokumentation lesen oder anfangen, die folgenden Datenstrukturen anzulegen.]]> homepage.first_steps.create_part - Oder Sie können direkt ein <a href="%url%">neues Bauteil erstellen</a>. + neues Bauteil erstellen.]]> @@ -10251,7 +10251,7 @@ Element 1 -> Element 1.2 homepage.forum.text - Für Fragen rund um Part-DB, nutze das <a class="link-external" rel="noopener" target="_blank" href="%href%">Diskussionsforum</a> + Diskussionsforum]]> @@ -10752,7 +10752,7 @@ Element 1 -> Element 1.2 Maßeinheit - + log.element_edited.changed_fields.partCustomState Benutzerdefinierter Bauteilstatus @@ -10917,7 +10917,7 @@ Element 1 -> Element 1.2 parts.import.help_documentation - Konsultieren Sie die <a href="%link%">Dokumentation</a> für weiter Informationen über das Dateiformat. + Dokumentation für weiter Informationen über das Dateiformat.]]> @@ -11022,13 +11022,13 @@ Element 1 -> Element 1.2 Bearbeite [Measurement_unit] - + part_custom_state.new Neuer [Part_custom_state] - + part_custom_state.edit Bearbeite [Part_custom_state] @@ -11109,7 +11109,7 @@ Element 1 -> Element 1.2 part.filter.lessThanDesired - Weniger vorhanden als gewünscht (Gesamtmenge < Mindestmenge) + @@ -11915,13 +11915,13 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön part.merge.confirm.title - Möchten Sie wirklich <b>%other%</b> in <b>%target%</b> zusammenführen? + %other% in %target% zusammenführen?]]> part.merge.confirm.message - <b>%other%</b> wird gelöscht, und das aktuelle Bauteil wird mit den angezeigten Daten gespeichert. + %other% wird gelöscht, und das aktuelle Bauteil wird mit den angezeigten Daten gespeichert.]]> @@ -12275,7 +12275,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.element14.apiKey.help - Sie können sich unter <a href="https://partner.element14.com/">https://partner.element14.com/</a> für einen API-Schlüssel registrieren. + https://partner.element14.com/ für einen API-Schlüssel registrieren.]]> @@ -12287,7 +12287,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.element14.storeId.help - Die Domain des Shops, aus dem die Daten abgerufen werden sollen. Diese bestimmt die Sprache und Währung der Ergebnisse. Eine Liste der gültigen Domains finden Sie <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">hier</a>. + hier.]]> @@ -12305,7 +12305,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.tme.token.help - Sie können einen API-Token und einen geheimen Schlüssel unter <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a> erhalten. + https://developers.tme.eu/en/ erhalten.]]> @@ -12353,7 +12353,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.mouser.apiKey.help - Sie können sich unter <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a> für einen API-Schlüssel registrieren. + https://eu.mouser.com/api-hub/ für einen API-Schlüssel registrieren.]]> @@ -12401,7 +12401,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.mouser.searchOptions.rohsAndInStock - Sofort verfügbar & RoHS konform + @@ -12431,7 +12431,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.attachments - Anhänge & Dateien + @@ -12455,7 +12455,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.attachments.allowDownloads.help - Mit dieser Option können Benutzer externe Dateien in die Part-DB herunterladen, indem sie eine URL angeben. <b>Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!</b> + Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!]]> @@ -12629,8 +12629,8 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.localization.base_currency_description - Die Währung, in der Preisinformationen und Wechselkurse gespeichert werden. Diese Währung wird angenommen, wenn für eine Preisinformation keine Währung festgelegt ist. -<b>Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!</b> + Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!]]> @@ -12660,7 +12660,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.misc.kicad_eda.category_depth.help - Dieser Wert bestimmt die Tiefe des Kategoriebaums, der in KiCad sichtbar ist. 0 bedeutet, dass nur die Kategorien der obersten Ebene sichtbar sind. Setzen Sie den Wert auf > 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen. + 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen.]]> @@ -12678,7 +12678,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.behavior.sidebar.items.help - Die Menüs, die standardmäßig in der Seitenleiste angezeigt werden. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden. + @@ -12726,7 +12726,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.behavior.table.parts_default_columns.help - Die Spalten, die standardmäßig in Bauteiltabellen angezeigt werden sollen. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden. + @@ -12780,7 +12780,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.ips.oemsecrets.sortMode.M - Vollständigkeit & Herstellername + @@ -13440,7 +13440,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.behavior.homepage.items.help - Die Elemente, die auf der Startseite angezeigt werden sollen. Die Reihenfolge kann per Drag & Drop geändert werden. + @@ -14154,7 +14154,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.localization.language_menu_entries.description - Die Sprachen, die im Sprachen Dropdown-Menü angezeigt werden sollen. Die Reihenfolge kann via Drag&Drop geändert werden. Lassen Sie das Feld leer, um alle verfügbaren Sprachen anzuzeigen. + @@ -14430,13 +14430,33 @@ Bitte beachten Sie, dass dieses System derzeit experimentell ist und die hier de - - Do not remove! Used for datatables rendering. - - - datatable.datatable.lengthMenu - _MENU_ - + + Do not remove! Used for datatables rendering. + + + datatable.datatable.lengthMenu + _MENU_ + + + + + settings.ips.buerklin + Buerklin + + + + + settings.ips.buerklin.username + Benutzername + + + + + settings.ips.buerklin.help + Buerklin-API-Zugriffsbeschränkungen: 100 Requests/Minute pro IP-Adresse +Buerklin-API-Authentication-Server: +10 Requests/Minute pro IP-Adresse +
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b651e94f..feea210a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -221,7 +221,7 @@ part.info.timetravel_hint - This is how the part appeared before %timestamp%. <i>Please note that this feature is experimental, so the info may not be correct.</i> + Please note that this feature is experimental, so the info may not be correct.]]> @@ -649,10 +649,10 @@ user.edit.tfa.disable_tfa_message - This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>! -<br> -The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br> -<b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b> + all active two-factor authentication methods of the user and delete the backup codes! +
+The user will have to set up all two-factor authentication methods again and print new backup codes!

+Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!]]>
@@ -803,9 +803,9 @@ The user will have to set up all two-factor authentication methods again and pri entity.delete.message - This can not be undone! -<br> -Sub elements will be moved upwards. + +Sub elements will be moved upwards.]]> @@ -1359,7 +1359,7 @@ Sub elements will be moved upwards. homepage.github.text - Source, downloads, bug reports, to-do-list etc. can be found on <a href="%href%" class="link-external" target="_blank">GitHub project page</a> + GitHub project page]]> @@ -1381,7 +1381,7 @@ Sub elements will be moved upwards. homepage.help.text - Help and tips can be found in Wiki the <a href="%href%" class="link-external" target="_blank">GitHub page</a> + GitHub page]]> @@ -1623,7 +1623,7 @@ Sub elements will be moved upwards. email.pw_reset.fallback - If this does not work for you, go to <a href="%url%">%url%</a> and enter the following info + %url% and enter the following info]]> @@ -1653,7 +1653,7 @@ Sub elements will be moved upwards. email.pw_reset.valid_unit %date% - The reset token will be valid until <i>%date%</i>. + %date%.]]> @@ -3526,8 +3526,8 @@ Sub elements will be moved upwards. tfa_google.disable.confirm_message - If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.<br> -Also note that without two-factor authentication, your account is no longer as well protected against attackers! + +Also note that without two-factor authentication, your account is no longer as well protected against attackers!]]> @@ -3547,7 +3547,7 @@ Also note that without two-factor authentication, your account is no longer as w tfa_google.step.download - Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>) + Google Authenticator oder FreeOTP Authenticator)]]> @@ -3789,8 +3789,8 @@ Also note that without two-factor authentication, your account is no longer as w tfa_trustedDevices.explanation - When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed. -If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of <i>all </i>computers here. + all computers here.]]> @@ -5236,7 +5236,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can label_options.lines_mode.help - If you select Twig here, the content field is interpreted as Twig template. See <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig documentation</a> and <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a> for more information. + Twig documentation and Wiki for more information.]]> @@ -7084,15 +7084,15 @@ Exampletown mass_creation.lines.placeholder - Element 1 + +Element 1 -> Element 1.1 +Element 1 -> Element 1.2]]> @@ -9152,25 +9152,25 @@ Element 1 -> Element 1.2 filter.parameter_value_constraint.operator.< - Typ. Value < + filter.parameter_value_constraint.operator.> - Typ. Value > + ]]> filter.parameter_value_constraint.operator.<= - Typ. Value <= + filter.parameter_value_constraint.operator.>= - Typ. Value >= + =]]> @@ -9278,7 +9278,7 @@ Element 1 -> Element 1.2 parts_list.search.searching_for - Searching parts with keyword <b>%keyword%</b> + %keyword%]]> @@ -10058,7 +10058,7 @@ Element 1 -> Element 1.2 entity.select.add_hint - Use -> to create nested structures, e.g. "Node 1->Node 1.1" + to create nested structures, e.g. "Node 1->Node 1.1"]]> @@ -10082,13 +10082,13 @@ Element 1 -> Element 1.2 homepage.first_steps.introduction - Your database is still empty. You might want to read the <a href="%url%">documentation</a> or start to creating the following data structures: + documentation or start to creating the following data structures:]]> homepage.first_steps.create_part - Or you can directly <a href="%url%">create a new part</a>. + create a new part.]]> @@ -10100,7 +10100,7 @@ Element 1 -> Element 1.2 homepage.forum.text - For questions about Part-DB use the <a href="%href%" class="link-external" target="_blank">discussion forum</a> + discussion forum]]> @@ -10766,7 +10766,7 @@ Element 1 -> Element 1.2 parts.import.help_documentation - See the <a href="%link%">documentation</a> for more information on the file format. + documentation for more information on the file format.]]> @@ -10958,7 +10958,7 @@ Element 1 -> Element 1.2 part.filter.lessThanDesired - In stock less than desired (total amount < min. amount) + @@ -11764,13 +11764,13 @@ Please note, that you can not impersonate a disabled user. If you try you will g part.merge.confirm.title - Do you really want to merge <b>%other%</b> into <b>%target%</b>? + %other% into %target%?]]> part.merge.confirm.message - <b>%other%</b> will be deleted, and the part will be saved with the shown information. + %other% will be deleted, and the part will be saved with the shown information.]]> @@ -12124,7 +12124,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.element14.apiKey.help - You can register for an API key on <a href="https://partner.element14.com/">https://partner.element14.com/</a>. + https://partner.element14.com/.]]> @@ -12136,7 +12136,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.element14.storeId.help - The store domain to retrieve the data from. This decides the language and currency of results. See <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">here</a> for a list of valid domains. + here for a list of valid domains.]]> @@ -12154,7 +12154,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.tme.token.help - You can get an API token and secret on <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a>. + https://developers.tme.eu/en/.]]> @@ -12202,7 +12202,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.mouser.apiKey.help - You can register for an API key on <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a>. + https://eu.mouser.com/api-hub/.]]> @@ -12280,7 +12280,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.attachments - Attachments & Files + @@ -12304,7 +12304,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.attachments.allowDownloads.help - With this option users can download external files into Part-DB by providing an URL. <b>Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!</b> + Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!]]> @@ -12478,8 +12478,8 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.localization.base_currency_description - The currency that is used to store price information and exchange rates in. This currency is assumed, when no currency is set for a price information. -<b>Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!</b> + Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!]]> @@ -12509,7 +12509,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.misc.kicad_eda.category_depth.help - This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad. + 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.]]> @@ -12527,7 +12527,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.sidebar.items.help - The menus which appear at the sidebar by default. Order of items can be changed via drag & drop. + @@ -12575,7 +12575,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.table.parts_default_columns.help - The columns to show by default in part tables. Order of items can be changed via drag & drop. + @@ -12629,7 +12629,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.oemsecrets.sortMode.M - Completeness & Manufacturer name + @@ -13289,7 +13289,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.homepage.items.help - The items to show at the homepage. Order can be changed via drag & drop. + @@ -14003,7 +14003,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.localization.language_menu_entries.description - The languages to show in the language drop-down menu. Order can be changed via drag & drop. Leave empty to show all available languages. + @@ -14287,6 +14287,27 @@ Please note that this system is currently experimental, and the synonyms defined _MENU_ + + + settings.ips.buerklin + Buerklin + + + + + settings.ips.buerklin.username + User name + + + + + settings.ips.buerklin.help + Buerklin-API access limits: +100 requests/minute per IP address +Buerklin-API Authentication server: +10 requests/minute per IP address + + project.bom.part_id