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

@ -70,4 +70,4 @@ class PropertyMetadataFactory implements PropertyMetadataFactoryInterface
return $metadata;
}
}
}

View file

@ -73,6 +73,9 @@ class CleanAttachmentsCommand extends Command
//Ignore image cache folder
$finder->exclude('cache');
//Ignore automigration folder
$finder->exclude('.automigration-backup');
$fs = new Filesystem();
$file_list = [];

View file

@ -0,0 +1,136 @@
<?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\Command\Attachments;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentUpload;
use App\Exceptions\AttachmentDownloadException;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:download', "Downloads all attachments which have only an external URL to the local filesystem.")]
class DownloadAttachmentsCommand extends Command
{
public function __construct(private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
private EntityManagerInterface $entityManager)
{
parent::__construct();
}
public function configure(): void
{
$this->setHelp('This command downloads all attachments, which only have an external URL, to the local filesystem, so that you have an offline copy of the attachments.');
$this->addOption('--private', null, null, 'If set, the attachments will be downloaded to the private storage.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$qb = $this->entityManager->createQueryBuilder();
$qb->select('attachment')
->from(Attachment::class, 'attachment')
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.external_path != \'\'')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
$attachments = $query->getResult();
if (count($attachments) === 0) {
$io->success('No attachments with external URL found.');
return Command::SUCCESS;
}
$io->note('Found ' . count($attachments) . ' attachments with external URL, that will be downloaded.');
//If the option --private is set, the attachments will be downloaded to the private storage.
$private = $input->getOption('private');
if ($private) {
if (!$io->confirm('Attachments will be downloaded to the private storage. Continue?')) {
return Command::SUCCESS;
}
} else {
if (!$io->confirm('Attachments will be downloaded to the public storage, where everybody knowing the correct URL can access it. Continue?')){
return Command::SUCCESS;
}
}
$progressBar = $io->createProgressBar(count($attachments));
$progressBar->setFormat("%current%/%max% [%bar%] %percent:3s%% %elapsed:16s%/%estimated:-16s% \n%message%");
$progressBar->setMessage('Starting download...');
$progressBar->start();
$errors = [];
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$progressBar->setMessage(sprintf('%s (ID: %s) from %s', $attachment->getName(), $attachment->getID(), $attachment->getHost()));
$progressBar->advance();
try {
$attachmentUpload = new AttachmentUpload(file: null, downloadUrl: true, private: $private);
$this->attachmentSubmitHandler->handleUpload($attachment, $attachmentUpload);
//Write changes to the database
$this->entityManager->flush();
} catch (AttachmentDownloadException $e) {
$errors[] = [
'attachment' => $attachment,
'error' => $e->getMessage()
];
}
}
$progressBar->finish();
//Fix the line break after the progress bar
$io->newLine();
$io->newLine();
if (count($errors) > 0) {
$io->warning('Some attachments could not be downloaded:');
foreach ($errors as $error) {
$io->warning(sprintf("Attachment %s (ID %s) could not be downloaded from %s:\n%s",
$error['attachment']->getName(),
$error['attachment']->getID(),
$error['attachment']->getExternalPath(),
$error['error'])
);
}
} else {
$io->success('All attachments downloaded successfully.');
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,90 @@
<?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\Command\Attachments;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:sanitize-svg', "Sanitize uploaded SVG files.")]
class SanitizeSVGAttachmentsCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly AttachmentSubmitHandler $attachmentSubmitHandler, ?string $name = null)
{
parent::__construct($name);
}
public function configure(): void
{
$this->setHelp('This command allows to sanitize SVG files uploaded via attachments. This happens automatically since version 1.17.1, this command is intended to be used for older files.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->info('This command will sanitize all uploaded SVG files. This is only required if you have uploaded (untrusted) SVG files before version 1.17.1. If you are running a newer version, you don\'t need to run this command (again).');
if (!$io->confirm('Do you want to continue?', false)) {
$io->success('Command aborted.');
return Command::FAILURE;
}
$io->info('Sanitizing SVG files...');
//Finding all attachments with svg files
$qb = $this->entityManager->createQueryBuilder();
$qb->select('a')
->from(Attachment::class, 'a')
->where('a.internal_path LIKE :pattern ESCAPE \'#\'')
->orWhere('a.original_filename LIKE :pattern ESCAPE \'#\'')
->setParameter('pattern', '%.svg');
$attachments = $qb->getQuery()->getResult();
$io->note('Found '.count($attachments).' attachments with SVG files.');
if (count($attachments) === 0) {
$io->success('No SVG files found.');
return Command::FAILURE;
}
$io->info('Sanitizing SVG files...');
$io->progressStart(count($attachments));
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$io->note('Sanitizing attachment '.$attachment->getId().' ('.($attachment->getFilename() ?? '???').')');
$this->attachmentSubmitHandler->sanitizeSVGAttachment($attachment);
$io->progressAdvance();
}
$io->progressFinish();
$io->success('Sanitization finished. All SVG files have been sanitized.');
return Command::SUCCESS;
}
}

View file

