Merge branch 'master' into settings-bundle

This commit is contained in:
Jan Böhmer 2025-01-17 22:06:18 +01:00
commit 8750573724
191 changed files with 27745 additions and 12133 deletions

View file

@ -50,7 +50,7 @@ final class LikeFilter extends AbstractFilter
}
$parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
$queryBuilder
->andWhere(sprintf('o.%s LIKE :%s', $property, $parameterName))
->andWhere(sprintf('ILIKE(o.%s, :%s) = TRUE', $property, $parameterName))
->setParameter($parameterName, $value);
}

View file

@ -0,0 +1,102 @@
<?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\ApiPlatform\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyInfo\Type;
/**
* Due to their nature, tags are stored in a single string, separated by commas, which requires some more complex search logic.
* This filter allows to easily search for tags in a part entity.
*/
final class TagFilter extends AbstractFilter
{
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
// Ignore filter if property is not enabled or mapped
if (
!$this->isPropertyEnabled($property, $resourceClass) ||
!$this->isPropertyMapped($property, $resourceClass)
) {
return;
}
//Escape any %, _ or \ in the tag
$value = addcslashes($value, '%_\\');
$tag_identifier_prefix = $queryNameGenerator->generateParameterName($property);
$expr = $queryBuilder->expr();
$tmp = $expr->orX(
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_1) = TRUE',
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_2) = TRUE',
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_3) = TRUE',
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_4) = TRUE',
);
$queryBuilder->andWhere($tmp);
//Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag)
$queryBuilder->setParameter($tag_identifier_prefix . '_1', '%,' . $value . ',%');
$queryBuilder->setParameter($tag_identifier_prefix . '_2', '%,' . $value);
$queryBuilder->setParameter($tag_identifier_prefix . '_3', $value . ',%');
$queryBuilder->setParameter($tag_identifier_prefix . '_4', $value);
}
public function getDescription(string $resourceClass): array
{
if (!$this->properties) {
return [];
}
$description = [];
foreach (array_keys($this->properties) as $property) {
$description[(string)$property] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter for tags of a part',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;
}
}

View file

@ -79,7 +79,7 @@ class CheckRequirementsCommand extends Command
//Checking 32-bit system
if (PHP_INT_SIZE === 4) {
$io->warning('You are using a 32-bit system. You will have problems with working with dates after the year 2038, therefore a 64-bit system is recommended.');
} elseif (PHP_INT_SIZE === 8) {
} elseif (PHP_INT_SIZE === 8) { //@phpstan-ignore-line //PHP_INT_SIZE is always 4 or 8
if (!$only_issues) {
$io->success('You are using a 64-bit system.');
}

View file

@ -79,6 +79,7 @@ class ConvertBBCodeCommand extends Command
/**
* Returns a list which entities and which properties need to be checked.
* @return array<class-string<AbstractNamedDBElement>, string[]>
*/
protected function getTargetsLists(): array
{
@ -109,7 +110,6 @@ class ConvertBBCodeCommand extends Command
$class
));
//Determine which entities of this type we need to modify
/** @var EntityRepository $repo */
$repo = $this->em->getRepository($class);
$qb = $repo->createQueryBuilder('e')
->select('e');

View file

@ -83,6 +83,19 @@ class SetPasswordCommand extends Command
while (!$success) {
$pw1 = $io->askHidden('Please enter new password:');
if ($pw1 === null) {
$io->error('No password entered! Please try again.');
//If we are in non-interactive mode, we can not ask again
if (!$input->isInteractive()) {
$io->warning('Non-interactive mode detected. No password can be entered that way! If you are using docker exec, please use -it flag.');
return Command::FAILURE;
}
continue;
}
$pw2 = $io->askHidden('Please confirm:');
if ($pw1 !== $pw2) {
$io->error('The entered password did not match! Please try again.');

View file

@ -206,12 +206,15 @@ class UsersPermissionsCommand extends Command
return '<fg=green>Allow</>';
} elseif ($permission_value === false) {
return '<fg=red>Disallow</>';
} elseif ($permission_value === null && !$inherit) {
}
// Permission value is null by this point
elseif (!$inherit) {
return '<fg=blue>Inherit</>';
} elseif ($permission_value === null && $inherit) {
} elseif ($inherit) {
return '<fg=red>Disallow (Inherited)</>';
}
//@phpstan-ignore-next-line This line is never reached, but PHPstorm complains otherwise
return '???';
}
}

View file

@ -35,6 +35,7 @@ use App\Entity\LabelSystem\LabelProcessMode;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Exceptions\AttachmentDownloadException;
use App\Exceptions\TwigModeException;
use App\Form\AdminPages\ImportType;
use App\Form\AdminPages\MassCreationForm;
use App\Repository\AbstractPartsContainingRepository;
@ -53,6 +54,7 @@ use InvalidArgumentException;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
@ -211,10 +213,14 @@ abstract class BaseAdminController extends AbstractController
//Show preview for LabelProfile if needed.
if ($entity instanceof LabelProfile) {
$example = $this->barcodeExampleGenerator->getElement($entity->getOptions()->getSupportedElement());
$pdf_data = $this->labelGenerator->generateLabel($entity->getOptions(), $example);
$pdf_data = null;
try {
$pdf_data = $this->labelGenerator->generateLabel($entity->getOptions(), $example);
} catch (TwigModeException $exception) {
$form->get('options')->get('lines')->addError(new FormError($exception->getSafeMessage()));
}
}
/** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class);
return $this->render($this->twig_template, [
@ -390,7 +396,7 @@ abstract class BaseAdminController extends AbstractController
{
if ($entity instanceof AbstractPartsContainingDBElement) {
/** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class);
$repo = $this->entityManager->getRepository($this->entity_class); //@phpstan-ignore-line
if ($repo->getPartsCount($entity) > 0) {
$this->addFlash('error', t('entity.delete.must_not_contain_parts', ['%PATH%' => $entity->getFullPath()]));

View file

@ -53,11 +53,11 @@ class AttachmentFileController extends AbstractController
}
if ($attachment->isExternal()) {
throw new RuntimeException('You can not download external attachments!');
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
}
if (!$helper->isFileExisting($attachment)) {
throw new RuntimeException('The file associated with the attachment is not existing!');
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteFilePath($attachment);
@ -82,11 +82,11 @@ class AttachmentFileController extends AbstractController
}
if ($attachment->isExternal()) {
throw new RuntimeException('You can not download external attachments!');
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
}
if (!$helper->isFileExisting($attachment)) {
throw new RuntimeException('The file associated with the attachment is not existing!');
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteFilePath($attachment);

View file

@ -23,10 +23,13 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -42,7 +45,9 @@ class InfoProviderController extends AbstractController
{
public function __construct(private readonly ProviderRegistry $providerRegistry,
private readonly PartInfoRetriever $infoRetriever)
private readonly PartInfoRetriever $infoRetriever,
private readonly ExistingPartFinder $existingPartFinder
)
{
}
@ -72,21 +77,49 @@ class InfoProviderController extends AbstractController
//When we are updating a part, use its name as keyword, to make searching easier
//However we can only do this, if the form was not submitted yet
if ($update_target !== null && !$form->isSubmitted()) {
$form->get('keyword')->setData($update_target->getName());
//Use the provider reference if available, otherwise use the manufacturer product number
$keyword = $update_target->getProviderReference()->getProviderId() ?? $update_target->getManufacturerProductNumber();
//Or the name if both are not available
if ($keyword === "") {
$keyword = $update_target->getName();
}
$form->get('keyword')->setData($keyword);
//If we are updating a part, which already has a provider, preselect that provider in the form
if ($update_target->getProviderReference()->getProviderKey() !== null) {
try {
$form->get('providers')->setData([$this->providerRegistry->getProviderByKey($update_target->getProviderReference()->getProviderKey())]);
} catch (\InvalidArgumentException $e) {
//If the provider is not found, just ignore it
}
}
}
if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();
$dtos = [];
try {
$results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
} catch (ClientException $e) {
$this->addFlash('error', t('info_providers.search.error.client_exception'));
$this->addFlash('error',$e->getMessage());
//Log the exception
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
}
// modify the array to an array of arrays that has a field for a matching local Part
// the advantage to use that format even when we don't look for local parts is that we
// always work with the same interface
$results = array_map(function ($result) {return ['dto' => $result, 'localPart' => null];}, $dtos);
if(!$update_target) {
foreach ($results as $index => $result) {
$results[$index]['localPart'] = $this->existingPartFinder->findFirstExisting($result['dto']);
}
}
}
return $this->render('info_providers/search/part_search.html.twig', [

View file

@ -108,8 +108,31 @@ class LabelController extends AbstractController
$pdf_data = null;
$filename = 'invalid.pdf';
//Generate PDF either when the form is submitted and valid, or the form was not submit yet, and generate is set
if (($form->isSubmitted() && $form->isValid()) || ($generate && !$form->isSubmitted() && $profile instanceof LabelProfile)) {
//Check if the label should be saved as profile
if ($form->get('save_profile')->isClicked() && $this->isGranted('@labels.create_profiles')) { //@phpstan-ignore-line Phpstan does not recognize the isClicked method
//Retrieve the profile name from the form
$new_name = $form->get('save_profile_name')->getData();
//ensure that the name is not empty
if ($new_name === '' || $new_name === null) {
$form->get('save_profile_name')->addError(new FormError($this->translator->trans('label_generator.profile_name_empty')));
goto render;
}
$profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options);
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
$target_id = (string) $form->get('target_id')->getData();
$targets = $this->findObjects($form_options->getSupportedElement(), $target_id);
if ($targets !== []) {
@ -117,7 +140,7 @@ class LabelController extends AbstractController
$pdf_data = $this->labelGenerator->generateLabel($form_options, $targets);
$filename = $this->getLabelName($targets[0], $profile);
} catch (TwigModeException $exception) {
$form->get('options')->get('lines')->addError(new FormError($exception->getMessage()));
$form->get('options')->get('lines')->addError(new FormError($exception->getSafeMessage()));
}
} else {
//$this->addFlash('warning', 'label_generator.no_entities_found');
@ -132,6 +155,7 @@ class LabelController extends AbstractController
}
}
render:
return $this->render('label_system/dialog.html.twig', [
'form' => $form,
'pdf_data' => $pdf_data,
@ -152,7 +176,7 @@ class LabelController extends AbstractController
{
$id_array = $this->rangeParser->parse($ids);
/** @var DBElementRepository $repo */
/** @var DBElementRepository<AbstractDBElement> $repo */
$repo = $this->em->getRepository($type->getEntityClass());
return $repo->getElementsFromIDArray($id_array);

View file

@ -229,6 +229,10 @@ class PartController extends AbstractController
$dto = $infoRetriever->getDetails($providerKey, $providerId);
$new_part = $infoRetriever->dtoToPart($dto);
if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) {
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
}
return $this->renderPartForm('new', $request, $new_part, [
'info_provider_dto' => $dto,
]);

View file

