Compare commits

..

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

46 changed files with 3930 additions and 4806 deletions

View file

@ -42,48 +42,6 @@ fi
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile) # Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
service phpPHP_VERSION-fpm start 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) # 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 if [ "${1#-}" != "$1" ]; then
set -- apache2-foreground "$@" set -- apache2-foreground "$@"

View file

@ -1 +1 @@
1.17.1 1.16.1

View file

@ -128,8 +128,6 @@ const PLACEHOLDERS = [
['[[BARCODE_QR]]', 'QR code linking to this element'], ['[[BARCODE_QR]]', 'QR code linking to this element'],
['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'], ['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'],
['[[BARCODE_C39]]', 'Code 39 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', '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 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 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', 'Location ID': 'Lagerort ID',
'Name': 'Name', 'Name': 'Name',

View file

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

View file

@ -112,10 +112,3 @@ ul.structural_link li a:hover {
background-color: var(--bs-success); background-color: var(--bs-success);
border-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", "api-platform/core": "^3.1",
"beberlei/doctrineextensions": "^1.2", "beberlei/doctrineextensions": "^1.2",
"brick/math": "0.12.1 as 0.11.0", "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", "composer/package-versions-deprecated": "^1.11.99.5",
"doctrine/data-fixtures": "^2.0.0", "doctrine/data-fixtures": "^2.0.0",
"doctrine/dbal": "^4.0.0", "doctrine/dbal": "^4.0.0",
@ -43,7 +43,6 @@
"omines/datatables-bundle": "^0.9.1", "omines/datatables-bundle": "^0.9.1",
"paragonie/sodium_compat": "^1.21", "paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0", "part-db/label-fonts": "^1.0",
"rhukster/dom-sanitizer": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1", "s9e/text-formatter": "^2.1",
"scheb/2fa-backup-code": "^6.8.0", "scheb/2fa-backup-code": "^6.8.0",

1611
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -48,12 +48,6 @@ services:
# In docker env logs will be redirected to stderr # In docker env logs will be redirected to stderr
- APP_ENV=docker - 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 # You can configure Part-DB using environment variables
# Below you can find the most essential ones predefined # Below you can find the most essential ones predefined
# However you can add any other environment configuration you want here # However you can add any other environment configuration you want here
@ -136,12 +130,6 @@ services:
# In docker env logs will be redirected to stderr # In docker env logs will be redirected to stderr
- APP_ENV=docker - 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 # You can configure Part-DB using environment variables
# Below you can find the most essential ones predefined # Below you can find the most essential ones predefined
# However you can add add any other environment configuration you want here # 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 You can run the console commands described in README by
executing `docker exec --user=www-data -it partdb bin/console [command]` 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 ## Troubleshooting
*Login is not possible. Login page is just reloading and no error message is shown or something like "CSFR token invalid"*: *Login is not possible. Login page is just reloading and no error message is shown or something like "CSFR token invalid"*:

View file

@ -53,11 +53,6 @@ server {
return 404; 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; error_log /var/log/nginx/parts.error.log;
access_log /var/log/nginx/parts.access.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 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 ## User management commands
* `php bin/console partdb:users:list`: List all users of this Part-DB instance * `php bin/console partdb:users:list`: List all users of this Part-DB instance
@ -71,9 +65,3 @@ docker exec --user=www-data partdb php bin/console cache:clear
* `php bin/console doctrine:migrations:migrate`: Migrate the database to the latest version * `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 * `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

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. 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). 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: The following env configuration options are available:
* `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory) * `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory)

View file

@ -22,7 +22,7 @@ final class Version20250220215048 extends AbstractMigration
//Copy the data from path to external_path and remove the path column //Copy the data from path to external_path and remove the path column
$this->addSql('UPDATE attachments SET external_path=path'); $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 \'#\''); $this->addSql('UPDATE attachments SET internal_path=external_path WHERE external_path LIKE \'#%MEDIA#%%\' ESCAPE \'#\'');
@ -36,7 +36,7 @@ final class Version20250220215048 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
$this->addSql('UPDATE attachments SET external_path=internal_path WHERE internal_path IS NOT NULL'); $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'); $this->addSql('ALTER TABLE attachments RENAME COLUMN external_path TO path');
} }
} }

View file

@ -118,10 +118,3 @@ DirectoryIndex index.php
# RedirectTemp cannot be used instead # RedirectTemp cannot be used instead
</IfModule> </IfModule>
</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