@ -69,8 +69,8 @@ class CheckRequirementsCommand extends Command
if ($io->isVerbose()) {
$io->comment('Checking PHP version...');
}
//We recommend PHP 8.2, but 8.1 is the minimum
if (PHP_VERSION_ID < 80200) {
//We recommend PHP 8.2, but 8.2 is the minimum
if (PHP_VERSION_ID < 80400) {
$io->warning('You are using PHP '. PHP_VERSION .'. This will work, but a newer version is recommended.');
} elseif (!$only_issues) {
$io->success('PHP version is sufficient.');
@ -84,7 +84,7 @@ class CheckRequirementsCommand extends Command
$io->success('You are using a 64-bit system.');
}
} else {
$io->warning('You are using a system with an unknown bit size. That is interesting xD');
$io->warning(' areYou using a system with an unknown bit size. That is interesting xD');
}
//Check if opcache is enabled

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Command\Currencies;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Entity\PriceInformations\Currency;
use App\Services\Tools\ExchangeRateUpdater;
@ -39,7 +40,7 @@ use function strlen;
#[AsCommand('partdb:currencies:update-exchange-rates|partdb:update-exchange-rates|app:update-exchange-rates', 'Updates the currency exchange rates.')]
class UpdateExchangeRatesCommand extends Command
{
public function __construct(protected string $base_current, protected EntityManagerInterface $em, protected ExchangeRateUpdater $exchangeRateUpdater)
public function __construct(protected EntityManagerInterface $em, protected ExchangeRateUpdater $exchangeRateUpdater, private readonly LocalizationSettings $localizationSettings)
{
parent::__construct();
}
@ -54,13 +55,13 @@ class UpdateExchangeRatesCommand extends Command
$io = new SymfonyStyle($input, $output);
//Check for valid base current
if (3 !== strlen($this->base_current)) {
if (3 !== strlen($this->localizationSettings->baseCurrency)) {
$io->error('Chosen Base current is not valid. Check your settings!');
return Command::FAILURE;
}
$io->note('Update currency exchange rates with base currency: '.$this->base_current);
$io->note('Update currency exchange rates with base currency: '.$this->localizationSettings->baseCurrency);
//Check what currencies we need to update:
$iso_code = $input->getArgument('iso_code');

View file

@ -44,7 +44,7 @@ class ImportPartKeeprCommand extends Command
protected PKDatastructureImporter $datastructureImporter, protected PKPartImporter $partImporter, protected PKImportHelper $importHelper,
protected PKOptionalImporter $optionalImporter)
{
parent::__construct(self::$defaultName);
parent::__construct();
}
protected function configure(): void

View file

@ -39,14 +39,7 @@ final class UpgradePermissionsSchemaCommand extends Command
{
public function __construct(private readonly PermissionSchemaUpdater $permissionSchemaUpdater, private readonly EntityManagerInterface $em, private readonly EventCommentHelper $eventCommentHelper)
{
parent::__construct(self::$defaultName);
}
protected function configure(): void
{
$this
->setDescription(self::$defaultDescription)
;
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int

View file

@ -46,7 +46,7 @@ class UsersPermissionsCommand extends Command
{
$this->userRepository = $entityManager->getRepository(User::class);
parent::__construct(self::$defaultName);
parent::__construct();
}
protected function configure(): void

View file

@ -24,10 +24,12 @@ namespace App\Controller;
use App\DataTables\AttachmentDataTable;
use App\DataTables\Filters\AttachmentFilter;
use App\DataTables\PartsDataTable;
use App\Entity\Attachments\Attachment;
use App\Form\Filters\AttachmentFilterType;
use App\Services\Attachments\AttachmentManager;
use App\Services\Trees\NodesListBuilder;
use App\Settings\BehaviorSettings\TableSettings;
use Omines\DataTablesBundle\DataTableFactory;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -98,7 +100,8 @@ class AttachmentFileController extends AbstractController
}
#[Route(path: '/attachment/list', name: 'attachment_list')]
public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder): Response
public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder,
TableSettings $tableSettings): Response
{
$this->denyAccessUnlessGranted('@attachments.list_attachments');
@ -110,7 +113,7 @@ class AttachmentFileController extends AbstractController
$filterForm->handleRequest($formRequest);
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter])
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request);
if ($table->isCallback()) {

View file

@ -29,10 +29,15 @@ use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Settings\AppSettings;
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -46,7 +51,9 @@ class InfoProviderController extends AbstractController
public function __construct(private readonly ProviderRegistry $providerRegistry,
private readonly PartInfoRetriever $infoRetriever,
private readonly ExistingPartFinder $existingPartFinder
private readonly ExistingPartFinder $existingPartFinder,
private readonly SettingsManagerInterface $settingsManager,
private readonly SettingsFormFactoryInterface $settingsFormFactory
)
{
@ -63,9 +70,51 @@ class InfoProviderController extends AbstractController
]);
}
#[Route('/provider/{provider}/settings', name: 'info_providers_provider_settings')]
public function providerSettings(string $provider, Request $request): Response
{
$this->denyAccessUnlessGranted('@config.change_system_settings');
$this->denyAccessUnlessGranted('@info_providers.create_parts');
$providerInstance = $this->providerRegistry->getProviderByKey($provider);
$settingsClass = $providerInstance->getProviderInfo()['settings_class'] ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined');
//Create a clone of the settings object
$settings = $this->settingsManager->createTemporaryCopy($settingsClass);
//Create a form builder for the settings object
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings);
//Add a submit button to the form
$builder->add('submit', SubmitType::class, ['label' => 'save']);
//Create the form
$form = $builder->getForm();
$form->handleRequest($request);
//If the form was submitted and is valid, save the settings
if ($form->isSubmitted() && $form->isValid()) {
$this->settingsManager->mergeTemporaryCopy($settings);
$this->settingsManager->save($settings);
$this->addFlash('success', t('settings.flash.saved'));
}
if ($form->isSubmitted() && !$form->isValid()) {
$this->addFlash('error', t('settings.flash.invalid'));
}
//Render the form
return $this->render('info_providers/settings/provider_settings.html.twig', [
'form' => $form,
'info_provider_key' => $provider,
'info_provider_info' => $providerInstance->getProviderInfo(),
]);
}
#[Route('/search', name: 'info_providers_search')]
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@ -96,6 +145,23 @@ class InfoProviderController extends AbstractController
}
}
//If the providers form is still empty, use our default value from the settings
if (count($form->get('providers')->getData() ?? []) === 0) {
$default_providers = $infoProviderSettings->defaultSearchProviders;
$provider_objects = [];
foreach ($default_providers as $provider_key) {
try {
$tmp = $this->providerRegistry->getProviderByKey($provider_key);
if ($tmp->isActive()) {
$provider_objects[] = $tmp;
}
} catch (\InvalidArgumentException $e) {
//If the provider is not found, just ignore it
}
}
$form->get('providers')->setData($provider_objects);
}
if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();
@ -128,4 +194,4 @@ class InfoProviderController extends AbstractController
'update_target' => $update_target
]);
}
}
}

View file

@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/label')]
class LabelController extends AbstractController
{
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
private readonly ValidatorInterface $validator
)
{
}
@ -85,6 +88,7 @@ class LabelController extends AbstractController
$form = $this->createForm(LabelDialogType::class, null, [
'disable_options' => $disable_options,
'profile' => $profile
]);
//Try to parse given target_type and target_id
@ -120,13 +124,50 @@ class LabelController extends AbstractController
goto render;
}
$profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options);
$this->em->persist($profile);
$new_profile = new LabelProfile();
$new_profile->setName($form->get('save_profile_name')->getData());
$new_profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($new_profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
}
goto render;
}
$this->em->persist($new_profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $new_profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
//Check if the current profile should be updated
if ($form->has('update_profile')
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
&& $profile instanceof LabelProfile
&& $this->isGranted('edit', $profile)) {
//Update the profile options
$profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$this->addFlash('error', $error->getMessage());
}
goto render;
}
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_updated');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()

View file

@ -38,6 +38,7 @@ use App\Services\LogSystem\LogEntryExtraFormatter;
use App\Services\LogSystem\LogLevelHelper;
use App\Services\LogSystem\LogTargetHelper;
use App\Services\LogSystem\TimeTravel;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Omines\DataTablesBundle\DataTableFactory;
@ -58,7 +59,7 @@ class LogController extends AbstractController
}
#[Route(path: '/', name: 'log_view')]
public function showLogs(Request $request, DataTableFactory $dataTable): Response
public function showLogs(Request $request, DataTableFactory $dataTable, TableSettings $tableSettings): Response
{
$this->denyAccessUnlessGranted('@system.show_logs');
@ -72,7 +73,7 @@ class LogController extends AbstractController
$table = $dataTable->createFromType(LogDataTable::class, [
'filter' => $filter,
])
], ['pageLength' => $tableSettings->fullDefaultPageSize])
->handleRequest($request);
if ($table->isCallback()) {

View file

@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use App\Settings\BehaviorSettings\PartInfoSettings;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@ -69,7 +70,7 @@ class PartController extends AbstractController
protected PartPreviewGenerator $partPreviewGenerator,
private readonly TranslatorInterface $translator,
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
protected EventCommentHelper $commentHelper)
protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
{
}
@ -119,8 +120,8 @@ class PartController extends AbstractController
'pricedetail_helper' => $this->pricedetailHelper,
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
'timeTravel' => $timeTravel_timestamp,
'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper,
]
);

View file