@ -66,6 +66,7 @@ class PartListsController extends AbstractController
$ids = $request->request->get('ids');
$action = $request->request->get('action');
$target = $request->request->get('target');
$redirectResponse = null;
if (!$this->isCsrfTokenValid('table_action', $request->request->get('_token'))) {
$this->addFlash('error', 'csfr_invalid');
@ -86,7 +87,7 @@ class PartListsController extends AbstractController
}
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.
if (isset($redirectResponse) && $redirectResponse instanceof Response) {
if ($redirectResponse !== null) {
return $redirectResponse;
}

View file

@ -42,10 +42,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Form\LabelSystem\ScanDialogType;
use App\Services\LabelSystem\Barcodes\BarcodeScanHelper;
use App\Services\LabelSystem\Barcodes\BarcodeRedirector;
use App\Services\LabelSystem\Barcodes\BarcodeScanResult;
use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -77,13 +77,21 @@ class ScanController extends AbstractController
$mode = $form['mode']->getData();
}
$infoModeData = null;
if ($input !== null) {
try {
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
try {
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
//Perform a redirect if the info mode is not enabled
if (!$form['info_mode']->getData()) {
try {
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
}
} else { //Otherwise retrieve infoModeData
$infoModeData = $scan_result->getDecodedForInfoMode();
}
} catch (InvalidArgumentException) {
$this->addFlash('error', 'scan.format_unknown');
@ -92,6 +100,7 @@ class ScanController extends AbstractController
return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form,
'infoModeData' => $infoModeData,
]);
}
@ -109,7 +118,7 @@ class ScanController extends AbstractController
throw new InvalidArgumentException('Unknown type: '.$type);
}
//Construct the scan result manually, as we don't have a barcode here
$scan_result = new BarcodeScanResult(
$scan_result = new LocalBarcodeScanResult(
target_type: BarcodeScanHelper::QR_TYPE_MAP[$type],
target_id: $id,
//The routes are only used on the internal generated QR codes

View file

@ -63,10 +63,10 @@ class ToolsController extends AbstractController
'default_theme' => $settings->system->customization->theme,
'enabled_locales' => $this->getParameter('partdb.locale_menu'),
'demo_mode' => $this->getParameter('partdb.demo_mode'),
'gpdr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'use_gravatar' => $settings->system->privacy->useGravatar,
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'),
'enviroment' => $this->getParameter('kernel.environment'),
'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'),

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parameters\AbstractParameter;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -92,7 +93,7 @@ class TypeaheadController extends AbstractController
/**
* This function map the parameter type to the class, so we can access its repository
* @return class-string
* @return class-string<AbstractParameter>
*/
private function typeToParameterClass(string $type): string
{
@ -155,7 +156,7 @@ class TypeaheadController extends AbstractController
//Ensure user has the correct permissions
$this->denyAccessUnlessGranted('read', $test_obj);
/** @var ParameterRepository $repository */
/** @var ParameterRepository<AbstractParameter> $repository */
$repository = $entityManager->getRepository($class);
$data = $repository->autocompleteParamName($query);

View file

@ -240,7 +240,10 @@ class UserSettingsController extends AbstractController
$page_need_reload = true;
}
/** @var Form $form We need a form implementation for the next calls */
if (!$form instanceof Form) {
throw new RuntimeException('Form is not an instance of Form, so we cannot retrieve the clicked button!');
}
//Remove the avatar attachment from the user if requested
if ($form->getClickedButton() && 'remove_avatar' === $form->getClickedButton()->getName() && $user->getMasterPictureAttachment() instanceof Attachment) {
$em->remove($user->getMasterPictureAttachment());

View file

@ -41,7 +41,7 @@ class APITokenFixtures extends Fixture implements DependentFixtureInterface
public function load(ObjectManager $manager): void
{
/** @var User $admin_user */
$admin_user = $this->getReference(UserFixtures::ADMIN);
$admin_user = $this->getReference(UserFixtures::ADMIN, User::class);
$read_only_token = new ApiToken();
$read_only_token->setUser($admin_user);

View file

@ -35,7 +35,7 @@ use Doctrine\Persistence\ObjectManager;
class LogEntryFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager)
public function load(ObjectManager $manager): void
{
$this->createCategoryEntries($manager);
$this->createDeletedCategory($manager);

View file

@ -106,7 +106,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$partLot2->setComment('Test');
$partLot2->setNeedsRefill(true);
$partLot2->setStorageLocation($manager->find(StorageLocation::class, 3));
$partLot2->setVendorBarcode('lot2_vendor_barcode');
$partLot2->setUserBarcode('lot2_vendor_barcode');
$part->addPartLot($partLot2);
$orderdetail = new Orderdetail();

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
@ -41,7 +42,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
{
$anonymous = new User();
$anonymous->setName('anonymous');
$anonymous->setGroup($this->getReference(GroupFixtures::READONLY));
$anonymous->setGroup($this->getReference(GroupFixtures::READONLY, Group::class));
$anonymous->setNeedPwChange(false);
$anonymous->setPassword($this->encoder->hashPassword($anonymous, 'test'));
$manager->persist($anonymous);
@ -50,7 +51,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$admin->setName('admin');
$admin->setPassword($this->encoder->hashPassword($admin, 'test'));
$admin->setNeedPwChange(false);
$admin->setGroup($this->getReference(GroupFixtures::ADMINS));
$admin->setGroup($this->getReference(GroupFixtures::ADMINS, Group::class));
$manager->persist($admin);
$this->addReference(self::ADMIN, $admin);
@ -60,7 +61,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$user->setEmail('user@invalid.invalid');
$user->setFirstName('Test')->setLastName('User');
$user->setPassword($this->encoder->hashPassword($user, 'test'));
$user->setGroup($this->getReference(GroupFixtures::USERS));
$user->setGroup($this->getReference(GroupFixtures::USERS, Group::class));
$manager->persist($user);
$noread = new User();

View file

@ -34,6 +34,7 @@ use App\Services\EntityURLGenerator;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\NumberColumn;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
@ -84,6 +85,11 @@ final class AttachmentDataTable implements DataTableTypeInterface
},
]);
$dataTable->add('id', NumberColumn::class, [
'label' => $this->translator->trans('part.table.id'),
'visible' => false,
]);
$dataTable->add('name', TextColumn::class, [
'label' => 'attachment.edit.name',
'orderField' => 'NATSORT(attachment.name)',

View file

@ -137,7 +137,7 @@ class EntityConstraint extends AbstractConstraint
}
//We need to handle null values differently, as they can not be compared with == or !=
if (!$this->value instanceof AbstractDBElement) {
if ($this->value === null) {
if($this->operator === '=' || $this->operator === 'INCLUDING_CHILDREN') {
$queryBuilder->andWhere(sprintf("%s IS NULL", $this->property));
return;
@ -152,8 +152,9 @@ class EntityConstraint extends AbstractConstraint
}
if($this->operator === '=' || $this->operator === '!=') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value);
return;
//Include null values on != operator, so that really all values are returned that are not equal to the given value
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value, $this->operator === '!=');
return;
}
//Otherwise retrieve the children list and apply the operator to it
@ -168,7 +169,8 @@ class EntityConstraint extends AbstractConstraint
}
if ($this->operator === 'EXCLUDING_CHILDREN') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $list);
//Include null values in the result, so that all elements that are not in the list are returned
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $list, true);
return;
}
} else {

View file

@ -56,8 +56,14 @@ trait FilterTrait
/**
* Adds a simple constraint in the form of (property OPERATOR value) (e.g. "part.name = :name") to the given query builder.
* @param QueryBuilder $queryBuilder The query builder to add the constraint to
* @param string $property The property to compare
* @param string $parameterIdentifier The identifier for the parameter
* @param string $comparison_operator The comparison operator to use
* @param mixed $value The value to compare to
* @param bool $include_null If true, the result of this constraint will also include null values of this property (useful for exclusion filters)
*/
protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, mixed $value): void
protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, mixed $value, bool $include_null = false): void
{
if ($comparison_operator === 'IN' || $comparison_operator === 'NOT IN') {
$expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier);
@ -65,6 +71,10 @@ trait FilterTrait
$expression = sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier);
}
if ($include_null) {
$expression = sprintf("(%s OR %s IS NULL)", $expression, $property);
}
if($this->useHaving || $this->isAggregateFunctionString($property)) { //If the property is an aggregate function, we have to use the "having" instead of the "where"
$queryBuilder->andHaving($expression);
} else {

View file

@ -85,15 +85,18 @@ class TagsConstraint extends AbstractConstraint
*/
protected function getExpressionForTag(QueryBuilder $queryBuilder, string $tag): Orx
{
//Escape any %, _ or \ in the tag
$tag = addcslashes($tag, '%_\\');
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
$expr = $queryBuilder->expr();
$tmp = $expr->orX(
$expr->like($this->property, ':' . $tag_identifier_prefix . '_1'),
$expr->like($this->property, ':' . $tag_identifier_prefix . '_2'),
$expr->like($this->property, ':' . $tag_identifier_prefix . '_3'),
$expr->eq($this->property, ':' . $tag_identifier_prefix . '_4'),
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_1) = TRUE',
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_2) = TRUE',
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_3) = TRUE',
'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_4) = TRUE',
);
//Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag)
@ -130,6 +133,7 @@ class TagsConstraint extends AbstractConstraint
return;
}
//@phpstan-ignore-next-line Keep this check to ensure that everything has the same structure even if we add a new operator
if ($this->operator === 'NONE') {
$queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions)));
return;

View file

@ -107,7 +107,8 @@ class TextConstraint extends AbstractConstraint
}
if ($like_value !== null) {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'LIKE', $like_value);
$queryBuilder->andWhere(sprintf('ILIKE(%s, :%s) = TRUE', $this->property, $this->identifier));
$queryBuilder->setParameter($this->identifier, $like_value);
return;
}

View file

@ -21,7 +21,6 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface
@ -132,15 +131,15 @@ class PartSearchFilter implements FilterInterface
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
}
return sprintf("%s LIKE :search_query", $field);
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
}, $fields_to_search);
//Add Or concatation of the expressions to our query
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
//For regex we pass the query as is, for like we add % to the start and end as wildcards
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
} else {

View file

@ -138,7 +138,8 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
'orderField' => 'NATSORT(_storelocations.name)',
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location')

View file

@ -87,16 +87,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
if(!$context->getPart() instanceof Part) {
return htmlspecialchars((string) $context->getName());
}
if($context->getPart() instanceof Part) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
return $tmp;
}
//@phpstan-ignore-next-line
throw new \RuntimeException('This should never happen!');
//Part exists if we reach this point
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
return $tmp;
},
])
->add('ipn', TextColumn::class, [

View file

@ -0,0 +1,71 @@
<?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\Functions;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
/**
* A platform invariant version of the case-insensitive LIKE operation.
* On MySQL and SQLite this is the normal LIKE, but on PostgreSQL it is the ILIKE operator.
*/
class ILike extends FunctionNode
{
public $value = null;
public $expr = null;
public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->StringPrimary();
$parser->match(TokenType::T_COMMA);
$this->expr = $parser->StringExpression();
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
public function getSql(SqlWalker $sqlWalker): string
{
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
//
if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
$operator = 'LIKE';
} elseif ($platform instanceof PostgreSQLPlatform) {
//Use the case-insensitive operator, to have the same behavior as MySQL
$operator = 'ILIKE';
} else {
throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.');
}
return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')';
}
}

View file

@ -44,15 +44,13 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
$native_connection = $connection->getNativeConnection();
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
if($native_connection instanceof \PDO && method_exists($native_connection, 'sqliteCreateFunction' )) {
if($native_connection instanceof \PDO) {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
if (method_exists($native_connection, 'sqliteCreateCollation')) {
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
}
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
}
}

View file

@ -44,7 +44,7 @@ class DoNotUsePurgerFactory implements PurgerFactory
throw new \LogicException('Do not use doctrine:fixtures:load directly. Use partdb:fixtures:load instead!');
}
public function setEntityManager(EntityManagerInterface $em)
public function setEntityManager(EntityManagerInterface $em): void
{
// TODO: Implement setEntityManager() method.
}

View file

