diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 5e56d5c55..4967684ef 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 c6d4c83f1..13df7f108 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 000000000..07125c733 --- /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 b74e0365d..2c5d68a38 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -248,4 +248,4 @@ public function getCapabilities(): array 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 000000000..c083c07ab --- /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 e6e258f5f..d4679e238 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 000000000..3193db89b --- /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 933214a04..10c7e7a72 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 b651e94ff..feea210a8 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