Merge branch 'master' into settings-bundle

This commit is contained in:
Jan Böhmer 2025-06-15 18:39:49 +02:00
commit 442457f11b
131 changed files with 12759 additions and 6750 deletions

View file

@ -1,116 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 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\ApiPlatform;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This decorator adds the properties given by DocumentedAPIProperty attributes on the classes to the schema.
*/
#[AsDecorator('api_platform.json_schema.schema_factory')]
class AddDocumentedAPIPropertiesJSONSchemaFactory implements SchemaFactoryInterface
{
public function __construct(private readonly SchemaFactoryInterface $decorated)
{
}
public function buildSchema(
string $className,
string $format = 'json',
string $type = Schema::TYPE_OUTPUT,
Operation $operation = null,
Schema $schema = null,
array $serializerContext = null,
bool $forceCollection = false
): Schema {
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
//Check if there is are DocumentedAPIProperty attributes on the class
$reflectionClass = new \ReflectionClass($className);
$attributes = $reflectionClass->getAttributes(DocumentedAPIProperty::class);
foreach ($attributes as $attribute) {
/** @var DocumentedAPIProperty $api_property */
$api_property = $attribute->newInstance();
$this->addPropertyToSchema($schema, $api_property->schemaName, $api_property->property,
$api_property, $serializerContext ?? [], $format);
}
return $schema;
}
private function addPropertyToSchema(Schema $schema, string $definitionName, string $normalizedPropertyName, DocumentedAPIProperty $propertyMetadata, array $serializerContext, string $format): void
{
$version = $schema->getVersion();
$swagger = Schema::VERSION_SWAGGER === $version;
$propertySchema = [];
if (false === $propertyMetadata->writeable) {
$propertySchema['readOnly'] = true;
}
if (!$swagger && false === $propertyMetadata->readable) {
$propertySchema['writeOnly'] = true;
}
if (null !== $description = $propertyMetadata->description) {
$propertySchema['description'] = $description;
}
$deprecationReason = $propertyMetadata->deprecationReason;
// see https://github.com/json-schema-org/json-schema-spec/pull/737
if (!$swagger && null !== $deprecationReason) {
$propertySchema['deprecated'] = true;
}
if (!empty($default = $propertyMetadata->default)) {
if ($default instanceof \BackedEnum) {
$default = $default->value;
}
$propertySchema['default'] = $default;
}
if (!empty($example = $propertyMetadata->example)) {
$propertySchema['example'] = $example;
}
if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
$propertySchema['example'] = $propertySchema['default'];
}
$propertySchema['type'] = $propertyMetadata->type;
$propertySchema['nullable'] = $propertyMetadata->nullable;
$propertySchema = new \ArrayObject($propertySchema);
$schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
}
}

View file

@ -21,7 +21,9 @@
declare(strict_types=1);
namespace App\ApiPlatform;
namespace App\ApiPlatform\DocumentedAPIProperties;
use ApiPlatform\Metadata\ApiProperty;
/**
* When this attribute is applied to a class, an property will be added to the API documentation using the given parameters.
@ -64,4 +66,55 @@ final class DocumentedAPIProperty
)
{
}
public function toAPIProperty(bool $use_swagger = false): ApiProperty
{
$openApiContext = [];
if (false === $this->writeable) {
$openApiContext['readOnly'] = true;
}
if (!$use_swagger && false === $this->readable) {
$openApiContext['writeOnly'] = true;
}
if (null !== $description = $this->description) {
$openApiContext['description'] = $description;
}
$deprecationReason = $this->deprecationReason;
// see https://github.com/json-schema-org/json-schema-spec/pull/737
if (!$use_swagger && null !== $deprecationReason) {
$openApiContext['deprecated'] = true;
}
if (!empty($default = $this->default)) {
if ($default instanceof \BackedEnum) {
$default = $default->value;
}
$openApiContext['default'] = $default;
}
if (!empty($example = $this->example)) {
$openApiContext['example'] = $example;
}
if (!isset($openApiContext['example']) && isset($openApiContext['default'])) {
$openApiContext['example'] = $openApiContext['default'];
}
$openApiContext['type'] = $this->type;
$openApiContext['nullable'] = $this->nullable;
return new ApiProperty(
description: $this->description,
readable: $this->readable,
writable: $this->writeable,
openapiContext: $openApiContext,
types: $this->type,
property: $this->property
);
}
}

View file

@ -0,0 +1,73 @@
<?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\ApiPlatform\DocumentedAPIProperties;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ReflectionClass;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This decorator adds the virtual properties defined by the DocumentedAPIProperty attribute to the property metadata
* which then get picked up by the openapi schema generator
*/
#[AsDecorator('api_platform.metadata.property.metadata_factory')]
class PropertyMetadataFactory implements PropertyMetadataFactoryInterface
{
public function __construct(private PropertyMetadataFactoryInterface $decorated)
{
}
public function create(string $resourceClass, string $property, array $options = []): ApiProperty
{
$metadata = $this->decorated->create($resourceClass, $property, $options);
//Only become active in the context of the openapi schema generation
if (!isset($options['schema_type'])) {
return $metadata;
}
if (!class_exists($resourceClass)) {
return $metadata;
}
$refClass = new ReflectionClass($resourceClass);
$attributes = $refClass->getAttributes(DocumentedAPIProperty::class);
//Look for the DocumentedAPIProperty attribute with the given property name
foreach ($attributes as $attribute) {
/** @var DocumentedAPIProperty $api_property */
$api_property = $attribute->newInstance();
//If attribute not matches the property name, skip it
if ($api_property->property !== $property) {
continue;
}
//Return the virtual property
return $api_property->toAPIProperty();
}
return $metadata;
}
}

View file

@ -0,0 +1,68 @@
<?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\ApiPlatform\DocumentedAPIProperties;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
use ReflectionClass;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* This decorator adds the virtual property names defined by the DocumentedAPIProperty attribute to the property name collection
* which then get picked up by the openapi schema generator
*/
#[AsDecorator('api_platform.metadata.property.name_collection_factory')]
class PropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface
{
public function __construct(private readonly PropertyNameCollectionFactoryInterface $decorated)
{
}
public function create(string $resourceClass, array $options = []): PropertyNameCollection
{
// Get the default properties from the decorated service
$propertyNames = $this->decorated->create($resourceClass, $options);
//Only become active in the context of the openapi schema generation
if (!isset($options['schema_type'])) {
return $propertyNames;
}
if (!class_exists($resourceClass)) {
return $propertyNames;
}
$properties = iterator_to_array($propertyNames);
$refClass = new ReflectionClass($resourceClass);
foreach ($refClass->getAttributes(DocumentedAPIProperty::class) as $attribute) {
/** @var DocumentedAPIProperty $instance */
$instance = $attribute->newInstance();
$properties[] = $instance->property;
}
return new PropertyNameCollection($properties);
}
}

View file