@ -531,7 +531,7 @@ abstract class Attachment extends AbstractNamedDBElement
$url = str_replace(' ', '%20', $url);
//Only set if the URL is not empty
if ($url !== null && $url !== '') {
if ($url !== '') {
if (str_contains($url, '%BASE%') || str_contains($url, '%MEDIA%')) {
throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!');
}

View file

@ -33,7 +33,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @template-covariant AT of Attachment
* @template AT of Attachment
*/
#[ORM\MappedSuperclass(repositoryClass: AttachmentContainingDBElementRepository::class)]
abstract class AttachmentContainingDBElement extends AbstractNamedDBElement implements HasMasterAttachmentInterface, HasAttachmentsInterface

View file

@ -33,8 +33,8 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* This abstract class is used for companies like suppliers or manufacturers.
*
* @template-covariant AT of Attachment
* @template-covariant PT of AbstractParameter
* @template AT of Attachment
* @template PT of AbstractParameter
* @extends AbstractPartsContainingDBElement<AT, PT>
*/
#[ORM\MappedSuperclass]

View file

@ -31,8 +31,8 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @template-covariant AT of Attachment
* @template-covariant PT of AbstractParameter
* @template AT of Attachment
* @template PT of AbstractParameter
* @extends AbstractStructuralDBElement<AT, PT>
*/
#[ORM\MappedSuperclass(repositoryClass: AbstractPartsContainingRepository::class)]

View file

@ -53,8 +53,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
*
* @see \App\Tests\Entity\Base\AbstractStructuralDBElementTest
*
* @template-covariant AT of Attachment
* @template-covariant PT of AbstractParameter
* @template AT of Attachment
* @template PT of AbstractParameter
* @template-use ParametersTrait<PT>
* @extends AttachmentContainingDBElement<AT>
* @uses ParametersTrait<PT>

View file

@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Entity\LabelSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
@ -34,7 +36,7 @@ enum LabelSupportedElement: string
/**
* Returns the entity class for the given element type
* @return string
* @return class-string<AbstractDBElement>
*/
public function getEntityClass(): string
{

View file

@ -44,9 +44,9 @@ namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\UserSystem\User;
use App\Events\SecurityEvents;
use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* This log entry is created when something security related to a user happens.
@ -127,14 +127,14 @@ class SecurityEventLogEntry extends AbstractLogEntry
* Sets the IP address used to log in the user.
*
* @param string $ip the IP address used to log in the user
* @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant
* @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
*
* @return $this
*/
public function setIPAddress(string $ip, bool $anonymize = true): self
{
if ($anonymize) {
$ip = IpUtils::anonymize($ip);
$ip = IPAnonymizer::anonymize($ip);
}
$this->extra['i'] = $ip;

View file

@ -22,8 +22,9 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* This log entry is created when a user logs in.
@ -52,14 +53,14 @@ class UserLoginLogEntry extends AbstractLogEntry
* Sets the IP address used to log in the user.
*
* @param string $ip the IP address used to log in the user
* @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant
* @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
*
* @return $this
*/
public function setIPAddress(string $ip, bool $anonymize = true): self
{
if ($anonymize) {
$ip = IpUtils::anonymize($ip);
$ip = IPAnonymizer::anonymize($ip);
}
$this->extra['i'] = $ip;

View file

@ -22,8 +22,8 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\IpUtils;
#[ORM\Entity]
class UserLogoutLogEntry extends AbstractLogEntry
@ -49,14 +49,14 @@ class UserLogoutLogEntry extends AbstractLogEntry
* Sets the IP address used to log in the user.
*
* @param string $ip the IP address used to log in the user
* @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant
* @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
*
* @return $this
*/
public function setIPAddress(string $ip, bool $anonymize = true): self
{
if ($anonymize) {
$ip = IpUtils::anonymize($ip);
$ip = IPAnonymizer::anonymize($ip);
}
$this->extra['i'] = $ip;

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use App\ApiPlatform\Filter\TagFilter;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
@ -97,7 +98,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])]
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "tags", "manufacturer_product_number"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
#[ApiFilter(TagFilter::class, properties: ["tags"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
@ -118,7 +120,7 @@ class Part extends AttachmentContainingDBElement
/** @var Collection<int, PartParameter>
*/
#[Assert\Valid]
#[Groups(['full', 'part:read', 'part:write'])]
#[Groups(['full', 'part:read', 'part:write', 'import'])]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: PartParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[UniqueObjectCollection(fields: ['name', 'group', 'element'])]

View file

@ -68,7 +68,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
#[ValidPartLot]
#[UniqueEntity(['vendor_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
@ -166,10 +166,10 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
#[Groups(['part_lot:read', 'part_lot:write'])]
#[Length(max: 255)]
protected ?string $vendor_barcode = null;
protected ?string $user_barcode = null;
public function __clone()
{
@ -185,7 +185,6 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
*
* @return bool|null True, if the part lot is expired. Returns null, if no expiration date was set.
*
* @throws Exception If an error with the DateTime occurs
*/
public function isExpired(): ?bool
{
@ -376,19 +375,19 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
* null if no barcode is set.
* @return string|null
*/
public function getVendorBarcode(): ?string
public function getUserBarcode(): ?string
{
return $this->vendor_barcode;
return $this->user_barcode;
}
/**
* Set the content of the barcode of this part lot (e.g. a barcode on the package put by the vendor).
* @param string|null $vendor_barcode
* @param string|null $user_barcode
* @return $this
*/
public function setVendorBarcode(?string $vendor_barcode): PartLot
public function setUserBarcode(?string $user_barcode): PartLot
{
$this->vendor_barcode = $vendor_barcode;
$this->user_barcode = $user_barcode;
return $this;
}

View file

@ -103,7 +103,7 @@ class Supplier extends AbstractCompany
protected ?AbstractStructuralDBElement $parent = null;
/**
* @var Collection<int, Orderdetail>|Orderdetail[]
* @var Collection<int, Orderdetail>
*/
#[ORM\OneToMany(mappedBy: 'supplier', targetEntity: Orderdetail::class)]
protected Collection $orderdetails;

View file

@ -333,7 +333,6 @@ class Project extends AbstractStructuralDBElement
{
//If this project has subprojects, and these have builds part, they must be included in the BOM
foreach ($this->getChildren() as $child) {
/** @var $child Project */
if (!$child->getBuildPart() instanceof Part) {
continue;
}

View file

@ -102,7 +102,7 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
#[ApiFilter(LikeFilter::class, properties: ["name", "aboutMe"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
#[NoLockout]
#[NoLockout(groups: ['permissions:edit'])]
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface,
BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface, SamlUserInterface
{
@ -256,7 +256,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
protected ?string $password = null;
#[Assert\NotBlank]
#[Assert\Regex('/^[\w\.\+\-\$]+$/', message: 'user.invalid_username')]
#[Assert\Regex('/^[\w\.\+\-\$]+[\w\.\+\-\$\@]*$/', message: 'user.invalid_username')]
#[Groups(['user:read'])]
protected string $name = '';
@ -893,8 +893,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @param string[] $codes An array containing the backup codes
*
* @return $this
*
* @throws Exception If an error with the datetime occurs
*/
public function setBackupCodes(array $codes): self
{

View file

@ -1,57 +0,0 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent;
/**
* This class fixes the wrong pathes generated by webpack using the auto publicPath mode.
* Basically it replaces the wrong /auto/ part of the path with the correct /build/ in all encore entrypoints.
*/
class WebpackAutoPathSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
RenderAssetTagEvent::class => 'onRenderAssetTag'
];
}
public function onRenderAssetTag(RenderAssetTagEvent $event): void
{
if ($event->isScriptTag()) {
$event->setAttribute('src', $this->resolveAuto($event->getUrl()));
}
if ($event->isLinkTag()) {
$event->setAttribute('href', $this->resolveAuto($event->getUrl()));
}
}
private function resolveAuto(string $path): string
{
//Replace the first occurence of /auto/ with /build/ to get the correct path
return preg_replace('/\/auto\//', '/build/', $path, 1);
}
}

View file

@ -46,8 +46,23 @@ use Twig\Error\Error;
class TwigModeException extends RuntimeException
{
private const PROJECT_PATH = __DIR__ . '/../../';
public function __construct(?Error $previous = null)
{
parent::__construct($previous->getMessage(), 0, $previous);
}
/**
* Returns the message of this exception, where it is tried to remove any sensitive information (like filepaths).
* @return string
*/
public function getSafeMessage(): string
{
//Resolve project root path
$projectPath = realpath(self::PROJECT_PATH);
//Remove occurrences of the project path from the message
return str_replace($projectPath, '[Part-DB Root Folder]', $this->getMessage());
}
}

View file

@ -1,51 +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\Form\Fixes;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
class FixNumberType extends AbstractTypeExtension
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
//Remove existing view transformers
$builder->resetViewTransformers();
//And add our fixed version
$builder->addViewTransformer(new FixedNumberToLocalizedStringTransformer(
$options['scale'],
$options['grouping'],
$options['rounding_mode'],
$options['html5'] ? 'en' : null
));
}
public static function getExtendedTypes(): iterable
{
return [NumberType::class];
}
}

View file

@ -1,228 +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/>.
*/
namespace App\Form\Fixes;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* Same as the default NumberToLocalizedStringTransformer, but with a fix for the decimal separator.
* See https://github.com/symfony/symfony/pull/57861
*/
class FixedNumberToLocalizedStringTransformer implements DataTransformerInterface
{
protected $grouping;
protected $roundingMode;
private ?int $scale;
private ?string $locale;
public function __construct(?int $scale = null, ?bool $grouping = false, ?int $roundingMode = \NumberFormatter::ROUND_HALFUP, ?string $locale = null)
{
$this->scale = $scale;
$this->grouping = $grouping ?? false;
$this->roundingMode = $roundingMode ?? \NumberFormatter::ROUND_HALFUP;
$this->locale = $locale;
}
/**
* Transforms a number type into localized number.
*
* @param int|float|null $value Number value
*
* @throws TransformationFailedException if the given value is not numeric
* or if the value cannot be transformed
*/
public function transform(mixed $value): string
{
if (null === $value) {
return '';
}
if (!is_numeric($value)) {
throw new TransformationFailedException('Expected a numeric.');
}
$formatter = $this->getNumberFormatter();
$value = $formatter->format($value);
if (intl_is_failure($formatter->getErrorCode())) {
throw new TransformationFailedException($formatter->getErrorMessage());
}
// Convert non-breaking and narrow non-breaking spaces to normal ones
$value = str_replace(["\xc2\xa0", "\xe2\x80\xaf"], ' ', $value);
return $value;
}
/**
* Transforms a localized number into an integer or float.
*
* @param string $value The localized value
*
* @throws TransformationFailedException if the given value is not a string
* or if the value cannot be transformed
*/
public function reverseTransform(mixed $value): int|float|null
{
if (null !== $value && !\is_string($value)) {
throw new TransformationFailedException('Expected a string.');
}
if (null === $value || '' === $value) {
return null;
}
if (\in_array($value, ['NaN', 'NAN', 'nan'], true)) {
throw new TransformationFailedException('"NaN" is not a valid number.');
}
$position = 0;
$formatter = $this->getNumberFormatter();
$groupSep = $formatter->getSymbol(\NumberFormatter::GROUPING_SEPARATOR_SYMBOL);
$decSep = $formatter->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL);
if ('.' !== $decSep && (!$this->grouping || '.' !== $groupSep)) {
$value = str_replace('.', $decSep, $value);
}
if (',' !== $decSep && (!$this->grouping || ',' !== $groupSep)) {
$value = str_replace(',', $decSep, $value);
}
//If the value is in exponential notation with a negative exponent, we end up with a float value too
if (str_contains($value, $decSep) || stripos($value, 'e-') !== false) {
$type = \NumberFormatter::TYPE_DOUBLE;
} else {
$type = \PHP_INT_SIZE === 8
? \NumberFormatter::TYPE_INT64
: \NumberFormatter::TYPE_INT32;
}
$result = $formatter->parse($value, $type, $position);
if (intl_is_failure($formatter->getErrorCode())) {
throw new TransformationFailedException($formatter->getErrorMessage());
}
if ($result >= \PHP_INT_MAX || $result <= -\PHP_INT_MAX) {
throw new TransformationFailedException('I don\'t have a clear idea what infinity looks like.');
}
$result = $this->castParsedValue($result);
if (false !== $encoding = mb_detect_encoding($value, null, true)) {
$length = mb_strlen($value, $encoding);
$remainder = mb_substr($value, $position, $length, $encoding);
} else {
$length = \strlen($value);
$remainder = substr($value, $position, $length);
}
// After parsing, position holds the index of the character where the
// parsing stopped
if ($position < $length) {
// Check if there are unrecognized characters at the end of the
// number (excluding whitespace characters)
$remainder = trim($remainder, " \t\n\r\0\x0b\xc2\xa0");
if ('' !== $remainder) {
throw new TransformationFailedException(sprintf('The number contains unrecognized characters: "%s".', $remainder));
}
}
// NumberFormatter::parse() does not round
return $this->round($result);
}
/**
* Returns a preconfigured \NumberFormatter instance.
*/
protected function getNumberFormatter(): \NumberFormatter
{
$formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::DECIMAL);
if (null !== $this->scale) {
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $this->scale);
$formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode);
}
$formatter->setAttribute(\NumberFormatter::GROUPING_USED, $this->grouping);
return $formatter;
}
/**
* @internal
*/
protected function castParsedValue(int|float $value): int|float
{
if (\is_int($value) && $value === (int) $float = (float) $value) {
return $float;
}
return $value;
}
/**
* Rounds a number according to the configured scale and rounding mode.
*/
private function round(int|float $number): int|float
{
if (null !== $this->scale && null !== $this->roundingMode) {
// shift number to maintain the correct scale during rounding
$roundingCoef = 10 ** $this->scale;
// string representation to avoid rounding errors, similar to bcmul()
$number = (string) ($number * $roundingCoef);
switch ($this->roundingMode) {
case \NumberFormatter::ROUND_CEILING:
$number = ceil($number);
break;
case \NumberFormatter::ROUND_FLOOR:
$number = floor($number);
break;
case \NumberFormatter::ROUND_UP:
$number = $number > 0 ? ceil($number) : floor($number);
break;
case \NumberFormatter::ROUND_DOWN:
$number = $number > 0 ? floor($number) : ceil($number);
break;
case \NumberFormatter::ROUND_HALFEVEN:
$number = round($number, 0, \PHP_ROUND_HALF_EVEN);
break;
case \NumberFormatter::ROUND_HALFUP:
$number = round($number, 0, \PHP_ROUND_HALF_UP);
break;
case \NumberFormatter::ROUND_HALFDOWN:
$number = round($number, 0, \PHP_ROUND_HALF_DOWN);
break;
}
$number = 1 === $roundingCoef ? (int) $number : $number / $roundingCoef;
}
return $number;
}
}

