mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-15 23:49:31 +00:00
Merge branch 'master' into settings-bundle
This commit is contained in:
commit
8750573724
191 changed files with 27745 additions and 12133 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
102
src/ApiPlatform/Filter/TagFilter.php
Normal file
102
src/ApiPlatform/Filter/TagFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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 '???';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
71
src/Doctrine/Functions/ILike.php
Normal file
71
src/Doctrine/Functions/ILike.php
Normal 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) . ')';
|
||||
}
|
||||
}
|
||||
|
|
@ -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(...));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'])]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
src/Helpers/IPAnonymizer.php
Normal file
49
src/Helpers/IPAnonymizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.'%');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'] ?? '->';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
77
src/Services/InfoProviderSystem/ExistingPartFinder.php
Normal file
77
src/Services/InfoProviderSystem/ExistingPartFinder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
1471
src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php
Normal file
1471
src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php
Normal file
File diff suppressed because it is too large
Load diff
166
src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
Normal file
166
src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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.'%');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
src/Translation/Fixes/SegmentAwareXliffFileDumper.php
Normal file
242
src/Translation/Fixes/SegmentAwareXliffFileDumper.php
Normal 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
Loading…
Add table
Add a link
Reference in a new issue