Merge tag 'v2.1.2' into Buerklin-provider

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

View file

@ -40,6 +40,7 @@ use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Exceptions\AttachmentDownloadException;
use App\Settings\SystemSettings\AttachmentsSettings;
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
use const DIRECTORY_SEPARATOR;
@ -64,12 +65,14 @@ class AttachmentSubmitHandler
'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
'htpasswd', ''];
public function __construct(protected AttachmentPathResolver $pathResolver, protected bool $allow_attachments_downloads,
protected HttpClientInterface $httpClient, protected MimeTypesInterface $mimeTypes,
protected FileTypeFilterTools $filterTools, /**
* @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to
*/
protected string $max_upload_size)
public function __construct(
protected AttachmentPathResolver $pathResolver,
protected HttpClientInterface $httpClient,
protected MimeTypesInterface $mimeTypes,
protected FileTypeFilterTools $filterTools,
protected AttachmentsSettings $settings,
protected readonly SVGSanitizer $SVGSanitizer,
)
{
//The mapping used to determine which folder will be used for an attachment type
$this->folder_mapping = [
@ -214,6 +217,9 @@ class AttachmentSubmitHandler
//Move the attachment files to secure location (and back) if needed
$this->moveFile($attachment, $secure_attachment);
//Sanitize the SVG if needed
$this->sanitizeSVGAttachment($attachment);
//Rename blacklisted (unsecure) files to a better extension
$this->renameBlacklistedExtensions($attachment);
@ -334,7 +340,7 @@ class AttachmentSubmitHandler
protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
{
//Check if we are allowed to download files
if (!$this->allow_attachments_downloads) {
if (!$this->settings->allowDownloads) {
throw new RuntimeException('Download of attachments is not allowed!');
}
@ -345,9 +351,28 @@ class AttachmentSubmitHandler
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
try {
$response = $this->httpClient->request('GET', $url, [
$opts = [
'buffer' => false,
]);
//Use user-agent and other headers to make the server think we are a browser
'headers' => [
"sec-ch-ua" => "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
"sec-ch-ua-mobile" => "?0",
"sec-ch-ua-platform" => "\"Windows\"",
"user-agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
"sec-fetch-site" => "none",
"sec-fetch-mode" => "navigate",
],
];
$response = $this->httpClient->request('GET', $url, $opts);
//Digikey wants TLSv1.3, so try again with that if we get a 403
if ($response->getStatusCode() === 403) {
$opts['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
$response = $this->httpClient->request('GET', $url, $opts);
}
# if you have these changes and downloads still fail, check if it's due to an unknown certificate. Curl by
# default uses the systems ca store and that doesn't contain all the intermediate certificates needed to
# verify the leafs
if (200 !== $response->getStatusCode()) {
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
@ -474,9 +499,37 @@ class AttachmentSubmitHandler
$this->max_upload_size_bytes = min(
$this->parseFileSizeString(ini_get('post_max_size')),
$this->parseFileSizeString(ini_get('upload_max_filesize')),
$this->parseFileSizeString($this->max_upload_size),
$this->parseFileSizeString($this->settings->maxFileSize)
);
return $this->max_upload_size_bytes;
}
/**
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
* @param Attachment $attachment
* @return Attachment
*/
public function sanitizeSVGAttachment(Attachment $attachment): Attachment
{
//We can not do anything on builtins or external ressources
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment;
}
//Resolve the path to the file
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
//Check if the file exists
if (!file_exists($path)) {
return $attachment;
}
//Check if the file is an SVG
if ($attachment->getExtension() === "svg") {
$this->SVGSanitizer->sanitizeFile($path);
}
return $attachment;
}
}

View file

@ -112,12 +112,12 @@ class AttachmentURLGenerator
/**
* Returns a URL to a thumbnail of the attachment file.
* For external files the original URL is returned.
* @return string|null The URL or null if the attachment file is not existing
* @return string|null The URL or null if the attachment file is not existing or is invalid
*/
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
{
if (!$attachment->isPicture()) {
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
return null;
}
if (!$attachment->hasInternal()){

View file

@ -0,0 +1,58 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\Attachments;
use Rhukster\DomSanitizer\DOMSanitizer;
class SVGSanitizer
{
/**
* Sanitizes the given SVG string by removing any potentially harmful content (like inline scripts).
* @param string $input
* @return string
*/
public function sanitizeString(string $input): string
{
return (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($input);
}
/**
* Sanitizes the given SVG file by removing any potentially harmful content (like inline scripts).
* The sanitized content is written back to the file.
* @param string $filepath
*/
public function sanitizeFile(string $filepath): void
{
//Open the file and read the content
$content = file_get_contents($filepath);
if ($content === false) {
throw new \RuntimeException('Could not read file: ' . $filepath);
}
//Sanitize the content
$sanitizedContent = $this->sanitizeString($content);
//Write the sanitized content back to the file
file_put_contents($filepath, $sanitizedContent);
}
}

View file

@ -27,7 +27,9 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\NodesListBuilder;
use App\Settings\MiscSettings\KiCadEDASettings;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@ -38,16 +40,20 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class KiCadHelper
{
/** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
private readonly int $category_depth;
public function __construct(
private readonly NodesListBuilder $nodesListBuilder,
private readonly TagAwareCacheInterface $kicadCache,
private readonly EntityManagerInterface $em,
private readonly ElementCacheTagGenerator $tagGenerator,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator,
/** The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
private readonly int $category_depth,
KiCadEDASettings $kiCadEDASettings,
) {
$this->category_depth = $kiCadEDASettings->categoryDepth;
}
/**
@ -64,6 +70,10 @@ class KiCadHelper
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
$item->tag($secure_class_name);
//Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
$item->tag($secure_class_name);
//If the category depth is smaller than 0, create only one dummy category
if ($this->category_depth < 0) {
return [
@ -108,6 +118,8 @@ class KiCadHelper
$result[] = [
'id' => (string)$category->getId(),
'name' => $category->getFullPath('/'),
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
'description' => $this->entityURLGenerator->listPartsURL($category),
];
}
@ -229,6 +241,49 @@ class KiCadHelper
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
}
// Add supplier information from orderdetails (include obsolete orderdetails)
if ($part->getOrderdetails(false)->count() > 0) {
$supplierCounts = [];
foreach ($part->getOrderdetails(false) as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$supplierName = $orderdetail->getSupplier()->getName();
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
if (!isset($supplierCounts[$supplierName])) {
$supplierCounts[$supplierName] = 0;
}
$supplierCounts[$supplierName]++;
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
$fieldName = $supplierCounts[$supplierName] > 1
? $supplierName . ' ' . $supplierCounts[$supplierName]
: $supplierName;
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
//Add fields for KiCost:
if ($part->getManufacturer() !== null) {
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
}
if ($part->getManufacturerProductNumber() !== "") {
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
}
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
if ($part->getOrderdetails(false)->count() > 0) {
foreach ($part->getOrderdetails(false) as $orderdetail) {
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
}
}
}
return $result;
}

View file

@ -156,8 +156,10 @@ class EntityURLGenerator
public function viewURL(Attachment $entity): string
{
if ($entity->hasInternal()) {
return $this->attachmentURLGenerator->getInternalViewURL($entity);
//If the underlying file path is invalid, null gets returned, which is not allowed here.
//We still have the chance to use an external path, if it is set.
if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
return $url;
}
if($entity->hasExternal()) {

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\Formatters;
use App\Entity\PriceInformations\Currency;
use App\Settings\SystemSettings\LocalizationSettings;
use Locale;
use NumberFormatter;
@ -30,7 +31,7 @@ class MoneyFormatter
{
protected string $locale;
public function __construct(protected string $base_currency)
public function __construct(private readonly LocalizationSettings $localizationSettings)
{
$this->locale = Locale::getDefault();
}
@ -45,7 +46,7 @@ class MoneyFormatter
*/
public function format(string|float $value, ?Currency $currency = null, int $decimals = 5, bool $show_all_digits = false): string
{
$iso_code = $this->base_currency;
$iso_code = $this->localizationSettings->baseCurrency;
if ($currency instanceof Currency && ($currency->getIsoCode() !== '')) {
$iso_code = $currency->getIsoCode();
}

View file

@ -22,10 +22,13 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use League\Csv\Reader;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -44,14 +47,25 @@ class BOMImporter
5 => 'Supplier and ref',
];
public function __construct()
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BOMValidationService $validationService
) {
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setRequired('type');
$resolver->setAllowedValues('type', ['kicad_pcbnew']);
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
// For flexible schematic import with field mapping
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
$resolver->setDefault('delimiter', ',');
$resolver->setDefault('field_priorities', []);
$resolver->setAllowedTypes('field_mapping', 'array');
$resolver->setAllowedTypes('field_priorities', 'array');
$resolver->setAllowedTypes('delimiter', 'string');
return $resolver;
}
@ -82,6 +96,23 @@ class BOMImporter
return $this->stringToBOMEntries($file->getContent(), $options);
}
/**
* Validate BOM data before importing
* @return array Validation result with errors, warnings, and info
*/
public function validateBOMData(string $data, array $options): array
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->validateKiCADPCB($data),
'kicad_schematic' => $this->validateKiCADSchematicData($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
* @param string $data The data to import
@ -95,12 +126,13 @@ class BOMImporter
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->parseKiCADPCB($data, $options),
'kicad_pcbnew' => $this->parseKiCADPCB($data),
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
private function parseKiCADPCB(string $data, array $options = []): array
private function parseKiCADPCB(string $data): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
@ -113,17 +145,17 @@ class BOMImporter
$entry = $this->normalizeColumnNames($entry);
//Ensure that the entry has all required fields
if (!isset ($entry['Designator'])) {
throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
if (!isset($entry['Designator'])) {
throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!');
}
if (!isset ($entry['Package'])) {
throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
if (!isset($entry['Package'])) {
throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!');
}
if (!isset ($entry['Designation'])) {
throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
if (!isset($entry['Designation'])) {
throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!');
}
if (!isset ($entry['Quantity'])) {
throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
if (!isset($entry['Quantity'])) {
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
}
$bom_entry = new ProjectBOMEntry();
@ -138,6 +170,63 @@ class BOMImporter
return $bom_entries;
}
/**
* Validate KiCad PCB data
*/
private function validateKiCADPCB(string $data): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
$csv->setHeaderOffset(0);
$mapped_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
// Translate the german field names to english
$entry = $this->normalizeColumnNames($entry);
$mapped_entries[] = $entry;
}
return $this->validationService->validateBOMEntries($mapped_entries);
}
/**
* Validate KiCad schematic data
*/
private function validateKiCADSchematicData(string $data, array $options): array
{
$delimiter = $options['delimiter'] ?? ',';
$field_mapping = $options['field_mapping'] ?? [];
$field_priorities = $options['field_priorities'] ?? [];
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
$csv = Reader::createFromString($data);
$csv->setDelimiter($delimiter);
$csv->setHeaderOffset(0);
// Handle quoted fields properly
$csv->setEscape('\\');
$csv->setEnclosure('"');
$mapped_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
// Extract footprint package name if it contains library prefix
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
}
$mapped_entries[] = $mapped_entry;
}
return $this->validationService->validateBOMEntries($mapped_entries, $options);
}
/**
* This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry
@ -160,4 +249,482 @@ class BOMImporter
return $out;
}
/**
* Parse KiCad schematic BOM with flexible field mapping
*/
private function parseKiCADSchematic(string $data, array $options = []): array
{
$delimiter = $options['delimiter'] ?? ',';
$field_mapping = $options['field_mapping'] ?? [];
$field_priorities = $options['field_priorities'] ?? [];
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
$csv = Reader::createFromString($data);
$csv->setDelimiter($delimiter);
$csv->setHeaderOffset(0);
// Handle quoted fields properly
$csv->setEscape('\\');
$csv->setEnclosure('"');
$bom_entries = [];
$entries_by_key = []; // Track entries by name+part combination
$mapped_entries = []; // Collect all mapped entries for validation
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
// Extract footprint package name if it contains library prefix
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
}
$mapped_entries[] = $mapped_entry;
}
// Validate all entries before processing
$validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options);
// Log validation results
$this->logger->info('BOM import validation completed', [
'total_entries' => $validation_result['total_entries'],
'valid_entries' => $validation_result['valid_entries'],
'invalid_entries' => $validation_result['invalid_entries'],
'error_count' => count($validation_result['errors']),
'warning_count' => count($validation_result['warnings']),
]);
// If there are validation errors, throw an exception with detailed messages
if (!empty($validation_result['errors'])) {
$error_message = $this->validationService->getErrorMessage($validation_result);
throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message);
}
// Process validated entries
foreach ($mapped_entries as $offset => $mapped_entry) {
// Set name - prefer MPN, fall back to Value, then default format
$mpn = trim($mapped_entry['MPN'] ?? '');
$designation = trim($mapped_entry['Designation'] ?? '');
$value = trim($mapped_entry['Value'] ?? '');
// Use the first non-empty value, or 'Unknown Component' if all are empty
$name = '';
if (!empty($mpn)) {
$name = $mpn;
} elseif (!empty($designation)) {
$name = $designation;
} elseif (!empty($value)) {
$name = $value;
} else {
$name = 'Unknown Component';
}
if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) {
$name .= ' (' . trim($mapped_entry['Package']) . ')';
}
// Set mountnames and quantity
// The Designator field contains comma-separated mount names for all instances
$designator = trim($mapped_entry['Designator']);
$quantity = (float) $mapped_entry['Quantity'];
// Get mountnames array (validation already ensured they match quantity)
$mountnames_array = array_map('trim', explode(',', $designator));
// Try to link existing Part-DB part if ID is provided
$part = null;
if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
$partDbId = (int) $mapped_entry['Part-DB ID'];
$existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId);
if ($existingPart) {
$part = $existingPart;
// Update name with actual part name
$name = $existingPart->getName();
}
}
// Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
// Check if we already have an entry with the same name and part
if (isset($entries_by_key[$entry_key])) {
// Merge with existing entry
$existing_entry = $entries_by_key[$entry_key];
// Combine mountnames
$existing_mountnames = $existing_entry->getMountnames();
$combined_mountnames = $existing_mountnames . ',' . $designator;
$existing_entry->setMountnames($combined_mountnames);
// Add quantities
$existing_quantity = $existing_entry->getQuantity();
$existing_entry->setQuantity($existing_quantity + $quantity);
$this->logger->info('Merged duplicate BOM entry', [
'name' => $name,
'part_id' => $part ? $part->getID() : null,
'original_quantity' => $existing_quantity,
'added_quantity' => $quantity,
'new_quantity' => $existing_quantity + $quantity,
'original_mountnames' => $existing_mountnames,
'added_mountnames' => $designator,
]);
continue; // Skip creating new entry
}
// Create new BOM entry
$bom_entry = new ProjectBOMEntry();
$bom_entry->setName($name);
$bom_entry->setMountnames($designator);
$bom_entry->setQuantity($quantity);
if ($part) {
$bom_entry->setPart($part);
}
// Set comment with additional info
$comment_parts = [];
if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) {
$comment_parts[] = 'Value: ' . $mapped_entry['Value'];
}
if (isset($mapped_entry['MPN'])) {
$comment_parts[] = 'MPN: ' . $mapped_entry['MPN'];
}
if (isset($mapped_entry['Manufacturer'])) {
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
}
if (isset($mapped_entry['LCSC'])) {
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
}
if (isset($mapped_entry['Supplier and ref'])) {
$comment_parts[] = $mapped_entry['Supplier and ref'];
}
if ($part) {
$comment_parts[] = "Part-DB ID: " . $part->getID();
} elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
$comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)";
}
$bom_entry->setComment(implode(', ', $comment_parts));
$bom_entries[] = $bom_entry;
$entries_by_key[$entry_key] = $bom_entry;
}
return $bom_entries;
}
/**
* Get all available field mapping targets with descriptions
*/
public function getAvailableFieldTargets(): array
{
$targets = [
'Designator' => [
'label' => 'Designator',
'description' => 'Component reference designators (e.g., R1, C2, U3)',
'required' => true,
'multiple' => false,
],
'Quantity' => [
'label' => 'Quantity',
'description' => 'Number of components',
'required' => true,
'multiple' => false,
],
'Designation' => [
'label' => 'Designation',
'description' => 'Component designation/part number',
'required' => false,
'multiple' => true,
],
'Value' => [
'label' => 'Value',
'description' => 'Component value (e.g., 10k, 100nF)',
'required' => false,
'multiple' => true,
],
'Package' => [
'label' => 'Package',
'description' => 'Component package/footprint',
'required' => false,
'multiple' => true,
],
'MPN' => [
'label' => 'MPN',
'description' => 'Manufacturer Part Number',
'required' => false,
'multiple' => true,
],
'Manufacturer' => [
'label' => 'Manufacturer',
'description' => 'Component manufacturer name',
'required' => false,
'multiple' => true,
],
'Part-DB ID' => [
'label' => 'Part-DB ID',
'description' => 'Existing Part-DB part ID for linking',
'required' => false,
'multiple' => false,
],
'Comment' => [
'label' => 'Comment',
'description' => 'Additional component information',
'required' => false,
'multiple' => true,
],
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
'label' => $supplierName . ' SPN',
'description' => "Supplier part number for {$supplierName}",
'required' => false,
'multiple' => true,
'supplier_id' => $supplier->getID(),
];
}
return $targets;
}
/**
* Get suggested field mappings based on common field names
*/
public function getSuggestedFieldMapping(array $detected_fields): array
{
$suggestions = [];
$field_patterns = [
'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'],
'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'],
'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'],
'Value' => ['value', 'val', 'component_value'],
'Designation' => ['designation', 'part_number', 'partnumber', 'part'],
'Package' => ['footprint', 'package', 'housing', 'fp'],
'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'],
'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'],
'Comment' => ['comment', 'comments', 'note', 'notes', 'description'],
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);
// Create patterns for each supplier
$field_patterns[$supplierName . ' SPN'] = [
$supplierLower,
$supplierLower . '#',
$supplierLower . '_part',
$supplierLower . '_number',
$supplierLower . 'pn',
$supplierLower . '_spn',
$supplierLower . ' spn',
// Common abbreviations
$supplierLower === 'mouser' ? 'mouser' : null,
$supplierLower === 'digikey' ? 'dk' : null,
$supplierLower === 'farnell' ? 'farnell' : null,
$supplierLower === 'rs' ? 'rs' : null,
$supplierLower === 'lcsc' ? 'lcsc' : null,
];
// Remove null values
$field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null);
}
foreach ($detected_fields as $field) {
$field_lower = strtolower(trim($field));
foreach ($field_patterns as $target => $patterns) {
foreach ($patterns as $pattern) {
if (str_contains($field_lower, $pattern)) {
$suggestions[$field] = $target;
break 2; // Break both loops
}
}
}
}
return $suggestions;
}
/**
* Validate field mapping configuration
*/
public function validateFieldMapping(array $field_mapping, array $detected_fields): array
{
$errors = [];
$warnings = [];
$available_targets = $this->getAvailableFieldTargets();
// Check for required fields
$mapped_targets = array_values($field_mapping);
$required_fields = ['Designator', 'Quantity'];
foreach ($required_fields as $required) {
if (!in_array($required, $mapped_targets, true)) {
$errors[] = "Required field '{$required}' is not mapped from any CSV column.";
}
}
// Check for invalid target fields
foreach ($field_mapping as $csv_field => $target) {
if (!empty($target) && !isset($available_targets[$target])) {
$errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'.";
}
}
// Check for unmapped fields (warnings)
$unmapped_fields = array_diff($detected_fields, array_keys($field_mapping));
if (!empty($unmapped_fields)) {
$warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields);
}
return [
'errors' => $errors,
'warnings' => $warnings,
'is_valid' => empty($errors),
];
}
/**
* Apply field mapping with support for multiple fields and priority
*/
private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array
{
$mapped = [];
$field_groups = [];
// Group fields by target with priority information
foreach ($field_mapping as $csv_field => $target) {
if (!empty($target)) {
if (!isset($field_groups[$target])) {
$field_groups[$target] = [];
}
$priority = $field_priorities[$csv_field] ?? 10;
$field_groups[$target][] = [
'field' => $csv_field,
'priority' => $priority,
'value' => $entry[$csv_field] ?? ''
];
}
}
// Process each target field
foreach ($field_groups as $target => $field_data) {
// Sort by priority (lower number = higher priority)
usort($field_data, function ($a, $b) {
return $a['priority'] <=> $b['priority'];
});
$values = [];
$non_empty_values = [];
// Collect all non-empty values for this target
foreach ($field_data as $data) {
$value = trim($data['value']);
if (!empty($value)) {
$non_empty_values[] = $value;
}
$values[] = $value;
}
// Use the first non-empty value (highest priority)
if (!empty($non_empty_values)) {
$mapped[$target] = $non_empty_values[0];
// If multiple non-empty values exist, add alternatives to comment
if (count($non_empty_values) > 1) {
$mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1);
}
}
}
return $mapped;
}
/**
* Detect available fields in CSV data for field mapping UI
*/
public function detectFields(string $data, ?string $delimiter = null): array
{
if ($delimiter === null) {
// Detect delimiter by counting occurrences in the first row (header)
$delimiters = [',', ';', "\t"];
$lines = explode("\n", $data, 2);
$header_line = $lines[0] ?? '';
$delimiter_counts = [];
foreach ($delimiters as $delim) {
$delimiter_counts[$delim] = substr_count($header_line, $delim);
}
// Choose the delimiter with the highest count, default to comma if all are zero
$max_count = max($delimiter_counts);
$delimiter = array_search($max_count, $delimiter_counts, true);
if ($max_count === 0 || $delimiter === false) {
$delimiter = ',';
}
}
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
// Get first line only for header detection
$lines = explode("\n", $data);
$header_line = trim($lines[0] ?? '');
// Simple manual parsing for header detection
// This handles quoted CSV fields better than the library for detection
$fields = [];
$current_field = '';
$in_quotes = false;
$quote_char = '"';
for ($i = 0; $i < strlen($header_line); $i++) {
$char = $header_line[$i];
if ($char === $quote_char && !$in_quotes) {
$in_quotes = true;
} elseif ($char === $quote_char && $in_quotes) {
// Check for escaped quote (double quote)
if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) {
$current_field .= $quote_char;
$i++; // Skip next quote
} else {
$in_quotes = false;
}
} elseif ($char === $delimiter && !$in_quotes) {
$fields[] = trim($current_field);
$current_field = '';
} else {
$current_field .= $char;
}
}
// Add the last field
if ($current_field !== '') {
$fields[] = trim($current_field);
}
// Clean up headers - remove quotes and trim whitespace
$headers = array_map(function ($header) {
return trim($header, '"\'');
}, $fields);
return array_values($headers);
}
}