View file

@ -71,6 +71,22 @@ class LabelDialogType extends AbstractType
'label' => false,
'disabled' => !$this->security->isGranted('@labels.edit_options') || $options['disable_options'],
]);
$builder->add('save_profile_name', TextType::class, [
'required' => false,
'attr' =>[
'placeholder' => 'label_generator.save_profile_name',
]
]);
$builder->add('save_profile', SubmitType::class, [
'label' => 'label_generator.save_profile',
'disabled' => !$this->security->isGranted('@labels.create_profiles'),
'attr' => [
'class' => 'btn btn-outline-success'
]
]);
$builder->add('update', SubmitType::class, [
'label' => 'label_generator.update',
]);

View file

@ -41,8 +41,9 @@ declare(strict_types=1);
namespace App\Form\LabelSystem;
use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@ -55,6 +56,8 @@ class ScanDialogType extends AbstractType
{
$builder->add('input', TextType::class, [
'label' => 'scan_dialog.input',
//Do not trim the input, otherwise this damages Format06 barcodes which end with non-printable characters
'trim' => false,
'attr' => [
'autofocus' => true,
'id' => 'scan_dialog_input',
@ -71,9 +74,14 @@ class ScanDialogType extends AbstractType
null => 'scan_dialog.mode.auto',
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::VENDOR => 'scan_dialog.mode.vendor',
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
},
]);
$builder->add('info_mode', CheckboxType::class, [
'label' => 'scan_dialog.info_mode',
'required' => false,
]);
$builder->add('submit', SubmitType::class, [

View file

@ -102,7 +102,7 @@ class ParameterType extends AbstractType
'step' => 'any',
'placeholder' => 'parameters.max.placeholder',
'class' => 'form-control-sm',
'style' => 'max-width: 12ch;',
'style' => 'max-width: 25ch;',
],
]);
$builder->add('value_min', ExponentialNumberType::class, [
@ -113,7 +113,7 @@ class ParameterType extends AbstractType
'step' => 'any',
'placeholder' => 'parameters.min.placeholder',
'class' => 'form-control-sm',
'style' => 'max-width: 12ch;',
'style' => 'max-width: 25ch;',
],
]);
$builder->add('value_typical', ExponentialNumberType::class, [
@ -124,7 +124,7 @@ class ParameterType extends AbstractType
'step' => 'any',
'placeholder' => 'parameters.typical.placeholder',
'class' => 'form-control-sm',
'style' => 'max-width: 12ch;',
'style' => 'max-width: 25ch;',
],
]);
$builder->add('unit', TextType::class, [

View file

@ -102,6 +102,8 @@ class PartBaseType extends AbstractType
'dto_value' => $dto?->category,
'label' => 'part.edit.category',
'disable_not_selectable' => true,
//Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity)
'required' => !$new_part,
])
->add('footprint', StructuralEntityType::class, [
'class' => Footprint::class,

View file

@ -103,10 +103,12 @@ class PartLotType extends AbstractType
'help' => 'part_lot.owner.help',
]);
$builder->add('vendor_barcode', TextType::class, [
'label' => 'part_lot.edit.vendor_barcode',
$builder->add('user_barcode', TextType::class, [
'label' => 'part_lot.edit.user_barcode',
'help' => 'part_lot.edit.vendor_barcode.help',
'required' => false,
//Do not remove whitespace chars on the beginning and end of the string
'trim' => false,
]);
}

View file

@ -53,6 +53,7 @@ class TFAGoogleSettingsType extends AbstractType
'google_confirmation',
TextType::class,
[
'label' => 'tfa.check.code.confirmation',
'mapped' => false,
'attr' => [
'maxlength' => '6',
@ -60,7 +61,7 @@ class TFAGoogleSettingsType extends AbstractType
'pattern' => '\d*',
'autocomplete' => 'off',
],
'constraints' => [new ValidGoogleAuthCode()],
'constraints' => [new ValidGoogleAuthCode(groups: ["google_authenticator"])],
]
);
@ -92,6 +93,7 @@ class TFAGoogleSettingsType extends AbstractType
{
$resolver->setDefaults([
'data_class' => User::class,
'validation_groups' => ['google_authenticator'],
]);
}
}

View file

@ -27,6 +27,7 @@ use App\Form\Type\Helper\ExponentialNumberTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Similar to the NumberType, but formats small values in scienfitic notation instead of rounding it to 0, like NumberType
@ -38,7 +39,15 @@ class ExponentialNumberType extends AbstractType
return NumberType::class;
}
public function buildForm(FormBuilderInterface $builder, array $options)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
//We want to allow the full precision of the number, so disable rounding
'scale' => null,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->resetViewTransformers();

View file

@ -23,21 +23,22 @@ declare(strict_types=1);
namespace App\Form\Type\Helper;
use App\Form\Fixes\FixedNumberToLocalizedStringTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\DataTransformer\NumberToLocalizedStringTransformer;
/**
* This transformer formats small values in scienfitic notation instead of rounding it to 0, like the default
* NumberFormatter.
*/
class ExponentialNumberTransformer extends FixedNumberToLocalizedStringTransformer
class ExponentialNumberTransformer extends NumberToLocalizedStringTransformer
{
public function __construct(
protected ?int $scale = null,
private ?int $scale = null,
?bool $grouping = false,
?int $roundingMode = \NumberFormatter::ROUND_HALFUP,
protected ?string $locale = null
) {
//Set scale to null, to disable rounding of values
parent::__construct($scale, $grouping, $roundingMode, $locale);
}
@ -85,12 +86,28 @@ class ExponentialNumberTransformer extends FixedNumberToLocalizedStringTransform
$formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::SCIENTIFIC);
if (null !== $this->scale) {
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $this->scale);
$formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $this->scale);
$formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode);
}
$formatter->setAttribute(\NumberFormatter::GROUPING_USED, (int) $this->grouping);
return $formatter;
}
protected function getNumberFormatter(): \NumberFormatter
{
$formatter = parent::getNumberFormatter();
//Unset the fraction digits, as we don't want to round the number
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, 0);
if (null !== $this->scale) {
$formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $this->scale);
} else {
$formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 100);
}
return $formatter;
}
}

View file

@ -43,7 +43,7 @@ class StructuralEntityChoiceHelper
/**
* Generates the choice attributes for the given AbstractStructuralDBElement.
* @return array|string[]
* @return array<string, mixed>
*/
public function generateChoiceAttr(AbstractNamedDBElement $choice, Options|array $options): array
{

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Form\Type\Helper;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Repository\StructuralDBElementRepository;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
@ -33,6 +34,9 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @template T of AbstractStructuralDBElement
*/
class StructuralEntityChoiceLoader extends AbstractChoiceLoader
{
private ?string $additional_element = null;
@ -90,10 +94,14 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader
}
}
/** @var class-string<T> $class */
$class = $this->options['class'];
/** @var StructuralDBElementRepository $repo */
/** @var StructuralDBElementRepository<T> $repo */
$repo = $this->entityManager->getRepository($class);
$entities = $repo->getNewEntityFromPath($value, '->');
$results = [];

View file

