Compare commits

..

No commits in common. "master" and "v1.16.0" have entirely different histories.

49 changed files with 3981 additions and 4822 deletions

View file

@ -42,48 +42,6 @@ fi
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
service phpPHP_VERSION-fpm start
# Run migrations if automigration is enabled via env variable DB_AUTOMIGRATE
if [ "$DB_AUTOMIGRATE" = "true" ]; then
echo "Waiting for database to be ready..."
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(sudo -E -u www-data php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
if [ $? -eq 255 ]; then
# If the Doctrine command exits with 255, an unrecoverable error occurred
ATTEMPTS_LEFT_TO_REACH_DATABASE=0
break
fi
sleep 1
ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
done
if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
echo "The database is not up or not reachable:"
echo "$DATABASE_ERROR"
exit 1
else
echo "The database is now ready and reachable"
fi
# Check if there are any available migrations to do, by executing doctrine:migrations:up-to-date
# and checking if the exit code is 0 (up to date) or 1 (not up to date)
if sudo -E -u www-data php bin/console doctrine:migrations:up-to-date --no-interaction; then
echo "Database is up to date, no migrations necessary."
else
echo "Migrations available..."
echo "Do backup of database..."
sudo -E -u www-data mkdir -p /var/www/html/uploads/.automigration-backup/
# Backup the database
sudo -E -u www-data php bin/console partdb:backup -n --database /var/www/html/uploads/.automigration-backup/backup-$(date +%Y-%m-%d_%H-%M-%S).zip
# Check if there are any migration files
sudo -E -u www-data php bin/console doctrine:migrations:migrate --no-interaction
fi
fi
# first arg is `-f` or `--some-option` (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/docker-php-entrypoint)
if [ "${1#-}" != "$1" ]; then
set -- apache2-foreground "$@"

View file

@ -1 +1 @@
1.17.1
1.16.0

View file

@ -128,8 +128,6 @@ const PLACEHOLDERS = [
['[[BARCODE_QR]]', 'QR code linking to this element'],
['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'],
['[[BARCODE_C39]]', 'Code 39 barcode linking to this element'],
['[[BARCODE_C93]]', 'Code 93 barcode linking to this element'],
['[[BARCODE_DATAMATRIX]]', 'Datamatrix code linking to this element'],
]
},
{

View file

@ -69,8 +69,6 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'QR code linking to this element': 'QR Code verknüpft mit diesem Element',
'Code 128 barcode linking to this element': 'Code 128 Barcode verknüpft mit diesem Element',
'Code 39 barcode linking to this element': 'Code 39 Barcode verknüpft mit diesem Element',
'Code 93 barcode linking to this element': 'Code 93 Barcode verknüpft mit diesem Element',
'Datamatrix code linking to this element': 'Datamatrix Code verknüpft mit diesem Element',
'Location ID': 'Lagerort ID',
'Name': 'Name',

View file

@ -25,20 +25,9 @@ import "katex/dist/katex.css";
export default class extends Controller {
static targets = ["input", "preview"];
static values = {
unit: {type: Boolean, default: false} //Render as upstanding (non-italic) text, useful for units
}
updatePreview()
{
let value = "";
if (this.unitValue) {
value = "\\mathrm{" + this.inputTarget.value + "}";
} else {
value = this.inputTarget.value;
}
katex.render(value, this.previewTarget, {
katex.render(this.inputTarget.value, this.previewTarget, {
throwOnError: false,
});
}

View file

@ -111,11 +111,4 @@ ul.structural_link li a:hover {
.permission-checkbox:checked {
background-color: var(--bs-success);
border-color: var(--bs-success);
}
/***********************************************
* Katex rendering with same height as text
***********************************************/
.katex-same-height-as-text .katex {
font-size: 1.0em;
}

View file

@ -15,7 +15,7 @@
"api-platform/core": "^3.1",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "0.12.1 as 0.11.0",
"composer/ca-bundle": "^1.5",
"composer/ca-bundle": "^1.3",
"composer/package-versions-deprecated": "^1.11.99.5",
"doctrine/data-fixtures": "^2.0.0",
"doctrine/dbal": "^4.0.0",
@ -43,7 +43,6 @@
"omines/datatables-bundle": "^0.9.1",
"paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0",
"rhukster/dom-sanitizer": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1",
"scheb/2fa-backup-code": "^6.8.0",

1611
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -47,12 +47,6 @@ services:
- DATABASE_URL=sqlite:///%kernel.project_dir%/var/db/app.db
# In docker env logs will be redirected to stderr
- APP_ENV=docker
# Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# You can configure Part-DB using environment variables
# Below you can find the most essential ones predefined
@ -136,12 +130,6 @@ services:
# In docker env logs will be redirected to stderr
- APP_ENV=docker
# Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# You can configure Part-DB using environment variables
# Below you can find the most essential ones predefined
# However you can add add any other environment configuration you want here
@ -213,10 +201,6 @@ You also have to create the database as described above in step 4.
You can run the console commands described in README by
executing `docker exec --user=www-data -it partdb bin/console [command]`
{: .warning }
> If you run a root console inside the container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
## Troubleshooting
*Login is not possible. Login page is just reloading and no error message is shown or something like "CSFR token invalid"*:

View file

@ -52,11 +52,6 @@ server {
location ~ \.php$ {
return 404;
}
# Set Content-Security-Policy for svg files, to block embedded javascript in there
location ~* \.svg$ {
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';";
}
error_log /var/log/nginx/parts.error.log;
access_log /var/log/nginx/parts.access.log;

View file

@ -25,12 +25,6 @@ is named `partdb`, you can execute the command `php bin/console cache:clear` wit
docker exec --user=www-data partdb php bin/console cache:clear
```
{: .warning }
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
## Troubleshooting
## User management commands
* `php bin/console partdb:users:list`: List all users of this Part-DB instance
@ -70,10 +64,4 @@ docker exec --user=www-data partdb php bin/console cache:clear
## Database commands
* `php bin/console doctrine:migrations:migrate`: Migrate the database to the latest version
* `php bin/console doctrine:migrations:up-to-date`: Check if the database is up-to-date
## Attachment commands
* `php bin/console partdb:attachments:download`: Download all attachments, which are not already downloaded, to the
local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote and
also makes pictures thumbnails available for the frontend for them
* `php bin/console doctrine:migrations:up-to-date`: Check if the database is up-to-date

View file

@ -127,6 +127,9 @@ You must create an organization there and create a "Production app". Most settin
grant access to the "Product Information" API.
You will get a Client ID and a Client Secret, which you have to put in the Part-DB env configuration (see below).
**Attention**: Currently only the "Product Information V3 (Deprecated)" is supported by Part-DB.
Using "Product Information V4" will not work.
The following env configuration options are available:
* `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory)

View file

@ -4,25 +4,24 @@ declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250220215048 extends AbstractMigration
final class Version20250220215048 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Split $path property for attachments into $internal_path and $external_path';
}
public function up(Schema $schema): void
public function mySQLUp(Schema $schema): void
{
//Create the new columns as nullable (that is easier modifying them)
$this->addSql('ALTER TABLE attachments ADD internal_path VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE attachments ADD external_path VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE attachments ADD internal_path VARCHAR(255) DEFAULT NULL, ADD external_path VARCHAR(255) DEFAULT NULL');
//Copy the data from path to external_path and remove the path column
$this->addSql('UPDATE attachments SET external_path=path');
$this->addSql('ALTER TABLE attachments DROP COLUMN path');
$this->addSql('ALTER TABLE attachments DROP path');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%MEDIA#%%\' ESCAPE \'#\'');
@ -33,10 +32,52 @@ final class Version20250220215048 extends AbstractMigration
$this->addSql('UPDATE attachments SET external_path=NULL WHERE internal_path IS NOT NULL');
}
public function down(Schema $schema): void
public function mySQLDown(Schema $schema): void
{
$this->addSql('UPDATE attachments SET external_path=internal_path WHERE internal_path IS NOT NULL');
$this->addSql('ALTER TABLE attachments DROP COLUMN internal_path');
$this->addSql('ALTER TABLE attachments DROP internal_path');
$this->addSql('ALTER TABLE attachments RENAME COLUMN external_path TO path');
}
public function postgreSQLUp(Schema $schema): void
{
//We can use the same SQL for PostgreSQL as for MySQL
$this->mySQLUp($schema);
}
public function postgreSQLDown(Schema $schema): void
{
//We can use the same SQL for PostgreSQL as for MySQL
$this->mySQLDown($schema);
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__attachments AS SELECT id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, path FROM attachments');
$this->addSql('DROP TABLE attachments');
$this->addSql('CREATE TABLE attachments (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, type_id INTEGER NOT NULL, original_filename VARCHAR(255) DEFAULT NULL, show_in_table BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, class_name VARCHAR(255) NOT NULL, element_id INTEGER NOT NULL, internal_path VARCHAR(255) DEFAULT NULL, external_path VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_47C4FAD6C54C8C93 FOREIGN KEY (type_id) REFERENCES attachment_types (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO attachments (id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, external_path) SELECT id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, path FROM __temp__attachments');
$this->addSql('DROP TABLE __temp__attachments');
$this->addSql('CREATE INDEX attachment_element_idx ON attachments (class_name, element_id)');
$this->addSql('CREATE INDEX attachment_name_idx ON attachments (name)');
$this->addSql('CREATE INDEX attachments_idx_class_name_id ON attachments (class_name, id)');
$this->addSql('CREATE INDEX attachments_idx_id_element_id_class_name ON attachments (id, element_id, class_name)');
$this->addSql('CREATE INDEX IDX_47C4FAD6C54C8C93 ON attachments (type_id)');
$this->addSql('CREATE INDEX IDX_47C4FAD61F1F2A24 ON attachments (element_id)');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%MEDIA#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%BASE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%SECURE#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%FOOTPRINTS3D#%%\' ESCAPE \'#\'');
$this->addSql('UPDATE attachments SET external_path=NULL WHERE internal_path IS NOT NULL');
}
public function sqLiteDown(Schema $schema): void
{
//Reuse the MySQL down migration:
$this->mySQLDown($schema);
}
}

View file

@ -118,10 +118,3 @@ DirectoryIndex index.php
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>
# Set Content-Security-Policy for svg files (and compressed variants), to block embedded javascript in there
<IfModule mod_headers.c>
<FilesMatch "\.(svg|svg\.gz|svg\.br)$">
Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';"
</FilesMatch>
</IfModule>

View file

@ -65,7 +65,7 @@ class NormalizePropertyNameCollectionFactory implements PropertyNameCollectionFa
$camelized = u($property)->camel()->toString();
//If the camelized version exists, remove it from the collection
$index = array_search($camelized, $properties, true);
$index = array_search($camelized, $properties);
if ($index !== false) {
unset($properties[$index]);
}

View file

@ -73,9 +73,6 @@ 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

@ -1,136 +0,0 @@
<?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

@ -1,90 +0,0 @@
<?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

@ -29,7 +29,6 @@ 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,11 +43,8 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
class PartListsController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator)
@ -75,32 +71,13 @@ 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, $errors);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
//Save changes
$this->entityManager->flush();
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']
])
);
}
}
$this->addFlash('success', 'part.table.actions.success');
}
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.