View file

@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Service for validating BOM import data with comprehensive validation rules
* and user-friendly error messages.
*/
class BOMValidationService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
) {
}
/**
* Validation result structure
*/
public static function createValidationResult(): array
{
return [
'errors' => [],
'warnings' => [],
'info' => [],
'is_valid' => true,
'total_entries' => 0,
'valid_entries' => 0,
'invalid_entries' => 0,
];
}
/**
* Validate a single BOM entry with comprehensive checks
*/
public function validateBOMEntry(array $mapped_entry, int $line_number, array $options = []): array
{
$result = [
'line_number' => $line_number,
'errors' => [],
'warnings' => [],
'info' => [],
'is_valid' => true,
];
// Run all validation rules
$this->validateRequiredFields($mapped_entry, $result);
$this->validateDesignatorFormat($mapped_entry, $result);
$this->validateQuantityFormat($mapped_entry, $result);
$this->validateDesignatorQuantityMatch($mapped_entry, $result);
$this->validatePartDBLink($mapped_entry, $result);
$this->validateComponentName($mapped_entry, $result);
$this->validatePackageFormat($mapped_entry, $result);
$this->validateNumericFields($mapped_entry, $result);
$result['is_valid'] = empty($result['errors']);
return $result;
}
/**
* Validate multiple BOM entries and provide summary
*/
public function validateBOMEntries(array $mapped_entries, array $options = []): array
{
$result = self::createValidationResult();
$result['total_entries'] = count($mapped_entries);
$line_results = [];
$all_errors = [];
$all_warnings = [];
$all_info = [];
foreach ($mapped_entries as $index => $entry) {
$line_number = $index + 1;
$line_result = $this->validateBOMEntry($entry, $line_number, $options);
$line_results[] = $line_result;
if ($line_result['is_valid']) {
$result['valid_entries']++;
} else {
$result['invalid_entries']++;
}
// Collect all messages
$all_errors = array_merge($all_errors, $line_result['errors']);
$all_warnings = array_merge($all_warnings, $line_result['warnings']);
$all_info = array_merge($all_info, $line_result['info']);
}
// Add summary messages
$this->addSummaryMessages($result, $all_errors, $all_warnings, $all_info);
$result['errors'] = $all_errors;
$result['warnings'] = $all_warnings;
$result['info'] = $all_info;
$result['line_results'] = $line_results;
$result['is_valid'] = empty($all_errors);
return $result;
}
/**
* Validate required fields are present
*/
private function validateRequiredFields(array $entry, array &$result): void
{
$required_fields = ['Designator', 'Quantity'];
foreach ($required_fields as $field) {
if (!isset($entry[$field]) || trim($entry[$field]) === '') {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.required_field_missing', [
'%line%' => $result['line_number'],
'%field%' => $field
]);
}
}
}
/**
* Validate designator format and content
*/
private function validateDesignatorFormat(array $entry, array &$result): void
{
if (!isset($entry['Designator']) || trim($entry['Designator']) === '') {
return; // Already handled by required fields validation
}
$designator = trim($entry['Designator']);
$mountnames = array_map('trim', explode(',', $designator));
// Remove empty entries
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
if (empty($mountnames)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.no_valid_designators', [
'%line%' => $result['line_number']
]);
return;
}
// Validate each mountname format (allow 1-2 uppercase letters, followed by 1+ digits)
$invalid_mountnames = [];
foreach ($mountnames as $mountname) {
if (!preg_match('/^[A-Z]{1,2}[0-9]+$/', $mountname)) {
$invalid_mountnames[] = $mountname;
}
}
if (!empty($invalid_mountnames)) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.unusual_designator_format', [
'%line%' => $result['line_number'],
'%designators%' => implode(', ', $invalid_mountnames)
]);
}
// Check for duplicate mountnames within the same line
$duplicates = array_diff_assoc($mountnames, array_unique($mountnames));
if (!empty($duplicates)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.duplicate_designators', [
'%line%' => $result['line_number'],
'%designators%' => implode(', ', array_unique($duplicates))
]);
}
}
/**
* Validate quantity format and value
*/
private function validateQuantityFormat(array $entry, array &$result): void
{
if (!isset($entry['Quantity']) || trim($entry['Quantity']) === '') {
return; // Already handled by required fields validation
}
$quantity_str = trim($entry['Quantity']);
// Check if it's a valid number
if (!is_numeric($quantity_str)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_quantity', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
return;
}
$quantity = (float) $quantity_str;
// Check for reasonable quantity values
if ($quantity <= 0) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_zero_or_negative', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
} elseif ($quantity > 10000) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_unusually_high', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
}
// Check if quantity is a whole number when it should be
if (isset($entry['Designator'])) {
$designator = trim($entry['Designator']);
$mountnames = array_map('trim', explode(',', $designator));
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
if (count($mountnames) > 0 && $quantity != (int) $quantity) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_not_whole_number', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str,
'%count%' => count($mountnames)
]);
}
}
}
/**
* Validate that designator count matches quantity
*/
private function validateDesignatorQuantityMatch(array $entry, array &$result): void
{
if (!isset($entry['Designator']) || !isset($entry['Quantity'])) {
return; // Already handled by required fields validation
}
$designator = trim($entry['Designator']);
$quantity_str = trim($entry['Quantity']);
if (!is_numeric($quantity_str)) {
return; // Already handled by quantity validation
}
$mountnames = array_map('trim', explode(',', $designator));
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
$mountnames_count = count($mountnames);
$quantity = (float) $quantity_str;
if ($mountnames_count !== (int) $quantity) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_designator_mismatch', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str,
'%count%' => $mountnames_count,
'%designators%' => $designator
]);
}
}
/**
* Validate Part-DB ID link
*/
private function validatePartDBLink(array $entry, array &$result): void
{
if (!isset($entry['Part-DB ID']) || trim($entry['Part-DB ID']) === '') {
return;
}
$part_db_id = trim($entry['Part-DB ID']);
if (!is_numeric($part_db_id)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_partdb_id', [
'%line%' => $result['line_number'],
'%id%' => $part_db_id
]);
return;
}
$part_id = (int) $part_db_id;
if ($part_id <= 0) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.partdb_id_zero_or_negative', [
'%line%' => $result['line_number'],
'%id%' => $part_id
]);
return;
}
// Check if part exists in database
$existing_part = $this->entityManager->getRepository(Part::class)->find($part_id);
if (!$existing_part) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.partdb_id_not_found', [
'%line%' => $result['line_number'],
'%id%' => $part_id
]);
} else {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.partdb_link_success', [
'%line%' => $result['line_number'],
'%name%' => $existing_part->getName(),
'%id%' => $part_id
]);
}
}
/**
* Validate component name/designation
*/
private function validateComponentName(array $entry, array &$result): void
{
$name_fields = ['MPN', 'Designation', 'Value'];
$has_name = false;
foreach ($name_fields as $field) {
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
$has_name = true;
break;
}
}
if (!$has_name) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.no_component_name', [
'%line%' => $result['line_number']
]);
}
}
/**
* Validate package format
*/
private function validatePackageFormat(array $entry, array &$result): void
{
if (!isset($entry['Package']) || trim($entry['Package']) === '') {
return;
}
$package = trim($entry['Package']);
// Check for common package format issues
if (strlen($package) > 100) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.package_name_too_long', [
'%line%' => $result['line_number'],
'%package%' => $package
]);
}
// Check for library prefixes (KiCad format)
if (str_contains($package, ':')) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.library_prefix_detected', [
'%line%' => $result['line_number'],
'%package%' => $package
]);
}
}
/**
* Validate numeric fields
*/
private function validateNumericFields(array $entry, array &$result): void
{
$numeric_fields = ['Quantity', 'Part-DB ID'];
foreach ($numeric_fields as $field) {
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
$value = trim($entry[$field]);
if (!is_numeric($value)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.non_numeric_field', [
'%line%' => $result['line_number'],
'%field%' => $field,
'%value%' => $value
]);
}
}
}
}
/**
* Add summary messages to validation result
*/
private function addSummaryMessages(array &$result, array $errors, array $warnings, array $info): void
{
$total_entries = $result['total_entries'];
$valid_entries = $result['valid_entries'];
$invalid_entries = $result['invalid_entries'];
// Add summary info
if ($total_entries > 0) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.import_summary', [
'%total%' => $total_entries,
'%valid%' => $valid_entries,
'%invalid%' => $invalid_entries
]);
}
// Add error summary
if (!empty($errors)) {
$error_count = count($errors);
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.summary', [
'%count%' => $error_count
]);
}
// Add warning summary
if (!empty($warnings)) {
$warning_count = count($warnings);
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.summary', [
'%count%' => $warning_count
]);
}
// Add success message if all entries are valid
if ($total_entries > 0 && $invalid_entries === 0) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.all_valid');
}
}
/**
* Get user-friendly error message for a validation result
*/
public function getErrorMessage(array $validation_result): string
{
if ($validation_result['is_valid']) {
return '';
}
$messages = [];
if (!empty($validation_result['errors'])) {
$messages[] = 'Errors:';
foreach ($validation_result['errors'] as $error) {
$messages[] = '• ' . $error;
}
}
if (!empty($validation_result['warnings'])) {
$messages[] = 'Warnings:';
foreach ($validation_result['warnings'] as $warning) {
$messages[] = '• ' . $warning;
}
}
return implode("\n", $messages);
}
/**
* Get validation statistics
*/
public function getValidationStats(array $validation_result): array
{
return [
'total_entries' => $validation_result['total_entries'] ?? 0,
'valid_entries' => $validation_result['valid_entries'] ?? 0,
'invalid_entries' => $validation_result['invalid_entries'] ?? 0,
'error_count' => count($validation_result['errors'] ?? []),
'warning_count' => count($validation_result['warnings'] ?? []),
'info_count' => count($validation_result['info'] ?? []),
'success_rate' => $validation_result['total_entries'] > 0
? round(($validation_result['valid_entries'] / $validation_result['total_entries']) * 100, 1)
: 0,
];
}
}