@ -99,7 +99,6 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
*
* @return mixed The value in the transformed representation
*
* @throws TransformationFailedException when the transformation fails
*/
public function transform(mixed $value)
{
@ -142,8 +141,6 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
* @param mixed $value The value in the transformed representation
*
* @return mixed The value in the original representation
*
* @throws TransformationFailedException when the transformation fails
*/
public function reverseTransform(mixed $value)
{

View file

@ -57,6 +57,8 @@ class UserAdminForm extends AbstractType
parent::configureOptions($resolver); // TODO: Change the autogenerated stub
$resolver->setRequired('attachment_class');
$resolver->setDefault('parameter_class', false);
$resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
}
public function buildForm(FormBuilderInterface $builder, array $options): void

View file

@ -36,6 +36,9 @@ class FilenameSanatizer
*/
public static function sanitizeFilename(string $filename): string
{
//Convert to ASCII
$filename = iconv('UTF-8', 'ASCII//TRANSLIT', $filename);
$filename = preg_replace(
'~
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words

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\Helpers;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Utils to assist with IP anonymization.
* The IPUtils::anonymize has a certain edgecase with local-link addresses, which is handled here.
* See: https://github.com/Part-DB/Part-DB-server/issues/782
*/
final class IPAnonymizer
{
public static function anonymize(string $ip): string
{
/**
* If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007
* In that case, we only care about the part before the % symbol, as the following functions, can only work with
* the IP address itself. As the scope can leak information (containing interface name), we do not want to
* include it in our anonymized IP data.
*/
if (str_contains($ip, '%')) {
$ip = substr($ip, 0, strpos($ip, '%'));
}
return IpUtils::anonymize($ip);
}
}

View file

@ -75,8 +75,8 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
->where('attachment.path LIKE :http')
->orWhere('attachment.path LIKE :https');
->where('ILIKE(attachment.path, :http) = TRUE')
->orWhere('ILIKE(attachment.path, :https) = TRUE');
$qb->setParameter('http', 'http://%');
$qb->setParameter('https', 'https://%');
$query = $qb->getQuery();

View file

@ -44,7 +44,7 @@ class ParameterRepository extends DBElementRepository
->select('parameter.name')
->addSelect('parameter.symbol')
->addSelect('parameter.unit')
->where('parameter.name LIKE :name');
->where('ILIKE(parameter.name, :name) = TRUE');
if ($exact) {
$qb->setParameter('name', $name);
} else {

View file

@ -81,10 +81,10 @@ class PartRepository extends NamedDBElementRepository
->leftJoin('part.category', 'category')
->leftJoin('part.footprint', 'footprint')
->where('part.name LIKE :query')
->orWhere('part.description LIKE :query')
->orWhere('category.name LIKE :query')
->orWhere('footprint.name LIKE :query')
->where('ILIKE(part.name, :query) = TRUE')
->orWhere('ILIKE(part.description, :query) = TRUE')
->orWhere('ILIKE(category.name, :query) = TRUE')
->orWhere('ILIKE(footprint.name, :query) = TRUE')
;
$qb->setParameter('query', '%'.$query.'%');

View file

@ -151,7 +151,7 @@ class StructuralDBElementRepository extends AttachmentContainingDBElementReposit
}
if (null === $entity) {
$class = $this->getClassName();
/** @var AbstractStructuralDBElement $entity */
/** @var TEntityClass $entity */
$entity = new $class;
$entity->setName($name);
$entity->setParent($parent);
@ -265,7 +265,7 @@ class StructuralDBElementRepository extends AttachmentContainingDBElementReposit
}
$class = $this->getClassName();
/** @var AbstractStructuralDBElement $entity */
/** @var TEntityClass $entity */
$entity = new $class;
$entity->setName($name);

View file

@ -25,6 +25,7 @@ namespace App\Security;
use App\Entity\UserSystem\User;
use Nbgrp\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -50,13 +51,20 @@ class EnsureSAMLUserForSAMLLoginChecker implements EventSubscriberInterface
$token = $event->getAuthenticationToken();
$user = $token->getUser();
//If we are using SAML, we need to check that the user is a SAML user.
if ($token instanceof SamlToken) {
if ($user instanceof User && !$user->isSamlUser()) {
throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_local_user_per_saml', [], 'security'));
}
} elseif ($user instanceof User && $user->isSamlUser()) {
//Ensure that you can not login locally with a SAML user (even if this should not happen, as the password is not set)
//Do not check for anonymous users
if (!$user instanceof User) {
return;
}
//Do not allow SAML users to login as local user
if ($token instanceof SamlToken && !$user->isSamlUser()) {
throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_local_user_per_saml',
[], 'security'));
}
//Do not allow local users to login as SAML user via local username and password
if ($token instanceof UsernamePasswordToken && $user->isSamlUser()) {
//Ensure that you can not login locally with a SAML user (even though this should not happen, as the password is not set)
throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_saml_user_locally', [], 'security'));
}
}

View file

@ -40,12 +40,10 @@ final class UserChecker implements UserCheckerInterface
/**
* Checks the user account before authentication.
*
* @throws AccountStatusException
*/
public function checkPreAuth(UserInterface $user): void
{
// TODO: Implement checkPreAuth() method.
//We don't need to check the user before authentication, just implemented to fulfill the interface
}
/**

View file

@ -94,7 +94,14 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
{
return !isset($context[self::ALREADY_CALLED]) && is_array($data) && is_a($type, Part::class, true);
//Only denormalize if we are doing a file import operation
if (!($context['partdb_import'] ?? false)) {
return false;
}
//Only make the denormalizer available on import operations
return !isset($context[self::ALREADY_CALLED])
&& is_array($data) && is_a($type, Part::class, true);
}
private function normalizeKeys(array &$data): array

View file

@ -69,6 +69,15 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
&& in_array('import', $context['groups'] ?? [], true);
}
/**
* @template T of AbstractStructuralDBElement
* @param $data
* @phpstan-param class-string<T> $type
* @param string|null $format
* @param array $context
* @return AbstractStructuralDBElement|null
* @phpstan-return T|null
*/
public function denormalize($data, string $type, string $format = null, array $context = []): ?AbstractStructuralDBElement
{
//Do not use API Platform's denormalizer
@ -85,7 +94,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
$deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context);
//Check if we already have the entity in the database (via path)
/** @var StructuralDBElementRepository $repo */
/** @var StructuralDBElementRepository<T> $repo */
$repo = $this->entityManager->getRepository($type);
$path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);

View file

@ -54,7 +54,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
public function denormalize($data, string $type, string $format = null, array $context = []): AbstractStructuralDBElement|null
{
//Retrieve the repository for the given type
/** @var StructuralDBElementRepository $repo */
/** @var StructuralDBElementRepository<T> $repo */
$repo = $this->em->getRepository($type);
$path_delimiter = $context['path_delimiter'] ?? '->';

View file

@ -47,7 +47,7 @@ class MoneyFormatter
public function format(string|float $value, ?Currency $currency = null, int $decimals = 5, bool $show_all_digits = false): string
{
$iso_code = $this->localizationSettings->baseCurrency;
if ($currency instanceof Currency && ($currency->getIsoCode() !== null && $currency->getIsoCode() !== '')) {
if ($currency instanceof Currency && ($currency->getIsoCode() !== '')) {
$iso_code = $currency->getIsoCode();
}

View file

@ -153,6 +153,7 @@ class BOMImporter
break;
}
//@phpstan-ignore-next-line We want to keep this check just to be safe when something changes
$new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new \UnexpectedValueException('Invalid field index!');
$out[$new_index] = $field;
}

View file

@ -44,6 +44,12 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
*/
class EntityImporter
{
/**
* The encodings that are supported by the importer, and that should be autodeceted.
*/
private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator)
{
}
@ -58,13 +64,16 @@ class EntityImporter
* @phpstan-param class-string<T> $class_name
* @param AbstractStructuralDBElement|null $parent the element which will be used as parent element for new elements
* @param array $errors an associative array containing all validation errors
* @param-out array<string, array{'entity': object, 'violations': ConstraintViolationListInterface}> $errors
* @param-out list<array{'entity': object, 'violations': ConstraintViolationListInterface}> $errors
*
* @return AbstractNamedDBElement[] An array containing all valid imported entities (with the type $class_name)
* @return T[]
*/
public function massCreation(string $lines, string $class_name, ?AbstractStructuralDBElement $parent = null, array &$errors = []): array
{
//Try to detect the text encoding of the data and convert it to UTF-8
$lines = mb_convert_encoding($lines, 'UTF-8', mb_detect_encoding($lines, self::ENCODINGS));
//Expand every line to a single entry:
$names = explode("\n", $lines);
@ -124,13 +133,15 @@ class EntityImporter
if ($repo instanceof StructuralDBElementRepository) {
$entities = $repo->getNewEntityFromPath($new_path);
$entity = end($entities);
if ($entity === false) {
throw new InvalidArgumentException('getNewEntityFromPath returned an empty array!');
}
} else { //Otherwise just create a new entity
$entity = new $class_name;
$entity->setName($name);
}
//Validate entity
$tmp = $this->validator->validate($entity);
//If no error occured, write entry to DB:
@ -159,6 +170,9 @@ class EntityImporter
*/
public function importString(string $data, array $options = [], array &$errors = []): array
{
//Try to detect the text encoding of the data and convert it to UTF-8
$data = mb_convert_encoding($data, 'UTF-8', mb_detect_encoding($data, self::ENCODINGS));
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
@ -215,6 +229,11 @@ class EntityImporter
//Iterate over each $entity write it to DB.
foreach ($entities as $key => $entity) {
//Ensure that entity is a NamedDBElement
if (!$entity instanceof AbstractNamedDBElement) {
throw new \RuntimeException("Encountered an entity that is not a NamedDBElement!");
}
//Validate entity
$tmp = $this->validator->validate($entity);
@ -269,7 +288,7 @@ class EntityImporter
*
* @param File $file the file that should be used for importing
* @param array $options options for the import process
* @param AbstractNamedDBElement[] $entities The imported entities are returned in this array
* @param-out AbstractNamedDBElement[] $entities The imported entities are returned in this array
*
* @return array<string, array{'entity': object, 'violations': ConstraintViolationListInterface}> An associative array containing an ConstraintViolationList and the entity name as key are returned,
* if an error happened during validation. When everything was successfully, the array should be empty.
@ -305,7 +324,7 @@ class EntityImporter
* @param array $options options for the import process
* @param-out array<string, array{'entity': object, 'violations': ConstraintViolationListInterface}> $errors
*
* @return array an array containing the deserialized elements
* @return AbstractNamedDBElement[] an array containing the deserialized elements
*/
public function importFile(File $file, array $options = [], array &$errors = []): array
{

View file

@ -205,10 +205,6 @@ trait PKImportHelperTrait
*/
protected function setIDOfEntity(AbstractDBElement $element, int|string $id): void
{
if (!is_int($id) && !is_string($id)) {
throw new \InvalidArgumentException('ID must be an integer or string');
}
$id = (int) $id;
$metadata = $this->em->getClassMetadata($element::class);

View file

@ -27,6 +27,7 @@ use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\InfoProviderReference;
use App\Entity\Parts\Manufacturer;
@ -36,6 +37,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Repository\Parts\CategoryRepository;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
@ -160,6 +162,12 @@ final class DTOtoEntityConverter
$entity->setMass($dto->mass);
//Try to map the category to an existing entity (but never create a new one)
if ($dto->category) {
//@phpstan-ignore-next-line For some reason php does not recognize the repo returns a category
$entity->setCategory($this->em->getRepository(Category::class)->findForInfoProvider($dto->category));
}
$entity->setManufacturer($this->getOrCreateEntity(Manufacturer::class, $dto->manufacturer));
$entity->setFootprint($this->getOrCreateEntity(Footprint::class, $dto->footprint));

View file

@ -0,0 +1,77 @@
<?php
namespace App\Services\InfoProviderSystem;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Doctrine\ORM\EntityManagerInterface;
/**
* This service assists in finding existing local parts for a SearchResultDTO, so that the user
* does not accidentally add a duplicate.
*
* A part is considered to be a duplicate, if the provider reference matches, or if the manufacturer and the MPN of the
* DTO and the local part match. This checks also for alternative names of the manufacturer and the part name (as alternative
* for the MPN).
*/
final class ExistingPartFinder
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
/**
* Return the first existing local part, that matches the search result.
* If no part is found, return null.
* @param SearchResultDTO $dto
* @return Part|null
*/
public function findFirstExisting(SearchResultDTO $dto): ?Part
{
$results = $this->findAllExisting($dto);
return count($results) > 0 ? $results[0] : null;
}
/**
* Returns all existing local parts that match the search result.
* If no part is found, return an empty array.
* @param SearchResultDTO $dto
* @return Part[]
*/
public function findAllExisting(SearchResultDTO $dto): array
{
$qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
$qb->select('part')
->leftJoin('part.manufacturer', 'manufacturer')
->Orwhere($qb->expr()->andX(
'part.providerReference.provider_key = :providerKey',
'part.providerReference.provider_id = :providerId',
))
//Or the manufacturer (allowing for alternative names) and the MPN (or part name) must match
->OrWhere(
$qb->expr()->andX(
$qb->expr()->orX(
"ILIKE(manufacturer.name, :manufacturerName) = TRUE",
"ILIKE(manufacturer.alternative_names, :manufacturerAltNames) = TRUE",
),
$qb->expr()->orX(
"ILIKE(part.manufacturer_product_number, :mpn) = TRUE",
"ILIKE(part.name, :mpn) = TRUE",
)
)
)
;
$qb->setParameter('providerKey', $dto->provider_key);
$qb->setParameter('providerId', $dto->provider_id);
$qb->setParameter('manufacturerName', $dto->manufacturer);
$qb->setParameter('manufacturerAltNames', '%'.$dto->manufacturer.'%');
$qb->setParameter('mpn', $dto->mpn);
return $qb->getQuery()->getResult();
}
}

View file

@ -45,6 +45,14 @@ class DigikeyProvider implements InfoProviderInterface
private readonly HttpClientInterface $digikeyClient;
/**
* A list of parameter IDs, that are always assumed as text only and will never be converted to a numerical value.
* This allows to fix issues like #682, where the "Supplier Device Package" was parsed as a numerical value.
*/
private const TEXT_ONLY_PARAMETERS = [
1291, //Supplier Device Package
39246, //Package / Case
];
public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager,
private readonly string $currency, private readonly string $clientId,
@ -214,7 +222,12 @@ class DigikeyProvider implements InfoProviderInterface
continue;
}
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
//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']);
} else { //Otherwise try to parse it as a numerical value
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
}
}
return $results;

View file

@ -98,16 +98,19 @@ class LCSCProvider implements InfoProviderInterface
private function getRealDatasheetUrl(?string $url): string
{
if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) {
if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
$url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
}
$response = $this->lcscClient->request('GET', $url, [
'headers' => [
'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
],
]);
if (preg_match('/(pdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
//HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
//See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
$jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}');
$url = $jsonObj->pdfUrl;
$url = $jsonObj->previewPdfUrl;
}
}
return $url;

View file

@ -203,7 +203,7 @@ class MouserProvider implements InfoProviderInterface
if (isset($arr['SearchResults'])) {
$products = $arr['SearchResults']['Parts'] ?? [];
} else {
throw new \RuntimeException('Unknown response format');
throw new \RuntimeException('Unknown response format: ' .json_encode($arr, JSON_THROW_ON_ERROR));
}
$result = [];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,166 @@
<?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);
/**
* 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/>.
*/
namespace App\Services\LabelSystem\BarcodeScanner;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
*/
final class BarcodeRedirector
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em)
{
}
/**
* Determines the URL to which the user should be redirected, when scanning a QR code.
*
* @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
* @return string the URL to which should be redirected
*
* @throws EntityNotFoundException
*/
public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string
{
if($barcodeScan instanceof LocalBarcodeScanResult) {
return $this->getURLLocalBarcode($barcodeScan);
}
if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
return $this->getURLVendorBarcode($barcodeScan);
}
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
}
private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string
{
switch ($barcodeScan->target_type) {
case LabelSupportedElement::PART:
return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
case LabelSupportedElement::PART_LOT:
//Try to determine the part to the given lot
$lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
if (!$lot instanceof PartLot) {
throw new EntityNotFoundException();
}
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
case LabelSupportedElement::STORELOCATION:
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
default:
throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
}
}
/**
* Gets the URL to a part from a scan of a Vendor Barcode
*/
private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string
{
$part = $this->getPartFromVendor($barcodeScan);
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
}
/**
* Gets a part from a scan of a Vendor Barcode by filtering for parts
* with the same Info Provider Id or, if that fails, by looking for parts with a
* matching manufacturer product number. Only returns the first matching part.
*/
private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part
{
// first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
// the info provider system or if the part was bought from a different vendor than the data was retrieved
// from.
if($barcodeScan->digikeyPartNumber) {
$qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
//Lower() to be case insensitive
$qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)'));
$qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber);
$results = $qb->getQuery()->getResult();
if ($results) {
return $results[0];
}
}
if(!$barcodeScan->supplierPartNumber){
throw new EntityNotFoundException();
}
//Fallback to the manufacturer part number. This may return false positives, since it is common for
//multiple manufacturers to use the same part number for their version of a common product
//We assume the user is able to realize when this returns the wrong part
//If the barcode specifies the manufacturer we try to use that as well
$mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
$mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)'));
$mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber);
if($barcodeScan->mouserManufacturer){
$manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer");
$manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)"));
$manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer);
$manufacturers = $manufacturerQb->getQuery()->getResult();
if($manufacturers) {
$mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer"));
$mpnQb->setParameter("manufacturer", $manufacturers);
}
}
$results = $mpnQb->getQuery()->getResult();
if($results){
return $results[0];
}
throw new EntityNotFoundException();
}
}