View file

@ -29,7 +29,6 @@ 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;
@ -79,12 +78,6 @@ 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

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

View file

@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/
#[Groups(['parameter:read', 'full'])]
#[SerializedName('formatted')]
public function getFormattedValue(bool $latex_formatted = false): string
public function getFormattedValue(): 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($latex_formatted);
$str .= $this->getValueTypicalWithUnit();
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($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
} elseif ($this->value_max) {
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
$str .= 'max. '.$this->getValueMaxWithUnit();
} elseif ($this->value_min) {
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
$str .= 'min. '.$this->getValueMinWithUnit();
}
//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(bool $with_latex = false): string
public function getValueTypicalWithUnit(): string
{
return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
return $this->formatWithUnit($this->value_typical);
}
/**
* Return a formatted version with the maximum value with the unit of this parameter.
*/
public function getValueMaxWithUnit(bool $with_latex = false): string
public function getValueMaxWithUnit(): string
{
return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
return $this->formatWithUnit($this->value_max);
}
/**
* Return a formatted version with the typical value with the unit of this parameter.
*/
public function getValueMinWithUnit(bool $with_latex = false): string
public function getValueMinWithUnit(): string
{
return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
return $this->formatWithUnit($this->value_min);
}
/**
@ -441,18 +441,11 @@ 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', bool $with_latex = false): string
protected function formatWithUnit(float $value, string $format = '%g'): string
{
$str = sprintf($format, $value);
if ($this->unit !== '') {
if (!$with_latex) {
$unit = $this->unit;
} else {
$unit = '$\mathrm{'.$this->unit.'}$';
}
return $str.' '.$unit;
return $str.' '.$this->unit;
}
return $str;

View file

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

View file

@ -65,7 +65,7 @@ class AttachmentSubmitHandler
'htpasswd', ''];
public function __construct(protected AttachmentPathResolver $pathResolver, protected bool $allow_attachments_downloads,
protected HttpClientInterface $httpClient, protected MimeTypesInterface $mimeTypes, protected readonly SVGSanitizer $SVGSanitizer,
protected HttpClientInterface $httpClient, protected MimeTypesInterface $mimeTypes,
protected FileTypeFilterTools $filterTools, /**
* @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to
*/
@ -214,9 +214,6 @@ class AttachmentSubmitHandler
//Move the attachment files to secure location (and back) if needed
$this->moveFile($attachment, $secure_attachment);
//Sanitize the SVG if needed
$this->sanitizeSVGAttachment($attachment);
//Rename blacklisted (unsecure) files to a better extension
$this->renameBlacklistedExtensions($attachment);
@ -348,28 +345,9 @@ class AttachmentSubmitHandler
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
try {
$opts = [
$response = $this->httpClient->request('GET', $url, [
'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());
@ -501,32 +479,4 @@ 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

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

View file

@ -27,7 +27,6 @@ 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 Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -45,7 +44,6 @@ class KiCadHelper
private readonly EntityManagerInterface $em,
private readonly ElementCacheTagGenerator $tagGenerator,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator,
/** The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
private readonly int $category_depth,
@ -66,10 +64,6 @@ 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 [
@ -114,8 +108,6 @@ 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

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

View file

@ -108,15 +108,12 @@ class DigikeyProvider implements InfoProviderInterface
{
$request = [
'Keywords' => $keyword,
'Limit' => 50,
'Offset' => 0,
'FilterOptionsRequest' => [
'MarketPlaceFilter' => 'ExcludeMarketPlace',
],
'RecordCount' => 50,
'RecordStartPosition' => 0,
'ExcludeMarketPlaceProducts' => 'true',
];
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
'json' => $request,
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
@ -127,21 +124,18 @@ class DigikeyProvider implements InfoProviderInterface
$result = [];
$products = $response_array['Products'];
foreach ($products as $product) {
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)
);
}
$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'],
);
}
return $result;
@ -149,79 +143,62 @@ class DigikeyProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO
{
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
$response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . urlencode($id), [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
$response_array = $response->toArray();
$product = $response_array['Product'];
$product = $response->toArray();
$footprint = null;
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
$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;
}
}
$media = $this->mediaToDTOs($product['MediaLinks']);
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $id,
name: $product['ManufacturerProductNumber'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
provider_id: $product['DigiKeyPartNumber'],
name: $product['ManufacturerPartNumber'],
description: $product['DetailedDescription'] ?? $product['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']),
manufacturer: $product['Manufacturer']['Value'] ?? null,
mpn: $product['ManufacturerPartNumber'],
preview_image_url: $product['PrimaryPhoto'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
provider_url: $product['ProductUrl'],
footprint: $footprint,
datasheets: $media['datasheets'],
images: $media['images'],
parameters: $parameters,
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']),
);
}
/**
* Converts the product status from the Digikey API to the manufacturing status used in Part-DB
* @param int|null $dk_status
* @param string|null $dk_status
* @return ManufacturingStatus|null
*/
private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
private function productStatusToManufacturingStatus(?string $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,
0 => ManufacturingStatus::ACTIVE,
1 => ManufacturingStatus::DISCONTINUED,
2, 4 => ManufacturingStatus::EOL,
7 => ManufacturingStatus::NRFND,
//'Preliminary' => ManufacturingStatus::ANNOUNCED,
'Active' => ManufacturingStatus::ACTIVE,
'Obsolete' => ManufacturingStatus::DISCONTINUED,
'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
'Not For New Designs' => ManufacturingStatus::NRFND,
'Preliminary' => ManufacturingStatus::ANNOUNCED,
default => ManufacturingStatus::NOT_SET,
};
}
private function getCategoryString(array $product): string
{
$category = $product['Category']['Name'];
$sub_category = current($product['Category']['ChildCategories']);
$category = $product['Category']['Value'];
$sub_category = $product['Family']['Value'];
if ($sub_category) {
//Replace the ' - ' category separator with ' -> '
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
}
//Replace the ' - ' category separator with ' -> '
$sub_category = str_replace(' - ', ' -> ', $sub_category);
return $category;
return $category . ' -> ' . $sub_category;
}
/**
@ -238,18 +215,18 @@ class DigikeyProvider implements InfoProviderInterface
foreach ($parameters as $parameter) {
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint"
$footprint_name = $parameter['ValueText'];
$footprint_name = $parameter['Value'];
}
if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
if (in_array(trim((string) $parameter['Value']), ['', '-'], 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['ParameterText'], value_text: $parameter['ValueText']);
$results[] = new ParameterDTO(name: $parameter['Parameter'], value_text: $parameter['Value']);
} else { //Otherwise try to parse it as a numerical value
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
}
}
@ -277,22 +254,16 @@ class DigikeyProvider implements InfoProviderInterface
}
/**
* @param string $id The Digikey product number, to get the media for
* @param array $media_links
* @return FileDTO[][]
* @phpstan-return array<string, FileDTO[]>
*/
private function mediaToDTOs(string $id): array
private function mediaToDTOs(array $media_links): array
{
$datasheets = [];
$images = [];
$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) {
foreach ($media_links as $media_link) {
$file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
switch ($media_link['MediaType']) {

View file

@ -29,7 +29,6 @@ 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 Composer\CaBundle\CaBundle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Element14Provider implements InfoProviderInterface
@ -44,19 +43,9 @@ 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(HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id)
public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id)
{
/* 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

View file

@ -76,7 +76,7 @@ class LCSCProvider implements InfoProviderInterface
'Cookie' => new Cookie('currencyCode', $this->currency)
],
'query' => [
'productCode' => $id,
'prductCode' => $id,
],
]);

View file

@ -94,7 +94,6 @@ 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.
@ -177,16 +176,11 @@ 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);
throw new \RuntimeException('Multiple parts found with ID '.$id . ' ('.count($tmp).' found). This is basically a bug in Mousers API response. See issue #616.');
}
return reset($tmp);
return $tmp[0];
}
public function getCapabilities(): array

View file

@ -49,8 +49,8 @@ class PollinProvider implements InfoProviderInterface
{
return [
'name' => 'Pollin',
'description' => 'Webscraping from pollin.de to get part information',
'url' => 'https://www.pollin.de/',
'description' => 'Webscrapping from pollin.de to get part information',
'url' => 'https://www.reichelt.de/',
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
];
}
@ -215,7 +215,7 @@ class PollinProvider implements InfoProviderInterface
private function parseNotes(Crawler $dom): string
{
//Concat product highlights and product description
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
return $dom->filter('div.product-detail-top-features')->html() . '<br><br>' . $dom->filter('div.product-detail-description-text')->html();
}
private function parsePrices(Crawler $dom): array

View file

@ -57,7 +57,7 @@ class ReicheltProvider implements InfoProviderInterface
{
return [
'name' => 'Reichelt',
'description' => 'Webscraping from reichelt.com to get part information',
'description' => 'Webscrapping from reichelt.com to get part information',
'url' => 'https://www.reichelt.com/',
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
];

View file

@ -63,24 +63,12 @@ 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

@ -22,7 +22,6 @@ 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;
@ -36,9 +35,6 @@ 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
{
@ -65,9 +61,8 @@ 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, array &$errors = []): ?RedirectResponse
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
{
if ($action === 'add_to_project') {
return new RedirectResponse(
@ -166,29 +161,6 @@ 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

@ -38,7 +38,7 @@
{% if not app.user.theme is defined or app.user.theme is null %}
{% if not app.user.theme is defined %}
{% set theme = global_theme %}
{% else %}
{% set theme = app.user.theme %}

View file

@ -54,7 +54,6 @@
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_footprint" data-url="{{ path('select_footprint') }}">{% trans %}part_list.action.action.change_footprint{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_manufacturer" data-url="{{ path('select_manufacturer') }}">{% trans %}part_list.action.action.change_manufacturer{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_unit" data-url="{{ path('select_measurement_unit') }}">{% trans %}part_list.action.action.change_unit{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_location" data-url="{{ path('select_storage_location') }}">{% trans %}part_list.action.action.change_location{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.group.labels{% endtrans %}">
<option {% if not is_granted('@labels.create_labels') %}disabled{% endif %} value="generate_label_lot" data-url="{{ path('select_label_profiles_lot')}}">{% trans %}part_list.action.projects.generate_label_lot{% endtrans %}</option>

View file

@ -54,7 +54,7 @@
<td class="col-sm-2">{{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value) }}</td>
<td {{ stimulus_controller('pages/latex_preview', {"unit": true}) }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">

View file

@ -218,16 +218,14 @@
<thead>
<tr>
<th>{% trans %}specifications.property{% endtrans %}</th>
<th>{% trans %}specifications.symbol{% endtrans %}</th>
<th>{% trans %}specifications.value{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for param in parameters %}
<tr>
<td>{{ param.name }}</td>
<td>{% if param.symbol is not empty %}<span class="latex" {{ stimulus_controller('common/latex') }}>${{ param.symbol }}$</span>{% endif %}</td>
<td {{ stimulus_controller('common/latex') }} class="katex-same-height-as-text">{{ param.formattedValue(true) }}</td>
<td>{{ param.name }} {% if param.symbol is not empty %}<span class="latex" data-controller="common--latex">${{ param.symbol }}$</span>{% endif %}</td>
<td>{{ param.formattedValue }}</td>
</tr>
{% endfor %}
</tbody>

View file

@ -75,7 +75,7 @@
<td>{{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }}</td>
<td>{{ form_widget(form.value_typical) }}{{ form_errors(form.value_typical) }}</td>
<td>{{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }}</td>
<td {{ stimulus_controller('pages/latex_preview', {"unit": true}) }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
<td>

View file

@ -67,19 +67,6 @@ class PartParameterTest extends TestCase
yield ['10.23 V (9 V ... 11 V) [Test]', 9, 10.23, 11, 'V', 'Test'];
}
public function formattedValueWithLatexDataProvider(): \Iterator
{
yield ['Text Test', null, null, null, 'V', 'Text Test'];
yield ['10.23 $\mathrm{V}$', null, 10.23, null, 'V', ''];
yield ['10.23 $\mathrm{V}$ [Text]', null, 10.23, null, 'V', 'Text'];
yield ['max. 10.23 $\mathrm{V}$', null, null, 10.23, 'V', ''];
yield ['max. 10.23 [Text]', null, null, 10.23, '', 'Text'];
yield ['min. 10.23 $\mathrm{V}$', 10.23, null, null, 'V', ''];
yield ['10.23 $\mathrm{V}$ ... 11 $\mathrm{V}$', 10.23, null, 11, 'V', ''];
yield ['10.23 $\mathrm{V}$ (9 $\mathrm{V}$ ... 11 $\mathrm{V}$)', 9, 10.23, 11, 'V', ''];
yield ['10.23 $\mathrm{V}$ (9 $\mathrm{V}$ ... 11 $\mathrm{V}$) [Test]', 9, 10.23, 11, 'V', 'Test'];
}
/**
* @dataProvider valueWithUnitDataProvider
*/
@ -130,22 +117,4 @@ class PartParameterTest extends TestCase
$param->setValueText($text);
$this->assertSame($expected, $param->getFormattedValue());
}
/**
* @dataProvider formattedValueWithLatexDataProvider
*
* @param float $min
* @param float $typical
* @param float $max
*/
public function testGetFormattedValueWithLatex(string $expected, ?float $min, ?float $typical, ?float $max, string $unit, string $text): void
{
$param = new PartParameter();
$param->setUnit($unit);
$param->setValueMin($min);
$param->setValueTypical($typical);
$param->setValueMax($max);
$param->setValueText($text);
$this->assertSame($expected, $param->getFormattedValue(true));
}
}

View file

@ -12341,29 +12341,5 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<target>Externe Version anzeigen</target>
</segment>
</unit>
<unit id="X9HUFrv" name="part.table.actions.error">
<segment state="translated">
<source>part.table.actions.error</source>
<target>Es traten %count% Fehler bei der Aktion auf!</target>
</segment>
</unit>
<unit id=".ppbsNn" name="part.table.actions.error_detail">
<segment state="translated">
<source>part.table.actions.error_detail</source>
<target>%part_name% (ID: %part_id%): %message%</target>
</segment>
</unit>
<unit id="4wpp6h." name="part_list.action.action.change_location">
<segment state="translated">
<source>part_list.action.action.change_location</source>
<target>Lagerort ändern (nur für Bauteile mit einzelnem Bestand)</target>
</segment>
</unit>
<unit id="9_9I.m4" name="parts.table.action_handler.error.part_lots_multiple">
<segment state="translated">
<source>parts.table.action_handler.error.part_lots_multiple</source>
<target>Dieses Bauteil enthält mehr als einen Bestand. Ändere den Lagerort bei Hand, um auszuwählen, welcher Bestand geändert werden soll.</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -9304,7 +9304,7 @@ Element 3</target>
<unit id="oQmnwDq" name="part.filter.orderdetails_count">
<segment state="translated">
<source>part.filter.orderdetails_count</source>
<target>Number of order details</target>
<target>Number of orderdetails</target>
</segment>
</unit>
<unit id="EVZFWzr" name="part.filter.lotExpirationDate">
@ -12345,29 +12345,5 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>View external version</target>
</segment>
</unit>
<unit id="X9HUFrv" name="part.table.actions.error">
<segment state="translated">
<source>part.table.actions.error</source>
<target>%count% errors occured, while performing action:</target>
</segment>
</unit>
<unit id=".ppbsNn" name="part.table.actions.error_detail">
<segment state="translated">
<source>part.table.actions.error_detail</source>
<target>%part_name% (ID: %part_id%): %message%</target>
</segment>
</unit>
<unit id="4wpp6h." name="part_list.action.action.change_location">
<segment state="translated">
<source>part_list.action.action.change_location</source>
<target>Change location (only for parts with single stock)</target>
</segment>
</unit>
<unit id="9_9I.m4" name="parts.table.action_handler.error.part_lots_multiple">
<segment state="translated">
<source>parts.table.action_handler.error.part_lots_multiple</source>
<target>This part contains more than one stock. Change the location by hand to select, which stock to choose.</target>
</segment>
</unit>
</file>
</xliff>

File diff suppressed because it is too large Load diff

View file

@ -780,10 +780,18 @@ L'utente dovrà configurare nuovamente tutti i metodi di autenticazione a due fa
<target>Eliminare</target>
</segment>
</unit>
<unit id="FtktoBj" name="attachment.external_only">
<unit id="W80Gv6o" name="attachment.external">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\_attachments.html.twig:41</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:38</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:35</note>
<note category="file-source" priority="1">Part-DB1\src\DataTables\AttachmentDataTable.php:159</note>
<note priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:38</note>
<note priority="1">Part-DB1\src\DataTables\AttachmentDataTable.php:159</note>
</notes>
<segment state="translated">
<source>attachment.external_only</source>
<target>Solo allegato esterno</target>
<source>attachment.external</source>
<target>Esterno</target>
</segment>
</unit>
<unit id="JES0hrm" name="attachment.preview.alt">
@ -798,7 +806,7 @@ L'utente dovrà configurare nuovamente tutti i metodi di autenticazione a due fa
<target>Miniatura dell'allegato</target>
</segment>
</unit>
<unit id="I_HDnsL" name="attachment.view_local">
<unit id="fCQby7u" name="attachment.view">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\_attachments.html.twig:52</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:50</note>
@ -808,8 +816,8 @@ L'utente dovrà configurare nuovamente tutti i metodi di autenticazione a due fa
<note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:45</note>
</notes>
<segment state="translated">
<source>attachment.view_local</source>
<target>Visualizza la copia locale</target>
<source>attachment.view</source>
<target>Visualizzare</target>
</segment>
</unit>
<unit id="mEHEYM6" name="attachment.file_not_found">
@ -2111,14 +2119,14 @@ I sub elementi saranno spostati verso l'alto.</target>
<target>Immagine di anteprima</target>
</segment>
</unit>
<unit id="Uuy6Ntl" name="attachment.download_local">
<unit id="O2kBcDz" name="attachment.download">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:67</note>
<note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:50</note>
</notes>
<segment state="translated">
<source>attachment.download_local</source>
<target>Scarica la copia in locale</target>
<source>attachment.download</source>
<target>Download</target>
</segment>
</unit>
<unit id="mPK9Iyq" name="user.creating_user">
@ -12316,59 +12324,5 @@ Notare che non è possibile impersonare un utente disattivato. Quando si prova a
<target>Profilo salvato!</target>
</segment>
</unit>
<unit id="8C9ijHM" name="entity.export.flash.error.no_entities">
<segment state="translated">
<source>entity.export.flash.error.no_entities</source>
<target>Non ci sono entità da esportare!</target>
</segment>
</unit>
<unit id="0B3_rob" name="attachment.table.internal_file">
<segment state="translated">
<source>attachment.table.internal_file</source>
<target>File interno</target>
</segment>
</unit>
<unit id="uhfLnkB" name="attachment.table.external_link">
<segment state="translated">
<source>attachment.table.external_link</source>
<target>Link esterno</target>
</segment>
</unit>
<unit id="2WKNZAm" name="attachment.view_external.view_at">
<segment state="translated">
<source>attachment.view_external.view_at</source>
<target>Visualizza da %host%</target>
</segment>
</unit>
<unit id="nwO78O_" name="attachment.view_external">
<segment state="translated">
<source>attachment.view_external</source>
<target>Visualizza la versione esterna</target>
</segment>
</unit>
<unit id="X9HUFrv" name="part.table.actions.error">
<segment state="translated">
<source>part.table.actions.error</source>
<target>Si sono verificati %count% errori durante l'esecuzione dell'azione:</target>
</segment>
</unit>
<unit id=".ppbsNn" name="part.table.actions.error_detail">
<segment state="translated">
<source>part.table.actions.error_detail</source>
<target>%part_name% (ID: %part_id%): %message%</target>
</segment>
</unit>
<unit id="4wpp6h." name="part_list.action.action.change_location">
<segment state="translated">
<source>part_list.action.action.change_location</source>
<target>Cambia posizione (solo per componenti con stock singolo)</target>
</segment>
</unit>
<unit id="9_9I.m4" name="parts.table.action_handler.error.part_lots_multiple">
<segment state="translated">
<source>parts.table.action_handler.error.part_lots_multiple</source>
<target>Questo componente contiene più di uno stock. Cambia manualmente la posizione per selezionare quale stock scegliere.</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="es">
<file id="security.en">
<unit id="GrLNa9P" name="user.login_error.user_disabled">
<segment state="translated">
<source>user.login_error.user_disabled</source>
<target>Su cuenta ha sido deshabilitada. Contacte con el administrador si cree que podría ser un error.</target>
</segment>
</unit>
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
<segment state="translated">
<source>saml.error.cannot_login_local_user_per_saml</source>
<target>No puede identificarse como usuario local vía SSO. Utilice su contraseña local en su lugar.</target>
</segment>
</unit>
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
<segment state="translated">
<source>saml.error.cannot_login_saml_user_locally</source>
<target>No puede identificarse como usuario SAML utilizando su usuario local. Utilice su identificación SSO en su lugar.</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -1,23 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="fr">
<file id="security.en">
<unit id="GrLNa9P" name="user.login_error.user_disabled">
<unit id="aazoCks" name="user.login_error.user_disabled">
<segment state="translated">
<source>user.login_error.user_disabled</source>
<target>Votre compte est désactivé ! Contactez un administrateur si vous pensez que c'est une erreur.</target>
</segment>
</unit>
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
<segment state="translated">
<source>saml.error.cannot_login_local_user_per_saml</source>
<target>Impossible de se connecter via le SSO (Single Sign On)!</target>
</segment>
</unit>
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
<segment state="translated">
<source>saml.error.cannot_login_saml_user_locally</source>
<target>Vous ne pouvez pas utiliser l'authentification par mot de passe! Veuillez utiliser le SSO!</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -1,23 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="zh-CN">
<file id="security.en">
<unit id="GrLNa9P" name="user.login_error.user_disabled">
<unit id="aazoCks" name="user.login_error.user_disabled">
<segment state="translated">
<source>user.login_error.user_disabled</source>
<target>账户已被禁用。请联系管理员</target>
</segment>
</unit>
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml">
<segment state="translated">
<source>saml.error.cannot_login_local_user_per_saml</source>
<target>无法通过 SSO 以本地用户身份登录。请使用本地用户密码</target>
</segment>
</unit>
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
<segment state="translated">
<source>saml.error.cannot_login_saml_user_locally</source>
<target>无法使用本地身份验证器以SAML用户身份登录请改用SSO登录</target>
</segment>
</unit>
</file>
</xliff>

3202
yarn.lock

File diff suppressed because it is too large Load diff