View file

@ -137,7 +137,7 @@ class EntityExporter
$options = [
'format' => $request->get('format') ?? 'json',
'level' => $request->get('level') ?? 'extended',
'include_children' => $request->request->getBoolean('include_children') ?? false,
'include_children' => $request->request->getBoolean('include_children'),
];
if (!is_array($entities)) {

View file

@ -57,6 +57,7 @@ class EntityImporter
/**
* Creates many entries at once, based on a (text) list of name.
* The created entities are not persisted to database yet, so you have to do it yourself.
* It returns all entities in the hierachy chain (even if they are already persisted).
*
* @template T of AbstractNamedDBElement
* @param string $lines The list of names seperated by \n
@ -132,32 +133,38 @@ class EntityImporter
//We can only use the getNewEntityFromPath function, if the repository is a StructuralDBElementRepository
if ($repo instanceof StructuralDBElementRepository) {
$entities = $repo->getNewEntityFromPath($new_path);
$entity = end($entities);
if ($entity === false) {
if ($entities === []) {
throw new InvalidArgumentException('getNewEntityFromPath returned an empty array!');
}
} else { //Otherwise just create a new entity
$entity = new $class_name;
$entity->setName($name);
$entities = [$entity];
}
//Validate entity
$tmp = $this->validator->validate($entity);
//If no error occured, write entry to DB:
if (0 === count($tmp)) {
$valid_entities[] = $entity;
} else { //Otherwise log error
$errors[] = [
'entity' => $entity,
'violations' => $tmp,
];
foreach ($entities as $entity) {
$tmp = $this->validator->validate($entity);
//If no error occured, write entry to DB:
if (0 === count($tmp)) {
$valid_entities[] = $entity;
} else { //Otherwise log error
$errors[] = [
'entity' => $entity,
'violations' => $tmp,
];
}
}
$last_element = $entity;
$last_element = end($entities);
if ($last_element === false) {
$last_element = null;
}
}
return $valid_entities;
//Only return objects once
return array_values(array_unique($valid_entities));
}
/**

View file

@ -35,6 +35,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Settings\SystemSettings\LocalizationSettings;
use Brick\Math\BigDecimal;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Intl\Currencies;
@ -47,7 +48,7 @@ class PKPartImporter
{
use PKImportHelperTrait;
public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly string $base_currency)
public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly LocalizationSettings $localizationSettings)
{
$this->em = $em;
$this->propertyAccessor = $propertyAccessor;
@ -210,7 +211,7 @@ class PKPartImporter
$currency_iso_code = strtoupper($currency_iso_code);
//We do not have a currency for the base currency to be consistent with prices without currencies
if ($currency_iso_code === $this->base_currency) {
if ($currency_iso_code === $this->localizationSettings->baseCurrency) {
return null;
}

View file

@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Settings\SystemSettings\LocalizationSettings;
use Doctrine\ORM\EntityManagerInterface;
/**
@ -54,8 +55,11 @@ final class DTOtoEntityConverter
private const TYPE_DATASHEETS_NAME = 'Datasheet';
private const TYPE_IMAGE_NAME = 'Image';
public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency)
private readonly string $base_currency;
public function __construct(private readonly EntityManagerInterface $em, LocalizationSettings $localizationSettings)
{
$this->base_currency = $localizationSettings->baseCurrency;
}
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\PollinSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -39,8 +40,7 @@ class PollinProvider implements InfoProviderInterface
{
public function __construct(private readonly HttpClientInterface $client,
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
private readonly bool $enabled = true,
private readonly PollinSettings $settings,
)
{
}
@ -49,9 +49,10 @@ class PollinProvider implements InfoProviderInterface
{
return [
'name' => 'Pollin',
'description' => 'Webscrapping from pollin.de to get part information',
'url' => 'https://www.reichelt.de/',
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
'description' => 'Webscraping from pollin.de to get part information',
'url' => 'https://www.pollin.de/',
'disabled_help' => 'Enable the provider in provider settings',
'settings_class' => PollinSettings::class,
];
}
@ -62,7 +63,7 @@ class PollinProvider implements InfoProviderInterface
public function isActive(): bool
{
return $this->enabled;
return $this->settings->enabled;
}
public function searchByKeyword(string $keyword): array
@ -157,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
//TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),
@ -215,7 +217,7 @@ class PollinProvider implements InfoProviderInterface
private function parseNotes(Crawler $dom): string
{
//Concat product highlights and product description
return $dom->filter('div.product-detail-top-features')->html() . '<br><br>' . $dom->filter('div.product-detail-description-text')->html();
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
}
private function parsePrices(Crawler $dom): array

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ declare(strict_types=1);
namespace App\Services\LabelSystem;
use App\Entity\LabelSystem\LabelProcessMode;
use App\Settings\SystemSettings\CustomizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\LabelSystem\LabelOptions;
@ -60,7 +61,7 @@ final class LabelHTMLGenerator
private readonly LabelBarcodeGenerator $barcodeGenerator,
private readonly SandboxedTwigFactory $sandboxedTwigProvider,
private readonly Security $security,
private readonly string $partdb_title)
private readonly CustomizationSettings $customizationSettings,)
{
}
@ -88,7 +89,8 @@ final class LabelHTMLGenerator
'page' => $page,
'last_page' => count($elements),
'user' => $current_user,
'install_title' => $this->partdb_title,
'install_title' => $this->customizationSettings->instanceName,
'partdb_title' => $this->customizationSettings->instanceName,
'paper_width' => $options->getWidth(),
'paper_height' => $options->getHeight(),
]

View file

@ -63,12 +63,24 @@ final class BarcodeProvider implements PlaceholderProviderInterface
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_DATAMATRIX]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType(BarcodeType::DATAMATRIX);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C39]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType(BarcodeType::CODE39);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C93]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType(BarcodeType::CODE93);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C128]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType(BarcodeType::CODE128);

View file

@ -41,6 +41,7 @@ declare(strict_types=1);
namespace App\Services\LabelSystem\PlaceholderProviders;
use App\Settings\SystemSettings\CustomizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\UserSystem\User;
use DateTime;
@ -54,14 +55,18 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/
final class GlobalProviders implements PlaceholderProviderInterface
{
public function __construct(private readonly string $partdb_title, private readonly Security $security, private readonly UrlGeneratorInterface $url_generator)
public function __construct(
private readonly Security $security,
private readonly UrlGeneratorInterface $url_generator,
private CustomizationSettings $customizationSettings,
)
{
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
{
if ('[[INSTALL_NAME]]' === $placeholder) {
return $this->partdb_title;
return $this->customizationSettings->instanceName;
}
$user = $this->security->getUser();

View file

@ -46,7 +46,9 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
use App\Services\Formatters\SIFormatter;
use Parsedown;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\InlinesOnly\InlinesOnlyExtension;
use League\CommonMark\MarkdownConverter;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
@ -54,8 +56,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
final class PartProvider implements PlaceholderProviderInterface
{
private readonly MarkdownConverter $inlineConverter;
public function __construct(private readonly SIFormatter $siFormatter, private readonly TranslatorInterface $translator)
{
$environment = new Environment();
$environment->addExtension(new InlinesOnlyExtension());
$this->inlineConverter = new MarkdownConverter($environment);
}
public function replace(string $placeholder, object $part, array $options = []): ?string
@ -112,22 +119,20 @@ final class PartProvider implements PlaceholderProviderInterface
return $this->translator->trans($part->getManufacturingStatus()->toTranslationKey());
}
$parsedown = new Parsedown();
if ('[[DESCRIPTION]]' === $placeholder) {
return $parsedown->line($part->getDescription());
return trim($this->inlineConverter->convert($part->getDescription())->getContent());
}
if ('[[DESCRIPTION_T]]' === $placeholder) {
return strip_tags((string) $parsedown->line($part->getDescription()));
return trim(strip_tags($this->inlineConverter->convert($part->getDescription())->getContent()));
}
if ('[[COMMENT]]' === $placeholder) {
return $parsedown->line($part->getComment());
return trim($this->inlineConverter->convert($part->getComment())->getContent());
}
if ('[[COMMENT_T]]' === $placeholder) {
return strip_tags((string) $parsedown->line($part->getComment()));
return trim(strip_tags($this->inlineConverter->convert($part->getComment())->getContent()));
}
return null;

View file

@ -22,37 +22,25 @@ declare(strict_types=1);
*/
namespace App\Services\LogSystem;
use App\Settings\SystemSettings\HistorySettings;
/**
* This service is used to check if a log change comment is needed for a given operation type.
* It is configured using the "enforce_change_comments_for" config parameter.
* @see \App\Tests\Services\LogSystem\EventCommentNeededHelperTest
*/
class EventCommentNeededHelper
final class EventCommentNeededHelper
{
final public const VALID_OPERATION_TYPES = [
'part_edit',
'part_create',
'part_delete',
'part_stock_operation',
'datastructure_edit',
'datastructure_create',
'datastructure_delete',
];
public function __construct(protected array $enforce_change_comments_for)
public function __construct(private readonly HistorySettings $settings)
{
}
/**
* Checks if a log change comment is needed for the given operation type
*/
public function isCommentNeeded(string $comment_type): bool
public function isCommentNeeded(EventCommentType $comment_type): bool
{
//Check if the comment type is valid
if (! in_array($comment_type, self::VALID_OPERATION_TYPES, true)) {
throw new \InvalidArgumentException('The comment type "'.$comment_type.'" is not valid!');
}
return in_array($comment_type, $this->enforce_change_comments_for, true);
return in_array($comment_type, $this->settings->enforceComments, true);
}
}

View file

@ -0,0 +1,47 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\LogSystem;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* This enum represents the different types of event comments that could be required, by the system.
* They are almost only useful when working with the EventCommentNeededHelper service.
*/
enum EventCommentType: string implements TranslatableInterface
{
case PART_EDIT = 'part_edit';
case PART_CREATE = 'part_create';
case PART_DELETE = 'part_delete';
case PART_STOCK_OPERATION = 'part_stock_operation';
case DATASTRUCTURE_EDIT = 'datastructure_edit';
case DATASTRUCTURE_CREATE = 'datastructure_create';
case DATASTRUCTURE_DELETE = 'datastructure_delete';
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
return $translator->trans('settings.system.history.enforceComments.type.' . $this->value, locale: $locale);
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Services\Parts;
use App\Entity\Parts\StorageLocation;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -35,6 +36,9 @@ use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Contracts\Translation\TranslatableInterface;
use function Symfony\Component\Translation\t;
final class PartsTableActionHandler
{
@ -61,8 +65,9 @@ final class PartsTableActionHandler
/**
* @param Part[] $selected_parts
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
* //@param-out list<array{'part': Part, 'message': string|TranslatableInterface}>|array<void> $errors
*/
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse
{
if ($action === 'add_to_project') {
return new RedirectResponse(
@ -161,6 +166,29 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
$this->denyAccessUnlessGranted('@measurement_units.read');
$part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id));
break;
case 'change_location':
$this->denyAccessUnlessGranted('@storelocations.read');
//Retrieve the first part lot and set the location for it
$part_lots = $part->getPartLots();
if ($part_lots->count() > 0) {
if ($part_lots->count() > 1) {
$errors[] = [
'part' => $part,
'message' => t('parts.table.action_handler.error.part_lots_multiple'),
];
break;
}
$part_lot = $part_lots->first();
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
} else { //Create a new part lot if there are none
$part_lot = new PartLot();
$part_lot->setPart($part);
$part_lot->setInstockUnknown(true); //We do not know how many parts are in stock, so we set it to true
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
$this->entityManager->persist($part_lot);
}
break;
default:
throw new InvalidArgumentException('The given action is unknown! ('.$action.')');

View file

@ -25,6 +25,7 @@ namespace App\Services\Parts;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Pricedetail;
use App\Settings\SystemSettings\LocalizationSettings;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Doctrine\ORM\PersistentCollection;
@ -39,7 +40,7 @@ class PricedetailHelper
{
protected string $locale;
public function __construct(protected string $base_currency)
public function __construct()
{
$this->locale = Locale::getDefault();
}

View file

@ -23,12 +23,14 @@ declare(strict_types=1);
namespace App\Services\System;
use App\Settings\SystemSettings\CustomizationSettings;
/**
* Helper service to retrieve the banner of this Part-DB installation
*/
class BannerHelper
{
public function __construct(private readonly string $project_dir, private readonly string $partdb_banner)
public function __construct(private readonly CustomizationSettings $customizationSettings)
{
}
@ -39,18 +41,6 @@ class BannerHelper
*/
public function getBanner(): string
{
$banner = $this->partdb_banner;
if ($banner === '') {
$banner_path = $this->project_dir
.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md';
$tmp = file_get_contents($banner_path);
if (false === $tmp) {
throw new \RuntimeException('The banner file could not be read.');
}
$banner = $tmp;
}
return $banner;
return $this->customizationSettings->banner ?? "";
}
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\System;
use App\Settings\SystemSettings\PrivacySettings;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -43,7 +44,7 @@ class UpdateAvailableManager
public function __construct(private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
private readonly bool $check_for_updates, private readonly LoggerInterface $logger,
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode)
{
@ -83,7 +84,7 @@ class UpdateAvailableManager
public function isUpdateAvailable(): bool
{
//If we don't want to check for updates, we can return false
if (!$this->check_for_updates) {
if (!$this->privacySettings->checkForUpdates) {
return false;
}
@ -101,7 +102,7 @@ class UpdateAvailableManager
private function getLatestVersionInfo(): array
{
//If we don't want to check for updates, we can return dummy data
if (!$this->check_for_updates) {
if (!$this->privacySettings->checkForUpdates) {
return [
'version' => '0.0.1',
'url' => 'update-checking-disabled'

View file

@ -23,13 +23,16 @@ declare(strict_types=1);
namespace App\Services\Tools;
use App\Entity\PriceInformations\Currency;
use App\Settings\SystemSettings\LocalizationSettings;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Exchanger\Exception\UnsupportedCurrencyPairException;
use Exchanger\Exception\UnsupportedExchangeQueryException;
use Swap\Swap;
class ExchangeRateUpdater
{
public function __construct(private readonly string $base_currency, private readonly Swap $swap)
public function __construct(private LocalizationSettings $localizationSettings, private readonly Swap $swap)
{
}
@ -38,15 +41,21 @@ class ExchangeRateUpdater
*/
public function update(Currency $currency): Currency
{
//Currency pairs are always in the format "BASE/QUOTE"
$rate = $this->swap->latest($this->base_currency.'/'.$currency->getIsoCode());
//The rate says how many quote units are worth one base unit
//So we need to invert it to get the exchange rate
try {
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
$effective_rate = BigDecimal::of($rate->getValue());
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
//The rate says how many quote units are worth one base unit
//So we need to invert it to get the exchange rate
$rate_bd = BigDecimal::of($rate->getValue());
$rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
$rate_bd = BigDecimal::of($rate->getValue());
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
}
$currency->setExchangeRate($rate_inverted);
$currency->setExchangeRate($effective_rate);
return $currency;
}

View file

@ -289,6 +289,13 @@ class ToolsTreeBuilder
))->setIcon('fa-fw fa-treeview fa-solid fa-database');
}
if ($this->security->isGranted('@config.change_system_settings')) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.system.settings'),
$this->urlGenerator->generate('system_settings')
))->setIcon('fa fa-fw fa-gears fa-solid');
}
return $nodes;
}
}

View file

@ -38,6 +38,7 @@ use App\Repository\StructuralDBElementRepository;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\Cache\UserCacheKeyGenerator;
use App\Services\EntityURLGenerator;
use App\Settings\BehaviorSettings\SidebarSettings;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use RecursiveIteratorIterator;
@ -53,6 +54,10 @@ use function count;
*/
class TreeViewGenerator
{
private readonly bool $rootNodeExpandedByDefault;
private readonly bool $rootNodeEnabled;
public function __construct(
protected EntityURLGenerator $urlGenerator,
protected EntityManagerInterface $em,
@ -61,10 +66,10 @@ class TreeViewGenerator
protected UserCacheKeyGenerator $keyGenerator,
protected TranslatorInterface $translator,
private readonly UrlGeneratorInterface $router,
protected bool $rootNodeExpandedByDefault,
protected bool $rootNodeEnabled,
private readonly SidebarSettings $sidebarSettings,
) {
$this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled;
$this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded;
}
/**
@ -174,10 +179,7 @@ class TreeViewGenerator
}
if (($mode === 'list_parts_root' || $mode === 'devices') && $this->rootNodeEnabled) {
//We show the root node as a link to the list of all parts
$show_all_parts_url = $this->router->generate('parts_show_all');
$root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $show_all_parts_url, $generic);
$root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $this->entityClassToRootNodeHref($class), $generic);
$root_node->setExpanded($this->rootNodeExpandedByDefault);
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
@ -187,6 +189,27 @@ class TreeViewGenerator
return array_merge($head, $generic);
}
protected function entityClassToRootNodeHref(string $class): ?string
{
//If the root node should redirect to the new entity page, we return the URL for the new entity.
if ($this->sidebarSettings->rootNodeRedirectsToNewEntity) {
return match ($class) {
Category::class => $this->router->generate('category_new'),
StorageLocation::class => $this->router->generate('store_location_new'),
Footprint::class => $this->router->generate('footprint_new'),
Manufacturer::class => $this->router->generate('manufacturer_new'),
Supplier::class => $this->router->generate('supplier_new'),
Project::class => $this->router->generate('project_new'),
default => null,
};
}
return match ($class) {
Project::class => $this->router->generate('project_new'),
default => $this->router->generate('parts_show_all')
};
}
protected function entityClassToRootNodeString(string $class): string
{
return match ($class) {

View file

@ -105,6 +105,9 @@ class PermissionPresetsHelper
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW);
//Allow to change system settings
$this->permissionResolver->setPermission($perm_holder, 'config', 'change_system_settings', PermissionData::ALLOW);
//Allow to manage Oauth tokens
$this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW);
//Allow to show updates

View file

@ -30,6 +30,7 @@ use App\Entity\Attachments\UserAttachment;
use App\Entity\UserSystem\User;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Settings\SystemSettings\PrivacySettings;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Asset\Packages;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@ -42,7 +43,7 @@ class UserAvatarHelper
public const IMG_DEFAULT_AVATAR_PATH = 'img/default_avatar.svg';
public function __construct(
private readonly bool $use_gravatar,
private readonly PrivacySettings $privacySettings,
private readonly Packages $packages,
private readonly AttachmentURLGenerator $attachmentURLGenerator,
private readonly EntityManagerInterface $entityManager,
@ -65,7 +66,7 @@ class UserAvatarHelper
}
//If not check if gravatar is enabled (then use gravatar URL)
if ($this->use_gravatar) {
if ($this->privacySettings->useGravatar) {
return $this->getGravatar($user, 200); //200px wide picture
}
@ -82,7 +83,7 @@ class UserAvatarHelper
}
//If not check if gravatar is enabled (then use gravatar URL)
if ($this->use_gravatar) {
if ($this->privacySettings->useGravatar) {
return $this->getGravatar($user, 50); //50px wide picture
}
@ -99,7 +100,7 @@ class UserAvatarHelper
}
//If not check if gravatar is enabled (then use gravatar URL)
if ($this->use_gravatar) {
if ($this->privacySettings->useGravatar) {
return $this->getGravatar($user, 150);
}

View file

@ -28,6 +28,9 @@ use App\Repository\UserRepository;
use App\Security\ApiTokenAuthenticatedToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Tests\Services\UserSystem\VoterHelperTest
@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class VoterHelper
{
private readonly UserRepository $userRepository;
private readonly array $permissionStructure;
public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager)
public function __construct(private readonly PermissionManager $permissionManager,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $entityManager)
{
$this->userRepository = $this->entityManager->getRepository(User::class);
$this->permissionStructure = $this->permissionManager->getPermissionStructure();
}
/**
@ -47,11 +54,16 @@ final class VoterHelper
* @param TokenInterface $token The token to check
* @param string $permission The permission to check
* @param string $operation The operation to check
* @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added.
* @return bool
*/
public function isGranted(TokenInterface $token, string $permission, string $operation): bool
public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool
{
return $this->isGrantedTrinary($token, $permission, $operation) ?? false;
$tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false;
if ($tmp === false) {
$this->addReason($vote, $permission, $operation);
}
return $tmp;
}
/**
@ -124,4 +136,17 @@ final class VoterHelper
{
return $this->permissionManager->isValidOperation($permission, $operation);
}
}
public function addReason(?Vote $voter, string $permission, $operation): void
{
if ($voter !== null) {
$voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).",
$this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ),
$this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission),
$this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation),
$permission,
$operation
));
}
}
}