@ -29,12 +29,14 @@ use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Exceptions\InvalidRegexException;
use App\Form\Filters\PartFilterType;
use App\Services\Parts\PartsTableActionHandler;
use App\Services\Trees\NodesListBuilder;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
@ -43,11 +45,19 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
class PartListsController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator)
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly NodesListBuilder $nodesListBuilder,
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
private readonly TableSettings $tableSettings
)
{
}
@ -71,13 +81,32 @@ class PartListsController extends AbstractController
if (null === $action || null === $ids) {
$this->addFlash('error', 'part.table.actions.no_params_given');
} else {
$errors = [];
$parts = $actionHandler->idStringToArray($ids);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors);
//Save changes
$this->entityManager->flush();
$this->addFlash('success', 'part.table.actions.success');
if (count($errors) === 0) {
$this->addFlash('success', 'part.table.actions.success');
} else {
$this->addFlash('error', t('part.table.actions.error', ['%count%' => count($errors)]));
//Create a flash message for each error
foreach ($errors as $error) {
/** @var Part $part */
$part = $error['part'];
$this->addFlash('error',
t('part.table.actions.error_detail', [
'%part_name%' => $part->getName(),
'%part_id%' => $part->getID(),
'%message%' => $error['message']
])
);
}
}
}
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.
@ -132,11 +161,9 @@ class PartListsController extends AbstractController
$filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType(
PartsDataTable::class,
array_merge(['filter' => $filter], $additional_table_vars),
['lengthMenu' => PartsDataTable::LENGTH_MENU]
)
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
['filter' => $filter], $additional_table_vars),
['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request);
if ($table->isCallback()) {

View file

@ -31,10 +31,12 @@ use App\Form\ProjectSystem\ProjectBuildType;
use App\Helpers\Projects\ProjectBuildRequest;
use App\Services\ImportExportSystem\BOMImporter;
use App\Services\ProjectSystem\ProjectBuildHelper;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@ -55,11 +57,12 @@ class ProjectController extends AbstractController
}
#[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])]
public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper): Response
public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper, TableSettings $tableSettings): Response
{
$this->denyAccessUnlessGranted('read', $project);
$table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project])
$table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project],
['pageLength' => $tableSettings->fullDefaultPageSize])
->handleRequest($request);
if ($table->isCallback()) {
@ -100,9 +103,14 @@ class ProjectController extends AbstractController
$this->addFlash('success', 'project.build.flash.success');
return $this->redirect(
$request->get('_redirect',
$this->generateUrl('project_info', ['id' => $project->getID()]
)));
$request->get(
'_redirect',
$this->generateUrl(
'project_info',
['id' => $project->getID()]
)
)
);
}
$this->addFlash('error', 'project.build.flash.invalid_input');
@ -118,9 +126,13 @@ class ProjectController extends AbstractController
}
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
{
public function importBOM(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator
): Response {
$this->denyAccessUnlessGranted('edit', $project);
$builder = $this->createFormBuilder();
@ -136,6 +148,8 @@ class ProjectController extends AbstractController
'required' => true,
'choices' => [
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
'project.bom_import.type.generic_csv' => 'generic_csv',
]
]);
$builder->add('clear_existing_bom', CheckboxType::class, [
@ -159,25 +173,40 @@ class ProjectController extends AbstractController
$entityManager->flush();
}
$import_type = $form->get('type')->getData();
try {
// For schematic imports, redirect to field mapping step
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
// Store file content and options in session for field mapping step
$file_content = $form->get('file')->getData()->getContent();
$clear_existing = $form->get('clear_existing_bom')->getData();
$request->getSession()->set('bom_import_data', $file_content);
$request->getSession()->set('bom_import_clear', $clear_existing);
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
}
// For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
'type' => $form->get('type')->getData(),
'type' => $import_type,
]);
//Validate the project entries
// Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
//If no validation errors occured, save the changes and redirect to edit page
if (count ($errors) === 0) {
// If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
//When we get here, there were validation errors
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
} catch (\UnexpectedValueException|SyntaxError $e) {
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
@ -189,11 +218,267 @@ class ProjectController extends AbstractController
]);
}
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
public function importBOMMapFields(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator,
LoggerInterface $logger
): Response {
$this->denyAccessUnlessGranted('edit', $project);
// Get stored data from session
$file_content = $request->getSession()->get('bom_import_data');
$clear_existing = $request->getSession()->get('bom_import_clear', false);
if (!$file_content) {
$this->addFlash('error', 'project.bom_import.flash.session_expired');
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
}
// Detect fields and get suggestions
$detected_fields = $BOMImporter->detectFields($file_content);
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
// Create mapping of original field names to sanitized field names for template
$field_name_mapping = [];
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$field_name_mapping[$field] = $sanitized_field;
}
// Create form for field mapping
$builder = $this->createFormBuilder();
// Add delimiter selection
$builder->add('delimiter', ChoiceType::class, [
'label' => 'project.bom_import.delimiter',
'required' => true,
'data' => ',',
'choices' => [
'project.bom_import.delimiter.comma' => ',',
'project.bom_import.delimiter.semicolon' => ';',
'project.bom_import.delimiter.tab' => "\t",
]
]);
// Get dynamic field mapping targets from BOMImporter
$available_targets = $BOMImporter->getAvailableFieldTargets();
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
foreach ($available_targets as $target_key => $target_info) {
$target_fields[$target_info['label']] = $target_key;
}
foreach ($detected_fields as $field) {
// Sanitize field name for form use - replace invalid characters with underscores
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
'label' => $field,
'required' => false,
'choices' => $target_fields,
'data' => $suggested_mapping[$field] ?? '',
]);
}
$builder->add('submit', SubmitType::class, [
'label' => 'project.bom_import.preview',
]);
$form = $builder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Build field mapping array with priority support
$field_mapping = [];
$field_priorities = [];
$delimiter = $form->get('delimiter')->getData();
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$target = $form->get('mapping_' . $sanitized_field)->getData();
if (!empty($target)) {
$field_mapping[$field] = $target;
// Get priority from request (default to 10)
$priority = $request->request->get('priority_' . $sanitized_field, 10);
$field_priorities[$field] = (int) $priority;
}
}
// Validate field mapping
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
if (!$validation['is_valid']) {
foreach ($validation['errors'] as $error) {
$this->addFlash('error', $error);
}
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
// Show warnings but continue
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
try {
// Re-detect fields with chosen delimiter
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
// Clear existing BOM entries if requested
if ($clear_existing) {
$existing_count = $project->getBomEntries()->count();
$logger->info('Clearing existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
$project->getBomEntries()->clear();
$entityManager->flush();
$logger->info('Existing BOM entries cleared');
} else {
$existing_count = $project->getBomEntries()->count();
$logger->info('Keeping existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
}
// Validate data before importing
$validation_result = $BOMImporter->validateBOMData($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log validation results
$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']),
]);
// Show validation warnings to user
foreach ($validation_result['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
// If there are validation errors, show them and stop
if (!empty($validation_result['errors'])) {
foreach ($validation_result['errors'] as $error) {
$this->addFlash('error', $error);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
'validation_result' => $validation_result,
]);
}
// Import with field mapping and priorities (validation already passed)
$entries = $BOMImporter->stringToBOMEntries($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log entry details for debugging
$logger->info('BOM entries created', [
'total_entries' => count($entries),
]);
foreach ($entries as $index => $entry) {
$logger->debug("BOM entry {$index}", [
'name' => $entry->getName(),
'mountnames' => $entry->getMountnames(),
'quantity' => $entry->getQuantity(),
'comment' => $entry->getComment(),
'part_id' => $entry->getPart()?->getID(),
]);
}
// Assign entries to project
$logger->info('Adding BOM entries to project', [
'entries_count' => count($entries),
'project_id' => $project->getID(),
]);
foreach ($entries as $index => $entry) {
$logger->debug("Adding BOM entry {$index} to project", [
'name' => $entry->getName(),
'part_id' => $entry->getPart()?->getID(),
'quantity' => $entry->getQuantity(),
]);
$project->addBomEntry($entry);
}
// Validate the project entries (includes collection constraints)
$errors = $validator->validateProperty($project, 'bom_entries');
// If no validation errors occurred, save and redirect
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
// Clear session data
$request->getSession()->remove('bom_import_data');
$request->getSession()->remove('bom_import_clear');
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
//Print validation errors to log for debugging
foreach ($errors as $error) {
$logger->error('BOM entry validation error', [
'message' => $error->getMessage(),
'invalid_value' => $error->getInvalidValue(),
]);
//And show as flash message
$this->addFlash('error', $error->getMessage(),);
}
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form,
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
{
if($project instanceof Project) {
if ($project instanceof Project) {
$this->denyAccessUnlessGranted('edit', $project);
} else {
$this->denyAccessUnlessGranted('@projects.edit');
@ -240,7 +525,7 @@ class ProjectController extends AbstractController
$data = $form->getData();
$bom_entries = $data['bom_entries'];
foreach ($bom_entries as $bom_entry){
foreach ($bom_entries as $bom_entry) {
$target_project->addBOMEntry($bom_entry);
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Settings\SystemSettings\LocalizationSettings;
use function function_exists;
use function in_array;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -35,7 +36,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class RedirectController extends AbstractController
{
public function __construct(protected string $default_locale, protected TranslatorInterface $translator, protected bool $enforce_index_php)
public function __construct(private readonly LocalizationSettings $localizationSettings, protected TranslatorInterface $translator, protected bool $enforce_index_php)
{
}
@ -46,7 +47,7 @@ class RedirectController extends AbstractController
public function addLocalePart(Request $request): RedirectResponse
{
//By default, we use the global default locale
$locale = $this->default_locale;
$locale = $this->localizationSettings->locale;
//Check if a user has set a preferred language setting:
$user = $this->getUser();

View file

@ -29,6 +29,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\StorageLocation;
use App\Entity\ProjectSystem\Project;
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
use App\Services\Trees\NodesListBuilder;
@ -78,6 +79,12 @@ class SelectAPIController extends AbstractController
return $this->getResponseForClass(Project::class, false);
}
#[Route(path: '/storage_location', name: 'select_storage_location')]
public function locations(): Response
{
return $this->getResponseForClass(StorageLocation::class, true);
}
#[Route(path: '/export_level', name: 'select_export_level')]
public function exportLevel(): Response
{

View file

@ -0,0 +1,81 @@
<?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\Controller;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use App\Settings\AppSettings;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use function Symfony\Component\Translation\t;
class SettingsController extends AbstractController
{
public function __construct(private readonly SettingsManagerInterface $settingsManager, private readonly SettingsFormFactoryInterface $settingsFormFactory)
{}
#[Route("/settings", name: "system_settings")]
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
{
$this->denyAccessUnlessGranted('@config.change_system_settings');
//Create a clone of the settings object
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
//Create a form builder for the settings object
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings);
//Add a submit button to the form
$builder->add('submit', SubmitType::class, ['label' => 'save']);
//Create the form
$form = $builder->getForm();
$form->handleRequest($request);
//If the form was submitted and is valid, save the settings
if ($form->isSubmitted() && $form->isValid()) {
$this->settingsManager->mergeTemporaryCopy($settings);
$this->settingsManager->save($settings);
//It might be possible, that the tree settings have changed, so clear the cache
$cache->invalidateTags(['tree_treeview', 'sidebar_tree_update']);
$this->addFlash('success', t('settings.flash.saved'));
}
if ($form->isSubmitted() && !$form->isValid()) {
$this->addFlash('error', t('settings.flash.invalid'));
}
//Render the form
return $this->render('settings/settings.html.twig', [
'form' => $form
]);
}
}

View file

@ -29,6 +29,7 @@ use App\Services\Doctrine\DBInfoHelper;
use App\Services\Doctrine\NatsortDebugHelper;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\UpdateAvailableManager;
use App\Settings\AppSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -47,7 +48,8 @@ class ToolsController extends AbstractController
#[Route(path: '/server_infos', name: 'tools_server_infos')]
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager): Response
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
AppSettings $settings): Response
{
$this->denyAccessUnlessGranted('@system.server_infos');
@ -55,23 +57,23 @@ class ToolsController extends AbstractController
//Part-DB section
'git_branch' => $versionInfo->getGitBranchName(),
'git_commit' => $versionInfo->getGitCommitHash(),
'default_locale' => $this->getParameter('partdb.locale'),
'default_timezone' => $this->getParameter('partdb.timezone'),
'default_currency' => $this->getParameter('partdb.default_currency'),
'default_theme' => $this->getParameter('partdb.global_theme'),
'default_locale' => $settings->system->localization->locale,
'default_timezone' => $settings->system->localization->timezone,
'default_currency' => $settings->system->localization->baseCurrency,
'default_theme' => $settings->system->customization->theme,
'enabled_locales' => $this->getParameter('partdb.locale_menu'),
'demo_mode' => $this->getParameter('partdb.demo_mode'),
'use_gravatar' => $settings->system->privacy->useGravatar,
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'),
'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'),
'environment' => $this->getParameter('kernel.environment'),
'is_debug' => $this->getParameter('kernel.debug'),
'email_sender' => $this->getParameter('partdb.mail.sender_email'),
'email_sender_name' => $this->getParameter('partdb.mail.sender_name'),
'allow_attachments_downloads' => $this->getParameter('partdb.attachments.allow_downloads'),
'allow_attachments_downloads' => $settings->system->attachments->allowDownloads,
'detailed_error_pages' => $this->getParameter('partdb.error_pages.show_help'),
'error_page_admin_email' => $this->getParameter('partdb.error_pages.admin_email'),
'configured_max_file_size' => $this->getParameter('partdb.attachments.max_file_size'),
'configured_max_file_size' => $settings->system->attachments->maxFileSize,
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumAllowedUploadSize(),
'saml_enabled' => $this->getParameter('partdb.saml.enabled'),

View file

@ -0,0 +1,64 @@
<?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\DataFixtures;
use App\Entity\PriceInformations\Currency;
use Brick\Math\BigDecimal;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class CurrencyFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$currency1 = new Currency();
$currency1->setName('US-Dollar');
$currency1->setIsoCode('USD');
$manager->persist($currency1);
$currency2 = new Currency();
$currency2->setName('Swiss Franc');
$currency2->setIsoCode('CHF');
$currency2->setExchangeRate(BigDecimal::of('0.91'));
$manager->persist($currency2);
$currency3 = new Currency();
$currency3->setName('Great British Pound');
$currency3->setIsoCode('GBP');
$currency3->setExchangeRate(BigDecimal::of('0.78'));
$manager->persist($currency3);
$currency7 = new Currency();
$currency7->setName('Test Currency with long name');
$currency7->setIsoCode('CNY');
$manager->persist($currency7);
$manager->flush();
//Ensure that currency 7 gets ID 7
$manager->getRepository(Currency::class)->changeID($currency7, 7);
$manager->flush();
}
}

View file

@ -72,7 +72,8 @@ class ColumnSortHelper
* Apply the visibility configuration to the given DataTable and configure the columns.
* @param DataTable $dataTable
* @param string|array $visible_columns Either a list or a comma separated string of column names, which should
* be visible by default. If a column is not listed here, it will be hidden by default.
* be visible by default. If a column is not listed here, it will be hidden by default. If an array of enum values are passed,
* their value will be used as the column name.
* @return void
*/
public function applyVisibilityAndConfigureColumns(DataTable $dataTable, string|array $visible_columns,
@ -83,6 +84,14 @@ class ColumnSortHelper
$visible_columns = array_map(trim(...), explode(",", $visible_columns));
}
//If $visible_columns is a list of enum values, convert them to the column names
foreach ($visible_columns as &$value) {
if ($value instanceof \BackedEnum) {
$value = $value->value;
}
}
unset ($value);
$processed_columns = [];
//First add all columns which visibility is not configurable

View file

@ -45,6 +45,7 @@ use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
@ -65,8 +66,8 @@ final class PartsDataTable implements DataTableTypeInterface
private readonly AmountFormatter $amountFormatter,
private readonly PartDataTableHelper $partDataTableHelper,
private readonly Security $security,
private readonly string $visible_columns,
private readonly ColumnSortHelper $csh,
private readonly TableSettings $tableSettings,
) {
}
@ -246,7 +247,7 @@ final class PartsDataTable implements DataTableTypeInterface
]);
//Apply the user configured order and visibility and add the columns to the table
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns,
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->partsDefaultColumns,
"TABLE_PARTS_DEFAULT_COLUMNS");
$dataTable->addOrderBy('name')

View file

@ -0,0 +1,55 @@
<?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\Doctrine\Migration;
use App\Services\UserSystem\PermissionPresetsHelper;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Version\MigrationFactory;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Psr\Container\ContainerInterface;
#[AsDecorator("doctrine.migrations.migrations_factory")]
class ContainerAwareMigrationFactory implements MigrationFactory
{
public function __construct(private readonly MigrationFactory $decorated,
//List all services that should be available in migrations here
#[AutowireLocator([
PermissionPresetsHelper::class
])]
private readonly ContainerInterface $container)
{
}
public function createVersion(string $migrationClassName): AbstractMigration
{
$migration = $this->decorated->createVersion($migrationClassName);
if ($migration instanceof ContainerAwareMigrationInterface) {
$migration->setContainer($this->container);
}
return $migration;
}
}