View file

@ -39,7 +39,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\LabelSystem\Barcodes;
namespace App\Services\LabelSystem\BarcodeScanner;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\Part;
@ -75,20 +75,23 @@ final class BarcodeScanHelper
* will try to guess the type.
* @param string $input
* @param BarcodeSourceType|null $type
* @return BarcodeScanResult
* @return BarcodeScanResultInterface
*/
public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = null): BarcodeScanResult
public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = null): BarcodeScanResultInterface
{
//Do specific parsing
if ($type === BarcodeSourceType::INTERNAL) {
return $this->parseInternalBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
}
if ($type === BarcodeSourceType::VENDOR) {
return $this->parseVendorBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
if ($type === BarcodeSourceType::USER_DEFINED) {
return $this->parseUserDefinedBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
}
if ($type === BarcodeSourceType::IPN) {
return $this->parseIPNBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
}
if ($type === BarcodeSourceType::EIGP114) {
return $this->parseEIGP114Barcode($input);
}
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@ -97,12 +100,17 @@ final class BarcodeScanHelper
return $result;
}
//Try to parse as vendor barcode
$result = $this->parseVendorBarcode($input);
//Try to parse as User defined barcode
$result = $this->parseUserDefinedBarcode($input);
if ($result !== null) {
return $result;
}
//If the barcode is formatted as EIGP114, we can parse it directly
if (EIGP114BarcodeScanResult::isFormat06Code($input)) {
return $this->parseEIGP114Barcode($input);
}
//Try to parse as IPN barcode
$result = $this->parseIPNBarcode($input);
if ($result !== null) {
@ -112,11 +120,16 @@ final class BarcodeScanHelper
throw new InvalidArgumentException('Unknown barcode');
}
private function parseVendorBarcode(string $input): ?BarcodeScanResult
private function parseEIGP114Barcode(string $input): EIGP114BarcodeScanResult
{
return EIGP114BarcodeScanResult::parseFormat06Code($input);
}
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{
$lot_repo = $this->entityManager->getRepository(PartLot::class);
//Find only the first result
$results = $lot_repo->findBy(['vendor_barcode' => $input], limit: 1);
$results = $lot_repo->findBy(['user_barcode' => $input], limit: 1);
if (count($results) === 0) {
return null;
@ -124,14 +137,14 @@ final class BarcodeScanHelper
//We found a part, so use it to create the result
$lot = $results[0];
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: LabelSupportedElement::PART_LOT,
target_id: $lot->getID(),
source_type: BarcodeSourceType::VENDOR
source_type: BarcodeSourceType::USER_DEFINED
);
}
private function parseIPNBarcode(string $input): ?BarcodeScanResult
private function parseIPNBarcode(string $input): ?LocalBarcodeScanResult
{
$part_repo = $this->entityManager->getRepository(Part::class);
//Find only the first result
@ -143,7 +156,7 @@ final class BarcodeScanHelper
//We found a part, so use it to create the result
$part = $results[0];
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: LabelSupportedElement::PART,
target_id: $part->getID(),
source_type: BarcodeSourceType::IPN
@ -155,9 +168,9 @@ final class BarcodeScanHelper
* If the barcode could not be parsed at all, null is returned. If the barcode is a valid format, but could
* not be found in the database, an exception is thrown.
* @param string $input
* @return BarcodeScanResult|null
* @return LocalBarcodeScanResult|null
*/
private function parseInternalBarcode(string $input): ?BarcodeScanResult
private function parseInternalBarcode(string $input): ?LocalBarcodeScanResult
{
$input = trim($input);
$matches = [];
@ -167,7 +180,7 @@ final class BarcodeScanHelper
//Extract parts from QR code's URL
if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: self::QR_TYPE_MAP[strtolower($matches[1])],
target_id: (int) $matches[2],
source_type: BarcodeSourceType::INTERNAL
@ -183,7 +196,7 @@ final class BarcodeScanHelper
throw new InvalidArgumentException('Unknown prefix '.$prefix);
}
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: self::PREFIX_TYPE_MAP[$prefix],
target_id: $id,
source_type: BarcodeSourceType::INTERNAL
@ -199,7 +212,7 @@ final class BarcodeScanHelper
throw new InvalidArgumentException('Unknown prefix '.$prefix);
}
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: self::PREFIX_TYPE_MAP[$prefix],
target_id: $id,
source_type: BarcodeSourceType::INTERNAL
@ -208,7 +221,7 @@ final class BarcodeScanHelper
//Legacy Part-DB location labels used $L00336 format
if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: LabelSupportedElement::STORELOCATION,
target_id: (int) $matches[1],
source_type: BarcodeSourceType::INTERNAL
@ -217,7 +230,7 @@ final class BarcodeScanHelper
//Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
return new BarcodeScanResult(
return new LocalBarcodeScanResult(
target_type: LabelSupportedElement::PART,
target_id: (int) $matches[1],
source_type: BarcodeSourceType::INTERNAL

View file

@ -0,0 +1,36 @@
<?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\LabelSystem\BarcodeScanner;
interface BarcodeScanResultInterface
{
/**
* Returns all data that was decoded from the barcode in a format, that can be shown in a table to the user.
* The return values of this function are not meant to be parsed by code again, but should just give a information
* to the user.
* The keys of the returned array are the first column of the table and the values are the second column.
* @return array<string, string|int|float|null>
*/
public function getDecodedForInfoMode(): array;
}

View file

@ -21,7 +21,7 @@
declare(strict_types=1);
namespace App\Services\LabelSystem\Barcodes;
namespace App\Services\LabelSystem\BarcodeScanner;
/**
* This enum represents the different types, where a barcode/QR-code can be generated from
@ -32,9 +32,14 @@ enum BarcodeSourceType
case INTERNAL;
/** This barcode is containing an internal part number (IPN) */
case IPN;
/**
* This barcode is a custom barcode from a third party like a vendor, which was set via the vendor_barcode
* field of a part lot.
* This barcode is a user defined barcode defined on a part lot
*/
case VENDOR;
case USER_DEFINED;
/**
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
*/
case EIGP114;
}

View file

@ -0,0 +1,332 @@
<?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\LabelSystem\BarcodeScanner;
/**
* This class represents the content of a EIGP114 barcode.
* Based on PR 811, EIGP 114.2018 (https://www.ecianow.org/assets/docs/GIPC/EIGP-114.2018%20ECIA%20Labeling%20Specification%20for%20Product%20and%20Shipment%20Identification%20in%20the%20Electronics%20Industry%20-%202D%20Barcode.pdf),
* , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097
*/
class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
{
/**
* @var string|null Ship date in format YYYYMMDD
*/
public readonly ?string $shipDate;
/**
* @var string|null Customer assigned part number Optional based on
* agreements between Distributor and Supplier
*/
public readonly ?string $customerPartNumber;
/**
* @var string|null Supplier assigned part number
*/
public readonly ?string $supplierPartNumber;
/**
* @var int|null Quantity of product
*/
public readonly ?int $quantity;
/**
* @var string|null Customer assigned purchase order number
*/
public readonly ?string $customerPO;
/**
* @var string|null Line item number from PO. Required on Logistic Label when
* used on back of Packing Slip. See Section 4.9
*/
public readonly ?string $customerPOLine;
/**
* 9D - YYWW (Year and Week of Manufacture). ) If no date code is used
* for a particular part, this field should be populated with N/T
* to indicate the product is Not Traceable by this data field.
* @var string|null
*/
public readonly ?string $dateCode;
/**
* 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
* for a particular part, this field should be populated with N/T
* to indicate the product is Not Traceable by this data field.
* @var string|null
*/
public readonly ?string $alternativeDateCode;
/**
* Traceability number assigned to a batch or group of items. If
* no lot code is used for a particular part, this field should be
* populated with N/T to indicate the product is Not Traceable
* by this data field.
* @var string|null
*/
public readonly ?string $lotCode;
/**
* Country where part was manufactured. Two-letter code from
* ISO 3166 country code list
* @var string|null
*/
public readonly ?string $countryOfOrigin;
/**
* @var string|null Unique alphanumeric number assigned by supplier
* 3S - Package ID for Inner Pack when part of a mixed Logistic
* Carton. Always used in conjunction with a mixed logistic label
* with a 5S data identifier for Package ID.
*/
public readonly ?string $packageId1;
/**
* @var string|null
* 4S - Package ID for Logistic Carton with like items
*/
public readonly ?string $packageId2;
/**
* @var string|null
* 5S - Package ID for Logistic Carton with mixed items
*/
public readonly ?string $packageId3;
/**
* @var string|null Unique alphanumeric number assigned by supplier.
*/
public readonly ?string $packingListNumber;
/**
* @var string|null Ship date in format YYYYMMDD
*/
public readonly ?string $serialNumber;
/**
* @var string|null Code for sorting and classifying LEDs. Use when applicable
*/
public readonly ?string $binCode;
/**
* @var int|null Sequential carton count in format #/#” or “# of #”
*/
public readonly ?int $packageCount;
/**
* @var string|null Alphanumeric string assigned by the supplier to distinguish
* from one closely-related design variation to another. Use as
* required or when applicable
*/
public readonly ?string $revisionNumber;
/**
* @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number.
*/
public readonly ?string $digikeyPartNumber;
/**
* @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.)
*/
public readonly ?string $digikeySalesOrderNumber;
/**
* @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers
*/
public readonly ?string $digikeyInvoiceNumber;
/**
* @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type.
*/
public readonly ?string $digikeyLabelType;
/**
* @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907
*/
public readonly ?string $digikeyPartID;
/**
* @var string|null Digikey Extension: For internal use of Digikey. Probably not needed
*/
public readonly ?string $digikeyNA;
/**
* @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore.
*/
public readonly ?string $digikeyPadding;
public readonly ?string $mouserPositionInOrder;
public readonly ?string $mouserManufacturer;
/**
*
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
*/
public function __construct(public readonly array $data)
{
//IDs per EIGP 114.2018
$this->shipDate = $data['6D'] ?? null;
$this->customerPartNumber = $data['P'] ?? null;
$this->supplierPartNumber = $data['1P'] ?? null;
$this->quantity = isset($data['Q']) ? (int)$data['Q'] : null;
$this->customerPO = $data['K'] ?? null;
$this->customerPOLine = $data['4K'] ?? null;
$this->dateCode = $data['9D'] ?? null;
$this->alternativeDateCode = $data['10D'] ?? null;
$this->lotCode = $data['1T'] ?? null;
$this->countryOfOrigin = $data['4L'] ?? null;
$this->packageId1 = $data['3S'] ?? null;
$this->packageId2 = $data['4S'] ?? null;
$this->packageId3 = $data['5S'] ?? null;
$this->packingListNumber = $data['11K'] ?? null;
$this->serialNumber = $data['S'] ?? null;
$this->binCode = $data['33P'] ?? null;
$this->packageCount = isset($data['13Q']) ? (int)$data['13Q'] : null;
$this->revisionNumber = $data['2P'] ?? null;
//IDs used by Digikey
$this->digikeyPartNumber = $data['30P'] ?? null;
$this->digikeySalesOrderNumber = $data['1K'] ?? null;
$this->digikeyInvoiceNumber = $data['10K'] ?? null;
$this->digikeyLabelType = $data['11Z'] ?? null;
$this->digikeyPartID = $data['12Z'] ?? null;
$this->digikeyNA = $data['13Z'] ?? null;
$this->digikeyPadding = $data['20Z'] ?? null;
//IDs used by Mouser
$this->mouserPositionInOrder = $data['14K'] ?? null;
$this->mouserManufacturer = $data['1V'] ?? null;
}
/**
* Tries to guess the vendor of the barcode based on the supplied data field.
* This is experimental and should not be relied upon.
* @return string|null The guessed vendor as smallcase string (e.g. "digikey", "mouser", etc.), or null if the vendor could not be guessed
*/
public function guessBarcodeVendor(): ?string
{
//If the barcode data contains the digikey extensions, we assume it is a digikey barcode
if (isset($this->data['13Z']) || isset($this->data['20Z']) || isset($this->data['12Z']) || isset($this->data['11Z'])) {
return 'digikey';
}
//If the barcode data contains the mouser extensions, we assume it is a mouser barcode
if (isset($this->data['14K']) || isset($this->data['1V'])) {
return 'mouser';
}
//According to this thread (https://github.com/inventree/InvenTree/issues/853), Newark/element14 codes contains a "3P" field
if (isset($this->data['3P'])) {
return 'element14';
}
return null;
}
/**
* Checks if the given input is a valid format06 formatted data.
* This just perform a simple check for the header, the content might be malformed still.
* @param string $input
* @return bool
*/
public static function isFormat06Code(string $input): bool
{
//Code must begin with [)><RS>06<GS>
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){
return false;
}
//Digikey does not put a trailer onto the barcode, so we just check for the header
return true;
}
/**
* Parses a format06 code a returns a new instance of this class
* @param string $input
* @return self
*/
public static function parseFormat06Code(string $input): self
{
//Ensure that the input is a valid format06 code
if (!self::isFormat06Code($input)) {
throw new \InvalidArgumentException("The given input is not a valid format06 code");
}
//Remove the trailer, if present
if (str_ends_with($input, "\u{1E}\u{04}")){
$input = substr($input, 5, -2);
}
//Split the input into the different fields (using the <GS> separator)
$parts = explode("\u{1D}", $input);
//The first field is the format identifier, which we do not need
array_shift($parts);
//Split the fields into key-value pairs
$results = [];
foreach($parts as $part) {
//^ 0* ([1-9]? \d* [A-Z])
//Start of the string Leading zeros are discarded Not a zero Any number of digits single uppercase Letter
// 00 1 4 K
if(!preg_match('/^0*([1-9]?\d*[A-Z])/', $part, $matches)) {
throw new \LogicException("Could not parse field: $part");
}
//Extract the key
$key = $matches[0];
//Extract the field value
$fieldValue = substr($part, strlen($matches[0]));
$results[$key] = $fieldValue;
}
return new self($results);
}
public function getDecodedForInfoMode(): array
{
$tmp = [
'Barcode type' => 'EIGP114',
'Guessed vendor from barcode' => $this->guessBarcodeVendor() ?? 'Unknown',
];
//Iterate over all fields of this object and add them to the array if they are not null
foreach((array) $this as $key => $value) {
//Skip data key
if ($key === 'data') {
continue;
}
if($value !== null) {
$tmp[$key] = $value;
}
}
return $tmp;
}
}

View file

@ -21,14 +21,15 @@
declare(strict_types=1);
namespace App\Services\LabelSystem\Barcodes;
namespace App\Services\LabelSystem\BarcodeScanner;
use App\Entity\LabelSystem\LabelSupportedElement;
/**
* This class represents the result of a barcode scan, with the target type and the ID of the element
* This class represents the result of a barcode scan of a barcode that uniquely identifies a local entity,
* like an internally generated barcode or a barcode that was added manually to the system by a user
*/
class BarcodeScanResult
class LocalBarcodeScanResult implements BarcodeScanResultInterface
{
public function __construct(
public readonly LabelSupportedElement $target_type,
@ -36,4 +37,13 @@ class BarcodeScanResult
public readonly BarcodeSourceType $source_type,
) {
}
public function getDecodedForInfoMode(): array
{
return [
'Barcode type' => $this->source_type->name,
'Target type' => $this->target_type->name,
'Target ID' => $this->target_id,
];
}
}

View file

@ -1,89 +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);
/**
* 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/>.
*/
namespace App\Services\LabelSystem\Barcodes;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
*/
final class BarcodeRedirector
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em)
{
}
/**
* Determines the URL to which the user should be redirected, when scanning a QR code.
*
* @param BarcodeScanResult $barcodeScan The result of the barcode scan
* @return string the URL to which should be redirected
*
* @throws EntityNotFoundException
*/
public function getRedirectURL(BarcodeScanResult $barcodeScan): string
{
switch ($barcodeScan->target_type) {
case LabelSupportedElement::PART:
return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
case LabelSupportedElement::PART_LOT:
//Try to determine the part to the given lot
$lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
if (!$lot instanceof PartLot) {
throw new EntityNotFoundException();
}
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
case LabelSupportedElement::STORELOCATION:
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
default:
throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
}
}
}