@ -37,7 +37,7 @@ class EntityFilter extends AbstractFilter
public function __construct(
ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper,
LoggerInterface $logger = null,
?LoggerInterface $logger = null,
?array $properties = null,
?NameConverterInterface $nameConverter = null
) {
@ -50,7 +50,7 @@ class EntityFilter extends AbstractFilter
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
?Operation $operation = null,
array $context = []
): void {
if (

View file

@ -92,12 +92,6 @@ class EntityFilterHelper
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter using a comma seperated list of element IDs. Use + to include all direct children and ++ to include all children recursively.',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;

View file

@ -38,7 +38,7 @@ final class LikeFilter extends AbstractFilter
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
?Operation $operation = null,
array $context = []
): void {
// Otherwise filter is applied to order and page as well
@ -67,12 +67,6 @@ final class LikeFilter extends AbstractFilter
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter using a LIKE SQL expression. Use % as wildcard for multiple characters and _ for single characters. For example, to search for all items containing foo, use foo. To search for all items starting with foo, use foo%. To search for all items ending with foo, use %foo',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;

View file

@ -38,7 +38,7 @@ class PartStoragelocationFilter extends AbstractFilter
public function __construct(
ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper,
LoggerInterface $logger = null,
?LoggerInterface $logger = null,
?array $properties = null,
?NameConverterInterface $nameConverter = null
) {
@ -51,7 +51,7 @@ class PartStoragelocationFilter extends AbstractFilter
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
?Operation $operation = null,
array $context = []
): void {
//Do not check for mapping here, as we are using a virtual property

View file

@ -89,12 +89,6 @@ final class TagFilter extends AbstractFilter
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter for tags of a part',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;

View file

@ -0,0 +1,77 @@
<?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\ApiPlatform;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use function Symfony\Component\String\u;
/**
* This decorator removes all camelCase property names from the property name collection, if a snake_case version exists.
* This is a fix for https://github.com/Part-DB/Part-DB-server/issues/862, as the openapi schema generator wrongly collects
* both camelCase and snake_case property names, which leads to duplicate properties in the schema.
* This seems to come from the fact that the openapi schema generator uses no serializerContext, which seems then to collect
* the getters too...
*/
#[AsDecorator('api_platform.metadata.property.name_collection_factory')]
class NormalizePropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface
{
public function __construct(private readonly PropertyNameCollectionFactoryInterface $decorated)
{
}
public function create(string $resourceClass, array $options = []): PropertyNameCollection
{
// Get the default properties from the decorated service
$propertyNames = $this->decorated->create($resourceClass, $options);
//Only become active in the context of the openapi schema generation
if (!isset($options['schema_type'])) {
return $propertyNames;
}
//If we are not in the jsonapi generator (which sets no serializer groups), return the property names as is
if (isset($options['serializer_groups'])) {
return $propertyNames;
}
//Remove all camelCase property names from the collection, if a snake_case version exists
$properties = iterator_to_array($propertyNames);
foreach ($properties as $property) {
if (str_contains($property, '_')) {
$camelized = u($property)->camel()->toString();
//If the camelized version exists, remove it from the collection
$index = array_search($camelized, $properties, true);
if ($index !== false) {
unset($properties[$index]);
}
}
}
return new PropertyNameCollection($properties);
}
}

View file

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

View file

@ -0,0 +1,136 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Command\Attachments;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentUpload;
use App\Exceptions\AttachmentDownloadException;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:download', "Downloads all attachments which have only an external URL to the local filesystem.")]
class DownloadAttachmentsCommand extends Command
{
public function __construct(private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
private EntityManagerInterface $entityManager)
{
parent::__construct();
}
public function configure(): void
{
$this->setHelp('This command downloads all attachments, which only have an external URL, to the local filesystem, so that you have an offline copy of the attachments.');
$this->addOption('--private', null, null, 'If set, the attachments will be downloaded to the private storage.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$qb = $this->entityManager->createQueryBuilder();
$qb->select('attachment')
->from(Attachment::class, 'attachment')
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.external_path != \'\'')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
$attachments = $query->getResult();
if (count($attachments) === 0) {
$io->success('No attachments with external URL found.');
return Command::SUCCESS;
}
$io->note('Found ' . count($attachments) . ' attachments with external URL, that will be downloaded.');
//If the option --private is set, the attachments will be downloaded to the private storage.
$private = $input->getOption('private');
if ($private) {
if (!$io->confirm('Attachments will be downloaded to the private storage. Continue?')) {
return Command::SUCCESS;
}
} else {
if (!$io->confirm('Attachments will be downloaded to the public storage, where everybody knowing the correct URL can access it. Continue?')){
return Command::SUCCESS;
}
}
$progressBar = $io->createProgressBar(count($attachments));
$progressBar->setFormat("%current%/%max% [%bar%] %percent:3s%% %elapsed:16s%/%estimated:-16s% \n%message%");
$progressBar->setMessage('Starting download...');
$progressBar->start();
$errors = [];
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$progressBar->setMessage(sprintf('%s (ID: %s) from %s', $attachment->getName(), $attachment->getID(), $attachment->getHost()));
$progressBar->advance();
try {
$attachmentUpload = new AttachmentUpload(file: null, downloadUrl: true, private: $private);
$this->attachmentSubmitHandler->handleUpload($attachment, $attachmentUpload);
//Write changes to the database
$this->entityManager->flush();
} catch (AttachmentDownloadException $e) {
$errors[] = [
'attachment' => $attachment,
'error' => $e->getMessage()
];
}
}
$progressBar->finish();
//Fix the line break after the progress bar
$io->newLine();
$io->newLine();
if (count($errors) > 0) {
$io->warning('Some attachments could not be downloaded:');
foreach ($errors as $error) {
$io->warning(sprintf("Attachment %s (ID %s) could not be downloaded from %s:\n%s",
$error['attachment']->getName(),
$error['attachment']->getID(),
$error['attachment']->getExternalPath(),
$error['error'])
);
}
} else {
$io->success('All attachments downloaded successfully.');
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,90 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Command\Attachments;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:sanitize-svg', "Sanitize uploaded SVG files.")]
class SanitizeSVGAttachmentsCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly AttachmentSubmitHandler $attachmentSubmitHandler, ?string $name = null)
{
parent::__construct($name);
}
public function configure(): void
{
$this->setHelp('This command allows to sanitize SVG files uploaded via attachments. This happens automatically since version 1.17.1, this command is intended to be used for older files.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->info('This command will sanitize all uploaded SVG files. This is only required if you have uploaded (untrusted) SVG files before version 1.17.1. If you are running a newer version, you don\'t need to run this command (again).');
if (!$io->confirm('Do you want to continue?', false)) {
$io->success('Command aborted.');
return Command::FAILURE;
}
$io->info('Sanitizing SVG files...');
//Finding all attachments with svg files
$qb = $this->entityManager->createQueryBuilder();
$qb->select('a')
->from(Attachment::class, 'a')
->where('a.internal_path LIKE :pattern ESCAPE \'#\'')
->orWhere('a.original_filename LIKE :pattern ESCAPE \'#\'')
->setParameter('pattern', '%.svg');
$attachments = $qb->getQuery()->getResult();
$io->note('Found '.count($attachments).' attachments with SVG files.');
if (count($attachments) === 0) {
$io->success('No SVG files found.');
return Command::FAILURE;
}
$io->info('Sanitizing SVG files...');
$io->progressStart(count($attachments));
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$io->note('Sanitizing attachment '.$attachment->getId().' ('.($attachment->getFilename() ?? '???').')');
$this->attachmentSubmitHandler->sanitizeSVGAttachment($attachment);
$io->progressAdvance();
}
$io->progressFinish();
$io->success('Sanitization finished. All SVG files have been sanitized.');
return Command::SUCCESS;
}
}

View file

@ -35,7 +35,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:users:enable|partdb:user:enable', 'Enables/Disable the login of one or more users')]
class UserEnableCommand extends Command
{
public function __construct(protected EntityManagerInterface $entityManager, string $name = null)
public function __construct(protected EntityManagerInterface $entityManager, ?string $name = null)
{
parent::__construct($name);
}

View file

@ -467,6 +467,11 @@ abstract class BaseAdminController extends AbstractController
$this->denyAccessUnlessGranted('read', $entity);
$entities = $em->getRepository($this->entity_class)->findAll();
if (count($entities) === 0) {
$this->addFlash('error', 'entity.export.flash.error.no_entities');
return $this->redirectToRoute($this->route_base.'_new');
}
return $exporter->exportEntityFromRequest($entities, $request);
}

View file

@ -52,15 +52,15 @@ class AttachmentFileController extends AbstractController
$this->denyAccessUnlessGranted('show_private', $attachment);
}
if ($attachment->isExternal()) {
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
if (!$attachment->hasInternal()) {
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
}
if (!$helper->isFileExisting($attachment)) {
if (!$helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteFilePath($attachment);
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded
@ -81,15 +81,15 @@ class AttachmentFileController extends AbstractController
$this->denyAccessUnlessGranted('show_private', $attachment);
}
if ($attachment->isExternal()) {
throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
if (!$attachment->hasInternal()) {
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
}
if (!$helper->isFileExisting($attachment)) {
if (!$helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteFilePath($attachment);
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded

View file

@ -112,8 +112,9 @@ class PartImportExportController extends AbstractController
$ids = $request->query->get('ids', '');
$parts = $this->partsTableActionHandler->idStringToArray($ids);
if ($parts === []) {
throw new \RuntimeException('No parts found!');
if (count($parts) === 0) {
$this->addFlash('error', 'entity.export.flash.error.no_entities');
return $this->redirectToRoute('homepage');
}
//Ensure that we have access to the parts

View file

@ -29,6 +29,7 @@ use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Exceptions\InvalidRegexException;
@ -44,8 +45,11 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
class PartListsController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager,
@ -77,13 +81,32 @@ class PartListsController extends AbstractController
if (null === $action || null === $ids) {
$this->addFlash('error', 'part.table.actions.no_params_given');
} else {
$errors = [];
$parts = $actionHandler->idStringToArray($ids);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors);
//Save changes
$this->entityManager->flush();
$this->addFlash('success', 'part.table.actions.success');
if (count($errors) === 0) {
$this->addFlash('success', 'part.table.actions.success');
} else {
$this->addFlash('error', t('part.table.actions.error', ['%count%' => count($errors)]));
//Create a flash message for each error
foreach ($errors as $error) {
/** @var Part $part */
$part = $error['part'];
$this->addFlash('error',
t('part.table.actions.error_detail', [
'%part_name%' => $part->getName(),
'%part_id%' => $part->getID(),
'%message%' => $error['message']
])
);
}
}
}
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.

View file

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

View file

@ -131,7 +131,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$attachment = new PartAttachment();
$attachment->setName('Test2');
$attachment->setPath('invalid');
$attachment->setInternalPath('invalid');
$attachment->setShowInTable(true);
$attachment->setAttachmentType($manager->find(AttachmentType::class, 1));
$part->addAttachment($attachment);

View file

@ -54,7 +54,7 @@ class TwoStepORMAdapter extends ORMAdapter
private \Closure|null $query_modifier = null;
public function __construct(ManagerRegistry $registry = null)
public function __construct(?ManagerRegistry $registry = null)
{
parent::__construct($registry);
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {

View file

@ -50,8 +50,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
{
$dataTable->add('dont_matter', RowClassColumn::class, [
'render' => function ($value, Attachment $context): string {
//Mark attachments with missing files yellow
if(!$this->attachmentHelper->isFileExisting($context)){
//Mark attachments yellow which have an internal file linked that doesn't exist
if($context->hasInternal() && !$this->attachmentHelper->isInternalFileExisting($context)){
return 'table-warning';
}
@ -64,8 +64,8 @@ final class AttachmentDataTable implements DataTableTypeInterface
'className' => 'no-colvis',
'render' => function ($value, Attachment $context): string {
if ($context->isPicture()
&& !$context->isExternal()
&& $this->attachmentHelper->isFileExisting($context)) {
&& $this->attachmentHelper->isInternalFileExisting($context)) {
$title = htmlspecialchars($context->getName());
if ($context->getFilename()) {
$title .= ' ('.htmlspecialchars($context->getFilename()).')';
@ -93,26 +93,6 @@ final class AttachmentDataTable implements DataTableTypeInterface
$dataTable->add('name', TextColumn::class, [
'label' => 'attachment.edit.name',
'orderField' => 'NATSORT(attachment.name)',
'render' => function ($value, Attachment $context) {
//Link to external source
if ($context->isExternal()) {
return sprintf(
'<a href="%s" class="link-external">%s</a>',
htmlspecialchars((string) $context->getURL()),
htmlspecialchars($value)
);
}
if ($this->attachmentHelper->isFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
);
}
return $value;
},
]);
$dataTable->add('attachment_type', TextColumn::class, [
@ -136,25 +116,60 @@ final class AttachmentDataTable implements DataTableTypeInterface
),
]);
$dataTable->add('filename', TextColumn::class, [
'label' => $this->translator->trans('attachment.table.filename'),
$dataTable->add('internal_link', TextColumn::class, [
'label' => 'attachment.table.internal_file',
'propertyPath' => 'filename',
'orderField' => 'NATSORT(attachment.original_filename)',
'render' => function ($value, Attachment $context) {
if ($this->attachmentHelper->isInternalFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
);
}
return $value;
}
]);
$dataTable->add('external_link', TextColumn::class, [
'label' => 'attachment.table.external_link',
'propertyPath' => 'host',
'orderField' => 'attachment.external_path',
'render' => function ($value, Attachment $context) {
if ($context->hasExternal()) {
return sprintf(
'<a href="%s" class="link-external" title="%s" target="_blank" rel="noopener">%s</a>',
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars($value),
);
}
return $value;
}
]);
$dataTable->add('filesize', TextColumn::class, [
'label' => $this->translator->trans('attachment.table.filesize'),
'render' => function ($value, Attachment $context) {
if ($context->isExternal()) {
if (!$context->hasInternal()) {
return sprintf(
'<span class="badge bg-primary">
<i class="fas fa-globe fa-fw"></i>%s
</span>',
$this->translator->trans('attachment.external')
$this->translator->trans('attachment.external_only')
);
}
if ($this->attachmentHelper->isFileExisting($context)) {
return $this->attachmentHelper->getHumanFileSize($context);
if ($this->attachmentHelper->isInternalFileExisting($context)) {
return sprintf(
'<span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> %s
</span>',
$this->attachmentHelper->getHumanFileSize($context)
);
}
return sprintf(

View file

@ -45,6 +45,9 @@ class AttachmentFilter implements FilterInterface
public readonly DateTimeConstraint $lastModified;
public readonly DateTimeConstraint $addedDate;
public readonly TextConstraint $originalFileName;
public readonly TextConstraint $externalLink;
public function __construct(NodesListBuilder $nodesListBuilder)
{
@ -55,6 +58,9 @@ class AttachmentFilter implements FilterInterface
$this->lastModified = new DateTimeConstraint('attachment.lastModified');
$this->addedDate = new DateTimeConstraint('attachment.addedDate');
$this->showInTable = new BooleanConstraint('attachment.show_in_table');
$this->originalFileName = new TextConstraint('attachment.original_filename');
$this->externalLink = new TextConstraint('attachment.external_path');
}
public function apply(QueryBuilder $queryBuilder): void

View file

@ -45,7 +45,7 @@ abstract class AbstractConstraint implements FilterInterface
* @var string The property where this BooleanConstraint should apply to
*/
protected string $property,
string $identifier = null)
?string $identifier = null)
{
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
}

View file

@ -28,7 +28,7 @@ class BooleanConstraint extends AbstractConstraint
{
public function __construct(
string $property,
string $identifier = null,
?string $identifier = null,
/** @var bool|null The value of our constraint */
protected ?bool $value = null
)

View file

@ -34,7 +34,7 @@ class DateTimeConstraint extends AbstractConstraint
public function __construct(
string $property,
string $identifier = null,
?string $identifier = null,
/**
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/

View file

@ -46,7 +46,7 @@ class EntityConstraint extends AbstractConstraint
public function __construct(protected ?NodesListBuilder $nodesListBuilder,
protected string $class,
string $property,
string $identifier = null,
?string $identifier = null,
protected ?AbstractDBElement $value = null,
protected ?string $operator = null)
{

View file

@ -31,7 +31,7 @@ class NumberConstraint extends AbstractConstraint
public function __construct(
string $property,
string $identifier = null,
?string $identifier = null,
/**
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/

View file

@ -28,7 +28,7 @@ use Doctrine\ORM\QueryBuilder;
class LessThanDesiredConstraint extends BooleanConstraint
{
public function __construct(string $property = null, string $identifier = null, ?bool $default_value = null)
public function __construct(?string $property = null, ?string $identifier = null, ?bool $default_value = null)
{
parent::__construct($property ?? '(
SELECT COALESCE(SUM(ld_partLot.amount), 0.0)

View file

@ -30,7 +30,7 @@ class TagsConstraint extends AbstractConstraint
{
final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
public function __construct(string $property, string $identifier = null,
public function __construct(string $property, ?string $identifier = null,
protected ?string $value = null,
protected ?string $operator = '')
{

View file

@ -32,7 +32,7 @@ class TextConstraint extends AbstractConstraint
/**
* @param string $value
*/
public function __construct(string $property, string $identifier = null, /**
public function __construct(string $property, ?string $identifier = null, /**
* @var string|null The value to compare to
*/
protected ?string $value = null, /**

View file

@ -58,6 +58,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface
{
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
public function __construct(
private readonly EntityURLGenerator $urlGenerator,
private readonly TranslatorInterface $translator,

View file

@ -33,23 +33,24 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\ApiPlatform\DocumentedAPIProperty;
use App\ApiPlatform\DocumentedAPIProperties\DocumentedAPIProperty;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\HandleAttachmentsUploadsProcessor;
use App\Repository\AttachmentRepository;
use App\EntityListeners\AttachmentDeleteListener;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractNamedDBElement;
use App\EntityListeners\AttachmentDeleteListener;
use App\Repository\AttachmentRepository;
use App\Validator\Constraints\Selectable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use LogicException;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
use function in_array;
use InvalidArgumentException;
use LogicException;
/**
* Class Attachment.
@ -78,11 +79,16 @@ use LogicException;
denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
processor: HandleAttachmentsUploadsProcessor::class,
)]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true,
description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.',
example: '/media/part/2/bc547-6508afa5a79c8.pdf')]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.')]
//This property is added by the denormalizer in order to resolve the placeholder
#[DocumentedAPIProperty(
schemaName: 'Attachment-Read', property: 'internal_path', type: 'string', nullable: false,
description: 'The URL to the internally saved copy of the file, if one exists',
example: '/media/part/2/bc547-6508afa5a79c8.pdf'
)]
#[DocumentedAPIProperty(
schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.'
)]
#[ApiFilter(LikeFilter::class, properties: ["name"])]
#[ApiFilter(EntityFilter::class, properties: ["attachment_type"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
@ -91,8 +97,8 @@ use LogicException;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['PartDB\Part' => PartAttachment::class, 'Part' => PartAttachment::class,
'PartDB\Device' => ProjectAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class,
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class,
@ -119,10 +125,6 @@ abstract class Attachment extends AbstractNamedDBElement
*/
final public const MODEL_EXTS = ['x3d'];
/**
* When the path begins with one of the placeholders.
*/
final public const INTERNAL_PLACEHOLDER = ['%BASE%', '%MEDIA%', '%SECURE%'];
/**
* @var array placeholders for attachments which using built in files
@ -152,10 +154,21 @@ abstract class Attachment extends AbstractNamedDBElement
protected ?string $original_filename = null;
/**
* @var string The path to the file relative to a placeholder path like %MEDIA%
* @var string|null If a copy of the file is stored internally, the path to the file relative to a placeholder
* path like %MEDIA%
*/
#[ORM\Column(name: 'path', type: Types::STRING)]
protected string $path = '';
#[ORM\Column(type: Types::STRING, nullable: true)]
protected ?string $internal_path = null;
/**
* @var string|null The path to the external source if the file is stored externally or was downloaded from an
* external source. Null if there is no external source.
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['attachment:read'])]
#[ApiProperty(example: 'http://example.com/image.jpg')]
protected ?string $external_path = null;
/**
* @var string the name of this element
@ -237,7 +250,7 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Check if this attachment is a picture (analyse the file's extension).
* If the link is external, it is assumed that this is true.
* If the link is only external and doesn't contain an extension, it is assumed that this is true.
*
* @return bool * true if the file extension is a picture extension
* * otherwise false
@ -245,54 +258,67 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])]
public function isPicture(): bool
{
if ($this->isExternal()) {
if($this->hasInternal()){
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
}
if ($this->hasExternal()) {
//Check if we can extract a file extension from the URL
$extension = pathinfo(parse_url($this->path, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
$extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
//If no extension is found or it is known picture extension, we assume that this is a picture extension
return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true);
}
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
//File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture...
return false;
}
/**
* Check if this attachment is a 3D model and therefore can be directly shown to user.
* If the attachment is external, false is returned (3D Models must be internal).
* If no internal copy exists, false is returned (3D Models must be internal).
*/
#[Groups(['attachment:read'])]
#[SerializedName('3d_model')]
public function is3DModel(): bool
{
//We just assume that 3D Models are internally saved, otherwise we get problems loading them.
if ($this->isExternal()) {
if (!$this->hasInternal()) {
return false;
}
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::MODEL_EXTS, true);
}
/**
* Checks if the attachment file is externally saved (the database saves an URL).
* Checks if this attachment has a path to an external file
*
* @return bool true, if the file is saved externally
* @return bool true, if there is a path to an external file
* @phpstan-assert-if-true non-empty-string $this->external_path
* @phpstan-assert-if-true non-empty-string $this->getExternalPath())
*/
#[Groups(['attachment:read'])]
public function isExternal(): bool
public function hasExternal(): bool
{
//When path is empty, this attachment can not be external
if ($this->path === '') {
return false;
}
return $this->external_path !== null && $this->external_path !== '';
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $this->path);
return !in_array($tmp[0], array_merge(static::INTERNAL_PLACEHOLDER, static::BUILTIN_PLACEHOLDER), true);
/**
* Checks if this attachment has a path to an internal file.
* Does not check if the file exists.
*
* @return bool true, if there is a path to an internal file
* @phpstan-assert-if-true non-empty-string $this->internal_path
* @phpstan-assert-if-true non-empty-string $this->getInternalPath())
*/
#[Groups(['attachment:read'])]
public function hasInternal(): bool
{
return $this->internal_path !== null && $this->internal_path !== '';
}
/**
@ -305,8 +331,12 @@ abstract class Attachment extends AbstractNamedDBElement
#[SerializedName('private')]
public function isSecure(): bool
{
if ($this->internal_path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $this->path);
$tmp = explode('/', $this->internal_path);
return '%SECURE%' === $tmp[0];
}
@ -320,7 +350,11 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])]
public function isBuiltIn(): bool
{
return static::checkIfBuiltin($this->path);
if ($this->internal_path === null) {
return false;
}
return static::checkIfBuiltin($this->internal_path);
}
/********************************************************************************
@ -332,13 +366,13 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Returns the extension of the file referenced via the attachment.
* For a path like %BASE/path/foo.bar, bar will be returned.
* If this attachment is external null is returned.
* If this attachment is only external null is returned.
*
* @return string|null the file extension in lower case
*/
public function getExtension(): ?string
{
if ($this->isExternal()) {
if (!$this->hasInternal()) {
return null;
}
@ -346,7 +380,7 @@ abstract class Attachment extends AbstractNamedDBElement
return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION));
}
return strtolower(pathinfo($this->getPath(), PATHINFO_EXTENSION));
return strtolower(pathinfo($this->getInternalPath(), PATHINFO_EXTENSION));
}
/**
@ -361,52 +395,54 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
* The URL to the external file, or the path to the built-in file.
* The URL to the external file, or the path to the built-in file, but not paths to uploaded files.
* Returns null, if the file is not external (and not builtin).
* The output of this function is such, that no changes occur when it is fed back into setURL().
* Required for the Attachment form field.
*/
#[Groups(['attachment:read'])]
#[SerializedName('url')]
public function getURL(): ?string
{
if (!$this->isExternal() && !$this->isBuiltIn()) {
return null;
if($this->hasExternal()){
return $this->getExternalPath();
}
return $this->path;
if($this->isBuiltIn()){
return $this->getInternalPath();
}
return null;
}
/**
* Returns the hostname where the external file is stored.
* Returns null, if the file is not external.
* Returns null, if there is no external path.
*/
public function getHost(): ?string
{
if (!$this->isExternal()) {
if (!$this->hasExternal()) {
return null;
}
return parse_url((string) $this->getURL(), PHP_URL_HOST);
return parse_url($this->getExternalPath(), PHP_URL_HOST);
}
/**
* Get the filepath, relative to %BASE%.
*
* @return string A string like %BASE/path/foo.bar
*/
public function getPath(): string
public function getInternalPath(): ?string
{
return $this->path;
return $this->internal_path;
}
public function getExternalPath(): ?string
{
return $this->external_path;
}
/**
* Returns the filename of the attachment.
* For a path like %BASE/path/foo.bar, foo.bar will be returned.
*
* If the path is a URL (can be checked via isExternal()), null will be returned.
* If there is no internal copy of the file, null will be returned.
*/
public function getFilename(): ?string
{
if ($this->isExternal()) {
if (!$this->hasInternal()) {
return null;
}
@ -415,7 +451,7 @@ abstract class Attachment extends AbstractNamedDBElement
return $this->original_filename;
}
return pathinfo($this->getPath(), PATHINFO_BASENAME);
return pathinfo($this->getInternalPath(), PATHINFO_BASENAME);
}
/**
@ -488,15 +524,12 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
* Sets the filepath (with relative placeholder) for this attachment.
*
* @param string $path the new filepath of the attachment
*
* @return Attachment
* Sets the path to a file hosted internally. If you set this path to a file that was not downloaded from the
* external source in external_path, make sure to reset external_path.
*/
public function setPath(string $path): self
public function setInternalPath(?string $internal_path): self
{
$this->path = $path;
$this->internal_path = $internal_path;
return $this;
}
@ -512,34 +545,60 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
* Sets the url associated with this attachment.
* If the url is empty nothing is changed, to not override the file path.
*
* @return Attachment
* Sets up the paths using a user provided string which might contain an external path or a builtin path. Allows
* resetting the external path if an internal path exists. Resets any other paths if a (nonempty) new path is set.
*/
#[Groups(['attachment:write'])]
#[SerializedName('url')]
#[ApiProperty(description: 'Set the path of the attachment here.
Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
string if the attachment has an internal file associated and you\'d like to reset the external source.
If you set a new (nonempty) file path any associated internal file will be removed!')]
public function setURL(?string $url): self
{
//Do nothing if the URL is empty
if ($url === null || $url === '') {
//Don't allow the user to set an empty external path if the internal path is empty already
if (($url === null || $url === "") && !$this->hasInternal()) {
return $this;
}
$url = trim($url);
//Escape spaces in URL
$url = str_replace(' ', '%20', $url);
//Only set if the URL is not empty
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!');
}
$this->path = $url;
//Reset internal filename
$this->original_filename = null;
//The URL field can also contain the special builtin internal paths, so we need to distinguish here
if ($this::checkIfBuiltin($url)) {
$this->setInternalPath($url);
//make sure the external path isn't still pointing to something unrelated
$this->setExternalPath(null);
} else {
$this->setExternalPath($url);
}
return $this;
}
/**
* Sets the path to a file hosted on an external server. Setting the external path to a (nonempty) value different
* from the the old one _clears_ the internal path, so that the external path reflects where any associated internal
* file came from.
*/
public function setExternalPath(?string $external_path): self
{
//If we only clear the external path, don't reset the internal path, since that could be confusing
if($external_path === null || $external_path === '') {
$this->external_path = null;
return $this;
}
$external_path = trim($external_path);
//Escape spaces in URL
$external_path = str_replace(' ', '%20', $external_path);
if($this->external_path === $external_path) {
//Nothing changed, nothing to do
return $this;
}
$this->external_path = $external_path;
$this->internal_path = null;
//Reset internal filename
$this->original_filename = null;
return $this;
}
@ -551,12 +610,17 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Checks if the given path is a path to a builtin resource.
*
* @param string $path The path that should be checked
* @param string|null $path The path that should be checked
*
* @return bool true if the path is pointing to a builtin resource
*/
public static function checkIfBuiltin(string $path): bool
public static function checkIfBuiltin(?string $path): bool
{
//An empty path can't be a builtin
if ($path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $path);
//Builtins must have a %PLACEHOLDER% construction

View file

@ -162,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
*
* @return string the link to the article
*/
public function getAutoProductUrl(string $partnr = null): string
public function getAutoProductUrl(?string $partnr = null): string
{
if (is_string($partnr)) {
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);

View file

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

View file

@ -54,7 +54,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
*/
private const DEFAULT_EXPIRATION_TIME = 3600;
public function __construct(string $name, ?string $refresh_token, ?string $token = null, \DateTimeImmutable $expires_at = null)
public function __construct(string $name, ?string $refresh_token, ?string $token = null, ?\DateTimeImmutable $expires_at = null)
{
//If token is given, you also have to give the expires_at date
if ($token !== null && $expires_at === null) {

View file

@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/
#[Groups(['parameter:read', 'full'])]
#[SerializedName('formatted')]
public function getFormattedValue(): string
public function getFormattedValue(bool $latex_formatted = false): string
{
//If we just only have text value, return early
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) {
@ -218,7 +218,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
$str = '';
$bracket_opened = false;
if ($this->value_typical) {
$str .= $this->getValueTypicalWithUnit();
$str .= $this->getValueTypicalWithUnit($latex_formatted);
if ($this->value_min || $this->value_max) {
$bracket_opened = true;
$str .= ' (';
@ -226,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
}
if ($this->value_max && $this->value_min) {
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
$str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_max) {
$str .= 'max. '.$this->getValueMaxWithUnit();
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_min) {
$str .= 'min. '.$this->getValueMinWithUnit();
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
}
//Add closing bracket
@ -344,25 +344,25 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/**
* Return a formatted version with the minimum value with the unit of this parameter.
*/
public function getValueTypicalWithUnit(): string
public function getValueTypicalWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_typical);
return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
}
/**
* Return a formatted version with the maximum value with the unit of this parameter.
*/
public function getValueMaxWithUnit(): string
public function getValueMaxWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_max);
return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
}
/**
* Return a formatted version with the typical value with the unit of this parameter.
*/
public function getValueMinWithUnit(): string
public function getValueMinWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_min);
return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
}
/**
@ -441,11 +441,18 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/**
* Return a string representation and (if possible) with its unit.
*/
protected function formatWithUnit(float $value, string $format = '%g'): string
protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string
{
$str = sprintf($format, $value);
if ($this->unit !== '') {
return $str.' '.$this->unit;
if (!$with_latex) {
$unit = $this->unit;
} else {
$unit = '$\mathrm{'.$this->unit.'}$';
}
return $str.' '.$unit;
}
return $str;

View file

@ -52,8 +52,8 @@ class AttachmentDeleteListener
#[PreUpdate]
public function preUpdateHandler(Attachment $attachment, PreUpdateEventArgs $event): void
{
if ($event->hasChangedField('path')) {
$old_path = $event->getOldValue('path');
if ($event->hasChangedField('internal_path')) {
$old_path = $event->getOldValue('internal_path');
//Dont delete file if the attachment uses a builtin ressource:
if (Attachment::checkIfBuiltin($old_path)) {

View file

@ -100,6 +100,15 @@ class AttachmentFilterType extends AbstractType
'label' => 'attachment.edit.show_in_table'
]);
$builder->add('originalFileName', TextConstraintType::class, [
'label' => 'attachment.file_name'
]);
$builder->add('externalLink', TextConstraintType::class, [
'label' => 'attachment.table.external_link'
]);
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);

View file

@ -62,7 +62,7 @@ trait WithPermPresetsTrait
return json_encode($user->getPermissions());
}
public function setContainer(ContainerInterface $container = null): void
public function setContainer(?ContainerInterface $container = null): void
{
if ($container !== null) {
$this->container = $container;

View file

@ -58,15 +58,15 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
->where('attachment.path LIKE :like');
$qb->setParameter('like', '\\%SECURE\\%%');
->where('attachment.internal_path LIKE :like ESCAPE \'#\'');
$qb->setParameter('like', '#%SECURE#%%');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();
}
/**
* Gets the count of all external attachments (attachments only containing a URL).
* Gets the count of all external attachments (attachments containing only an external path).
*
* @throws NoResultException
* @throws NonUniqueResultException
@ -75,17 +75,16 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
->where('ILIKE(attachment.path, :http) = TRUE')
->orWhere('ILIKE(attachment.path, :https) = TRUE');
$qb->setParameter('http', 'http://%');
$qb->setParameter('https', 'https://%');
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();
}
/**
* Gets the count of all attachments where a user uploaded a file.
* Gets the count of all attachments where a user uploaded a file or a file was downloaded from an external source.
*
* @throws NoResultException
* @throws NonUniqueResultException
@ -94,12 +93,12 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
->where('attachment.path LIKE :base')
->orWhere('attachment.path LIKE :media')
->orWhere('attachment.path LIKE :secure');
$qb->setParameter('secure', '\\%SECURE\\%%');
$qb->setParameter('base', '\\%BASE\\%%');
$qb->setParameter('media', '\\%MEDIA\\%%');
->where('attachment.internal_path LIKE :base ESCAPE \'#\'')
->orWhere('attachment.internal_path LIKE :media ESCAPE \'#\'')
->orWhere('attachment.internal_path LIKE :secure ESCAPE \'#\'');
$qb->setParameter('secure', '#%SECURE#%%');
$qb->setParameter('base', '#%BASE#%%');
$qb->setParameter('media', '#%MEDIA#%%');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();

View file

@ -160,7 +160,7 @@ class LogEntryRepository extends DBElementRepository
* @param int|null $limit
* @param int|null $offset
*/
public function getLogsOrderedByTimestamp(string $order = 'DESC', int $limit = null, int $offset = null): array
public function getLogsOrderedByTimestamp(string $order = 'DESC', ?int $limit = null, ?int $offset = null): array
{
return $this->findBy([], ['timestamp' => $order], $limit, $offset);
}

View file

@ -131,7 +131,7 @@ class ApiTokenAuthenticator implements AuthenticatorInterface
/**
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
*/
private function getAuthenticateHeader(string $errorDescription = null): string
private function getAuthenticateHeader(?string $errorDescription = null): string
{
$data = [
'realm' => $this->realm,

View file

@ -47,7 +47,7 @@ class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
) {
}
public function start(Request $request, AuthenticationException $authException = null): Response
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
//Check if the request is an API request
if ($this->isJSONRequest($request)) {

View file

@ -116,10 +116,10 @@ class SamlUserFactory implements SamlUserFactoryInterface, EventSubscriberInterf
* Maps a list of SAML roles to a local group ID.
* The first available mapping will be used (so the order of the $map is important, first match wins).
* @param array $roles The list of SAML roles
* @param array $map|null The mapping from SAML roles. If null, the global mapping will be used.
* @param array|null $map The mapping from SAML roles. If null, the global mapping will be used.
* @return int|null The ID of the local group or null if no mapping was found.
*/
public function mapSAMLRolesToLocalGroupID(array $roles, array $map = null): ?int
public function mapSAMLRolesToLocalGroupID(array $roles, ?array $map = null): ?int
{
$map ??= $this->saml_role_mapping;

View file

@ -42,7 +42,7 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
{
}
public function normalize(mixed $object, string $format = null, array $context = []): array|null
public function normalize(mixed $object, ?string $format = null, array $context = []): array|null
{
if (!$object instanceof Attachment) {
throw new \InvalidArgumentException('This normalizer only supports Attachment objects!');
@ -52,15 +52,19 @@ class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterf
$context[self::ALREADY_CALLED] = true;
$data = $this->normalizer->normalize($object, $format, $context);
$data['internal_path'] = $this->attachmentURLGenerator->getInternalViewURL($object);
$data['media_url'] = $this->attachmentURLGenerator->getViewURL($object);
//Add thumbnail url if the attachment is a picture
$data['thumbnail_url'] = $object->isPicture() ? $this->attachmentURLGenerator->getThumbnailURL($object) : null;
//For backwards compatibility reasons
//Deprecated: Use internal_path and external_path instead
$data['media_url'] = $data['internal_path'] ?? $object->getExternalPath();
return $data;
}
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
// avoid recursion: only call once per object
if (isset($context[self::ALREADY_CALLED])) {

View file

@ -33,12 +33,12 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function supportsNormalization($data, string $format = null, array $context = []): bool
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
return $data instanceof BigNumber;
}
public function normalize($object, string $format = null, array $context = []): string
public function normalize($object, ?string $format = null, array $context = []): string
{
if (!$object instanceof BigNumber) {
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
@ -58,7 +58,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
];
}
public function denormalize(mixed $data, string $type, string $format = null, array $context = []): BigNumber|null
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): BigNumber|null
{
if (!is_a($type, BigNumber::class, true)) {
throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
@ -67,7 +67,7 @@ class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
return $type::of($data);
}
public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
//data must be a string or a number (int, float, etc.) and the type must be BigNumber or BigDecimal
return (is_string($data) || is_numeric($data)) && (is_subclass_of($type, BigNumber::class));

View file

@ -63,13 +63,13 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
{
}
public function supportsNormalization($data, string $format = null, array $context = []): bool
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
//We only remove the type field for CSV export
return !isset($context[self::ALREADY_CALLED]) && $format === 'csv' && $data instanceof Part ;
}
public function normalize($object, string $format = null, array $context = []): array
public function normalize($object, ?string $format = null, array $context = []): array
{
if (!$object instanceof Part) {
throw new \InvalidArgumentException('This normalizer only supports Part objects!');
@ -117,7 +117,7 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
return $data;
}
public function denormalize($data, string $type, string $format = null, array $context = []): ?Part
public function denormalize($data, string $type, ?string $format = null, array $context = []): ?Part
{
$this->normalizeKeys($data);

View file

@ -49,7 +49,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
{
}
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
{
//Only denormalize if we are doing a file import operation
if (!($context['partdb_import'] ?? false)) {
@ -78,7 +78,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
* @return AbstractStructuralDBElement|null
* @phpstan-return T|null
*/
public function denormalize($data, string $type, string $format = null, array $context = []): ?AbstractStructuralDBElement
public function denormalize($data, string $type, ?string $format = null, array $context = []): ?AbstractStructuralDBElement
{
//Do not use API Platform's denormalizer
$context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true;

View file

@ -36,7 +36,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
{
}
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
{
//Only denormalize if we are doing a file import operation
if (!($context['partdb_import'] ?? false)) {
@ -51,7 +51,7 @@ class StructuralElementFromNameDenormalizer implements DenormalizerInterface
* @phpstan-param class-string<T> $type
* @phpstan-return T|null
*/
public function denormalize($data, string $type, string $format = null, array $context = []): AbstractStructuralDBElement|null
public function denormalize($data, string $type, ?string $format = null, array $context = []): AbstractStructuralDBElement|null
{
//Retrieve the repository for the given type
/** @var StructuralDBElementRepository<T> $repo */

View file

@ -38,7 +38,7 @@ class StructuralElementNormalizer implements NormalizerInterface
{
}
public function supportsNormalization($data, string $format = null, array $context = []): bool
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
//Only normalize if we are doing a file export operation
if (!($context['partdb_export'] ?? false)) {
@ -48,7 +48,7 @@ class StructuralElementNormalizer implements NormalizerInterface
return $data instanceof AbstractStructuralDBElement;
}
public function normalize($object, string $format = null, array $context = []): mixed
public function normalize($object, ?string $format = null, array $context = []): mixed
{
if (!$object instanceof AbstractStructuralDBElement) {
throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');

View file

@ -44,35 +44,31 @@ class AttachmentManager
*
* @param Attachment $attachment The attachment for which the file should be generated
*
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is external or has
* @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is only external or has
* invalid file.
*/
public function attachmentToFile(Attachment $attachment): ?SplFileInfo
{
if ($attachment->isExternal() || !$this->isFileExisting($attachment)) {
if (!$this->isInternalFileExisting($attachment)) {
return null;
}
return new SplFileInfo($this->toAbsoluteFilePath($attachment));
return new SplFileInfo($this->toAbsoluteInternalFilePath($attachment));
}
/**
* Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved,
* or is not existing.
* Returns the absolute filepath to the internal copy of the attachment. Null is returned, if the attachment is
* only externally saved, or is not existing.
*
* @param Attachment $attachment The attachment for which the filepath should be determined
*/
public function toAbsoluteFilePath(Attachment $attachment): ?string
public function toAbsoluteInternalFilePath(Attachment $attachment): ?string
{
if ($attachment->getPath() === '') {
if (!$attachment->hasInternal()){
return null;
}
if ($attachment->isExternal()) {
return null;
}
$path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
//realpath does not work with null as argument
if (null === $path) {
@ -89,8 +85,8 @@ class AttachmentManager
}
/**
* Checks if the file in this attachement is existing. This works for files on the HDD, and for URLs
* (it's not checked if the ressource behind the URL is really existing, so for every external attachment true is returned).
* Checks if the file in this attachment is existing. This works for files on the HDD, and for URLs
* (it's not checked if the resource behind the URL is really existing, so for every external attachment true is returned).
*
* @param Attachment $attachment The attachment for which the existence should be checked
*
@ -98,15 +94,23 @@ class AttachmentManager
*/
public function isFileExisting(Attachment $attachment): bool
{
if ($attachment->getPath() === '') {
return false;
}
if ($attachment->isExternal()) {
if($attachment->hasExternal()){
return true;
}
return $this->isInternalFileExisting($attachment);
}
$absolute_path = $this->toAbsoluteFilePath($attachment);
/**
* Checks if the internal file in this attachment is existing. Returns false if the attachment doesn't have an
* internal file.
*
* @param Attachment $attachment The attachment for which the existence should be checked
*
* @return bool true if the file is existing
*/
public function isInternalFileExisting(Attachment $attachment): bool
{
$absolute_path = $this->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) {
return false;
@ -117,21 +121,17 @@ class AttachmentManager
/**
* Returns the filesize of the attachments in bytes.
* For external attachments or not existing attachments, null is returned.
* For purely external attachments or inexistent attachments, null is returned.
*
* @param Attachment $attachment the filesize for which the filesize should be calculated
*/
public function getFileSize(Attachment $attachment): ?int
{
if ($attachment->isExternal()) {
if (!$this->isInternalFileExisting($attachment)) {
return null;
}
if (!$this->isFileExisting($attachment)) {
return null;
}
$tmp = filesize($this->toAbsoluteFilePath($attachment));
$tmp = filesize($this->toAbsoluteInternalFilePath($attachment));
return false !== $tmp ? $tmp : null;
}

View file

@ -115,12 +115,16 @@ class AttachmentPathResolver
* Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk.
* The directory separator is always /. Relative pathes are not realy possible (.. is striped).
*
* @param string $placeholder_path the filepath with placeholder for which the real path should be determined
* @param string|null $placeholder_path the filepath with placeholder for which the real path should be determined
*
* @return string|null The absolute real path of the file, or null if the placeholder path is invalid
*/
public function placeholderToRealPath(string $placeholder_path): ?string
public function placeholderToRealPath(?string $placeholder_path): ?string
{
if (null === $placeholder_path) {
return null;
}
//The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory
//Older path entries are given via %BASE% which was the project root

View file

@ -55,7 +55,7 @@ class AttachmentReverseSearch
$repo = $this->em->getRepository(Attachment::class);
return $repo->findBy([
'path' => [$relative_path_new, $relative_path_old],
'internal_path' => [$relative_path_new, $relative_path_old],
]);
}

View file

@ -71,6 +71,7 @@ class AttachmentSubmitHandler
protected MimeTypesInterface $mimeTypes,
protected FileTypeFilterTools $filterTools,
protected AttachmentsSettings $settings,
protected readonly SVGSanitizer $SVGSanitizer,
)
{
//The mapping used to determine which folder will be used for an attachment type
@ -209,13 +210,16 @@ class AttachmentSubmitHandler
if ($file instanceof UploadedFile) {
$this->upload($attachment, $file, $secure_attachment);
} elseif ($upload->downloadUrl && $attachment->isExternal()) {
} elseif ($upload->downloadUrl && $attachment->hasExternal()) {
$this->downloadURL($attachment, $secure_attachment);
}
//Move the attachment files to secure location (and back) if needed
$this->moveFile($attachment, $secure_attachment);
//Sanitize the SVG if needed
$this->sanitizeSVGAttachment($attachment);
//Rename blacklisted (unsecure) files to a better extension
$this->renameBlacklistedExtensions($attachment);
@ -246,12 +250,12 @@ class AttachmentSubmitHandler
protected function renameBlacklistedExtensions(Attachment $attachment): Attachment
{
//We can not do anything on builtins or external ressources
if ($attachment->isBuiltIn() || $attachment->isExternal()) {
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment;
}
//Determine the old filepath
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
if ($old_path === null || $old_path === '' || !file_exists($old_path)) {
return $attachment;
}
@ -269,7 +273,7 @@ class AttachmentSubmitHandler
$fs->rename($old_path, $new_path);
//Update the attachment
$attachment->setPath($this->pathResolver->realPathToPlaceholder($new_path));
$attachment->setInternalPath($this->pathResolver->realPathToPlaceholder($new_path));
}
@ -277,17 +281,17 @@ class AttachmentSubmitHandler
}
/**
* Move the given attachment to secure location (or back to public folder) if needed.
* Move the internal copy of the given attachment to a secure location (or back to public folder) if needed.
*
* @param Attachment $attachment the attachment for which the file should be moved
* @param bool $secure_location this value determines, if the attachment is moved to the secure or public folder
*
* @return Attachment The attachment with the updated filepath
* @return Attachment The attachment with the updated internal filepath
*/
protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
{
//We can not do anything on builtins or external ressources
if ($attachment->isBuiltIn() || $attachment->isExternal()) {
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment;
}
@ -297,7 +301,7 @@ class AttachmentSubmitHandler
}
//Determine the old filepath
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
$old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
if (!file_exists($old_path)) {
return $attachment;
}
@ -321,7 +325,7 @@ class AttachmentSubmitHandler
//Save info to attachment entity
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
$attachment->setPath($new_path);
$attachment->setInternalPath($new_path);
return $attachment;
}
@ -331,7 +335,7 @@ class AttachmentSubmitHandler
*
* @param bool $secureAttachment True if the file should be moved to the secure attachment storage
*
* @return Attachment The attachment with the new filepath
* @return Attachment The attachment with the downloaded copy
*/
protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
{
@ -340,16 +344,35 @@ class AttachmentSubmitHandler
throw new RuntimeException('Download of attachments is not allowed!');
}
$url = $attachment->getURL();
$url = $attachment->getExternalPath();
$fs = new Filesystem();
$attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment);
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
try {
$response = $this->httpClient->request('GET', $url, [
$opts = [
'buffer' => false,
]);
//Use user-agent and other headers to make the server think we are a browser
'headers' => [
"sec-ch-ua" => "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
"sec-ch-ua-mobile" => "?0",
"sec-ch-ua-platform" => "\"Windows\"",
"user-agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
"sec-fetch-site" => "none",
"sec-fetch-mode" => "navigate",
],
];
$response = $this->httpClient->request('GET', $url, $opts);
//Digikey wants TLSv1.3, so try again with that if we get a 403
if ($response->getStatusCode() === 403) {
$opts['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
$response = $this->httpClient->request('GET', $url, $opts);
}
# if you have these changes and downloads still fail, check if it's due to an unknown certificate. Curl by
# default uses the systems ca store and that doesn't contain all the intermediate certificates needed to
# verify the leafs
if (200 !== $response->getStatusCode()) {
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
@ -401,7 +424,7 @@ class AttachmentSubmitHandler
//Make our file path relative to %BASE%
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
//Save the path to the attachment
$attachment->setPath($new_path);
$attachment->setInternalPath($new_path);
} catch (TransportExceptionInterface) {
throw new AttachmentDownloadException('Transport error!');
}
@ -429,7 +452,9 @@ class AttachmentSubmitHandler
//Make our file path relative to %BASE%
$file_path = $this->pathResolver->realPathToPlaceholder($file_path);
//Save the path to the attachment
$attachment->setPath($file_path);
$attachment->setInternalPath($file_path);
//reset any external paths the attachment might have had
$attachment->setExternalPath(null);
//And save original filename
$attachment->setFilename($file->getClientOriginalName());
@ -479,4 +504,32 @@ class AttachmentSubmitHandler
return $this->max_upload_size_bytes;
}
/**
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
* @param Attachment $attachment
* @return Attachment
*/
public function sanitizeSVGAttachment(Attachment $attachment): Attachment
{
//We can not do anything on builtins or external ressources
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment;
}
//Resolve the path to the file
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
//Check if the file exists
if (!file_exists($path)) {
return $attachment;
}
//Check if the file is an SVG
if ($attachment->getExtension() === "svg") {
$this->SVGSanitizer->sanitizeFile($path);
}
return $attachment;
}
}

View file

@ -92,9 +92,9 @@ class AttachmentURLGenerator
* Returns a URL under which the attachment file can be viewed.
* @return string|null The URL or null if the attachment file is not existing
*/
public function getViewURL(Attachment $attachment): ?string
public function getInternalViewURL(Attachment $attachment): ?string
{
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
$absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) {
return null;
}
@ -111,6 +111,7 @@ class AttachmentURLGenerator
/**
* Returns a URL to a thumbnail of the attachment file.
* For external files the original URL is returned.
* @return string|null The URL or null if the attachment file is not existing
*/
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
@ -119,11 +120,14 @@ class AttachmentURLGenerator
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
}
if ($attachment->isExternal() && ($attachment->getURL() !== null && $attachment->getURL() !== '')) {
return $attachment->getURL();
if (!$attachment->hasInternal()){
if($attachment->hasExternal()) {
return $attachment->getExternalPath();
}
return null;
}
$absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
$absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) {
return null;
}
@ -137,7 +141,7 @@ class AttachmentURLGenerator
//GD can not work with SVG, so serve it directly...
//We can not use getExtension here, because it uses the original filename and not the real extension
//Instead we use the logic, which is also used to determine if the attachment is a picture
$extension = pathinfo(parse_url($attachment->getPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
$extension = pathinfo(parse_url($attachment->getInternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
if ('svg' === $extension) {
return $this->assets->getUrl($asset_path);
}
@ -157,7 +161,7 @@ class AttachmentURLGenerator
/**
* Returns a download link to the file associated with the attachment.
*/
public function getDownloadURL(Attachment $attachment): string
public function getInternalDownloadURL(Attachment $attachment): string
{
//Redirect always to download controller, which sets the correct headers for downloading:
return $this->urlGenerator->generate('attachment_download', ['id' => $attachment->getID()]);

View file

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

View file

@ -27,6 +27,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\NodesListBuilder;
use App\Settings\MiscSettings\KiCadEDASettings;
use Doctrine\ORM\EntityManagerInterface;
@ -48,6 +49,7 @@ class KiCadHelper
private readonly EntityManagerInterface $em,
private readonly ElementCacheTagGenerator $tagGenerator,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator,
KiCadEDASettings $kiCadEDASettings,
) {
@ -68,6 +70,10 @@ class KiCadHelper
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
$item->tag($secure_class_name);
//Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
$item->tag($secure_class_name);
//If the category depth is smaller than 0, create only one dummy category
if ($this->category_depth < 0) {
return [
@ -112,6 +118,8 @@ class KiCadHelper
$result[] = [
'id' => (string)$category->getId(),
'name' => $category->getFullPath('/'),
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
'description' => $this->entityURLGenerator->listPartsURL($category),
];
}

View file

@ -247,7 +247,8 @@ trait EntityMergerHelperTrait
{
return $this->mergeCollections($target, $other, 'attachments', fn(Attachment $t, Attachment $o): bool => $t->getName() === $o->getName()
&& $t->getAttachmentType() === $o->getAttachmentType()
&& $t->getPath() === $o->getPath());
&& $t->getExternalPath() === $o->getExternalPath()
&& $t->getInternalPath() === $o->getInternalPath());
}
/**

View file

@ -156,25 +156,34 @@ class EntityURLGenerator
public function viewURL(Attachment $entity): string
{
if ($entity->isExternal()) { //For external attachments, return the link to external path
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
//If the underlying file path is invalid, null gets returned, which is not allowed here.
//We still have the chance to use an external path, if it is set.
if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
return $url;
}
//return $this->urlGenerator->generate('attachment_view', ['id' => $entity->getID()]);
return $this->attachmentURLGenerator->getViewURL($entity) ?? '';
if($entity->hasExternal()) {
return $entity->getExternalPath();
}
throw new \RuntimeException('Attachment has no internal nor external path!');
}
public function downloadURL($entity): string
{
if ($entity instanceof Attachment) {
if ($entity->isExternal()) { //For external attachments, return the link to external path
return $entity->getURL() ?? throw new \RuntimeException('External attachment has no URL!');
}
return $this->attachmentURLGenerator->getDownloadURL($entity);
if (!($entity instanceof Attachment)) {
throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
}
//Otherwise throw an error
throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
if ($entity->hasInternal()) {
return $this->attachmentURLGenerator->getInternalDownloadURL($entity);
}
if($entity->hasExternal()) {
return $entity->getExternalPath();
}
throw new \RuntimeException('Attachment has not internal or external path!');
}
/**

View file

@ -357,7 +357,7 @@ class EntityImporter
* @param iterable $entities the list of entities that should be fixed
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
*/
protected function correctParentEntites(iterable $entities, AbstractStructuralDBElement $parent = null): void
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
{
foreach ($entities as $entity) {
/** @var AbstractStructuralDBElement $entity */

View file

@ -105,7 +105,7 @@ trait PKImportHelperTrait
//Next comes the filename plus extension
$path .= '/'.$attachment_row['filename'].'.'.$attachment_row['extension'];
$attachment->setPath($path);
$attachment->setInternalPath($path);
return $attachment;
}

View file

@ -72,9 +72,9 @@ class ParameterDTO
group: $group);
}
//If the attribute contains "..." or a tilde we assume it is a range
if (preg_match('/(\.{3}|~)/', $value) === 1) {
$parts = preg_split('/\s*(\.{3}|~)\s*/', $value);
//If the attribute contains ".." or "..." or a tilde we assume it is a range
if (preg_match('/(\.{2,3}|~)/', $value) === 1) {
$parts = preg_split('/\s*(\.{2,3}|~)\s*/', $value);
if (count($parts) === 2) {
//Try to extract number and unit from value (allow leading +)
if ($unit === null || trim($unit) === '') {

View file

@ -178,9 +178,21 @@ final class DTOtoEntityConverter
//Set the provider reference on the part
$entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));
$param_groups = [];
//Add parameters
foreach ($dto->parameters ?? [] as $parameter) {
$entity->addParameter($this->convertParameter($parameter));
$new_param = $this->convertParameter($parameter);
$key = $new_param->getName() . '##' . $new_param->getGroup();
//If there is already an parameter with the same name and group, rename the new parameter, by suffixing a number
if (count($param_groups[$key] ?? []) > 0) {
$new_param->setName($new_param->getName() . ' (' . (count($param_groups[$key]) + 1) . ')');
}
$param_groups[$key][] = $new_param;
$entity->addParameter($new_param);
}
//Add preview image
@ -196,6 +208,8 @@ final class DTOtoEntityConverter
$entity->setMasterPictureAttachment($preview_image);
}
$attachments_grouped = [];
//Add other images
$images = $this->files_unique($dto->images ?? []);
foreach ($images as $image) {
@ -204,14 +218,29 @@ final class DTOtoEntityConverter
continue;
}
$entity->addAttachment($this->convertFile($image, $image_type));
$attachment = $this->convertFile($image, $image_type);
$attachments_grouped[$attachment->getName()][] = $attachment;
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')');
}
$entity->addAttachment($attachment);
}
//Add datasheets
$datasheet_type = $this->getDatasheetType();
$datasheets = $this->files_unique($dto->datasheets ?? []);
foreach ($datasheets as $datasheet) {
$entity->addAttachment($this->convertFile($datasheet, $datasheet_type));
$attachment = $this->convertFile($datasheet, $datasheet_type);
$attachments_grouped[$attachment->getName()][] = $attachment;
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')');
}
$entity->addAttachment($attachment);
}
//Add orderdetails and prices

View file

@ -27,6 +27,7 @@ use App\Entity\Parts\Part;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@ -34,10 +35,12 @@ final class PartInfoRetriever
{
private const CACHE_DETAIL_EXPIRATION = 60 * 60 * 24 * 4; // 4 days
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 7; // 7 days
private const CACHE_RESULT_EXPIRATION = 60 * 60 * 24 * 4; // 7 days
public function __construct(private readonly ProviderRegistry $provider_registry,
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache)
private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache,
#[Autowire(param: "kernel.debug")]
private readonly bool $debugMode = false)
{
}
@ -56,6 +59,11 @@ final class PartInfoRetriever
$provider = $this->provider_registry->getProviderByKey($provider);
}
//Ensure that the provider is active
if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
}
if (!$provider instanceof InfoProviderInterface) {
throw new \InvalidArgumentException("The provider must be either a provider key or a provider instance!");
}
@ -77,7 +85,7 @@ final class PartInfoRetriever
$escaped_keyword = urlencode($keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(self::CACHE_RESULT_EXPIRATION);
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
return $provider->searchByKeyword($keyword);
});
@ -94,11 +102,16 @@ final class PartInfoRetriever
{
$provider = $this->provider_registry->getProviderByKey($provider_key);
//Ensure that the provider is active
if (!$provider->isActive()) {
throw new \RuntimeException("The provider with key $provider_key is not active!");
}
//Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(self::CACHE_DETAIL_EXPIRATION);
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
return $provider->getDetails($part_id);
});

View file

@ -108,12 +108,15 @@ class DigikeyProvider implements InfoProviderInterface
{
$request = [
'Keywords' => $keyword,
'RecordCount' => 50,
'RecordStartPosition' => 0,
'ExcludeMarketPlaceProducts' => 'true',
'Limit' => 50,
'Offset' => 0,
'FilterOptionsRequest' => [
'MarketPlaceFilter' => 'ExcludeMarketPlace',
],
];
$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
'json' => $request,
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
@ -124,18 +127,21 @@ class DigikeyProvider implements InfoProviderInterface
$result = [];
$products = $response_array['Products'];
foreach ($products as $product) {
$result[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['DigiKeyPartNumber'],
name: $product['ManufacturerPartNumber'],
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Value'] ?? null,
mpn: $product['ManufacturerPartNumber'],
preview_image_url: $product['PrimaryPhoto'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
provider_url: $product['ProductUrl'],
);
foreach ($product['ProductVariations'] as $variation) {
$result[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $variation['DigiKeyProductNumber'],
name: $product['ManufacturerProductNumber'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Name'] ?? null,
mpn: $product['ManufacturerProductNumber'],
preview_image_url: $product['PhotoUrl'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
provider_url: $product['ProductUrl'],
footprint: $variation['PackageType']['Name'], //Use the footprint field, to show the user the package type (Tape & Reel, etc., as digikey has many different package types)
);
}
}
return $result;
@ -143,62 +149,79 @@ class DigikeyProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO
{
$response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . urlencode($id), [
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
$product = $response->toArray();
$response_array = $response->toArray();
$product = $response_array['Product'];
$footprint = null;
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
$media = $this->mediaToDTOs($product['MediaLinks']);
$media = $this->mediaToDTOs($id);
// Get the price_breaks of the selected variation
$price_breaks = [];
foreach ($product['ProductVariations'] as $variation) {
if ($variation['DigiKeyProductNumber'] == $id) {
$price_breaks = $variation['StandardPricing'] ?? [];
break;
}
}
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['DigiKeyPartNumber'],
name: $product['ManufacturerPartNumber'],
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
provider_id: $id,
name: $product['ManufacturerProductNumber'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Value'] ?? null,
mpn: $product['ManufacturerPartNumber'],
preview_image_url: $product['PrimaryPhoto'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
manufacturer: $product['Manufacturer']['Name'] ?? null,
mpn: $product['ManufacturerProductNumber'],
preview_image_url: $product['PhotoUrl'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
provider_url: $product['ProductUrl'],
footprint: $footprint,
datasheets: $media['datasheets'],
images: $media['images'],
parameters: $parameters,
vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']),
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
);
}
/**
* Converts the product status from the Digikey API to the manufacturing status used in Part-DB
* @param string|null $dk_status
* @param int|null $dk_status
* @return ManufacturingStatus|null
*/
private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus
private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
{
// The V4 can use strings to get the status, but if you have changed the PROVIDER_DIGIKEY_LANGUAGE it will not match.
// Using the Id instead which should be fixed.
//
// The API is not well documented and the ID are not there yet, so were extracted using "trial and error".
// The 'Preliminary' id was not found in several categories so I was unable to extract it. Disabled for now.
return match ($dk_status) {
null => null,
'Active' => ManufacturingStatus::ACTIVE,
'Obsolete' => ManufacturingStatus::DISCONTINUED,
'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
'Not For New Designs' => ManufacturingStatus::NRFND,
'Preliminary' => ManufacturingStatus::ANNOUNCED,
0 => ManufacturingStatus::ACTIVE,
1 => ManufacturingStatus::DISCONTINUED,
2, 4 => ManufacturingStatus::EOL,
7 => ManufacturingStatus::NRFND,
//'Preliminary' => ManufacturingStatus::ANNOUNCED,
default => ManufacturingStatus::NOT_SET,
};
}
private function getCategoryString(array $product): string
{
$category = $product['Category']['Value'];
$sub_category = $product['Family']['Value'];
$category = $product['Category']['Name'];
$sub_category = current($product['Category']['ChildCategories']);
//Replace the ' - ' category separator with ' -> '
$sub_category = str_replace(' - ', ' -> ', $sub_category);
if ($sub_category) {
//Replace the ' - ' category separator with ' -> '
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
}
return $category . ' -> ' . $sub_category;
return $category;
}
/**
@ -215,18 +238,18 @@ class DigikeyProvider implements InfoProviderInterface
foreach ($parameters as $parameter) {
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint"
$footprint_name = $parameter['Value'];
$footprint_name = $parameter['ValueText'];
}
if (in_array(trim((string) $parameter['Value']), ['', '-'], true)) {
if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
continue;
}
//If the parameter was marked as text only, then we do not try to parse it as a numerical value
if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) {
$results[] = new ParameterDTO(name: $parameter['Parameter'], value_text: $parameter['Value']);
$results[] = new ParameterDTO(name: $parameter['ParameterText'], value_text: $parameter['ValueText']);
} else { //Otherwise try to parse it as a numerical value
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
}
}
@ -254,16 +277,22 @@ class DigikeyProvider implements InfoProviderInterface
}
/**
* @param array $media_links
* @param string $id The Digikey product number, to get the media for
* @return FileDTO[][]
* @phpstan-return array<string, FileDTO[]>
*/
private function mediaToDTOs(array $media_links): array
private function mediaToDTOs(string $id): array
{
$datasheets = [];
$images = [];
foreach ($media_links as $media_link) {
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
$media_array = $response->toArray();
foreach ($media_array['MediaLinks'] as $media_link) {
$file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
switch ($media_link['MediaType']) {

View file

@ -29,14 +29,13 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Settings\InfoProviderSystem\Element14Settings;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Element14Provider implements InfoProviderInterface
{
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
private const API_VERSION_NUMBER = '1.2';
private const API_VERSION_NUMBER = '1.4';
private const NUMBER_OF_RESULTS = 20;
public const DISTRIBUTOR_NAME = 'Farnell';
@ -44,9 +43,19 @@ class Element14Provider implements InfoProviderInterface
private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant',
'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode'];
private readonly HttpClientInterface $element14Client;
public function __construct(private readonly HttpClientInterface $element14Client, private readonly Element14Settings $settings)
{
/* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems
* with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866
*
* This is a workaround until the issue is resolved in debian (or never).
* As this only affects this provider, this should have no negative impact and the CA bundle is still secure.
*/
$this->element14Client = $element14Client->withOptions([
'cafile' => CaBundle::getBundledCaBundlePath(),
]);
}
public function getProviderInfo(): array
@ -84,7 +93,7 @@ class Element14Provider implements InfoProviderInterface
'resultsSettings.responseGroup' => 'large',
'callInfo.apiKey' => $this->settings->apiKey,
'callInfo.responseDataFormat' => 'json',
'callInfo.version' => self::API_VERSION_NUMBER,
'versionNumber' => self::API_VERSION_NUMBER,
],
]);
@ -108,10 +117,12 @@ class Element14Provider implements InfoProviderInterface
mpn: $product['translatedManufacturerPartNumber'],
preview_image_url: $this->toImageUrl($product['image'] ?? null),
manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null),
provider_url: $this->generateProductURL($product['sku']),
provider_url: $product['productURL'],
notes: $product['productOverview']['description'] ?? null,
datasheets: $this->parseDataSheets($product['datasheets'] ?? null),
parameters: $this->attributesToParameters($product['attributes'] ?? null),
vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [])
vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [], $product['productURL']),
);
}
@ -120,7 +131,7 @@ class Element14Provider implements InfoProviderInterface
private function generateProductURL($sku): string
{
return 'https://' . $this->settings->storeId . '/' . $sku;
return 'https://' . $this->store_id . '/' . $sku;
}
/**
@ -162,7 +173,7 @@ class Element14Provider implements InfoProviderInterface
* @param array $prices
* @return array
*/
private function pricesToVendorInfo(string $sku, array $prices): array
private function pricesToVendorInfo(string $sku, array $prices, string $product_url): array
{
$price_dtos = [];
@ -180,7 +191,7 @@ class Element14Provider implements InfoProviderInterface
distributor_name: self::DISTRIBUTOR_NAME,
order_number: $sku,
prices: $price_dtos,
product_url: $this->generateProductURL($sku)
product_url: $product_url
)
];
}

View file

@ -92,6 +92,7 @@ class MouserProvider implements InfoProviderInterface
From the startingRecord, the number of records specified will be returned up to the end of the recordset.
This is useful for paging through the complete recordset of parts matching keyword.
searchOptions string
Optional.
If not provided, the default is None.
@ -174,11 +175,16 @@ class MouserProvider implements InfoProviderInterface
throw new \RuntimeException('No part found with ID '.$id);
}
//Manually filter out the part with the correct ID
$tmp = array_filter($tmp, fn(PartDetailDTO $part) => $part->provider_id === $id);
if (count($tmp) === 0) {
throw new \RuntimeException('No part found with ID '.$id);
}
if (count($tmp) > 1) {
throw new \RuntimeException('Multiple parts found with ID '.$id . ' ('.count($tmp).' found). This is basically a bug in Mousers API response. See issue #616.');
throw new \RuntimeException('Multiple parts found with ID '.$id);
}
return $tmp[0];
return reset($tmp);
}
public function getCapabilities(): array

View file

@ -1218,7 +1218,7 @@ class OEMSecretsProvider implements InfoProviderInterface
* - 'value_min' => string|null The minimum value in a range, if applicable.
* - 'value_max' => string|null The maximum value in a range, if applicable.
*/
private function customSplitIntoValueAndUnit(string $value1, string $value2 = null): array
private function customSplitIntoValueAndUnit(string $value1, ?string $value2 = null): array
{
// Separate numbers and units (basic parsing handling)
$unit = null;

View file

@ -0,0 +1,249 @@
<?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\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PollinProvider implements InfoProviderInterface
{
public function __construct(private readonly HttpClientInterface $client,
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
private readonly bool $enabled = true,
)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Pollin',
'description' => 'Webscraping from pollin.de to get part information',
'url' => 'https://www.pollin.de/',
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
];
}
public function getProviderKey(): string
{
return 'pollin';
}
public function isActive(): bool
{
return $this->enabled;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [
'search' => $keyword
]
]);
$content = $response->getContent();
//If the response has us redirected to the product page, then just return the single item
if ($response->getInfo('redirect_count') > 0) {
return [$this->parseProductPage($content)];
}
$dom = new Crawler($content);
$results = [];
//Iterate over each div.product-box
$dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
$results[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
name: $node->filter('a.product-name')->text(),
description: '',
preview_image_url: $node->filter('img.product-image')->attr('src'),
manufacturing_status: $this->mapAvailability($node->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $node->filter('a.product-name')->attr('href')
);
});
return $results;
}
private function mapAvailability(string $availabilityURI): ManufacturingStatus
{
return match( $availabilityURI) {
'http://schema.org/InStock' => ManufacturingStatus::ACTIVE,
'http://schema.org/OutOfStock' => ManufacturingStatus::DISCONTINUED,
default => ManufacturingStatus::NOT_SET
};
}
public function getDetails(string $id): PartDetailDTO
{
//Ensure that $id is numeric
if (!is_numeric($id)) {
throw new \InvalidArgumentException("The id must be numeric!");
}
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
'query' => [
'search' => $id
]
]);
//The response must have us redirected to the product page
if ($response->getInfo('redirect_count') > 0) {
throw new \RuntimeException("Could not resolve the product page for the given id!");
}
$content = $response->getContent();
return $this->parseProductPage($content);
}
private function parseProductPage(string $content): PartDetailDTO
{
$dom = new Crawler($content);
$productPageUrl = $dom->filter('meta[property="product:product_link"]')->attr('content');
$orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
//Calculate the mass
$massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
//Remove the unit
$massStr = str_replace('kg', '', $massStr);
//Convert to float and convert to grams
$mass = (float) $massStr * 1000;
//Parse purchase info
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $orderId,
name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
description: $dom->filter('meta[property="og:description"]')->attr('content'),
category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),
parameters: $this->parseParameters($dom),
vendor_infos: [$purchaseInfo],
mass: $mass,
);
}
private function parseDatasheets(Crawler $dom): array
{
//Iterate over each a element withing div.pol-product-detail-download-files
$datasheets = [];
$dom->filter('div.pol-product-detail-download-files a')->each(function (Crawler $node) use (&$datasheets) {
$datasheets[] = new FileDTO($node->attr('href'), $node->text());
});
return $datasheets;
}
private function parseParameters(Crawler $dom): array
{
$parameters = [];
//Iterate over each tr.properties-row inside table.product-detail-properties-table
$dom->filter('table.product-detail-properties-table tr.properties-row')->each(function (Crawler $node) use (&$parameters) {
$parameters[] = ParameterDTO::parseValueIncludingUnit(
name: rtrim($node->filter('th.properties-label')->text(), ':'),
value: trim($node->filter('td.properties-value')->text())
);
});
return $parameters;
}
private function parseCategory(Crawler $dom): string
{
$category = '';
//Iterate over each li.breadcrumb-item inside ol.breadcrumb
$dom->filter('ol.breadcrumb li.breadcrumb-item')->each(function (Crawler $node) use (&$category) {
//Skip if it has breadcrumb-item-home class
if (str_contains($node->attr('class'), 'breadcrumb-item-home')) {
return;
}
$category .= $node->text() . ' -> ';
});
//Remove the last ' -> '
return substr($category, 0, -4);
}
private function parseNotes(Crawler $dom): string
{
//Concat product highlights and product description
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
}
private function parsePrices(Crawler $dom): array
{
//TODO: Properly handle multiple prices, for now we just look at the price for one piece
//We assume the currency is always the same
$currency = $dom->filter('meta[property="product:price:currency"]')->attr('content');
//If there is meta[property=highPrice] then use this as the price
if ($dom->filter('meta[itemprop="highPrice"]')->count() > 0) {
$price = $dom->filter('meta[itemprop="highPrice"]')->attr('content');
} else {
$price = $dom->filter('meta[property="product:price:amount"]')->attr('content');
}
return [
new PriceDTO(1.0, $price, $currency)
];
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::PRICE,
ProviderCapabilities::DATASHEET
];
}
}

View file

@ -0,0 +1,285 @@
<?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\InfoProviderSystem\Providers;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ReicheltProvider implements InfoProviderInterface
{
public const DISTRIBUTOR_NAME = "Reichelt";
public function __construct(private readonly HttpClientInterface $client,
#[Autowire(env: "bool:PROVIDER_REICHELT_ENABLED")]
private readonly bool $enabled = true,
#[Autowire(env: "PROVIDER_REICHELT_LANGUAGE")]
private readonly string $language = "en",
#[Autowire(env: "PROVIDER_REICHELT_COUNTRY")]
private readonly string $country = "DE",
#[Autowire(env: "PROVIDER_REICHELT_INCLUDE_VAT")]
private readonly bool $includeVAT = false,
#[Autowire(env: "PROVIDER_REICHELT_CURRENCY")]
private readonly string $currency = "EUR",
)
{
}
public function getProviderInfo(): array
{
return [
'name' => 'Reichelt',
'description' => 'Webscraping from reichelt.com to get part information',
'url' => 'https://www.reichelt.com/',
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
];
}
public function getProviderKey(): string
{
return 'reichelt';
}
public function isActive(): bool
{
return $this->enabled;
}
public function searchByKeyword(string $keyword): array
{
$response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword));
$html = $response->getContent();
//Parse the HTML and return the results
$dom = new Crawler($html);
//Iterate over all div.al_gallery_article elements
$results = [];
$dom->filter('div.al_gallery_article')->each(function (Crawler $element) use (&$results) {
//Extract product id from data-product attribute
$artId = json_decode($element->attr('data-product'), true, 2, JSON_THROW_ON_ERROR)['artid'];
$productID = $element->filter('meta[itemprop="productID"]')->attr('content');
$name = $element->filter('meta[itemprop="name"]')->attr('content');
$sku = $element->filter('meta[itemprop="sku"]')->attr('content');
//Try to extract a picture URL:
$pictureURL = $element->filter("div.al_artlogo img")->attr('src');
$results[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $artId,
name: $productID,
description: $name,
category: null,
manufacturer: $sku,
preview_image_url: $pictureURL,
provider_url: $element->filter('a.al_artinfo_link')->attr('href')
);
});
return $results;
}
public function getDetails(string $id): PartDetailDTO
{
//Check that the ID is a number
if (!is_numeric($id)) {
throw new \InvalidArgumentException("Invalid ID");
}
//Use this endpoint to resolve the artID to a product page
$response = $this->client->request('GET',
sprintf(
'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s',
$id,
strtoupper($this->language),
strtoupper($this->country)
)
);
$json = $response->toArray();
//Retrieve the product page from the response
$productPage = $this->getBaseURL() . '/shop/product' . $json[0]['article_path'];
$response = $this->client->request('GET', $productPage, [
'query' => [
'CCTYPE' => $this->includeVAT ? 'private' : 'business',
'currency' => $this->currency,
],
]);
$html = $response->getContent();
$dom = new Crawler($html);
//Extract the product notes
$notes = $dom->filter('p[itemprop="description"]')->html();
//Extract datasheets
$datasheets = [];
$dom->filter('div.articleDatasheet a')->each(function (Crawler $element) use (&$datasheets) {
$datasheets[] = new FileDTO($element->attr('href'), $element->filter('span')->text());
});
//Determine price for one unit
$priceString = $dom->filter('meta[itemprop="price"]')->attr('content');
$currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR');
//Create purchase info
$purchaseInfo = new PurchaseInfoDTO(
distributor_name: self::DISTRIBUTOR_NAME,
order_number: $json[0]['article_artnr'],
prices: array_merge(
[new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)]
, $this->parseBatchPrices($dom, $currency)),
product_url: $productPage
);
//Create part object
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $id,
name: $json[0]['article_artnr'],
description: $json[0]['article_besch'],
category: $this->parseCategory($dom),
manufacturer: $json[0]['manufacturer_name'],
mpn: $this->parseMPN($dom),
preview_image_url: $json[0]['article_picture'],
provider_url: $productPage,
notes: $notes,
datasheets: $datasheets,
parameters: $this->parseParameters($dom),
vendor_infos: [$purchaseInfo]
);
}
private function parseMPN(Crawler $dom): string
{
//Find the small element directly after meta[itemprop="url"] element
$element = $dom->filter('meta[itemprop="url"] + small');
//If the text contains GTIN text, take the small element afterwards
if (str_contains($element->text(), 'GTIN')) {
$element = $dom->filter('meta[itemprop="url"] + small + small');
}
//The MPN is contained in the span inside the element
return $element->filter('span')->text();
}
private function parseBatchPrices(Crawler $dom, string $currency): array
{
//Iterate over each a.inline-block element in div.discountValue
$prices = [];
$dom->filter('div.discountValue a.inline-block')->each(function (Crawler $element) use (&$prices, $currency) {
//The minimum amount is the number in the span.block element
$minAmountText = $element->filter('span.block')->text();
//Extract a integer from the text
$matches = [];
if (!preg_match('/\d+/', $minAmountText, $matches)) {
return;
}
$minAmount = (int) $matches[0];
//The price is the text of the p.productPrice element
$priceString = $element->filter('p.productPrice')->text();
//Replace comma with dot
$priceString = str_replace(',', '.', $priceString);
//Strip any non-numeric characters
$priceString = preg_replace('/[^0-9.]/', '', $priceString);
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT);
});
return $prices;
}
private function parseCategory(Crawler $dom): string
{
// Look for ol.breadcrumb and iterate over the li elements
$category = '';
$dom->filter('ol.breadcrumb li.triangle-left')->each(function (Crawler $element) use (&$category) {
//Do not include the .breadcrumb-showmore element
if ($element->attr('id') === 'breadcrumb-showmore') {
return;
}
$category .= $element->text() . ' -> ';
});
//Remove the trailing ' -> '
$category = substr($category, 0, -4);
return $category;
}
/**
* @param Crawler $dom
* @return ParameterDTO[]
*/
private function parseParameters(Crawler $dom): array
{
$parameters = [];
//Iterate over each ul.articleTechnicalData which contains the specifications of each group
$dom->filter('ul.articleTechnicalData')->each(function (Crawler $groupElement) use (&$parameters) {
$groupName = $groupElement->filter('li.articleTechnicalHeadline')->text();
//Iterate over each second li in ul.articleAttribute, which contains the specifications
$groupElement->filter('ul.articleAttribute li:nth-child(2n)')->each(function (Crawler $specElement) use (&$parameters, $groupName) {
$parameters[] = ParameterDTO::parseValueIncludingUnit(
name: $specElement->previousAll()->text(),
value: $specElement->text(),
group: $groupName
);
});
});
return $parameters;
}
private function getBaseURL(): string
{
//Without the trailing slash
return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
];
}
}

View file

@ -51,6 +51,16 @@ class TMEClient
return !($this->settings->apiToken === '' || $this->settings->apiSecret === '');
}
/**
* Returns true if the client is using a private (account related token) instead of a deprecated anonymous token
* to authenticate with TME.
* @return bool
*/
public function isUsingPrivateToken(): bool
{
//Private tokens are longer than anonymous ones (50 instead of 45 characters)
return strlen($this->token) > 45;
}
/**
* Generates the signature for the given action and parameters.

View file

@ -37,9 +37,15 @@ class TMEProvider implements InfoProviderInterface
private const VENDOR_NAME = 'TME';
private readonly bool $get_gross_prices;
public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings)
{
//If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then
if ($this->tmeClient->isUsingPrivateToken()) {
$this->get_gross_prices = false;
} else {
$this->get_gross_prices = $get_gross_prices;
}
}
public function getProviderInfo(): array
@ -185,7 +191,7 @@ class TMEProvider implements InfoProviderInterface
'Country' => $this->settings->country,
'Language' => $this->settings->language,
'Currency' => $this->settings->currency,
'GrossPrices' => $this->settings->grossPrices,
'GrossPrices' => $this->get_gross_prices,
'SymbolList' => [$id],
]);

View file

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

View file

@ -122,8 +122,8 @@ final class SandboxedTwigFactory
'getFullPath', 'getPathArray', 'getSubelements', 'getChildren', 'isNotSelectable', ],
AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite', 'getAutoProductUrl'],
AttachmentContainingDBElement::class => ['getAttachments', 'getMasterPictureAttachment'],
Attachment::class => ['isPicture', 'is3DModel', 'isExternal', 'isSecure', 'isBuiltIn', 'getExtension',
'getElement', 'getURL', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable', ],
Attachment::class => ['isPicture', 'is3DModel', 'hasExternal', 'hasInternal', 'isSecure', 'isBuiltIn', 'getExtension',
'getElement', 'getExternalPath', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable'],
AbstractParameter::class => ['getFormattedValue', 'getGroup', 'getSymbol', 'getValueMin', 'getValueMax',
'getValueTypical', 'getUnit', 'getValueText', ],
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],

View file

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

View file

@ -43,12 +43,12 @@ class UniqueObjectCollection extends Constraint
* @param array|string $fields the combination of fields that must contain unique values or a set of options
*/
public function __construct(
array $options = null,
string $message = null,
callable $normalizer = null,
array $groups = null,
?array $options = null,
?string $message = null,
?callable $normalizer = null,
?array $groups = null,
mixed $payload = null,
array|string $fields = null,
array|string|null $fields = null,
public bool $allowNull = true,
) {
parent::__construct($options, $groups, $payload);

View file

@ -31,8 +31,8 @@ class ValidGoogleAuthCode extends Constraint
* @param TwoFactorInterface|null $user The user to use for the validation process, if null, the current user is used
*/
public function __construct(
array $options = null,
array $groups = null,
?array $options = null,
?array $groups = null,
mixed $payload = null,
public ?TwoFactorInterface $user = null)
{