View file

@ -0,0 +1,31 @@
<?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\Doctrine\Migration;
use Psr\Container\ContainerInterface;
interface ContainerAwareMigrationInterface
{
public function setContainer(?ContainerInterface $container = null): void;
}

View file

@ -1,116 +0,0 @@
<?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\Doctrine\Types;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Exception\SerializationFailed;
use Doctrine\DBAL\Types\Type;
use Doctrine\Deprecations\Deprecation;
use function is_resource;
use function restore_error_handler;
use function serialize;
use function set_error_handler;
use function stream_get_contents;
use function unserialize;
use const E_DEPRECATED;
use const E_USER_DEPRECATED;
/**
* This class is taken from doctrine ORM 3.8. https://github.com/doctrine/dbal/blob/3.8.x/src/Types/ArrayType.php
*
* It was removed in doctrine ORM 4.0. However, we require it for backward compatibility with WebauthnKey.
* Therefore, we manually added it here as a custom type as a forward compatibility layer.
*/
class ArrayType extends Type
{
/**
* {@inheritDoc}
*/
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getClobTypeDeclarationSQL($column);
}
/**
* {@inheritDoc}
*/
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string
{
return serialize($value);
}
/**
* {@inheritDoc}
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
{
if ($value === null) {
return null;
}
$value = is_resource($value) ? stream_get_contents($value) : $value;
set_error_handler(function (int $code, string $message): bool {
if ($code === E_DEPRECATED || $code === E_USER_DEPRECATED) {
return false;
}
//Change to original code. Use SerializationFailed instead of ConversionException.
throw new SerializationFailed("Serialization failed (Code $code): " . $message);
});
try {
//Change to original code. Use false for allowed_classes, to avoid unsafe unserialization of objects.
return unserialize($value, ['allowed_classes' => false]);
} finally {
restore_error_handler();
}
}
/**
* {@inheritDoc}
*/
public function getName(): string
{
return "array";
}
/**
* {@inheritDoc}
*
* @deprecated
*/
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
{
Deprecation::triggerIfCalledFromOutside(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/5509',
'%s is deprecated.',
__METHOD__,
);
return true;
}
}