View file

@ -146,11 +146,11 @@ final class LabelExampleElementsGenerator
throw new InvalidArgumentException('$class must be an child of AbstractStructuralDBElement');
}
/** @var AbstractStructuralDBElement $parent */
/** @var T $parent */
$parent = new $class();
$parent->setName('Example');
/** @var AbstractStructuralDBElement $child */
/** @var T $child */
$child = new $class();
$child->setName((new ReflectionClass($class))->getShortName());
$child->setParent($parent);

View file

@ -62,10 +62,6 @@ final class LabelGenerator
*/
public function generateLabel(LabelOptions $options, object|array $elements): string
{
if (!is_array($elements) && !is_object($elements)) {
throw new InvalidArgumentException('$element must be an object or an array of objects!');
}
if (!is_array($elements)) {
$elements = [$elements];
}

View file

@ -74,8 +74,7 @@ final class LabelProfileDropdownHelper
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(LabelProfile::class);
$key = 'profile_dropdown_'.$this->keyGenerator->generateKey().'_'.$secure_class_name.'_'.$type->value;
/** @var LabelProfileRepository $repo */
$repo = $this->entityManager->getRepository(LabelProfile::class);
return $this->cache->get($key, function (ItemInterface $item) use ($repo, $type, $secure_class_name) {

View file

@ -71,6 +71,7 @@ use App\Twig\TwigCoreExtension;
use InvalidArgumentException;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Extra\Html\HtmlExtension;
use Twig\Extra\Intl\IntlExtension;
use Twig\Extra\Markdown\MarkdownExtension;
use Twig\Extra\String\StringExtension;
@ -183,6 +184,7 @@ final class SandboxedTwigFactory
$twig->addExtension(new IntlExtension());
$twig->addExtension(new MarkdownExtension());
$twig->addExtension(new StringExtension());
$twig->addExtension(new HtmlExtension());
//Add Part-DB specific extension
$twig->addExtension($this->formatExtension);

View file

@ -216,7 +216,10 @@ class TimeTravel
$old_data = $logEntry->getOldData();
foreach ($old_data as $field => $data) {
if ($metadata->hasField($field)) {
//We use the fieldMappings property directly instead of the hasField method, as we do not want to match the embedded field itself
//The sub fields are handled in the setField method
if (isset($metadata->fieldMappings[$field])) {
//We need to convert the string to a BigDecimal first
if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)->type)) {
$data = BigDecimal::of($data);
@ -224,7 +227,12 @@ class TimeTravel
if (!$data instanceof \DateTimeInterface
&& (in_array($metadata->getFieldMapping($field)->type,
[Types::DATETIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATE_MUTABLE, Types::DATETIME_IMMUTABLE], true))) {
[
Types::DATETIME_IMMUTABLE,
Types::DATETIME_IMMUTABLE,
Types::DATE_MUTABLE,
Types::DATETIME_IMMUTABLE
], true))) {
$data = $this->dateTimeDecode($data, $metadata->getFieldMapping($field)->type);
}
@ -267,9 +275,11 @@ class TimeTravel
$embeddedReflection = new ReflectionClass($embeddedClass::class);
$property = $embeddedReflection->getProperty($embedded_field);
$target_element = $embeddedClass;
} else {
$reflection = new ReflectionClass($element::class);
$property = $reflection->getProperty($field);
$target_element = $element;
}
//Check if the property is an BackedEnum, then convert the int or float value to an enum instance
@ -281,6 +291,6 @@ class TimeTravel
$new_value = $enum_class::from($new_value);
}
$property->setValue($element, $new_value);
$property->setValue($target_element, $new_value);
}
}