@ -73,9 +73,6 @@ class CleanAttachmentsCommand extends Command
//Ignore image cache folder //Ignore image cache folder
$finder->exclude('cache'); $finder->exclude('cache');
//Ignore automigration folder
$finder->exclude('.automigration-backup');
$fs = new Filesystem(); $fs = new Filesystem();
$file_list = []; $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\Category;
use App\Entity\Parts\Footprint; use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation; use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
use App\Exceptions\InvalidRegexException; use App\Exceptions\InvalidRegexException;
@ -44,11 +43,8 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
class PartListsController extends AbstractController class PartListsController extends AbstractController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator) public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator)
@ -75,32 +71,13 @@ class PartListsController extends AbstractController
if (null === $action || null === $ids) { if (null === $action || null === $ids) {
$this->addFlash('error', 'part.table.actions.no_params_given'); $this->addFlash('error', 'part.table.actions.no_params_given');
} else { } else {
$errors = [];
$parts = $actionHandler->idStringToArray($ids); $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 //Save changes
$this->entityManager->flush(); $this->entityManager->flush();
if (count($errors) === 0) { $this->addFlash('success', 'part.table.actions.success');
$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. //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\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\StorageLocation;
use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\Project;
use App\Form\Type\Helper\StructuralEntityChoiceHelper; use App\Form\Type\Helper\StructuralEntityChoiceHelper;
use App\Services\Trees\NodesListBuilder; use App\Services\Trees\NodesListBuilder;
@ -79,12 +78,6 @@ class SelectAPIController extends AbstractController
return $this->getResponseForClass(Project::class, false); 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')] #[Route(path: '/export_level', name: 'select_export_level')]
public function exportLevel(): Response public function exportLevel(): Response
{ {

View file

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

View file

@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/ */
#[Groups(['parameter:read', 'full'])] #[Groups(['parameter:read', 'full'])]
#[SerializedName('formatted')] #[SerializedName('formatted')]
public function getFormattedValue(bool $latex_formatted = false): string public function getFormattedValue(): string
{ {
//If we just only have text value, return early //If we just only have text value, return early
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) { 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 = ''; $str = '';
$bracket_opened = false; $bracket_opened = false;
if ($this->value_typical) { if ($this->value_typical) {
$str .= $this->getValueTypicalWithUnit($latex_formatted); $str .= $this->getValueTypicalWithUnit();
if ($this->value_min || $this->value_max) { if ($this->value_min || $this->value_max) {
$bracket_opened = true; $bracket_opened = true;
$str .= ' ('; $str .= ' (';
@ -226,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
} }
if ($this->value_max && $this->value_min) { 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) { } elseif ($this->value_max) {
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted); $str .= 'max. '.$this->getValueMaxWithUnit();
} elseif ($this->value_min) { } elseif ($this->value_min) {
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted); $str .= 'min. '.$this->getValueMinWithUnit();
} }
//Add closing bracket //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. * 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. * 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. * 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. * 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); $str = sprintf($format, $value);
if ($this->unit !== '') { if ($this->unit !== '') {
return $str.' '.$this->unit;
if (!$with_latex) {
$unit = $this->unit;
} else {
$unit = '$\mathrm{'.$this->unit.'}$';
}
return $str.' '.$unit;
} }
return $str; 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 NoResultException
* @throws NonUniqueResultException * @throws NonUniqueResultException
@ -75,9 +75,8 @@ class AttachmentRepository extends DBElementRepository
{ {
$qb = $this->createQueryBuilder('attachment'); $qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)') $qb->select('COUNT(attachment)')
->where('attachment.external_path IS NOT NULL') ->andWhere('attaachment.internal_path IS NULL')
->andWhere('attachment.internal_path IS NULL'); ->where('attachment.external_path IS NOT NULL');
$query = $qb->getQuery(); $query = $qb->getQuery();
return (int) $query->getSingleScalarResult(); return (int) $query->getSingleScalarResult();

View file

@ -65,7 +65,7 @@ class AttachmentSubmitHandler
'htpasswd', '']; 'htpasswd', ''];
public function __construct(protected AttachmentPathResolver $pathResolver, protected bool $allow_attachments_downloads, 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, /** protected FileTypeFilterTools $filterTools, /**
* @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to * @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 //Move the attachment files to secure location (and back) if needed
$this->moveFile($attachment, $secure_attachment); $this->moveFile($attachment, $secure_attachment);
//Sanitize the SVG if needed
$this->sanitizeSVGAttachment($attachment);
//Rename blacklisted (unsecure) files to a better extension //Rename blacklisted (unsecure) files to a better extension
$this->renameBlacklistedExtensions($attachment); $this->renameBlacklistedExtensions($attachment);
@ -348,28 +345,9 @@ class AttachmentSubmitHandler
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp'); $tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
try { try {
$opts = [ $response = $this->httpClient->request('GET', $url, [
'buffer' => false, '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()) { if (200 !== $response->getStatusCode()) {
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode()); throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
@ -501,32 +479,4 @@ class AttachmentSubmitHandler
return $this->max_upload_size_bytes; 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\Footprint;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Services\Cache\ElementCacheTagGenerator; use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\NodesListBuilder; use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -45,7 +44,6 @@ class KiCadHelper
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly ElementCacheTagGenerator $tagGenerator, private readonly ElementCacheTagGenerator $tagGenerator,
private readonly UrlGeneratorInterface $urlGenerator, private readonly UrlGeneratorInterface $urlGenerator,
private readonly EntityURLGenerator $entityURLGenerator,
private readonly TranslatorInterface $translator, 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 */ /** 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, private readonly int $category_depth,
@ -66,10 +64,6 @@ class KiCadHelper
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class); $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
$item->tag($secure_class_name); $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 the category depth is smaller than 0, create only one dummy category
if ($this->category_depth < 0) { if ($this->category_depth < 0) {
return [ return [
@ -114,8 +108,6 @@ class KiCadHelper
$result[] = [ $result[] = [
'id' => (string)$category->getId(), 'id' => (string)$category->getId(),
'name' => $category->getFullPath('/'), '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 public function viewURL(Attachment $entity): string
{ {
//If the underlying file path is invalid, null gets returned, which is not allowed here. if ($entity->hasInternal()) {
//We still have the chance to use an external path, if it is set. return $this->attachmentURLGenerator->getInternalViewURL($entity);
if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
return $url;
} }
if($entity->hasExternal()) { if($entity->hasExternal()) {

View file

@ -108,15 +108,12 @@ class DigikeyProvider implements InfoProviderInterface
{ {
$request = [ $request = [
'Keywords' => $keyword, 'Keywords' => $keyword,
'Limit' => 50, 'RecordCount' => 50,
'Offset' => 0, 'RecordStartPosition' => 0,
'FilterOptionsRequest' => [ 'ExcludeMarketPlaceProducts' => 'true',
'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, 'json' => $request,
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]); ]);
@ -127,21 +124,18 @@ class DigikeyProvider implements InfoProviderInterface
$result = []; $result = [];
$products = $response_array['Products']; $products = $response_array['Products'];
foreach ($products as $product) { foreach ($products as $product) {
foreach ($product['ProductVariations'] as $variation) { $result[] = new SearchResultDTO(
$result[] = new SearchResultDTO( provider_key: $this->getProviderKey(),
provider_key: $this->getProviderKey(), provider_id: $product['DigiKeyPartNumber'],
provider_id: $variation['DigiKeyProductNumber'], name: $product['ManufacturerPartNumber'],
name: $product['ManufacturerProductNumber'], description: $product['DetailedDescription'] ?? $product['ProductDescription'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'], category: $this->getCategoryString($product),
category: $this->getCategoryString($product), manufacturer: $product['Manufacturer']['Value'] ?? null,
manufacturer: $product['Manufacturer']['Name'] ?? null, mpn: $product['ManufacturerPartNumber'],
mpn: $product['ManufacturerProductNumber'], preview_image_url: $product['PrimaryPhoto'] ?? null,
preview_image_url: $product['PhotoUrl'] ?? null, manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']), provider_url: $product['ProductUrl'],
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; return $result;
@ -149,79 +143,62 @@ class DigikeyProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO 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) 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]); ]);
$response_array = $response->toArray(); $product = $response->toArray();
$product = $response_array['Product'];
$footprint = null; $footprint = null;
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint); $parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
$media = $this->mediaToDTOs($id); $media = $this->mediaToDTOs($product['MediaLinks']);
// 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( return new PartDetailDTO(
provider_key: $this->getProviderKey(), provider_key: $this->getProviderKey(),
provider_id: $id, provider_id: $product['DigiKeyPartNumber'],
name: $product['ManufacturerProductNumber'], name: $product['ManufacturerPartNumber'],
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'], description: $product['DetailedDescription'] ?? $product['ProductDescription'],
category: $this->getCategoryString($product), category: $this->getCategoryString($product),
manufacturer: $product['Manufacturer']['Name'] ?? null, manufacturer: $product['Manufacturer']['Value'] ?? null,
mpn: $product['ManufacturerProductNumber'], mpn: $product['ManufacturerPartNumber'],
preview_image_url: $product['PhotoUrl'] ?? null, preview_image_url: $product['PrimaryPhoto'] ?? null,
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']), manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
provider_url: $product['ProductUrl'], provider_url: $product['ProductUrl'],
footprint: $footprint, footprint: $footprint,
datasheets: $media['datasheets'], datasheets: $media['datasheets'],
images: $media['images'], images: $media['images'],
parameters: $parameters, 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 * 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 * @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) { return match ($dk_status) {
null => null, null => null,
0 => ManufacturingStatus::ACTIVE, 'Active' => ManufacturingStatus::ACTIVE,
1 => ManufacturingStatus::DISCONTINUED, 'Obsolete' => ManufacturingStatus::DISCONTINUED,
2, 4 => ManufacturingStatus::EOL, 'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
7 => ManufacturingStatus::NRFND, 'Not For New Designs' => ManufacturingStatus::NRFND,
//'Preliminary' => ManufacturingStatus::ANNOUNCED, 'Preliminary' => ManufacturingStatus::ANNOUNCED,
default => ManufacturingStatus::NOT_SET, default => ManufacturingStatus::NOT_SET,
}; };
} }
private function getCategoryString(array $product): string private function getCategoryString(array $product): string
{ {
$category = $product['Category']['Name']; $category = $product['Category']['Value'];
$sub_category = current($product['Category']['ChildCategories']); $sub_category = $product['Family']['Value'];
if ($sub_category) { //Replace the ' - ' category separator with ' -> '
//Replace the ' - ' category separator with ' -> ' $sub_category = str_replace(' - ', ' -> ', $sub_category);
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
}
return $category; return $category . ' -> ' . $sub_category;
} }
/** /**
@ -238,18 +215,18 @@ class DigikeyProvider implements InfoProviderInterface
foreach ($parameters as $parameter) { foreach ($parameters as $parameter) {
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint" 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; continue;
} }
//If the parameter was marked as text only, then we do not try to parse it as a numerical value //If the parameter was marked as text only, then we do not try to parse it as a numerical value
if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) { 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 } 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[][] * @return FileDTO[][]
* @phpstan-return array<string, FileDTO[]> * @phpstan-return array<string, FileDTO[]>
*/ */
private function mediaToDTOs(string $id): array private function mediaToDTOs(array $media_links): array
{ {
$datasheets = []; $datasheets = [];
$images = []; $images = [];
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [ foreach ($media_links as $media_link) {
'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']); $file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
switch ($media_link['MediaType']) { 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\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use Composer\CaBundle\CaBundle;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class Element14Provider implements InfoProviderInterface class Element14Provider implements InfoProviderInterface
@ -44,19 +43,9 @@ class Element14Provider implements InfoProviderInterface
private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant', private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant',
'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode']; 'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode'];
private readonly HttpClientInterface $element14Client; public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id)
public function __construct(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 public function getProviderInfo(): array

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. 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. This is useful for paging through the complete recordset of parts matching keyword.
searchOptions string searchOptions string
Optional. Optional.
If not provided, the default is None. If not provided, the default is None.
@ -177,16 +176,11 @@ class MouserProvider implements InfoProviderInterface
throw new \RuntimeException('No part found with ID '.$id); 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) { 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 public function getCapabilities(): array

View file

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

View file

@ -57,7 +57,7 @@ class ReicheltProvider implements InfoProviderInterface
{ {
return [ return [
'name' => 'Reichelt', '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/', 'url' => 'https://www.reichelt.com/',
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1' '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); 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) { if ('[[BARCODE_C39]]' === $placeholder) {
$label_options = new LabelOptions(); $label_options = new LabelOptions();
$label_options->setBarcodeType(BarcodeType::CODE39); $label_options->setBarcodeType(BarcodeType::CODE39);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target); 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) { if ('[[BARCODE_C128]]' === $placeholder) {
$label_options = new LabelOptions(); $label_options = new LabelOptions();
$label_options->setBarcodeType(BarcodeType::CODE128); $label_options->setBarcodeType(BarcodeType::CODE128);

View file

@ -22,7 +22,6 @@ declare(strict_types=1);
*/ */
namespace App\Services\Parts; namespace App\Services\Parts;
use App\Entity\Parts\StorageLocation;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint; use App\Entity\Parts\Footprint;
@ -36,9 +35,6 @@ use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Contracts\Translation\TranslatableInterface;
use function Symfony\Component\Translation\t;
final class PartsTableActionHandler final class PartsTableActionHandler
{ {
@ -65,9 +61,8 @@ final class PartsTableActionHandler
/** /**
* @param Part[] $selected_parts * @param Part[] $selected_parts
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null * @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') { if ($action === 'add_to_project') {
return new RedirectResponse( return new RedirectResponse(
@ -166,29 +161,6 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
$this->denyAccessUnlessGranted('@measurement_units.read'); $this->denyAccessUnlessGranted('@measurement_units.read');
$part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id)); $part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id));
break; 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: default:
throw new InvalidArgumentException('The given action is unknown! ('.$action.')'); 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 %} {% set theme = global_theme %}
{% else %} {% else %}
{% set theme = app.user.theme %} {% 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_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_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_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>
<optgroup label="{% trans %}part_list.action.group.labels{% endtrans %}"> <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> <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 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 {{ 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>{{ 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>{{ form_widget(form.value_text) }}</td>
<td> <td>
<button type="button" class="btn btn-danger btn-sm" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}"> <button type="button" class="btn btn-danger btn-sm" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">

View file

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

View file

@ -75,7 +75,7 @@
<td>{{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }}</td> <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_typical) }}{{ form_errors(form.value_typical) }}</td>
<td>{{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }}</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.value_text) }}{{ form_errors(form.value_text) }}</td>
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td> <td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
<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']; 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 * @dataProvider valueWithUnitDataProvider
*/ */
@ -130,22 +117,4 @@ class PartParameterTest extends TestCase
$param->setValueText($text); $param->setValueText($text);
$this->assertSame($expected, $param->getFormattedValue()); $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> <target>Externe Version anzeigen</target>
</segment> </segment>
</unit> </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> </file>
</xliff> </xliff>

View file

@ -9304,7 +9304,7 @@ Element 3</target>
<unit id="oQmnwDq" name="part.filter.orderdetails_count"> <unit id="oQmnwDq" name="part.filter.orderdetails_count">
<segment state="translated"> <segment state="translated">
<source>part.filter.orderdetails_count</source> <source>part.filter.orderdetails_count</source>
<target>Number of order details</target> <target>Number of orderdetails</target>
</segment> </segment>
</unit> </unit>
<unit id="EVZFWzr" name="part.filter.lotExpirationDate"> <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> <target>View external version</target>
</segment> </segment>
</unit> </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> </file>
</xliff> </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> <target>Eliminare</target>
</segment> </segment>
</unit> </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"> <segment state="translated">
<source>attachment.external_only</source> <source>attachment.external</source>
<target>Solo allegato esterno</target> <target>Esterno</target>
</segment> </segment>
</unit> </unit>
<unit id="JES0hrm" name="attachment.preview.alt"> <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> <target>Miniatura dell'allegato</target>
</segment> </segment>
</unit> </unit>
<unit id="I_HDnsL" name="attachment.view_local"> <unit id="fCQby7u" name="attachment.view">
<notes> <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\AdminPages\_attachments.html.twig:52</note>
<note category="file-source" priority="1">Part-DB1\templates\Parts\edit\_attachments.html.twig:50</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> <note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:45</note>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>attachment.view_local</source> <source>attachment.view</source>
<target>Visualizza la copia locale</target> <target>Visualizzare</target>
</segment> </segment>
</unit> </unit>
<unit id="mEHEYM6" name="attachment.file_not_found"> <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> <target>Immagine di anteprima</target>
</segment> </segment>
</unit> </unit>
<unit id="Uuy6Ntl" name="attachment.download_local"> <unit id="O2kBcDz" name="attachment.download">
<notes> <notes>
<note category="file-source" priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:67</note> <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> <note priority="1">Part-DB1\templates\Parts\info\_attachments_info.html.twig:50</note>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>attachment.download_local</source> <source>attachment.download</source>
<target>Scarica la copia in locale</target> <target>Download</target>
</segment> </segment>
</unit> </unit>
<unit id="mPK9Iyq" name="user.creating_user"> <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> <target>Profilo salvato!</target>
</segment> </segment>
</unit> </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> </file>
</xliff> </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"?> <?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="fr"> <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="fr">
<file id="security.en"> <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"> <segment state="translated">
<source>user.login_error.user_disabled</source> <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> <target>Votre compte est désactivé ! Contactez un administrateur si vous pensez que c'est une erreur.</target>
</segment> </segment>
</unit> </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> </file>
</xliff> </xliff>

3202
yarn.lock

File diff suppressed because it is too large Load diff