View file

@ -39,6 +39,7 @@ use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\Parts\Footprint;
@ -67,7 +68,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
* Every database table which are managed with this class (or a subclass of it)
* must have the table row "id"!! The ID is the unique key to identify the elements.
*/
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => 'App\Entity\PriceInformation\Pricedetail', 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => Pricedetail::class, 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
#[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)]
abstract class AbstractDBElement implements JsonSerializable
{

View file

@ -318,6 +318,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
return new ArrayCollection();
}
//@phpstan-ignore-next-line
return $this->children ?? new ArrayCollection();
}

View file

@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/
#[Groups(['parameter:read', 'full'])]
#[SerializedName('formatted')]
public function getFormattedValue(): string
public function getFormattedValue(bool $latex_formatted = false): string
{
//If we just only have text value, return early
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) {
@ -217,20 +217,20 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
$str = '';
$bracket_opened = false;
if ($this->value_typical) {
$str .= $this->getValueTypicalWithUnit();
if ($this->value_typical !== null) {
$str .= $this->getValueTypicalWithUnit($latex_formatted);
if ($this->value_min || $this->value_max) {
$bracket_opened = true;
$str .= ' (';
}
}
if ($this->value_max && $this->value_min) {
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
} elseif ($this->value_max) {
$str .= 'max. '.$this->getValueMaxWithUnit();
} elseif ($this->value_min) {
$str .= 'min. '.$this->getValueMinWithUnit();
if ($this->value_max !== null && $this->value_min !== null) {
$str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_max !== null) {
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_min !== null) {
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
}
//Add closing bracket
@ -344,25 +344,25 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/**
* Return a formatted version with the minimum value with the unit of this parameter.
*/
public function getValueTypicalWithUnit(): string
public function getValueTypicalWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_typical);
return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
}
/**
* Return a formatted version with the maximum value with the unit of this parameter.
*/
public function getValueMaxWithUnit(): string
public function getValueMaxWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_max);
return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
}
/**
* Return a formatted version with the typical value with the unit of this parameter.
*/
public function getValueMinWithUnit(): string
public function getValueMinWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_min);
return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
}
/**
@ -441,16 +441,26 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/**
* Return a string representation and (if possible) with its unit.
*/
protected function formatWithUnit(float $value, string $format = '%g'): string
protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string
{
$str = sprintf($format, $value);
if ($this->unit !== '') {
return $str.' '.$this->unit;
if (!$with_latex) {
$unit = $this->unit;
} else {
//Escape the percentage sign for convenience (as latex uses it as comment and it is often used in units)
$escaped = preg_replace('/\\\\?%/', "\\\\%", $this->unit);
$unit = '$\mathrm{'.$escaped.'}$';
}
return $str.' '.$unit;
}
return $str;
}
/**
* Returns the class of the element that is allowed to be associated with this attachment.
* @return string

View file

@ -0,0 +1,35 @@
<?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\Entity;
use Doctrine\DBAL\Types\Types;
use Jbtronics\SettingsBundle\Entity\AbstractSettingsORMEntry;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class SettingsEntry extends AbstractSettingsORMEntry
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: Types::INTEGER)]
protected int $id;
}

View file

@ -197,7 +197,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* @var string|null The language/locale the user prefers
*/
#[Assert\Language]
#[Assert\Locale]
#[Groups(['full', 'import', 'user:read'])]
#[ORM\Column(name: 'config_language', type: Types::STRING, nullable: true)]
protected ?string $language = '';

View file

@ -100,16 +100,19 @@ class WebauthnKey extends BasePublicKeyCredentialSource implements TimeStampable
public static function fromRegistration(BasePublicKeyCredentialSource $registration): self
{
return new self(
$registration->getPublicKeyCredentialId(),
$registration->getType(),
$registration->getTransports(),
$registration->getAttestationType(),
$registration->getTrustPath(),
$registration->getAaguid(),
$registration->getCredentialPublicKey(),
$registration->getUserHandle(),
$registration->getCounter(),
$registration->getOtherUI()
publicKeyCredentialId: $registration->publicKeyCredentialId,
type: $registration->type,
transports: $registration->transports,
attestationType: $registration->attestationType,
trustPath: $registration->trustPath,
aaguid: $registration->aaguid,
credentialPublicKey: $registration->credentialPublicKey,
userHandle: $registration->userHandle,
counter: $registration->counter,
otherUI: $registration->otherUI,
backupEligible: $registration->backupEligible,
backupStatus: $registration->backupStatus,
uvInitialized: $registration->uvInitialized,
);
}
}

View file