View file

@ -53,7 +53,6 @@ final class PartsTableActionHandler
{
$id_array = explode(',', $ids);
/** @var PartRepository $repo */
$repo = $this->entityManager->getRepository(Part::class);
return $repo->getElementsFromIDArray($id_array);

View file

@ -122,7 +122,6 @@ class StatisticsHelper
throw new InvalidArgumentException('No count for the given type available!');
}
/** @var EntityRepository $repo */
$repo = $this->em->getRepository($arr[$type]);
return $repo->count([]);

View file

@ -66,7 +66,7 @@ class TagFinder
$qb->select('p.tags')
->from(Part::class, 'p')
->where('p.tags LIKE ?1')
->where('ILIKE(p.tags, ?1) = TRUE')
->setMaxResults($options['query_limit'])
//->orderBy('RAND()')
->setParameter(1, '%'.$keyword.'%');

View file

@ -23,9 +23,11 @@ declare(strict_types=1);
namespace App\Services\Trees;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Repository\AttachmentContainingDBElementRepository;
use App\Repository\DBElementRepository;
use App\Repository\NamedDBElementRepository;
use App\Repository\StructuralDBElementRepository;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\Cache\UserCacheKeyGenerator;
@ -51,7 +53,7 @@ class NodesListBuilder
* Gets a flattened hierarchical tree. Useful for generating option lists.
* In difference to the Repository Function, the results here are cached.
*
* @template T of AbstractDBElement
* @template T of AbstractNamedDBElement
*
* @param string $class_name the class name of the entity you want to retrieve
* @phpstan-param class-string<T> $class_name
@ -69,7 +71,7 @@ class NodesListBuilder
$ids = $this->getFlattenedIDs($class_name, $parent);
//Retrieve the elements from the IDs, the order is the same as in the $ids array
/** @var DBElementRepository $repo */
/** @var NamedDBElementRepository<T> $repo */
$repo = $this->em->getRepository($class_name);
if ($repo instanceof AttachmentContainingDBElementRepository) {
@ -81,7 +83,9 @@ class NodesListBuilder
/**
* This functions returns the (cached) list of the IDs of the elements for the flattened tree.
* @template T of AbstractNamedDBElement
* @param string $class_name
* @phpstan-param class-string<T> $class_name
* @param AbstractStructuralDBElement|null $parent
* @return int[]
*/
@ -96,10 +100,12 @@ class NodesListBuilder
// Invalidate when groups, an element with the class or the user changes
$item->tag(['groups', 'tree_list', $this->keyGenerator->generateKey(), $secure_class_name]);
/** @var StructuralDBElementRepository $repo */
/** @var NamedDBElementRepository<T> $repo */
$repo = $this->em->getRepository($class_name);
return array_map(static fn(AbstractDBElement $element) => $element->getID(), $repo->getFlatList($parent));
return array_map(static fn(AbstractDBElement $element) => $element->getID(),
//@phpstan-ignore-next-line For some reason phpstan does not understand that $repo is a StructuralDBElementRepository
$repo->getFlatList($parent));
});
}

View file

@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\ProjectSystem\Project;
use App\Helpers\Trees\TreeViewNode;
use App\Helpers\Trees\TreeViewNodeIterator;
use App\Repository\NamedDBElementRepository;
use App\Repository\StructuralDBElementRepository;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\Cache\UserCacheKeyGenerator;
@ -224,6 +225,7 @@ class TreeViewGenerator
* The treeview is generic, that means the href are null and ID values are set.
*
* @param string $class The class for which the tree should be generated
* @phpstan-param class-string<AbstractNamedDBElement> $class
* @param AbstractStructuralDBElement|null $parent the parent the root elements should have
*
* @return TreeViewNode[]
@ -237,12 +239,12 @@ class TreeViewGenerator
throw new InvalidArgumentException('$parent must be of the type $class!');
}
/** @var StructuralDBElementRepository $repo */
/** @var NamedDBElementRepository<AbstractNamedDBElement> $repo */
$repo = $this->em->getRepository($class);
//If we just want a part of a tree, don't cache it
if ($parent instanceof AbstractStructuralDBElement) {
return $repo->getGenericNodeTree($parent);
return $repo->getGenericNodeTree($parent); //@phpstan-ignore-line PHPstan does not seem to recognize, that we have a StructuralDBElementRepository here, which have 1 argument
}
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag($class);
@ -251,7 +253,7 @@ class TreeViewGenerator
return $this->cache->get($key, function (ItemInterface $item) use ($repo, $parent, $secure_class_name) {
// Invalidate when groups, an element with the class or the user changes
$item->tag(['groups', 'tree_treeview', $this->keyGenerator->generateKey(), $secure_class_name]);
return $repo->getGenericNodeTree($parent);
return $repo->getGenericNodeTree($parent); //@phpstan-ignore-line
});
}
}

View file

@ -0,0 +1,242 @@
<?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\Translation\Fixes;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Translation\Dumper\FileDumper;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
/**
* Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
* metadata when editing the translations from inside Symfony.
*/
#[AsDecorator("translation.dumper.xliff")]
class SegmentAwareXliffFileDumper extends FileDumper
{
public function __construct(
private string $extension = 'xlf',
) {
}
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
{
$xliffVersion = '1.2';
if (\array_key_exists('xliff_version', $options)) {
$xliffVersion = $options['xliff_version'];
}
if (\array_key_exists('default_locale', $options)) {
$defaultLocale = $options['default_locale'];
} else {
$defaultLocale = \Locale::getDefault();
}
if ('1.2' === $xliffVersion) {
return $this->dumpXliff1($defaultLocale, $messages, $domain, $options);
}
if ('2.0' === $xliffVersion) {
return $this->dumpXliff2($defaultLocale, $messages, $domain);
}
throw new InvalidArgumentException(\sprintf('No support implemented for dumping XLIFF version "%s".', $xliffVersion));
}
protected function getExtension(): string
{
return $this->extension;
}
private function dumpXliff1(string $defaultLocale, MessageCatalogue $messages, ?string $domain, array $options = []): string
{
$toolInfo = ['tool-id' => 'symfony', 'tool-name' => 'Symfony'];
if (\array_key_exists('tool_info', $options)) {
$toolInfo = array_merge($toolInfo, $options['tool_info']);
}
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$xliff = $dom->appendChild($dom->createElement('xliff'));
$xliff->setAttribute('version', '1.2');
$xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:1.2');
$xliffFile = $xliff->appendChild($dom->createElement('file'));
$xliffFile->setAttribute('source-language', str_replace('_', '-', $defaultLocale));
$xliffFile->setAttribute('target-language', str_replace('_', '-', $messages->getLocale()));
$xliffFile->setAttribute('datatype', 'plaintext');
$xliffFile->setAttribute('original', 'file.ext');
$xliffHead = $xliffFile->appendChild($dom->createElement('header'));
$xliffTool = $xliffHead->appendChild($dom->createElement('tool'));
foreach ($toolInfo as $id => $value) {
$xliffTool->setAttribute($id, $value);
}
if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
$xliffPropGroup = $xliffHead->appendChild($dom->createElement('prop-group'));
foreach ($catalogueMetadata as $key => $value) {
$xliffProp = $xliffPropGroup->appendChild($dom->createElement('prop'));
$xliffProp->setAttribute('prop-type', $key);
$xliffProp->appendChild($dom->createTextNode($value));
}
}
$xliffBody = $xliffFile->appendChild($dom->createElement('body'));
foreach ($messages->all($domain) as $source => $target) {
$translation = $dom->createElement('trans-unit');
$translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
$translation->setAttribute('resname', $source);
$s = $translation->appendChild($dom->createElement('source'));
$s->appendChild($dom->createTextNode($source));
// Does the target contain characters requiring a CDATA section?
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
$targetElement = $dom->createElement('target');
$metadata = $messages->getMetadata($source, $domain);
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
foreach ($metadata['target-attributes'] as $name => $value) {
$targetElement->setAttribute($name, $value);
}
}
$t = $translation->appendChild($targetElement);
$t->appendChild($text);
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
foreach ($metadata['notes'] as $note) {
if (!isset($note['content'])) {
continue;
}
$n = $translation->appendChild($dom->createElement('note'));
$n->appendChild($dom->createTextNode($note['content']));
if (isset($note['priority'])) {
$n->setAttribute('priority', $note['priority']);
}
if (isset($note['from'])) {
$n->setAttribute('from', $note['from']);
}
}
}
$xliffBody->appendChild($translation);
}
return $dom->saveXML();
}
private function dumpXliff2(string $defaultLocale, MessageCatalogue $messages, ?string $domain): string
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$xliff = $dom->appendChild($dom->createElement('xliff'));
$xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:2.0');
$xliff->setAttribute('version', '2.0');
$xliff->setAttribute('srcLang', str_replace('_', '-', $defaultLocale));
$xliff->setAttribute('trgLang', str_replace('_', '-', $messages->getLocale()));
$xliffFile = $xliff->appendChild($dom->createElement('file'));
if (str_ends_with($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
$xliffFile->setAttribute('id', substr($domain, 0, -\strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX)).'.'.$messages->getLocale());
} else {
$xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale());
}
if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
$xliff->setAttribute('xmlns:m', 'urn:oasis:names:tc:xliff:metadata:2.0');
$xliffMetadata = $xliffFile->appendChild($dom->createElement('m:metadata'));
foreach ($catalogueMetadata as $key => $value) {
$xliffMeta = $xliffMetadata->appendChild($dom->createElement('prop'));
$xliffMeta->setAttribute('type', $key);
$xliffMeta->appendChild($dom->createTextNode($value));
}
}
foreach ($messages->all($domain) as $source => $target) {
$translation = $dom->createElement('unit');
$translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
if (\strlen($source) <= 80) {
$translation->setAttribute('name', $source);
}
$metadata = $messages->getMetadata($source, $domain);
// Add notes section
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
$notesElement = $dom->createElement('notes');
foreach ($metadata['notes'] as $note) {
$n = $dom->createElement('note');
$n->appendChild($dom->createTextNode($note['content'] ?? ''));
unset($note['content']);
foreach ($note as $name => $value) {
$n->setAttribute($name, $value);
}
$notesElement->appendChild($n);
}
$translation->appendChild($notesElement);
}
$segment = $translation->appendChild($dom->createElement('segment'));
if ($this->hasMetadataArrayInfo('segment-attributes', $metadata)) {
foreach ($metadata['segment-attributes'] as $name => $value) {
$segment->setAttribute($name, $value);
}
}
$s = $segment->appendChild($dom->createElement('source'));
$s->appendChild($dom->createTextNode($source));
// Does the target contain characters requiring a CDATA section?
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
$targetElement = $dom->createElement('target');
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
foreach ($metadata['target-attributes'] as $name => $value) {
$targetElement->setAttribute($name, $value);
}
}
$t = $segment->appendChild($targetElement);
$t->appendChild($text);
$xliffFile->appendChild($translation);
}
return $dom->saveXML();
}
private function hasMetadataArrayInfo(string $key, ?array $metadata = null): bool
{
return is_iterable($metadata[$key] ?? null);
}
}

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