@ -39,6 +39,8 @@ use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\EventLogger;
use App\Services\LogSystem\EventUndoHelper;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use App\Settings\SystemSettings\HistorySettings;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
@ -74,14 +76,15 @@ class EventLoggerListener
];
protected const MAX_STRING_LENGTH = 2000;
protected bool $save_new_data;
public function __construct(protected EventLogger $logger, protected SerializerInterface $serializer, protected EventCommentHelper $eventCommentHelper,
protected bool $save_changed_fields, protected bool $save_changed_data, protected bool $save_removed_data, bool $save_new_data,
protected PropertyAccessorInterface $propertyAccessor, protected EventUndoHelper $eventUndoHelper)
public function __construct(
protected EventLogger $logger,
protected SerializerInterface $serializer,
protected EventCommentHelper $eventCommentHelper,
private readonly HistorySettings $settings,
protected PropertyAccessorInterface $propertyAccessor,
protected EventUndoHelper $eventUndoHelper)
{
//This option only makes sense if save_changed_data is true
$this->save_new_data = $save_new_data && $save_changed_data;
}
public function onFlush(OnFlushEventArgs $eventArgs): void
@ -200,14 +203,14 @@ class EventLoggerListener
if ($this->eventUndoHelper->isUndo()) {
$log->setUndoneEvent($this->eventUndoHelper->getUndoneEvent(), $this->eventUndoHelper->getMode());
}
if ($this->save_removed_data) {
if ($this->settings->saveRemovedData) {
//The 4th param is important here, as we delete the element...
$this->saveChangeSet($entity, $log, $em, true);
}
$this->logger->logFromOnFlush($log);
//Check if we have to log CollectionElementDeleted entries
if ($this->save_changed_data) {
if ($this->settings->saveOldData) {
$metadata = $em->getClassMetadata($entity::class);
$mappings = $metadata->getAssociationMappings();
//Check if class is whitelisted for CollectionElementDeleted entry
@ -243,9 +246,9 @@ class EventLoggerListener
}
$log = new ElementEditedLogEntry($entity);
if ($this->save_changed_data) {
if ($this->settings->saveOldData) {
$this->saveChangeSet($entity, $log, $em);
} elseif ($this->save_changed_fields) {
} elseif ($this->settings->saveChangedFields) {
$changed_fields = array_keys($uow->getEntityChangeSet($entity));
//Remove lastModified field, as this is always changed (gives us no additional info)
$changed_fields = array_diff($changed_fields, ['lastModified']);
@ -313,7 +316,7 @@ class EventLoggerListener
$changeSet = $uow->getEntityChangeSet($entity);
$old_data = array_combine(array_keys($changeSet), array_column($changeSet, 0));
//If save_new_data is enabled, we extract it from the change set
if ($this->save_new_data) {
if ($this->settings->saveNewData && $this->settings->saveOldData) { //Only useful if we save old data too
$new_data = array_combine(array_keys($changeSet), array_column($changeSet, 1));
}
}

View file

@ -1,69 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
* This subscriber sets a Header in Debug mode that signals the Symfony Profiler to also update on Ajax requests.
*/
final class SymfonyDebugToolbarSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly bool $kernel_debug_enabled)
{
}
/**
* Returns an array of event names this subscriber wants to listen to.
*
* The array keys are event names and the value can be:
*
* * The method name to call (priority defaults to 0)
* * An array composed of the method name to call and the priority
* * An array of arrays composed of the method names to call and respective
* priorities, or 0 if unset
*
* For instance:
*
* * ['eventName' => 'methodName']
* * ['eventName' => ['methodName', $priority]]
* * ['eventName' => [['methodName1', $priority], ['methodName2']]]
*
* @return array The event names to listen to
*/
public static function getSubscribedEvents(): array
{
return ['kernel.response' => 'onKernelResponse'];
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$this->kernel_debug_enabled) {
return;
}
$response = $event->getResponse();
$response->headers->set('Symfony-Debug-Toolbar-Replace', '1');
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
use App\Entity\UserSystem\User;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
@ -33,7 +34,7 @@ use Symfony\Component\HttpKernel\KernelEvents;
*/
final class SetUserTimezoneSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly string $default_timezone, private readonly Security $security)
public function __construct(private readonly LocalizationSettings $localizationSettings, private readonly Security $security)
{
}
@ -48,8 +49,8 @@ final class SetUserTimezoneSubscriber implements EventSubscriberInterface
}
//Fill with default value if needed
if (null === $timezone && $this->default_timezone !== '') {
$timezone = $this->default_timezone;
if (null === $timezone && $this->localizationSettings->timezone !== '') {
$timezone = $this->localizationSettings->timezone;
}
//If timezone was configured anywhere set it, otherwise just use the one from php.ini

View file

@ -25,6 +25,7 @@ namespace App\Form\AdminPages;
use App\Entity\PriceInformations\Currency;
use App\Entity\ProjectSystem\Project;
use App\Entity\UserSystem\Group;
use App\Services\LogSystem\EventCommentType;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
@ -152,7 +153,7 @@ class BaseEntityAdminForm extends AbstractType
$builder->add('log_comment', TextType::class, [
'label' => 'edit.log_comment',
'mapped' => false,
'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? 'datastructure_create': 'datastructure_edit'),
'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? EventCommentType::DATASTRUCTURE_CREATE: EventCommentType::DATASTRUCTURE_EDIT),
'empty_data' => null,
]);

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\Type\BigDecimalMoneyType;
@ -32,7 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface;
class CurrencyAdminForm extends BaseEntityAdminForm
{
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly string $base_currency)
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly LocalizationSettings $localizationSettings)
{
parent::__construct($security, $eventCommentNeededHelper);
}
@ -51,7 +52,7 @@ class CurrencyAdminForm extends BaseEntityAdminForm
$builder->add('exchange_rate', BigDecimalMoneyType::class, [
'required' => false,
'label' => 'currency.edit.exchange_rate',
'currency' => $this->base_currency,
'currency' => $this->localizationSettings->baseCurrency,
'scale' => 6,
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\PriceInformations\Currency;
@ -32,7 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface;
class SupplierForm extends CompanyForm
{
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, protected string $base_currency)
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly LocalizationSettings $localizationSettings)
{
parent::__construct($security, $eventCommentNeededHelper);
}
@ -53,7 +54,7 @@ class SupplierForm extends CompanyForm
$builder->add('shipping_costs', BigDecimalMoneyType::class, [
'required' => false,
'currency' => $this->base_currency,
'currency' => $this->localizationSettings->baseCurrency,
'scale' => 3,
'label' => 'supplier.shipping_costs.label',
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form;
use App\Settings\SystemSettings\AttachmentsSettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
@ -54,9 +55,7 @@ class AttachmentFormType extends AbstractType
protected Security $security,
protected AttachmentSubmitHandler $submitHandler,
protected TranslatorInterface $translator,
protected bool $allow_attachments_download,
protected bool $download_by_default,
protected string $max_file_size
protected AttachmentsSettings $settings,
) {
}
@ -108,7 +107,7 @@ class AttachmentFormType extends AbstractType
'required' => false,
'label' => 'attachment.edit.download_url',
'mapped' => false,
'disabled' => !$this->allow_attachments_download,
'disabled' => !$this->settings->allowDownloads,
]);
$builder->add('file', FileType::class, [
@ -177,7 +176,7 @@ class AttachmentFormType extends AbstractType
//If the attachment should be downloaded by default (and is download allowed at all), register a listener,
// which sets the downloadURL checkbox to true for new attachments
if ($this->download_by_default && $this->allow_attachments_download) {
if ($this->settings->downloadByDefault && $this->settings->allowDownloads) {
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
$form = $event->getForm();
$attachment = $form->getData();
@ -204,7 +203,7 @@ class AttachmentFormType extends AbstractType
{
$resolver->setDefaults([
'data_class' => Attachment::class,
'max_file_size' => $this->max_file_size,
'max_file_size' => $this->settings->maxFileSize,
'allow_builtins' => true,
]);
}

View file

@ -39,7 +39,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form;
namespace App\Form\Extension;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

View file

@ -1,4 +1,22 @@
<?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);
@ -20,7 +38,7 @@ declare(strict_types=1);
* 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\Form;
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;

View file

@ -0,0 +1,60 @@
<?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\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SelectTypeOrderExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [
ChoiceType::class,
EnumType::class
];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('ordered', false);
$resolver->setDefault('by_reference', function (Options $options) {
//Disable by_reference if the field is ordered (otherwise the order will be lost)
return !$options['ordered'];
});
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
//Pass the data in ordered form to the frontend controller, so it can make the items appear in the correct order.
if ($options['ordered']) {
$view->vars['attr']['data-ordered-value'] = json_encode($form->getViewData(), JSON_THROW_ON_ERROR);
}
}
}

View file

@ -0,0 +1,122 @@
<?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\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
final class TogglePasswordTypeExtension extends AbstractTypeExtension
{
public function __construct(private readonly ?TranslatorInterface $translator)
{
}
public static function getExtendedTypes(): iterable
{
return [PasswordType::class];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'toggle' => false,
'hidden_label' => 'Hide',
'visible_label' => 'Show',
'hidden_icon' => 'Default',
'visible_icon' => 'Default',
'button_classes' => ['toggle-password-button'],
'toggle_container_classes' => ['toggle-password-container'],
'toggle_translation_domain' => null,
'use_toggle_form_theme' => true,
]);
$resolver->setNormalizer(
'toggle_translation_domain',
static fn (Options $options, $labelTranslationDomain) => $labelTranslationDomain ?? $options['translation_domain'],
);
$resolver->setAllowedTypes('toggle', ['bool']);
$resolver->setAllowedTypes('hidden_label', ['string', TranslatableMessage::class, 'null']);
$resolver->setAllowedTypes('visible_label', ['string', TranslatableMessage::class, 'null']);
$resolver->setAllowedTypes('hidden_icon', ['string', 'null']);
$resolver->setAllowedTypes('visible_icon', ['string', 'null']);
$resolver->setAllowedTypes('button_classes', ['string[]']);
$resolver->setAllowedTypes('toggle_container_classes', ['string[]']);
$resolver->setAllowedTypes('toggle_translation_domain', ['string', 'bool', 'null']);
$resolver->setAllowedTypes('use_toggle_form_theme', ['bool']);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['toggle'] = $options['toggle'];
if (!$options['toggle']) {
return;
}
if ($options['use_toggle_form_theme']) {
array_splice($view->vars['block_prefixes'], -1, 0, 'toggle_password');
}
$controllerName = 'toggle-password';
$controllerValues = [];
$view->vars['attr']['data-controller'] = trim(\sprintf('%s %s', $view->vars['attr']['data-controller'] ?? '', $controllerName));
if (false !== $options['toggle_translation_domain']) {
$controllerValues['hidden-label'] = $this->translateLabel($options['hidden_label'], $options['toggle_translation_domain']);
$controllerValues['visible-label'] = $this->translateLabel($options['visible_label'], $options['toggle_translation_domain']);
} else {
$controllerValues['hidden-label'] = $options['hidden_label'];
$controllerValues['visible-label'] = $options['visible_label'];
}
$controllerValues['hidden-icon'] = $options['hidden_icon'];
$controllerValues['visible-icon'] = $options['visible_icon'];
$controllerValues['button-classes'] = json_encode($options['button_classes'], \JSON_THROW_ON_ERROR);
foreach ($controllerValues as $name => $value) {
$view->vars['attr'][\sprintf('data-%s-%s-value', $controllerName, $name)] = $value;
}
$view->vars['toggle_container_classes'] = $options['toggle_container_classes'];
}
private function translateLabel(string|TranslatableMessage|null $label, ?string $translationDomain): ?string
{
if (null === $this->translator || null === $label) {
return $label;
}
if ($label instanceof TranslatableMessage) {
return $label->trans($this->translator);
}
return $this->translator->trans($label, domain: $translationDomain);
}
}

View file

@ -0,0 +1,49 @@
<?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\Form\History;
use App\Services\LogSystem\EventCommentType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The type for the "enforceComments" setting in the HistorySettings.
*/
class EnforceEventCommentTypesType extends AbstractType
{
public function getParent(): string
{
return EnumType::class;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'multiple' => true,
'class' => EventCommentType::class,
'empty_data' => [],
]);
}
}

View file

@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProviderSelectType extends AbstractType
@ -44,13 +45,43 @@ class ProviderSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => $this->providerRegistry->getActiveProviders(),
'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
$providers = $this->providerRegistry->getActiveProviders();
'multiple' => true,
]);
$resolver->setDefault('input', 'object');
$resolver->setAllowedTypes('input', 'string');
//Either the form returns the provider objects or their keys
$resolver->setAllowedValues('input', ['object', 'string']);
$resolver->setDefault('multiple', true);
$resolver->setDefault('choices', function (Options $options) use ($providers) {
if ('object' === $options['input']) {
return $this->providerRegistry->getActiveProviders();
}
$tmp = [];
foreach ($providers as $provider) {
$name = $provider->getProviderInfo()['name'];
$tmp[$name] = $provider->getProviderKey();
}
return $tmp;
});
//The choice_label and choice_value only needs to be set if we want the objects
$resolver->setDefault('choice_label', function (Options $options){
if ('object' === $options['input']) {
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
}
return null;
});
$resolver->setDefault('choice_value', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
}
return null;
});
}
}
}

View file

@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
]
]);
if ($options['profile'] !== null) {
$builder->add('update_profile', SubmitType::class, [
'label' => 'label_generator.update_profile',
'disabled' => !$this->security->isGranted('edit', $options['profile']),
'attr' => [
'class' => 'btn btn-outline-success'
]
]);
}
$builder->add('update', SubmitType::class, [
'label' => 'label_generator.update',
]);
@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
parent::configureOptions($resolver);
$resolver->setDefault('mapped', false);
$resolver->setDefault('disable_options', false);
$resolver->setDefault('profile', null);
}
}

View file

@ -40,6 +40,7 @@ use App\Form\Type\SIUnitType;
use App\Form\Type\StructuralEntityType;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Services\LogSystem\EventCommentType;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -265,7 +266,7 @@ class PartBaseType extends AbstractType
$builder->add('log_comment', TextType::class, [
'label' => 'edit.log_comment',
'mapped' => false,
'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? 'part_create' : 'part_edit'),
'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? EventCommentType::PART_CREATE : EventCommentType::PART_EDIT),
'empty_data' => null,
]);

View file

@ -0,0 +1,81 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
class APIKeyType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function getParent(): string
{
return PasswordType::class;
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$viewData = $form->getViewData();
//If the field is disabled, show the redacted API key
if ($options['disabled'] ?? false) {
if ($viewData === null || $viewData === '') {
$view->vars['value'] = $viewData;
} else {
$view->vars['value'] = self::redact((string)$viewData) . ' (' . $this ->translator->trans("form.apikey.redacted") . ')';
}
} else { //Otherwise, show the actual value
$view->vars['value'] = $viewData;
}
}
public static function redact(string $apiKey): string
{
//Show only the last 2 characters of the API key if it is long enough (more than 16 characters)
//Replace all other characters with dots
if (strlen($apiKey) > 16) {
return str_repeat('*', strlen($apiKey) - 2) . substr($apiKey, -2);
}
return str_repeat('*', strlen($apiKey));
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'always_empty' => false,
'toggle' => true,
'empty_data' => null,
'attr' => ['autocomplete' => 'off'],
]);
}
}

View file

@ -25,6 +25,7 @@ namespace App\Form\Type;
use App\Entity\PriceInformations\Currency;
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
use App\Services\Trees\NodesListBuilder;
use App\Settings\SystemSettings\LocalizationSettings;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\OptionsResolver\Options;
@ -36,7 +37,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class CurrencyEntityType extends StructuralEntityType
{
public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, protected ?string $base_currency)
public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, private readonly LocalizationSettings $localizationSettings)
{
parent::__construct($em, $builder, $translator, $choiceHelper);
}
@ -57,7 +58,7 @@ class CurrencyEntityType extends StructuralEntityType
$resolver->setDefault('empty_message', function (Options $options) {
//By default, we use the global base currency:
$iso_code = $this->base_currency;
$iso_code = $this->localizationSettings->baseCurrency;
if ($options['base_currency']) { //Allow to override it
$iso_code = $options['base_currency'];

View file

@ -0,0 +1,53 @@
<?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\Form\Type;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A locale select field that uses the preferred languages from the configuration.
*/
class LocaleSelectType extends AbstractType
{
public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
{
}
public function getParent(): string
{
return LocaleType::class;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'preferred_choices' => $this->preferred_languages,
]);
}
}

View file

@ -100,7 +100,7 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
* @return mixed The value in the transformed representation
*
*/
public function transform(mixed $value)
public function transform(mixed $value): mixed
{
if (true === $value) {
return 'true';
@ -142,7 +142,7 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
*
* @return mixed The value in the original representation
*/
public function reverseTransform(mixed $value)
public function reverseTransform(mixed $value): mixed
{
return match ($value) {
'true' => true,

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form;
use App\Form\Type\LocaleSelectType;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\UserSystem\Group;
@ -35,7 +36,6 @@ use App\Form\Type\ThemeChoiceType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
@ -140,11 +140,10 @@ class UserAdminForm extends AbstractType
])
//Config section
->add('language', LanguageType::class, [
->add('language', LocaleSelectType::class, [
'required' => false,
'placeholder' => 'user_settings.language.placeholder',
'label' => 'user.language_select',
'preferred_choices' => ['en', 'de'],
'disabled' => !$this->security->isGranted('change_user_settings', $entity),
])
->add('timezone', TimezoneType::class, [

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form;
use App\Form\Type\LocaleSelectType;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\UserSystem\User;
use App\Form\Type\CurrencyEntityType;
@ -33,7 +34,6 @@ use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@ -47,7 +47,7 @@ class UserSettingsType extends AbstractType
{
public function __construct(protected Security $security,
protected bool $demo_mode,
#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
)
{
}
@ -107,12 +107,11 @@ class UserSettingsType extends AbstractType
'mode' => 'markdown-full',
'disabled' => !$this->security->isGranted('edit_infos', $options['data']) || $this->demo_mode,
])
->add('language', LanguageType::class, [
->add('language', LocaleSelectType::class, [
'disabled' => $this->demo_mode,
'required' => false,
'placeholder' => 'user_settings.language.placeholder',
'label' => 'user.language_select',
'preferred_choices' => $this->preferred_languages,
])
->add('timezone', TimezoneType::class, [
'disabled' => $this->demo_mode,

View file

@ -26,7 +26,7 @@ namespace App\Migration;
use App\Entity\UserSystem\PermissionData;
use App\Security\Interfaces\HasPermissionsInterface;
use App\Services\UserSystem\PermissionPresetsHelper;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Psr\Container\ContainerInterface;
trait WithPermPresetsTrait
{

View file

@ -66,7 +66,7 @@ class AttachmentRepository extends DBElementRepository
}
/**
* Gets the count of all external attachments (attachments containing an external path).
* Gets the count of all external attachments (attachments containing only an external path).
*
* @throws NoResultException
* @throws NonUniqueResultException
@ -75,8 +75,9 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
->andWhere('attaachment.internal_path IS NULL')
->where('attachment.external_path IS NOT NULL');
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();

View file

@ -33,6 +33,7 @@ use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Webauthn\PublicKeyCredential;
/**
* This class decorates the Webauthn TwoFactorProvider and adds additional logic which allows us to set a last used date
@ -88,10 +89,12 @@ class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface
private function getWebauthnKeyFromCode(string $authenticationCode): ?WebauthnKey
{
$publicKeyCredentialLoader = $this->webauthnProvider->getPublicKeyCredentialLoader();
$serializer = $this->webauthnProvider->getWebauthnSerializer();
//Try to load the public key credential from the code
$publicKeyCredential = $publicKeyCredentialLoader->load($authenticationCode);
$publicKeyCredential = $serializer->deserialize($authenticationCode, PublicKeyCredential::class, 'json', [
'json_decode_options' => JSON_THROW_ON_ERROR
]);
//Find the credential source for the given credential id
$publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId);
@ -103,4 +106,4 @@ class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface
return $publicKeyCredentialSource;
}
}
}

View file

@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
use RuntimeException;
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 function in_array;
@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//This voter only works for attachments
@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
}
if ($attribute === 'show_private') {
return $this->helper->isGranted($token, 'attachments', 'show_private');
$vote?->addReason('User is not allowed to view private attachments.');
return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
}
@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
}
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
$vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
}
return false;

View file

@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\Group;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -43,9 +44,9 @@ final class GroupVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'groups', $attribute);
return $this->helper->isGranted($token, 'groups', $attribute, $vote);
}
/**

View file

@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\VoterHelper;
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\Component\Security\Core\User\UserInterface;
@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
&& $subject instanceof UserInterface;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'users', 'impersonate');
$result = $this->helper->isGranted($token, 'users', 'impersonate');
if ($result === false) {
$vote?->addReason('User is not allowed to impersonate other users.');
$this->helper->addReason($vote, 'users', 'impersonate');
}
return $result;
}
public function supportsAttribute(string $attribute): bool
@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
{
return is_a($subjectType, User::class, true);
}
}
}

View file

@ -44,6 +44,7 @@ namespace App\Security\Voter;
use App\Entity\LabelSystem\LabelProfile;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
'delete' => 'delete_profiles',
'show_history' => 'show_history',
'revert_element' => 'revert_element',
'import' => 'import',
];
public function __construct(private readonly VoterHelper $helper)
{}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
}
protected function supports($attribute, $subject): bool

View file

@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LogSystem\AbstractLogEntry;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
}
if ('delete' === $attribute) {
return $this->helper->isGranted($token, 'system', 'delete_logs');
return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
}
if ('read' === $attribute) {
//Allow read of the users own log entries
if (
$subject->getUser() === $user
&& $this->helper->isGranted($token, 'self', 'show_logs')
&& $this->helper->isGranted($token, 'self', 'show_logs', $vote)
) {
return true;
}
return $this->helper->isGranted($token, 'system', 'show_logs');
return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
}
if ('show_details' === $attribute) {

View file

@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (! is_a($subject, Orderdetail::class, true)) {
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
}
return $this->helper->isGranted($token, $param, $attribute);
return $this->helper->isGranted($token, $param, $attribute, $vote);
}
protected function supports(string $attribute, $subject): bool

View file

@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_string($subject) && !$subject instanceof PartAssociation) {
throw new \RuntimeException('Invalid subject type!');
@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -59,13 +60,13 @@ final class PartLotVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
{
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute);
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
$lot_permission = true;
//If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
@ -73,6 +74,10 @@ final class PartLotVoter extends Voter
$lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
}
if (!$lot_permission) {
$vote->addReason('User is not the owner of the lot.');
}
return $base_permission && $lot_permission;
}
@ -86,7 +91,7 @@ final class PartLotVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\Parts\Part;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -52,10 +53,9 @@ final class PartVoter extends Voter
return false;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//Null concealing operator means, that no
return $this->helper->isGranted($token, 'parts', $attribute);
return $this->helper->isGranted($token, 'parts', $attribute, $vote);
}
public function supportsAttribute(string $attribute): bool

View file

@ -24,6 +24,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -39,12 +40,17 @@ final class PermissionVoter extends Voter
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute);
return $this->helper->isGranted($token, $perm, $op);
$result = $this->helper->isGranted($token, $perm, $op);
if ($result === false) {
$this->helper->addReason($vote, $perm, $op);
}
return $result;
}
public function supportsAttribute(string $attribute): bool

View file

@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Pricedetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$operation = match ($attribute) {
'read' => 'read',
@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Services\UserSystem\VoterHelper;
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 function is_object;
@ -113,10 +114,10 @@ final class StructureVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$permission_name = $this->instanceToPermissionName($subject);
//Just resolve the permission
return $this->helper->isGranted($token, $permission_name, $attribute);
return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
}
}

View file

@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
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 function in_array;
@ -79,7 +80,7 @@ final class UserVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@ -97,7 +98,7 @@ final class UserVoter extends Voter
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
$this->helper->isValidOperation('self', $attribute)) {
//Then we also need to check the self permission
$tmp = $this->helper->isGranted($token, 'self', $attribute);
$tmp = $this->helper->isGranted($token, 'self', $attribute, $vote);
//But if the self value is not allowed then use just the user value:
if ($tmp) {
return $tmp;
@ -106,7 +107,7 @@ final class UserVoter extends Voter
//Else just check user permission:
if ($this->helper->isValidOperation('users', $attribute)) {
return $this->helper->isGranted($token, 'users', $attribute);
return $this->helper->isGranted($token, 'users', $attribute, $vote);
}
return false;

View file

@ -24,7 +24,7 @@ declare(strict_types=1);
namespace App\Serializer\APIPlatform;
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@ -121,4 +121,4 @@ class DetermineTypeFromElementIRIDenormalizer implements DenormalizerInterface,
return $tmp;
}
}
}

View file

@ -92,7 +92,7 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
return $data;
}
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
{
//Only denormalize if we are doing a file import operation
if (!($context['partdb_import'] ?? false)) {

View file

@ -122,7 +122,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
return $deserialized_entity;
}
public function getSupportedTypes(): array
public function getSupportedTypes(?string $format): array
{
//Must be false, because we use in_array in supportsDenormalization
return [

View file

@ -23,20 +23,21 @@ declare(strict_types=1);
namespace App\Serializer;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Serializer\APIPlatform\SkippableItemNormalizer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
/**
* @see \App\Tests\Serializer\StructuralElementNormalizerTest
*/
class StructuralElementNormalizer implements NormalizerInterface
class StructuralElementNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
public function __construct(
#[Autowire(service: ObjectNormalizer::class)]private readonly NormalizerInterface $normalizer
)
{
}
use NormalizerAwareTrait;
public const ALREADY_CALLED = 'STRUCTURAL_ELEMENT_NORMALIZER_ALREADY_CALLED';
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
@ -45,15 +46,25 @@ class StructuralElementNormalizer implements NormalizerInterface
return false;
}
if (isset($context[self::ALREADY_CALLED]) && in_array($data, $context[self::ALREADY_CALLED], true)) {
//If we already handled this object, skip it
return false;
}
return $data instanceof AbstractStructuralDBElement;
}
public function normalize($object, ?string $format = null, array $context = []): mixed
public function normalize($object, ?string $format = null, array $context = []): \ArrayObject|bool|float|int|string|array
{
if (!$object instanceof AbstractStructuralDBElement) {
throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');
}
//Avoid infinite recursion by checking if we already handled this object
$context[self::ALREADY_CALLED] = $context[self::ALREADY_CALLED] ?? [];
$context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true;
$context[self::ALREADY_CALLED][] = $object;
$data = $this->normalizer->normalize($object, $format, $context);
//If the data is not an array, we can't do anything with it
@ -77,7 +88,8 @@ class StructuralElementNormalizer implements NormalizerInterface
public function getSupportedTypes(?string $format): array
{
return [
AbstractStructuralDBElement::class => true,
//We cannot cache the result, as it depends on the context
AbstractStructuralDBElement::class => false,
];
}
}

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);

Some files were not shown because too many files have changed in this diff Show more