Merge remote-tracking branch 'upstream/master'

This commit is contained in:
d-buchmann 2025-09-10 13:32:31 +02:00
commit 4c713d24ba
88 changed files with 15021 additions and 10477 deletions

View file

@ -3,7 +3,7 @@
![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg) ![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg)
[![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server) [![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server)
![GitHub License](https://img.shields.io/github/license/Part-DB/Part-DB-symfony) ![GitHub License](https://img.shields.io/github/license/Part-DB/Part-DB-symfony)
![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-green) ![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.2-green)
![Docker Pulls](https://img.shields.io/docker/pulls/jbtronics/part-db1) ![Docker Pulls](https://img.shields.io/docker/pulls/jbtronics/part-db1)
![Docker Build Status](https://github.com/Part-DB/Part-DB-symfony/workflows/Docker%20Image%20Build/badge.svg) ![Docker Build Status](https://github.com/Part-DB/Part-DB-symfony/workflows/Docker%20Image%20Build/badge.svg)

View file

@ -1 +1 @@
2.0.2 2.1.2

View file

@ -56,6 +56,9 @@ export default class MarkdownController extends Controller {
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw))); this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
for(let a of this.element.querySelectorAll('a')) { for(let a of this.element.querySelectorAll('a')) {
// test if link is absolute
var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
if (r.test(a.getAttribute('href'))) {
//Mark all links as external //Mark all links as external
a.classList.add('link-external'); a.classList.add('link-external');
//Open links in new tag //Open links in new tag
@ -63,6 +66,7 @@ export default class MarkdownController extends Controller {
//Dont track //Dont track
a.setAttribute('rel', 'noopener'); a.setAttribute('rel', 'noopener');
} }
}
//Apply bootstrap styles to tables //Apply bootstrap styles to tables
for(let table of this.element.querySelectorAll('table')) { for(let table of this.element.querySelectorAll('table')) {

View file

@ -42,6 +42,7 @@ export default class extends Controller {
selectOnTab: true, selectOnTab: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: 'body',
render: { render: {
item: (data, escape) => { item: (data, escape) => {
return '<span>' + escape(data.label) + '</span>'; return '<span>' + escape(data.label) + '</span>';

View file

@ -45,8 +45,10 @@ export default class extends DatatablesController {
//Hide/Unhide panel with the selection tools //Hide/Unhide panel with the selection tools
if (count > 0) { if (count > 0) {
selectPanel.classList.remove('d-none'); selectPanel.classList.remove('d-none');
selectPanel.classList.add('sticky-select-bar');
} else { } else {
selectPanel.classList.add('d-none'); selectPanel.classList.add('d-none');
selectPanel.classList.remove('sticky-select-bar');
} }
//Update selection count text //Update selection count text

View file

@ -16,6 +16,7 @@ export default class extends Controller {
searchField: ["name", "description", "category", "footprint"], searchField: ["name", "description", "category", "footprint"],
valueField: "id", valueField: "id",
labelField: "name", labelField: "name",
dropdownParent: 'body',
preload: "focus", preload: "focus",
render: { render: {
item: (data, escape) => { item: (data, escape) => {

View file

@ -44,6 +44,7 @@ export default class extends Controller {
allowEmptyOption: true, allowEmptyOption: true,
selectOnTab: true, selectOnTab: true,
maxOptions: null, maxOptions: null,
dropdownParent: 'body',
render: { render: {
item: this.renderItem.bind(this), item: this.renderItem.bind(this),

View file

@ -29,6 +29,7 @@ export default class extends Controller {
this._tomSelect = new TomSelect(this.element, { this._tomSelect = new TomSelect(this.element, {
maxItems: 1000, maxItems: 1000,
allowEmptyOption: true, allowEmptyOption: true,
dropdownParent: 'body',
plugins: ['remove_button'], plugins: ['remove_button'],
}); });
} }

View file

@ -50,6 +50,7 @@ export default class extends Controller {
valueField: 'text', valueField: 'text',
searchField: 'text', searchField: 'text',
orderField: 'text', orderField: 'text',
dropdownParent: 'body',
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',

View file

@ -54,6 +54,7 @@ export default class extends Controller {
maxItems: 1, maxItems: 1,
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$", delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null, splitOn: null,
dropdownParent: 'body',
searchField: [ searchField: [
{field: "text", weight : 2}, {field: "text", weight : 2},

View file

@ -43,6 +43,7 @@ export default class extends Controller {
selectOnTab: true, selectOnTab: true,
createOnBlur: true, createOnBlur: true,
create: true, create: true,
dropdownParent: 'body',
}; };
if(this.element.dataset.autocomplete) { if(this.element.dataset.autocomplete) {

View file

@ -18,8 +18,8 @@
*/ */
.hoverpic { .hoverpic {
min-width: 10px; min-width: var(--table-image-preview-min-size, 20px);
max-width: 30px; max-width: var(--table-image-preview-max-size, 35px);
display: block; display: block;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@ -49,7 +49,7 @@
} }
.part-table-image { .part-table-image {
max-height: 40px; max-height: calc(1.2*var(--table-image-preview-max-size, 35px)); /** Aspect ratio of maximum 1.2 */
object-fit: contain; object-fit: contain;
} }

View file

@ -17,6 +17,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
/****************************************
* Action bar
****************************************/
.sticky-select-bar {
position: sticky;
top: 120px;
z-index: 1000; /* Ensure the bar is above other content */
}
/**************************************** /****************************************
* Tables * Tables
****************************************/ ****************************************/

View file

@ -71,6 +71,8 @@
--ck-color-button-on-hover-background: var(--bs-secondary-bg); --ck-color-button-on-hover-background: var(--bs-secondary-bg);
--ck-color-button-on-active-background: var(--bs-secondary-bg); --ck-color-button-on-active-background: var(--bs-secondary-bg);
--ck-color-button-on-disabled-background: var(--bs-secondary-bg); --ck-color-button-on-disabled-background: var(--bs-secondary-bg);
--ck-color-button-on-color: var(--bs-primary) --ck-color-button-on-color: var(--bs-primary);
--ck-content-font-color: var(--ck-color-base-text);
} }

View file

@ -25,8 +25,7 @@
"doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^3.2.0", "doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^v3.0.0", "dompdf/dompdf": "^v3.0.0",
"florianv/swap": "^4.0", "part-db/swap-bundle": "^6.0.0",
"florianv/swap-bundle": "dev-master",
"gregwar/captcha-bundle": "^2.1.0", "gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0", "hshn/base64-encoded-file": "^5.0",
"jbtronics/2fa-webauthn": "^3.0.0", "jbtronics/2fa-webauthn": "^3.0.0",

691
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -69,9 +69,3 @@ nelmio_security:
- 'data:' - 'data:'
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport # upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
when@dev:
# disables the Content-Security-Policy header
nelmio_security:
csp:
enabled: false

View file

@ -6,3 +6,10 @@ jbtronics_settings:
orm_storage: orm_storage:
default_entity_class: App\Entity\SettingsEntry default_entity_class: App\Entity\SettingsEntry
# Disable caching for development environment
when@dev:
jbtronics_settings:
cache:
default_cacheable: false

View file

@ -5,6 +5,12 @@ florianv_swap:
providers: providers:
european_central_bank: ~ # European Central Bank (only works for EUR base currency) european_central_bank: ~ # European Central Bank (only works for EUR base currency)
central_bank_of_czech_republic: ~
central_bank_of_republic_turkey: ~
national_bank_of_romania: ~
fixer: # Fixer.io (needs an API key) fixer: # Fixer.io (needs an API key)
access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%" access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
#exchange_rates_api: ~
frankfurter: ~
fawazahmed_currency_api: ~

View file

@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
label: "perm.revert_elements" label: "perm.revert_elements"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles'] alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
apiTokenRole: ROLE_API_EDIT apiTokenRole: ROLE_API_EDIT
import:
label: "perm.import"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
apiTokenRole: ROLE_API_EDIT
api: api:
label: "perm.api" label: "perm.api"

View file

@ -28,9 +28,14 @@ It is recommended to install Part-DB on a 64-bit system, as the 32-bit version o
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command: For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
```bash ```bash
sudo apt install git curl zip ca-certificates software-properties-common apt-transport-https lsb-release nano wget sudo apt update && apt upgrade
sudo apt install git curl zip ca-certificates software-properties-common \
apt-transport-https lsb-release nano wget sqlite3
``` ```
Please run `sqlite3 --version` to assert that the SQLite version is 3.35 or higher.
Otherwise some database migrations will not succeed.
### Install PHP and apache2 ### Install PHP and apache2
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or

View file

@ -34,3 +34,12 @@ select the BOM file you want to import and some options for the import process:
has a different format and does not work with this type. has a different format and does not work with this type.
You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save
the file to your desired location. the file to your desired location.
* **KiCAD Schematic BOM (CSV file)**: A CSV file of the Bill of Material (BOM) generated
by [KiCAD Eeschema](https://www.kicad.org/).
You can generate this BOM file by going to "Tools" -> "Generate Bill of Materials" in Eeschema and save the file to your
desired location. In the next step you can customize the mapping of the fields in Part-DB, if you have any special fields
in your BOM to locate your fields correctly.
* **Generic CSV file**: A generic CSV file. You can use this option if you use some different ECAD software or wanna create
your own CSV file. You will need to specify at least the designators, quantity and value fields in the CSV. In the next
step you can customize the mapping of the fields in Part-DB, if you have any special fields in your BOM to locate your
parts correctly.

112
makefile Normal file
View file

@ -0,0 +1,112 @@
# PartDB Makefile for Test Environment Management
.PHONY: help test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset deps-install
# Default target
help:
@echo "PartDB Test Environment Management"
@echo "=================================="
@echo ""
@echo "Available targets:"
@echo " deps-install - Install PHP dependencies with unlimited memory"
@echo ""
@echo "Development Environment:"
@echo " dev-setup - Complete development environment setup (clean, create DB, migrate, warmup)"
@echo " dev-clean - Clean development cache and database files"
@echo " dev-db-create - Create development database (if not exists)"
@echo " dev-db-migrate - Run database migrations for development environment"
@echo " dev-cache-clear - Clear development cache"
@echo " dev-warmup - Warm up development cache"
@echo " dev-reset - Quick development reset (clean + migrate)"
@echo ""
@echo "Test Environment:"
@echo " test-setup - Complete test environment setup (clean, create DB, migrate, load fixtures)"
@echo " test-clean - Clean test cache and database files"
@echo " test-db-create - Create test database (if not exists)"
@echo " test-db-migrate - Run database migrations for test environment"
@echo " test-cache-clear- Clear test cache"
@echo " test-fixtures - Load test fixtures"
@echo " test-run - Run PHPUnit tests"
@echo ""
@echo " help - Show this help message"
# Install PHP dependencies with unlimited memory
deps-install:
@echo "📦 Installing PHP dependencies..."
COMPOSER_MEMORY_LIMIT=-1 composer install
@echo "✅ Dependencies installed"
# Complete test environment setup
test-setup: deps-install test-clean test-db-create test-db-migrate test-fixtures
@echo "✅ Test environment setup complete!"
# Clean test environment
test-clean:
@echo "🧹 Cleaning test environment..."
rm -rf var/cache/test
rm -f var/app_test.db
@echo "✅ Test environment cleaned"
# Create test database
test-db-create:
@echo "🗄️ Creating test database..."
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
# Run database migrations for test environment
test-db-migrate:
@echo "🔄 Running database migrations..."
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
test-cache-clear:
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
test-fixtures:
@echo "📦 Loading test fixtures..."
php bin/console partdb:fixtures:load -n --env test
# Run PHPUnit tests
test-run:
@echo "🧪 Running tests..."
php bin/phpunit
test-typecheck:
@echo "🧪 Running type checks..."
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
# Quick test reset (clean + migrate + fixtures, skip DB creation)
test-reset: test-cache-clear test-db-migrate test-fixtures
@echo "✅ Test environment reset complete!"
# Development helpers
dev-setup: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup
@echo "✅ Development environment setup complete!"
dev-clean:
@echo "🧹 Cleaning development environment..."
rm -rf var/cache/dev
rm -f var/app_dev.db
@echo "✅ Development environment cleaned"
dev-db-create:
@echo "🗄️ Creating development database..."
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
dev-db-migrate:
@echo "🔄 Running database migrations..."
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev
dev-cache-clear:
@echo "🗑️ Clearing development cache..."
php -d memory_limit=1G bin/console cache:clear --env dev -n
@echo "✅ Development cache cleared"
dev-warmup:
@echo "🔥 Warming up development cache..."
php -d memory_limit=1G bin/console cache:warmup --env dev -n
dev-reset: dev-cache-clear dev-db-migrate
@echo "✅ Development environment reset complete!"

View file

@ -24,6 +24,7 @@ namespace App\Controller;
use App\DataTables\AttachmentDataTable; use App\DataTables\AttachmentDataTable;
use App\DataTables\Filters\AttachmentFilter; use App\DataTables\Filters\AttachmentFilter;
use App\DataTables\PartsDataTable;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Form\Filters\AttachmentFilterType; use App\Form\Filters\AttachmentFilterType;
use App\Services\Attachments\AttachmentManager; use App\Services\Attachments\AttachmentManager;
@ -112,7 +113,7 @@ class AttachmentFileController extends AbstractController
$filterForm->handleRequest($formRequest); $filterForm->handleRequest($formRequest);
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize]) $table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request); ->handleRequest($request);
if ($table->isCallback()) { if ($table->isCallback()) {

View file

@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Settings\AppSettings; use App\Settings\AppSettings;
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface; use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
@ -113,7 +114,7 @@ class InfoProviderController extends AbstractController
#[Route('/search', name: 'info_providers_search')] #[Route('/search', name: 'info_providers_search')]
#[Route('/update/{target}', name: 'info_providers_update_part_search')] #[Route('/update/{target}', name: 'info_providers_update_part_search')]
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
{ {
$this->denyAccessUnlessGranted('@info_providers.create_parts'); $this->denyAccessUnlessGranted('@info_providers.create_parts');
@ -144,6 +145,23 @@ class InfoProviderController extends AbstractController
} }
} }
//If the providers form is still empty, use our default value from the settings
if (count($form->get('providers')->getData() ?? []) === 0) {
$default_providers = $infoProviderSettings->defaultSearchProviders;
$provider_objects = [];
foreach ($default_providers as $provider_key) {
try {
$tmp = $this->providerRegistry->getProviderByKey($provider_key);
if ($tmp->isActive()) {
$provider_objects[] = $tmp;
}
} catch (\InvalidArgumentException $e) {
//If the provider is not found, just ignore it
}
}
$form->get('providers')->setData($provider_objects);
}
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData(); $keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData(); $providers = $form->get('providers')->getData();

View file

@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
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\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/label')] #[Route(path: '/label')]
class LabelController extends AbstractController class LabelController extends AbstractController
{ {
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator) public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
private readonly ValidatorInterface $validator
)
{ {
} }
@ -85,6 +88,7 @@ class LabelController extends AbstractController
$form = $this->createForm(LabelDialogType::class, null, [ $form = $this->createForm(LabelDialogType::class, null, [
'disable_options' => $disable_options, 'disable_options' => $disable_options,
'profile' => $profile
]); ]);
//Try to parse given target_type and target_id //Try to parse given target_type and target_id
@ -120,13 +124,50 @@ class LabelController extends AbstractController
goto render; goto render;
} }
$profile = new LabelProfile(); $new_profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData()); $new_profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options); $new_profile->setOptions($form_options);
$this->em->persist($profile);
//Validate the profile name
$errors = $this->validator->validate($new_profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
}
goto render;
}
$this->em->persist($new_profile);
$this->em->flush(); $this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved'); $this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $new_profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
//Check if the current profile should be updated
if ($form->has('update_profile')
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
&& $profile instanceof LabelProfile
&& $this->isGranted('edit', $profile)) {
//Update the profile options
$profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$this->addFlash('error', $error->getMessage());
}
goto render;
}
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_updated');
return $this->redirectToRoute('label_dialog_profile', [ return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(), 'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData() 'target_id' => (string) $form->get('target_id')->getData()

View file

@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
use App\Services\Parts\PartLotWithdrawAddHelper; use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper; use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper; use App\Services\ProjectSystem\ProjectBuildPartHelper;
use App\Settings\BehaviorSettings\PartInfoSettings;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Exception; use Exception;
@ -69,7 +70,7 @@ class PartController extends AbstractController
protected PartPreviewGenerator $partPreviewGenerator, protected PartPreviewGenerator $partPreviewGenerator,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em, private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
protected EventCommentHelper $commentHelper) protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
{ {
} }
@ -119,8 +120,8 @@ class PartController extends AbstractController
'pricedetail_helper' => $this->pricedetailHelper, 'pricedetail_helper' => $this->pricedetailHelper,
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part), 'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
'timeTravel' => $timeTravel_timestamp, 'timeTravel' => $timeTravel_timestamp,
'description_params' => $parameterExtractor->extractParameters($part->getDescription()), 'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
'comment_params' => $parameterExtractor->extractParameters($part->getComment()), 'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper, 'withdraw_add_helper' => $withdrawAddHelper,
] ]
); );

View file

@ -161,7 +161,9 @@ class PartListsController extends AbstractController
$filterForm->handleRequest($formRequest); $filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(['filter' => $filter], $additional_table_vars), ['pageLength' => $this->tableSettings->fullDefaultPageSize]) $table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
['filter' => $filter], $additional_table_vars),
['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
->handleRequest($request); ->handleRequest($request);
if ($table->isCallback()) { if ($table->isCallback()) {

View file

@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError; use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory; use Omines\DataTablesBundle\DataTableFactory;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@ -102,9 +103,14 @@ class ProjectController extends AbstractController
$this->addFlash('success', 'project.build.flash.success'); $this->addFlash('success', 'project.build.flash.success');
return $this->redirect( return $this->redirect(
$request->get('_redirect', $request->get(
$this->generateUrl('project_info', ['id' => $project->getID()] '_redirect',
))); $this->generateUrl(
'project_info',
['id' => $project->getID()]
)
)
);
} }
$this->addFlash('error', 'project.build.flash.invalid_input'); $this->addFlash('error', 'project.build.flash.invalid_input');
@ -120,9 +126,13 @@ class ProjectController extends AbstractController
} }
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])] #[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project, public function importBOM(
BOMImporter $BOMImporter, ValidatorInterface $validator): Response Request $request,
{ EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator
): Response {
$this->denyAccessUnlessGranted('edit', $project); $this->denyAccessUnlessGranted('edit', $project);
$builder = $this->createFormBuilder(); $builder = $this->createFormBuilder();
@ -138,6 +148,8 @@ class ProjectController extends AbstractController
'required' => true, 'required' => true,
'choices' => [ 'choices' => [
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew', 'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
'project.bom_import.type.generic_csv' => 'generic_csv',
] ]
]); ]);
$builder->add('clear_existing_bom', CheckboxType::class, [ $builder->add('clear_existing_bom', CheckboxType::class, [
@ -161,15 +173,30 @@ class ProjectController extends AbstractController
$entityManager->flush(); $entityManager->flush();
} }
$import_type = $form->get('type')->getData();
try { try {
// For schematic imports, redirect to field mapping step
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
// Store file content and options in session for field mapping step
$file_content = $form->get('file')->getData()->getContent();
$clear_existing = $form->get('clear_existing_bom')->getData();
$request->getSession()->set('bom_import_data', $file_content);
$request->getSession()->set('bom_import_clear', $clear_existing);
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
}
// For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [ $entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
'type' => $form->get('type')->getData(), 'type' => $import_type,
]); ]);
// Validate the project entries // Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries'); $errors = $validator->validateProperty($project, 'bom_entries');
//If no validation errors occured, save the changes and redirect to edit page // If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) { if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)])); $this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush(); $entityManager->flush();
@ -191,6 +218,262 @@ class ProjectController extends AbstractController
]); ]);
} }
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
public function importBOMMapFields(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator,
LoggerInterface $logger
): Response {
$this->denyAccessUnlessGranted('edit', $project);
// Get stored data from session
$file_content = $request->getSession()->get('bom_import_data');
$clear_existing = $request->getSession()->get('bom_import_clear', false);
if (!$file_content) {
$this->addFlash('error', 'project.bom_import.flash.session_expired');
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
}
// Detect fields and get suggestions
$detected_fields = $BOMImporter->detectFields($file_content);
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
// Create mapping of original field names to sanitized field names for template
$field_name_mapping = [];
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$field_name_mapping[$field] = $sanitized_field;
}
// Create form for field mapping
$builder = $this->createFormBuilder();
// Add delimiter selection
$builder->add('delimiter', ChoiceType::class, [
'label' => 'project.bom_import.delimiter',
'required' => true,
'data' => ',',
'choices' => [
'project.bom_import.delimiter.comma' => ',',
'project.bom_import.delimiter.semicolon' => ';',
'project.bom_import.delimiter.tab' => "\t",
]
]);
// Get dynamic field mapping targets from BOMImporter
$available_targets = $BOMImporter->getAvailableFieldTargets();
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
foreach ($available_targets as $target_key => $target_info) {
$target_fields[$target_info['label']] = $target_key;
}
foreach ($detected_fields as $field) {
// Sanitize field name for form use - replace invalid characters with underscores
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
'label' => $field,
'required' => false,
'choices' => $target_fields,
'data' => $suggested_mapping[$field] ?? '',
]);
}
$builder->add('submit', SubmitType::class, [
'label' => 'project.bom_import.preview',
]);
$form = $builder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Build field mapping array with priority support
$field_mapping = [];
$field_priorities = [];
$delimiter = $form->get('delimiter')->getData();
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$target = $form->get('mapping_' . $sanitized_field)->getData();
if (!empty($target)) {
$field_mapping[$field] = $target;
// Get priority from request (default to 10)
$priority = $request->request->get('priority_' . $sanitized_field, 10);
$field_priorities[$field] = (int) $priority;
}
}
// Validate field mapping
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
if (!$validation['is_valid']) {
foreach ($validation['errors'] as $error) {
$this->addFlash('error', $error);
}
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
// Show warnings but continue
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
try {
// Re-detect fields with chosen delimiter
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
// Clear existing BOM entries if requested
if ($clear_existing) {
$existing_count = $project->getBomEntries()->count();
$logger->info('Clearing existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
$project->getBomEntries()->clear();
$entityManager->flush();
$logger->info('Existing BOM entries cleared');
} else {
$existing_count = $project->getBomEntries()->count();
$logger->info('Keeping existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
}
// Validate data before importing
$validation_result = $BOMImporter->validateBOMData($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log validation results
$logger->info('BOM import validation completed', [
'total_entries' => $validation_result['total_entries'],
'valid_entries' => $validation_result['valid_entries'],
'invalid_entries' => $validation_result['invalid_entries'],
'error_count' => count($validation_result['errors']),
'warning_count' => count($validation_result['warnings']),
]);
// Show validation warnings to user
foreach ($validation_result['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
// If there are validation errors, show them and stop
if (!empty($validation_result['errors'])) {
foreach ($validation_result['errors'] as $error) {
$this->addFlash('error', $error);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
'validation_result' => $validation_result,
]);
}
// Import with field mapping and priorities (validation already passed)
$entries = $BOMImporter->stringToBOMEntries($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log entry details for debugging
$logger->info('BOM entries created', [
'total_entries' => count($entries),
]);
foreach ($entries as $index => $entry) {
$logger->debug("BOM entry {$index}", [
'name' => $entry->getName(),
'mountnames' => $entry->getMountnames(),
'quantity' => $entry->getQuantity(),
'comment' => $entry->getComment(),
'part_id' => $entry->getPart()?->getID(),
]);
}
// Assign entries to project
$logger->info('Adding BOM entries to project', [
'entries_count' => count($entries),
'project_id' => $project->getID(),
]);
foreach ($entries as $index => $entry) {
$logger->debug("Adding BOM entry {$index} to project", [
'name' => $entry->getName(),
'part_id' => $entry->getPart()?->getID(),
'quantity' => $entry->getQuantity(),
]);
$project->addBomEntry($entry);
}
// Validate the project entries (includes collection constraints)
$errors = $validator->validateProperty($project, 'bom_entries');
// If no validation errors occurred, save and redirect
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
// Clear session data
$request->getSession()->remove('bom_import_data');
$request->getSession()->remove('bom_import_clear');
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
//Print validation errors to log for debugging
foreach ($errors as $error) {
$logger->error('BOM entry validation error', [
'message' => $error->getMessage(),
'invalid_value' => $error->getInvalidValue(),
]);
//And show as flash message
$this->addFlash('error', $error->getMessage(),);
}
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form,
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')] #[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])] #[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response

View file

@ -0,0 +1,64 @@
<?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\DataFixtures;
use App\Entity\PriceInformations\Currency;
use Brick\Math\BigDecimal;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class CurrencyFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$currency1 = new Currency();
$currency1->setName('US-Dollar');
$currency1->setIsoCode('USD');
$manager->persist($currency1);
$currency2 = new Currency();
$currency2->setName('Swiss Franc');
$currency2->setIsoCode('CHF');
$currency2->setExchangeRate(BigDecimal::of('0.91'));
$manager->persist($currency2);
$currency3 = new Currency();
$currency3->setName('Great British Pound');
$currency3->setIsoCode('GBP');
$currency3->setExchangeRate(BigDecimal::of('0.78'));
$manager->persist($currency3);
$currency7 = new Currency();
$currency7->setName('Test Currency with long name');
$currency7->setIsoCode('CNY');
$manager->persist($currency7);
$manager->flush();
//Ensure that currency 7 gets ID 7
$manager->getRepository(Currency::class)->changeID($currency7, 7);
$manager->flush();
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/ */
namespace App\DataTables\Filters; namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\EntityConstraint;
@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder; use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Filter\AbstractFilter;
class AttachmentFilter implements FilterInterface class AttachmentFilter implements FilterInterface
{ {
@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
public function __construct(NodesListBuilder $nodesListBuilder) public function __construct(NodesListBuilder $nodesListBuilder)
{ {
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->dbId = new IntConstraint('attachment.id'); $this->dbId = new IntConstraint('attachment.id');
$this->name = new TextConstraint('attachment.name'); $this->name = new TextConstraint('attachment.name');
$this->targetType = new InstanceOfConstraint('attachment'); $this->targetType = new InstanceOfConstraint('attachment');

View file

@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
{ {
use FilterTrait; use FilterTrait;
/** protected ?string $identifier;
* @var string
*/
protected string $identifier;
/** /**

View file

@ -28,6 +28,7 @@ trait FilterTrait
{ {
protected bool $useHaving = false; protected bool $useHaving = false;
protected static int $parameterCounter = 0;
public function useHaving($value = true): static public function useHaving($value = true): static
{ {
@ -50,8 +51,18 @@ trait FilterTrait
{ {
//Replace all special characters with underscores //Replace all special characters with underscores
$property = preg_replace('/\W/', '_', $property); $property = preg_replace('/\W/', '_', $property);
//Add a random number to the end of the property name for uniqueness return $property . '_' . (self::$parameterCounter++) . '_';
return $property . '_' . uniqid("", false); }
/**
* Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again.
* This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter
* identifiers are deterministic so that they are cacheable.
* @return void
*/
public static function resetParameterCounter(): void
{
self::$parameterCounter = 0;
} }
/** /**

View file

@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
//Escape any %, _ or \ in the tag //Escape any %, _ or \ in the tag
$tag = addcslashes($tag, '%_\\'); $tag = addcslashes($tag, '%_\\');
$tag_identifier_prefix = uniqid($this->identifier . '_', false); $tag_identifier_prefix = $this->generateParameterIdentifier('tag');
$expr = $queryBuilder->expr(); $expr = $queryBuilder->expr();

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/ */
namespace App\DataTables\Filters; namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint; use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\EntityConstraint;
@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
public function __construct() public function __construct()
{ {
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->timestamp = new DateTimeConstraint('log.timestamp'); $this->timestamp = new DateTimeConstraint('log.timestamp');
$this->dbId = new IntConstraint('log.id'); $this->dbId = new IntConstraint('log.id');
$this->level = new ChoiceConstraint('log.level'); $this->level = new ChoiceConstraint('log.level');

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/ */
namespace App\DataTables\Filters; namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint; use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint;
@ -103,6 +104,9 @@ class PartFilter implements FilterInterface
public function __construct(NodesListBuilder $nodesListBuilder) public function __construct(NodesListBuilder $nodesListBuilder)
{ {
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->name = new TextConstraint('part.name'); $this->name = new TextConstraint('part.name');
$this->description = new TextConstraint('part.description'); $this->description = new TextConstraint('part.description');
$this->comment = new TextConstraint('part.comment'); $this->comment = new TextConstraint('part.comment');

View file

@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\DataTables\Filters; namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface class PartSearchFilter implements FilterInterface

View file

@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList; use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class ProviderSelectType extends AbstractType class ProviderSelectType extends AbstractType
@ -44,13 +45,43 @@ class ProviderSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $providers = $this->providerRegistry->getActiveProviders();
'choices' => $this->providerRegistry->getActiveProviders(),
'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
'multiple' => true, $resolver->setDefault('input', 'object');
]); $resolver->setAllowedTypes('input', 'string');
//Either the form returns the provider objects or their keys
$resolver->setAllowedValues('input', ['object', 'string']);
$resolver->setDefault('multiple', true);
$resolver->setDefault('choices', function (Options $options) use ($providers) {
if ('object' === $options['input']) {
return $this->providerRegistry->getActiveProviders();
}
$tmp = [];
foreach ($providers as $provider) {
$name = $provider->getProviderInfo()['name'];
$tmp[$name] = $provider->getProviderKey();
}
return $tmp;
});
//The choice_label and choice_value only needs to be set if we want the objects
$resolver->setDefault('choice_label', function (Options $options){
if ('object' === $options['input']) {
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
}
return null;
});
$resolver->setDefault('choice_value', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
}
return null;
});
} }
} }

View file

@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
] ]
]); ]);
if ($options['profile'] !== null) {
$builder->add('update_profile', SubmitType::class, [
'label' => 'label_generator.update_profile',
'disabled' => !$this->security->isGranted('edit', $options['profile']),
'attr' => [
'class' => 'btn btn-outline-success'
]
]);
}
$builder->add('update', SubmitType::class, [ $builder->add('update', SubmitType::class, [
'label' => 'label_generator.update', 'label' => 'label_generator.update',
]); ]);
@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
parent::configureOptions($resolver); parent::configureOptions($resolver);
$resolver->setDefault('mapped', false); $resolver->setDefault('mapped', false);
$resolver->setDefault('disable_options', false); $resolver->setDefault('disable_options', false);
$resolver->setDefault('profile', null);
} }
} }

View file

@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
use RuntimeException; use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array; use function in_array;
@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
{ {
} }
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
//This voter only works for attachments //This voter only works for attachments
@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
} }
if ($attribute === 'show_private') { if ($attribute === 'show_private') {
return $this->helper->isGranted($token, 'attachments', 'show_private'); $vote?->addReason('User is not allowed to view private attachments.');
return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
} }
@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject); throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
} }
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute)); $vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
} }
return false; return false;

View file

@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\Group; use App\Entity\UserSystem\Group;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -43,9 +44,9 @@ final class GroupVoter extends Voter
* *
* @param string $attribute * @param string $attribute
*/ */
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
return $this->helper->isGranted($token, 'groups', $attribute); return $this->helper->isGranted($token, 'groups', $attribute, $vote);
} }
/** /**

View file

@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
&& $subject instanceof UserInterface; && $subject instanceof UserInterface;
} }
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
return $this->helper->isGranted($token, 'users', 'impersonate'); $result = $this->helper->isGranted($token, 'users', 'impersonate');
if ($result === false) {
$vote?->addReason('User is not allowed to impersonate other users.');
$this->helper->addReason($vote, 'users', 'impersonate');
}
return $result;
} }
public function supportsAttribute(string $attribute): bool public function supportsAttribute(string $attribute): bool

View file

@ -44,6 +44,7 @@ namespace App\Security\Voter;
use App\Entity\LabelSystem\LabelProfile; use App\Entity\LabelSystem\LabelProfile;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
'delete' => 'delete_profiles', 'delete' => 'delete_profiles',
'show_history' => 'show_history', 'show_history' => 'show_history',
'revert_element' => 'revert_element', 'revert_element' => 'revert_element',
'import' => 'import',
]; ];
public function __construct(private readonly VoterHelper $helper) public function __construct(private readonly VoterHelper $helper)
{} {}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]); return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
} }
protected function supports($attribute, $subject): bool protected function supports($attribute, $subject): bool

View file

@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LogSystem\AbstractLogEntry; use App\Entity\LogSystem\AbstractLogEntry;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
{ {
} }
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
$user = $this->helper->resolveUser($token); $user = $this->helper->resolveUser($token);
@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
} }
if ('delete' === $attribute) { if ('delete' === $attribute) {
return $this->helper->isGranted($token, 'system', 'delete_logs'); return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
} }
if ('read' === $attribute) { if ('read' === $attribute) {
//Allow read of the users own log entries //Allow read of the users own log entries
if ( if (
$subject->getUser() === $user $subject->getUser() === $user
&& $this->helper->isGranted($token, 'self', 'show_logs') && $this->helper->isGranted($token, 'self', 'show_logs', $vote)
) { ) {
return true; return true;
} }
return $this->helper->isGranted($token, 'system', 'show_logs'); return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
} }
if ('show_details' === $attribute) { if ('show_details' === $attribute) {

View file

@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Orderdetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
if (! is_a($subject, Orderdetail::class, true)) { if (! is_a($subject, Orderdetail::class, true)) {
throw new \RuntimeException('This voter can only handle Orderdetail objects!'); throw new \RuntimeException('This voter can only handle Orderdetail objects!');
@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
//If we have no part associated use the generic part permission //If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) { if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation); return $this->helper->isGranted($token, 'parts', $operation, $vote);
} }
//Otherwise vote on the part //Otherwise vote on the part

View file

@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter; use App\Entity\Parameters\SupplierParameter;
use RuntimeException; use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
{ {
} }
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false; //return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject)); throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
} }
return $this->helper->isGranted($token, $param, $attribute); return $this->helper->isGranted($token, $param, $attribute, $vote);
} }
protected function supports(string $attribute, $subject): bool protected function supports(string $attribute, $subject): bool

View file

@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
if (!is_string($subject) && !$subject instanceof PartAssociation) { if (!is_string($subject) && !$subject instanceof PartAssociation) {
throw new \RuntimeException('Invalid subject type!'); throw new \RuntimeException('Invalid subject type!');
@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
//If we have no part associated use the generic part permission //If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOwner() instanceof Part) { if (is_string($subject) || !$subject->getOwner() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation); return $this->helper->isGranted($token, 'parts', $operation, $vote);
} }
//Otherwise vote on the part //Otherwise vote on the part

View file

@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot; use App\Entity\Parts\PartLot;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -59,13 +60,13 @@ final class PartLotVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move']; protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
$user = $this->helper->resolveUser($token); $user = $this->helper->resolveUser($token);
if (in_array($attribute, ['withdraw', 'add', 'move'], true)) if (in_array($attribute, ['withdraw', 'add', 'move'], true))
{ {
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute); $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
$lot_permission = true; $lot_permission = true;
//If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it. //If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
@ -73,6 +74,10 @@ final class PartLotVoter extends Voter
$lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID(); $lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
} }
if (!$lot_permission) {
$vote->addReason('User is not the owner of the lot.');
}
return $base_permission && $lot_permission; return $base_permission && $lot_permission;
} }
@ -86,7 +91,7 @@ final class PartLotVoter extends Voter
//If we have no part associated use the generic part permission //If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) { if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation); return $this->helper->isGranted($token, 'parts', $operation, $vote);
} }
//Otherwise vote on the part //Otherwise vote on the part

View file

@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -52,10 +53,9 @@ final class PartVoter extends Voter
return false; return false;
} }
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
//Null concealing operator means, that no return $this->helper->isGranted($token, 'parts', $attribute, $vote);
return $this->helper->isGranted($token, 'parts', $attribute);
} }
public function supportsAttribute(string $attribute): bool public function supportsAttribute(string $attribute): bool

View file

@ -24,6 +24,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -39,12 +40,17 @@ final class PermissionVoter extends Voter
} }
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
$attribute = ltrim($attribute, '@'); $attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute); [$perm, $op] = explode('.', $attribute);
return $this->helper->isGranted($token, $perm, $op); $result = $this->helper->isGranted($token, $perm, $op);
if ($result === false) {
$this->helper->addReason($vote, $perm, $op);
}
return $result;
} }
public function supportsAttribute(string $attribute): bool public function supportsAttribute(string $attribute): bool

View file

@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Pricedetail; use App\Entity\PriceInformations\Pricedetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** /**
@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
$operation = match ($attribute) { $operation = match ($attribute) {
'read' => 'read', 'read' => 'read',
@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
//If we have no part associated use the generic part permission //If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) { if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation); return $this->helper->isGranted($token, 'parts', $operation, $vote);
} }
//Otherwise vote on the part //Otherwise vote on the part

View file

@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Currency;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function is_object; use function is_object;
@ -113,10 +114,10 @@ final class StructureVoter extends Voter
* *
* @param string $attribute * @param string $attribute
*/ */
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
$permission_name = $this->instanceToPermissionName($subject); $permission_name = $this->instanceToPermissionName($subject);
//Just resolve the permission //Just resolve the permission
return $this->helper->isGranted($token, $permission_name, $attribute); return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
} }
} }

View file

@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager; use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper; use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array; use function in_array;
@ -79,7 +80,7 @@ final class UserVoter extends Voter
* *
* @param string $attribute * @param string $attribute
*/ */
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
$user = $this->helper->resolveUser($token); $user = $this->helper->resolveUser($token);
@ -97,7 +98,7 @@ final class UserVoter extends Voter
if (($subject instanceof User) && $subject->getID() === $user->getID() && if (($subject instanceof User) && $subject->getID() === $user->getID() &&
$this->helper->isValidOperation('self', $attribute)) { $this->helper->isValidOperation('self', $attribute)) {
//Then we also need to check the self permission //Then we also need to check the self permission
$tmp = $this->helper->isGranted($token, 'self', $attribute); $tmp = $this->helper->isGranted($token, 'self', $attribute, $vote);
//But if the self value is not allowed then use just the user value: //But if the self value is not allowed then use just the user value:
if ($tmp) { if ($tmp) {
return $tmp; return $tmp;
@ -106,7 +107,7 @@ final class UserVoter extends Voter
//Else just check user permission: //Else just check user permission:
if ($this->helper->isValidOperation('users', $attribute)) { if ($this->helper->isValidOperation('users', $attribute)) {
return $this->helper->isGranted($token, 'users', $attribute); return $this->helper->isGranted($token, 'users', $attribute, $vote);
} }
return false; return false;

View file

@ -22,10 +22,13 @@ declare(strict_types=1);
*/ */
namespace App\Services\ImportExportSystem; namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use League\Csv\Reader; use League\Csv\Reader;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@ -44,14 +47,25 @@ class BOMImporter
5 => 'Supplier and ref', 5 => 'Supplier and ref',
]; ];
public function __construct() public function __construct(
{ private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BOMValidationService $validationService
) {
} }
protected function configureOptions(OptionsResolver $resolver): OptionsResolver protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{ {
$resolver->setRequired('type'); $resolver->setRequired('type');
$resolver->setAllowedValues('type', ['kicad_pcbnew']); $resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
// For flexible schematic import with field mapping
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
$resolver->setDefault('delimiter', ',');
$resolver->setDefault('field_priorities', []);
$resolver->setAllowedTypes('field_mapping', 'array');
$resolver->setAllowedTypes('field_priorities', 'array');
$resolver->setAllowedTypes('delimiter', 'string');
return $resolver; return $resolver;
} }
@ -82,6 +96,23 @@ class BOMImporter
return $this->stringToBOMEntries($file->getContent(), $options); return $this->stringToBOMEntries($file->getContent(), $options);
} }
/**
* Validate BOM data before importing
* @return array Validation result with errors, warnings, and info
*/
public function validateBOMData(string $data, array $options): array
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->validateKiCADPCB($data),
'kicad_schematic' => $this->validateKiCADSchematicData($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
/** /**
* Import string data into an array of BOM entries, which are not yet assigned to a project. * Import string data into an array of BOM entries, which are not yet assigned to a project.
* @param string $data The data to import * @param string $data The data to import
@ -95,12 +126,13 @@ class BOMImporter
$options = $resolver->resolve($options); $options = $resolver->resolve($options);
return match ($options['type']) { return match ($options['type']) {
'kicad_pcbnew' => $this->parseKiCADPCB($data, $options), 'kicad_pcbnew' => $this->parseKiCADPCB($data),
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'), default => throw new InvalidArgumentException('Invalid import type!'),
}; };
} }
private function parseKiCADPCB(string $data, array $options = []): array private function parseKiCADPCB(string $data): array
{ {
$csv = Reader::createFromString($data); $csv = Reader::createFromString($data);
$csv->setDelimiter(';'); $csv->setDelimiter(';');
@ -138,6 +170,63 @@ class BOMImporter
return $bom_entries; return $bom_entries;
} }
/**
* Validate KiCad PCB data
*/
private function validateKiCADPCB(string $data): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
$csv->setHeaderOffset(0);
$mapped_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
// Translate the german field names to english
$entry = $this->normalizeColumnNames($entry);
$mapped_entries[] = $entry;
}
return $this->validationService->validateBOMEntries($mapped_entries);
}
/**
* Validate KiCad schematic data
*/
private function validateKiCADSchematicData(string $data, array $options): array
{
$delimiter = $options['delimiter'] ?? ',';
$field_mapping = $options['field_mapping'] ?? [];
$field_priorities = $options['field_priorities'] ?? [];
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
$csv = Reader::createFromString($data);
$csv->setDelimiter($delimiter);
$csv->setHeaderOffset(0);
// Handle quoted fields properly
$csv->setEscape('\\');
$csv->setEnclosure('"');
$mapped_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
// Extract footprint package name if it contains library prefix
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
}
$mapped_entries[] = $mapped_entry;
}
return $this->validationService->validateBOMEntries($mapped_entries, $options);
}
/** /**
* This function uses the order of the fields in the CSV files to make them locale independent. * This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry * @param array $entry
@ -160,4 +249,482 @@ class BOMImporter
return $out; return $out;
} }
/**
* Parse KiCad schematic BOM with flexible field mapping
*/
private function parseKiCADSchematic(string $data, array $options = []): array
{
$delimiter = $options['delimiter'] ?? ',';
$field_mapping = $options['field_mapping'] ?? [];
$field_priorities = $options['field_priorities'] ?? [];
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
$csv = Reader::createFromString($data);
$csv->setDelimiter($delimiter);
$csv->setHeaderOffset(0);
// Handle quoted fields properly
$csv->setEscape('\\');
$csv->setEnclosure('"');
$bom_entries = [];
$entries_by_key = []; // Track entries by name+part combination
$mapped_entries = []; // Collect all mapped entries for validation
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
// Extract footprint package name if it contains library prefix
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
}
$mapped_entries[] = $mapped_entry;
}
// Validate all entries before processing
$validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options);
// Log validation results
$this->logger->info('BOM import validation completed', [
'total_entries' => $validation_result['total_entries'],
'valid_entries' => $validation_result['valid_entries'],
'invalid_entries' => $validation_result['invalid_entries'],
'error_count' => count($validation_result['errors']),
'warning_count' => count($validation_result['warnings']),
]);
// If there are validation errors, throw an exception with detailed messages
if (!empty($validation_result['errors'])) {
$error_message = $this->validationService->getErrorMessage($validation_result);
throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message);
}
// Process validated entries
foreach ($mapped_entries as $offset => $mapped_entry) {
// Set name - prefer MPN, fall back to Value, then default format
$mpn = trim($mapped_entry['MPN'] ?? '');
$designation = trim($mapped_entry['Designation'] ?? '');
$value = trim($mapped_entry['Value'] ?? '');
// Use the first non-empty value, or 'Unknown Component' if all are empty
$name = '';
if (!empty($mpn)) {
$name = $mpn;
} elseif (!empty($designation)) {
$name = $designation;
} elseif (!empty($value)) {
$name = $value;
} else {
$name = 'Unknown Component';
}
if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) {
$name .= ' (' . trim($mapped_entry['Package']) . ')';
}
// Set mountnames and quantity
// The Designator field contains comma-separated mount names for all instances
$designator = trim($mapped_entry['Designator']);
$quantity = (float) $mapped_entry['Quantity'];
// Get mountnames array (validation already ensured they match quantity)
$mountnames_array = array_map('trim', explode(',', $designator));
// Try to link existing Part-DB part if ID is provided
$part = null;
if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
$partDbId = (int) $mapped_entry['Part-DB ID'];
$existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId);
if ($existingPart) {
$part = $existingPart;
// Update name with actual part name
$name = $existingPart->getName();
}
}
// Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
// Check if we already have an entry with the same name and part
if (isset($entries_by_key[$entry_key])) {
// Merge with existing entry
$existing_entry = $entries_by_key[$entry_key];
// Combine mountnames
$existing_mountnames = $existing_entry->getMountnames();
$combined_mountnames = $existing_mountnames . ',' . $designator;
$existing_entry->setMountnames($combined_mountnames);
// Add quantities
$existing_quantity = $existing_entry->getQuantity();
$existing_entry->setQuantity($existing_quantity + $quantity);
$this->logger->info('Merged duplicate BOM entry', [
'name' => $name,
'part_id' => $part ? $part->getID() : null,
'original_quantity' => $existing_quantity,
'added_quantity' => $quantity,
'new_quantity' => $existing_quantity + $quantity,
'original_mountnames' => $existing_mountnames,
'added_mountnames' => $designator,
]);
continue; // Skip creating new entry
}
// Create new BOM entry
$bom_entry = new ProjectBOMEntry();
$bom_entry->setName($name);
$bom_entry->setMountnames($designator);
$bom_entry->setQuantity($quantity);
if ($part) {
$bom_entry->setPart($part);
}
// Set comment with additional info
$comment_parts = [];
if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) {
$comment_parts[] = 'Value: ' . $mapped_entry['Value'];
}
if (isset($mapped_entry['MPN'])) {
$comment_parts[] = 'MPN: ' . $mapped_entry['MPN'];
}
if (isset($mapped_entry['Manufacturer'])) {
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
}
if (isset($mapped_entry['LCSC'])) {
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
}
if (isset($mapped_entry['Supplier and ref'])) {
$comment_parts[] = $mapped_entry['Supplier and ref'];
}
if ($part) {
$comment_parts[] = "Part-DB ID: " . $part->getID();
} elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
$comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)";
}
$bom_entry->setComment(implode(', ', $comment_parts));
$bom_entries[] = $bom_entry;
$entries_by_key[$entry_key] = $bom_entry;
}
return $bom_entries;
}
/**
* Get all available field mapping targets with descriptions
*/
public function getAvailableFieldTargets(): array
{
$targets = [
'Designator' => [
'label' => 'Designator',
'description' => 'Component reference designators (e.g., R1, C2, U3)',
'required' => true,
'multiple' => false,
],
'Quantity' => [
'label' => 'Quantity',
'description' => 'Number of components',
'required' => true,
'multiple' => false,
],
'Designation' => [
'label' => 'Designation',
'description' => 'Component designation/part number',
'required' => false,
'multiple' => true,
],
'Value' => [
'label' => 'Value',
'description' => 'Component value (e.g., 10k, 100nF)',
'required' => false,
'multiple' => true,
],
'Package' => [
'label' => 'Package',
'description' => 'Component package/footprint',
'required' => false,
'multiple' => true,
],
'MPN' => [
'label' => 'MPN',
'description' => 'Manufacturer Part Number',
'required' => false,
'multiple' => true,
],
'Manufacturer' => [
'label' => 'Manufacturer',
'description' => 'Component manufacturer name',
'required' => false,
'multiple' => true,
],
'Part-DB ID' => [
'label' => 'Part-DB ID',
'description' => 'Existing Part-DB part ID for linking',
'required' => false,
'multiple' => false,
],
'Comment' => [
'label' => 'Comment',
'description' => 'Additional component information',
'required' => false,
'multiple' => true,
],
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
'label' => $supplierName . ' SPN',
'description' => "Supplier part number for {$supplierName}",
'required' => false,
'multiple' => true,
'supplier_id' => $supplier->getID(),
];
}
return $targets;
}
/**
* Get suggested field mappings based on common field names
*/
public function getSuggestedFieldMapping(array $detected_fields): array
{
$suggestions = [];
$field_patterns = [
'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'],
'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'],
'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'],
'Value' => ['value', 'val', 'component_value'],
'Designation' => ['designation', 'part_number', 'partnumber', 'part'],
'Package' => ['footprint', 'package', 'housing', 'fp'],
'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'],
'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'],
'Comment' => ['comment', 'comments', 'note', 'notes', 'description'],
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);
// Create patterns for each supplier
$field_patterns[$supplierName . ' SPN'] = [
$supplierLower,
$supplierLower . '#',
$supplierLower . '_part',
$supplierLower . '_number',
$supplierLower . 'pn',
$supplierLower . '_spn',
$supplierLower . ' spn',
// Common abbreviations
$supplierLower === 'mouser' ? 'mouser' : null,
$supplierLower === 'digikey' ? 'dk' : null,
$supplierLower === 'farnell' ? 'farnell' : null,
$supplierLower === 'rs' ? 'rs' : null,
$supplierLower === 'lcsc' ? 'lcsc' : null,
];
// Remove null values
$field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null);
}
foreach ($detected_fields as $field) {
$field_lower = strtolower(trim($field));
foreach ($field_patterns as $target => $patterns) {
foreach ($patterns as $pattern) {
if (str_contains($field_lower, $pattern)) {
$suggestions[$field] = $target;
break 2; // Break both loops
}
}
}
}
return $suggestions;
}
/**
* Validate field mapping configuration
*/
public function validateFieldMapping(array $field_mapping, array $detected_fields): array
{
$errors = [];
$warnings = [];
$available_targets = $this->getAvailableFieldTargets();
// Check for required fields
$mapped_targets = array_values($field_mapping);
$required_fields = ['Designator', 'Quantity'];
foreach ($required_fields as $required) {
if (!in_array($required, $mapped_targets, true)) {
$errors[] = "Required field '{$required}' is not mapped from any CSV column.";
}
}
// Check for invalid target fields
foreach ($field_mapping as $csv_field => $target) {
if (!empty($target) && !isset($available_targets[$target])) {
$errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'.";
}
}
// Check for unmapped fields (warnings)
$unmapped_fields = array_diff($detected_fields, array_keys($field_mapping));
if (!empty($unmapped_fields)) {
$warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields);
}
return [
'errors' => $errors,
'warnings' => $warnings,
'is_valid' => empty($errors),
];
}
/**
* Apply field mapping with support for multiple fields and priority
*/
private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array
{
$mapped = [];
$field_groups = [];
// Group fields by target with priority information
foreach ($field_mapping as $csv_field => $target) {
if (!empty($target)) {
if (!isset($field_groups[$target])) {
$field_groups[$target] = [];
}
$priority = $field_priorities[$csv_field] ?? 10;
$field_groups[$target][] = [
'field' => $csv_field,
'priority' => $priority,
'value' => $entry[$csv_field] ?? ''
];
}
}
// Process each target field
foreach ($field_groups as $target => $field_data) {
// Sort by priority (lower number = higher priority)
usort($field_data, function ($a, $b) {
return $a['priority'] <=> $b['priority'];
});
$values = [];
$non_empty_values = [];
// Collect all non-empty values for this target
foreach ($field_data as $data) {
$value = trim($data['value']);
if (!empty($value)) {
$non_empty_values[] = $value;
}
$values[] = $value;
}
// Use the first non-empty value (highest priority)
if (!empty($non_empty_values)) {
$mapped[$target] = $non_empty_values[0];
// If multiple non-empty values exist, add alternatives to comment
if (count($non_empty_values) > 1) {
$mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1);
}
}
}
return $mapped;
}
/**
* Detect available fields in CSV data for field mapping UI
*/
public function detectFields(string $data, ?string $delimiter = null): array
{
if ($delimiter === null) {
// Detect delimiter by counting occurrences in the first row (header)
$delimiters = [',', ';', "\t"];
$lines = explode("\n", $data, 2);
$header_line = $lines[0] ?? '';
$delimiter_counts = [];
foreach ($delimiters as $delim) {
$delimiter_counts[$delim] = substr_count($header_line, $delim);
}
// Choose the delimiter with the highest count, default to comma if all are zero
$max_count = max($delimiter_counts);
$delimiter = array_search($max_count, $delimiter_counts, true);
if ($max_count === 0 || $delimiter === false) {
$delimiter = ',';
}
}
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
// Get first line only for header detection
$lines = explode("\n", $data);
$header_line = trim($lines[0] ?? '');
// Simple manual parsing for header detection
// This handles quoted CSV fields better than the library for detection
$fields = [];
$current_field = '';
$in_quotes = false;
$quote_char = '"';
for ($i = 0; $i < strlen($header_line); $i++) {
$char = $header_line[$i];
if ($char === $quote_char && !$in_quotes) {
$in_quotes = true;
} elseif ($char === $quote_char && $in_quotes) {
// Check for escaped quote (double quote)
if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) {
$current_field .= $quote_char;
$i++; // Skip next quote
} else {
$in_quotes = false;
}
} elseif ($char === $delimiter && !$in_quotes) {
$fields[] = trim($current_field);
$current_field = '';
} else {
$current_field .= $char;
}
}
// Add the last field
if ($current_field !== '') {
$fields[] = trim($current_field);
}
// Clean up headers - remove quotes and trim whitespace
$headers = array_map(function ($header) {
return trim($header, '"\'');
}, $fields);
return array_values($headers);
}
} }

View file

@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Service for validating BOM import data with comprehensive validation rules
* and user-friendly error messages.
*/
class BOMValidationService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
) {
}
/**
* Validation result structure
*/
public static function createValidationResult(): array
{
return [
'errors' => [],
'warnings' => [],
'info' => [],
'is_valid' => true,
'total_entries' => 0,
'valid_entries' => 0,
'invalid_entries' => 0,
];
}
/**
* Validate a single BOM entry with comprehensive checks
*/
public function validateBOMEntry(array $mapped_entry, int $line_number, array $options = []): array
{
$result = [
'line_number' => $line_number,
'errors' => [],
'warnings' => [],
'info' => [],
'is_valid' => true,
];
// Run all validation rules
$this->validateRequiredFields($mapped_entry, $result);
$this->validateDesignatorFormat($mapped_entry, $result);
$this->validateQuantityFormat($mapped_entry, $result);
$this->validateDesignatorQuantityMatch($mapped_entry, $result);
$this->validatePartDBLink($mapped_entry, $result);
$this->validateComponentName($mapped_entry, $result);
$this->validatePackageFormat($mapped_entry, $result);
$this->validateNumericFields($mapped_entry, $result);
$result['is_valid'] = empty($result['errors']);
return $result;
}
/**
* Validate multiple BOM entries and provide summary
*/
public function validateBOMEntries(array $mapped_entries, array $options = []): array
{
$result = self::createValidationResult();
$result['total_entries'] = count($mapped_entries);
$line_results = [];
$all_errors = [];
$all_warnings = [];
$all_info = [];
foreach ($mapped_entries as $index => $entry) {
$line_number = $index + 1;
$line_result = $this->validateBOMEntry($entry, $line_number, $options);
$line_results[] = $line_result;
if ($line_result['is_valid']) {
$result['valid_entries']++;
} else {
$result['invalid_entries']++;
}
// Collect all messages
$all_errors = array_merge($all_errors, $line_result['errors']);
$all_warnings = array_merge($all_warnings, $line_result['warnings']);
$all_info = array_merge($all_info, $line_result['info']);
}
// Add summary messages
$this->addSummaryMessages($result, $all_errors, $all_warnings, $all_info);
$result['errors'] = $all_errors;
$result['warnings'] = $all_warnings;
$result['info'] = $all_info;
$result['line_results'] = $line_results;
$result['is_valid'] = empty($all_errors);
return $result;
}
/**
* Validate required fields are present
*/
private function validateRequiredFields(array $entry, array &$result): void
{
$required_fields = ['Designator', 'Quantity'];
foreach ($required_fields as $field) {
if (!isset($entry[$field]) || trim($entry[$field]) === '') {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.required_field_missing', [
'%line%' => $result['line_number'],
'%field%' => $field
]);
}
}
}
/**
* Validate designator format and content
*/
private function validateDesignatorFormat(array $entry, array &$result): void
{
if (!isset($entry['Designator']) || trim($entry['Designator']) === '') {
return; // Already handled by required fields validation
}
$designator = trim($entry['Designator']);
$mountnames = array_map('trim', explode(',', $designator));
// Remove empty entries
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
if (empty($mountnames)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.no_valid_designators', [
'%line%' => $result['line_number']
]);
return;
}
// Validate each mountname format (allow 1-2 uppercase letters, followed by 1+ digits)
$invalid_mountnames = [];
foreach ($mountnames as $mountname) {
if (!preg_match('/^[A-Z]{1,2}[0-9]+$/', $mountname)) {
$invalid_mountnames[] = $mountname;
}
}
if (!empty($invalid_mountnames)) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.unusual_designator_format', [
'%line%' => $result['line_number'],
'%designators%' => implode(', ', $invalid_mountnames)
]);
}
// Check for duplicate mountnames within the same line
$duplicates = array_diff_assoc($mountnames, array_unique($mountnames));
if (!empty($duplicates)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.duplicate_designators', [
'%line%' => $result['line_number'],
'%designators%' => implode(', ', array_unique($duplicates))
]);
}
}
/**
* Validate quantity format and value
*/
private function validateQuantityFormat(array $entry, array &$result): void
{
if (!isset($entry['Quantity']) || trim($entry['Quantity']) === '') {
return; // Already handled by required fields validation
}
$quantity_str = trim($entry['Quantity']);
// Check if it's a valid number
if (!is_numeric($quantity_str)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_quantity', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
return;
}
$quantity = (float) $quantity_str;
// Check for reasonable quantity values
if ($quantity <= 0) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_zero_or_negative', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
} elseif ($quantity > 10000) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_unusually_high', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
}
// Check if quantity is a whole number when it should be
if (isset($entry['Designator'])) {
$designator = trim($entry['Designator']);
$mountnames = array_map('trim', explode(',', $designator));
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
if (count($mountnames) > 0 && $quantity != (int) $quantity) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_not_whole_number', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str,
'%count%' => count($mountnames)
]);
}
}
}
/**
* Validate that designator count matches quantity
*/
private function validateDesignatorQuantityMatch(array $entry, array &$result): void
{
if (!isset($entry['Designator']) || !isset($entry['Quantity'])) {
return; // Already handled by required fields validation
}
$designator = trim($entry['Designator']);
$quantity_str = trim($entry['Quantity']);
if (!is_numeric($quantity_str)) {
return; // Already handled by quantity validation
}
$mountnames = array_map('trim', explode(',', $designator));
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
$mountnames_count = count($mountnames);
$quantity = (float) $quantity_str;
if ($mountnames_count !== (int) $quantity) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_designator_mismatch', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str,
'%count%' => $mountnames_count,
'%designators%' => $designator
]);
}
}
/**
* Validate Part-DB ID link
*/
private function validatePartDBLink(array $entry, array &$result): void
{
if (!isset($entry['Part-DB ID']) || trim($entry['Part-DB ID']) === '') {
return;
}
$part_db_id = trim($entry['Part-DB ID']);
if (!is_numeric($part_db_id)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_partdb_id', [
'%line%' => $result['line_number'],
'%id%' => $part_db_id
]);
return;
}
$part_id = (int) $part_db_id;
if ($part_id <= 0) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.partdb_id_zero_or_negative', [
'%line%' => $result['line_number'],
'%id%' => $part_id
]);
return;
}
// Check if part exists in database
$existing_part = $this->entityManager->getRepository(Part::class)->find($part_id);
if (!$existing_part) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.partdb_id_not_found', [
'%line%' => $result['line_number'],
'%id%' => $part_id
]);
} else {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.partdb_link_success', [
'%line%' => $result['line_number'],
'%name%' => $existing_part->getName(),
'%id%' => $part_id
]);
}
}
/**
* Validate component name/designation
*/
private function validateComponentName(array $entry, array &$result): void
{
$name_fields = ['MPN', 'Designation', 'Value'];
$has_name = false;
foreach ($name_fields as $field) {
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
$has_name = true;
break;
}
}
if (!$has_name) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.no_component_name', [
'%line%' => $result['line_number']
]);
}
}
/**
* Validate package format
*/
private function validatePackageFormat(array $entry, array &$result): void
{
if (!isset($entry['Package']) || trim($entry['Package']) === '') {
return;
}
$package = trim($entry['Package']);
// Check for common package format issues
if (strlen($package) > 100) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.package_name_too_long', [
'%line%' => $result['line_number'],
'%package%' => $package
]);
}
// Check for library prefixes (KiCad format)
if (str_contains($package, ':')) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.library_prefix_detected', [
'%line%' => $result['line_number'],
'%package%' => $package
]);
}
}
/**
* Validate numeric fields
*/
private function validateNumericFields(array $entry, array &$result): void
{
$numeric_fields = ['Quantity', 'Part-DB ID'];
foreach ($numeric_fields as $field) {
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
$value = trim($entry[$field]);
if (!is_numeric($value)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.non_numeric_field', [
'%line%' => $result['line_number'],
'%field%' => $field,
'%value%' => $value
]);
}
}
}
}
/**
* Add summary messages to validation result
*/
private function addSummaryMessages(array &$result, array $errors, array $warnings, array $info): void
{
$total_entries = $result['total_entries'];
$valid_entries = $result['valid_entries'];
$invalid_entries = $result['invalid_entries'];
// Add summary info
if ($total_entries > 0) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.import_summary', [
'%total%' => $total_entries,
'%valid%' => $valid_entries,
'%invalid%' => $invalid_entries
]);
}
// Add error summary
if (!empty($errors)) {
$error_count = count($errors);
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.summary', [
'%count%' => $error_count
]);
}
// Add warning summary
if (!empty($warnings)) {
$warning_count = count($warnings);
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.summary', [
'%count%' => $warning_count
]);
}
// Add success message if all entries are valid
if ($total_entries > 0 && $invalid_entries === 0) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.all_valid');
}
}
/**
* Get user-friendly error message for a validation result
*/
public function getErrorMessage(array $validation_result): string
{
if ($validation_result['is_valid']) {
return '';
}
$messages = [];
if (!empty($validation_result['errors'])) {
$messages[] = 'Errors:';
foreach ($validation_result['errors'] as $error) {
$messages[] = '• ' . $error;
}
}
if (!empty($validation_result['warnings'])) {
$messages[] = 'Warnings:';
foreach ($validation_result['warnings'] as $warning) {
$messages[] = '• ' . $warning;
}
}
return implode("\n", $messages);
}
/**
* Get validation statistics
*/
public function getValidationStats(array $validation_result): array
{
return [
'total_entries' => $validation_result['total_entries'] ?? 0,
'valid_entries' => $validation_result['valid_entries'] ?? 0,
'invalid_entries' => $validation_result['invalid_entries'] ?? 0,
'error_count' => count($validation_result['errors'] ?? []),
'warning_count' => count($validation_result['warnings'] ?? []),
'info_count' => count($validation_result['info'] ?? []),
'success_rate' => $validation_result['total_entries'] > 0
? round(($validation_result['valid_entries'] / $validation_result['total_entries']) * 100, 1)
: 0,
];
}
}

View file

@ -123,11 +123,11 @@ class LCSCProvider implements InfoProviderInterface
*/ */
private function queryByTerm(string $term): array private function queryByTerm(string $term): array
{ {
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [ $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [ 'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency) 'Cookie' => new Cookie('currencyCode', $this->settings->currency)
], ],
'query' => [ 'json' => [
'keyword' => $term, 'keyword' => $term,
], ],
]); ]);
@ -165,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
if ($field === null) { if ($field === null) {
return null; return null;
} }
// Replace "range" indicators with mathematical tilde symbols
// so they don't get rendered as strikethrough by Markdown
$field = preg_replace("/~/", "\u{223c}", $field);
return strip_tags($field); return strip_tags($field);
} }
@ -197,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
$category = $product['parentCatalogName'] ?? null; $category = $product['parentCatalogName'] ?? null;
if (isset($product['catalogName'])) { if (isset($product['catalogName'])) {
$category = ($category ?? '') . ' -> ' . $product['catalogName']; $category = ($category ?? '') . ' -> ' . $product['catalogName'];
// Replace the / with a -> for better readability
$category = str_replace('/', ' -> ', $category);
} }
return new PartDetailDTO( return new PartDetailDTO(

View file

@ -158,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
category: $this->parseCategory($dom), category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null, manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'), preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')), //TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl, provider_url: $productPageUrl,
notes: $this->parseNotes($dom), notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom), datasheets: $this->parseDatasheets($dom),

View file

@ -26,6 +26,8 @@ use App\Entity\PriceInformations\Currency;
use App\Settings\SystemSettings\LocalizationSettings; use App\Settings\SystemSettings\LocalizationSettings;
use Brick\Math\BigDecimal; use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode; use Brick\Math\RoundingMode;
use Exchanger\Exception\UnsupportedCurrencyPairException;
use Exchanger\Exception\UnsupportedExchangeQueryException;
use Swap\Swap; use Swap\Swap;
class ExchangeRateUpdater class ExchangeRateUpdater
@ -39,15 +41,21 @@ class ExchangeRateUpdater
*/ */
public function update(Currency $currency): Currency public function update(Currency $currency): Currency
{ {
//Currency pairs are always in the format "BASE/QUOTE" try {
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
$effective_rate = BigDecimal::of($rate->getValue());
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode()); $rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
//The rate says how many quote units are worth one base unit //The rate says how many quote units are worth one base unit
//So we need to invert it to get the exchange rate //So we need to invert it to get the exchange rate
$rate_bd = BigDecimal::of($rate->getValue()); $rate_bd = BigDecimal::of($rate->getValue());
$rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP); $effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
}
$currency->setExchangeRate($rate_inverted); $currency->setExchangeRate($effective_rate);
return $currency; return $currency;
} }

View file

@ -28,6 +28,9 @@ use App\Repository\UserRepository;
use App\Security\ApiTokenAuthenticatedToken; use App\Security\ApiTokenAuthenticatedToken;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* @see \App\Tests\Services\UserSystem\VoterHelperTest * @see \App\Tests\Services\UserSystem\VoterHelperTest
@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class VoterHelper final class VoterHelper
{ {
private readonly UserRepository $userRepository; private readonly UserRepository $userRepository;
private readonly array $permissionStructure;
public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager) public function __construct(private readonly PermissionManager $permissionManager,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $entityManager)
{ {
$this->userRepository = $this->entityManager->getRepository(User::class); $this->userRepository = $this->entityManager->getRepository(User::class);
$this->permissionStructure = $this->permissionManager->getPermissionStructure();
} }
/** /**
@ -47,11 +54,16 @@ final class VoterHelper
* @param TokenInterface $token The token to check * @param TokenInterface $token The token to check
* @param string $permission The permission to check * @param string $permission The permission to check
* @param string $operation The operation to check * @param string $operation The operation to check
* @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added.
* @return bool * @return bool
*/ */
public function isGranted(TokenInterface $token, string $permission, string $operation): bool public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool
{ {
return $this->isGrantedTrinary($token, $permission, $operation) ?? false; $tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false;
if ($tmp === false) {
$this->addReason($vote, $permission, $operation);
}
return $tmp;
} }
/** /**
@ -124,4 +136,17 @@ final class VoterHelper
{ {
return $this->permissionManager->isValidOperation($permission, $operation); return $this->permissionManager->isValidOperation($permission, $operation);
} }
public function addReason(?Vote $voter, string $permission, $operation): void
{
if ($voter !== null) {
$voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).",
$this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ),
$this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission),
$this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation),
$permission,
$operation
));
}
}
} }

View file

@ -40,4 +40,10 @@ class PartInfoSettings
#[SettingsParameter(label: new TM("settings.behavior.part_info.show_part_image_overlay"), description: new TM("settings.behavior.part_info.show_part_image_overlay.help"), #[SettingsParameter(label: new TM("settings.behavior.part_info.show_part_image_overlay"), description: new TM("settings.behavior.part_info.show_part_image_overlay.help"),
envVar: "bool:SHOW_PART_IMAGE_OVERLAY", envVarMode: EnvVarMode::OVERWRITE)] envVar: "bool:SHOW_PART_IMAGE_OVERLAY", envVarMode: EnvVarMode::OVERWRITE)]
public bool $showPartImageOverlay = true; public bool $showPartImageOverlay = true;
#[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_description"))]
public bool $extractParamsFromDescription = true;
#[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_notes"))]
public bool $extractParamsFromNotes = true;
} }

View file

@ -70,6 +70,20 @@ class TableSettings
PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER, PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER,
PartTableColumns::LOCATION, PartTableColumns::AMOUNT]; PartTableColumns::LOCATION, PartTableColumns::AMOUNT];
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"),
formOptions: ['attr' => ['min' => 1, 'max' => 100]],
envVar: "int:TABLE_IMAGE_PREVIEW_MIN_SIZE", envVarMode: EnvVarMode::OVERWRITE
)]
#[Assert\Range(min: 1, max: 100)]
public int $previewImageMinWidth = 20;
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_max_width"),
formOptions: ['attr' => ['min' => 1, 'max' => 100]],
envVar: "int:TABLE_IMAGE_PREVIEW_MAX_SIZE", envVarMode: EnvVarMode::OVERWRITE
)]
#[Assert\Range(min: 1, max: 100)]
#[Assert\GreaterThanOrEqual(propertyPath: 'previewImageMinWidth')]
public int $previewImageMaxWidth = 35;
public static function mapPartsDefaultColumnsEnv(string $columns): array public static function mapPartsDefaultColumnsEnv(string $columns): array
{ {

View file

@ -0,0 +1,45 @@
<?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\Settings\InfoProviderSystem;
use App\Form\InfoProviderSystem\ProviderSelectType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(label: new TM("settings.ips.general"))]
#[SettingsIcon("fa-magnifying-glass")]
class InfoProviderGeneralSettings
{
/**
* @var string[]
*/
#[SettingsParameter(type: ArrayType::class, label: new TM("settings.ips.default_providers"),
description: new TM("settings.ips.default_providers.help"), options: ['type' => StringType::class],
formType: ProviderSelectType::class, formOptions: ['input' => 'string', 'required' => false, 'empty_data' => []])]
public array $defaultSearchProviders = [];
}

View file

@ -25,6 +25,7 @@ namespace App\Settings\InfoProviderSystem;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings; use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait; use Jbtronics\SettingsBundle\Settings\SettingsTrait;
#[Settings()] #[Settings()]
@ -32,6 +33,9 @@ class InfoProviderSettings
{ {
use SettingsTrait; use SettingsTrait;
#[EmbeddedSettings]
public ?InfoProviderGeneralSettings $general = null;
#[EmbeddedSettings] #[EmbeddedSettings]
public ?DigikeySettings $digikey = null; public ?DigikeySettings $digikey = null;

View file

@ -28,10 +28,13 @@ use App\Form\Type\ThemeChoiceType;
use App\Settings\SettingsIcon; use App\Settings\SettingsIcon;
use App\Validator\Constraints\ValidTheme; use App\Validator\Constraints\ValidTheme;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode; use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\EnumType;
use Jbtronics\SettingsBundle\Settings\Settings; use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter; use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait; use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Translation\TranslatableMessage as TM; use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(name: "customization", label: new TM("settings.system.customization"))] #[Settings(name: "customization", label: new TM("settings.system.customization"))]
#[SettingsIcon("fa-paint-roller")] #[SettingsIcon("fa-paint-roller")]
@ -46,6 +49,13 @@ class CustomizationSettings
)] )]
public string $instanceName = "Part-DB"; public string $instanceName = "Part-DB";
#[SettingsParameter(
label: new TM("settings.system.customization.theme"),
formType: ThemeChoiceType::class, formOptions: ['placeholder' => false]
)]
#[ValidTheme]
public string $theme = 'bootstrap';
#[SettingsParameter( #[SettingsParameter(
label: new TM("settings.system.customization.banner"), label: new TM("settings.system.customization.banner"),
formType: RichTextEditorType::class, formOptions: ['mode' => 'markdown-full'], formType: RichTextEditorType::class, formOptions: ['mode' => 'markdown-full'],
@ -53,10 +63,22 @@ class CustomizationSettings
)] )]
public ?string $banner = null; public ?string $banner = null;
#[SettingsParameter( /**
label: new TM("settings.system.customization.theme"), * @var HomepageItems[] The items to show in the sidebar.
formType: ThemeChoiceType::class, formOptions: ['placeholder' => false] */
#[SettingsParameter(ArrayType::class,
label: new TM("settings.behavior.hompepage.items"),
description: new TM("settings.behavior.homepage.items.help"),
options: ['type' => EnumType::class, 'options' => ['class' => HomepageItems::class]],
formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
formOptions: ['class' => HomepageItems::class, 'multiple' => true, 'ordered' => true]
)] )]
#[ValidTheme] #[Assert\NotBlank()]
public string $theme = 'bootstrap'; #[Assert\Unique()]
public array $homepageitems = [HomepageItems::SEARCH, HomepageItems::BANNER, HomepageItems::FIRST_STEPS, HomepageItems::LICENSE, HomepageItems::LAST_ACTIVITY];
#[SettingsParameter(
label: new TM("settings.system.customization.showVersionOnHomepage")
)]
public bool $showVersionOnHomepage = true;
} }

View file

@ -0,0 +1,51 @@
<?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\Settings\SystemSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
enum HomepageItems: string implements TranslatableInterface
{
case SEARCH = 'search';
case BANNER = 'banner';
case LICENSE = 'license';
case FIRST_STEPS = 'first_steps';
case LAST_ACTIVITY = 'last_activity';
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
$key = match($this) {
self::SEARCH => 'search.placeholder',
self::BANNER => 'settings.system.customization.banner',
self::LICENSE => 'homepage.license',
self::FIRST_STEPS => 'homepage.first_steps.title',
self::LAST_ACTIVITY => 'homepage.last_activity',
};
return $translator->trans($key, locale: $locale);
}
}

View file

@ -133,15 +133,6 @@
"ekino/phpstan-banned-code": { "ekino/phpstan-banned-code": {
"version": "v0.3.1" "version": "v0.3.1"
}, },
"florianv/exchanger": {
"version": "1.4.1"
},
"florianv/swap": {
"version": "3.5.0"
},
"florianv/swap-bundle": {
"version": "5.0.x-dev"
},
"gregwar/captcha": { "gregwar/captcha": {
"version": "v1.1.7" "version": "v1.1.7"
}, },
@ -254,6 +245,9 @@
"./config/packages/datatables.yaml" "./config/packages/datatables.yaml"
] ]
}, },
"part-db/swap-bundle": {
"version": "v6.0.0"
},
"php-http/discovery": { "php-http/discovery": {
"version": "1.18", "version": "1.18",
"recipe": { "recipe": {

View file

@ -53,6 +53,14 @@
{% endif %} {% endif %}
{{ encore_entry_link_tags('app') }} {{ encore_entry_link_tags('app') }}
{% set table_settings = settings_instance('table') %}
<style>
:root {
--table-image-preview-min-size: {{ table_settings.previewImageMinWidth }}px;
--table-image-preview-max-size: {{ table_settings.previewImageMaxWidth }}px;
}
</style>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}

View file

@ -1,6 +1,9 @@
{% extends "bundles/TwigBundle/Exception/error.html.twig" %} {% extends "bundles/TwigBundle/Exception/error.html.twig" %}
{% block status_comment %} {% block status_comment %}
Nice try! But you are not allowed to do this! Nice try! But you are not allowed to do this!<br>
<code>{{ exception.message }}</code>
<br> <small>If you think you should have access to this ressource, contact the adminstrator.</small> <br> <small>If you think you should have access to this ressource, contact the adminstrator.</small>
{% endblock %} {% endblock %}

View file

@ -29,7 +29,7 @@
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value=""> <input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
<div class="d-none mb-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}> <div class="d-none mb-2 bg-body-tertiary shadow-sm border border-secondary rounded mx-2 p-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
{# <span id="select_count"></span> #} {# <span id="select_count"></span> #}
<div class="input-group"> <div class="input-group">

View file

@ -4,26 +4,23 @@
{% import "components/search.macro.html.twig" as search %} {% import "components/search.macro.html.twig" as search %}
{% import "vars.macro.twig" as vars %} {% import "vars.macro.twig" as vars %}
{% block content %} {% block item_search %}
{% if is_granted('@system.show_updates') %}
{{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
{% endif %}
{% if is_granted('@parts.read') %} {% if is_granted('@parts.read') %}
{{ search.search_form("standalone") }} {{ search.search_form("standalone") }}
<div class="mb-2"></div>
{% endif %} {% endif %}
{% endblock %}
{% block item_banner %}
<div class="rounded p-4 bg-body-secondary"> <div class="rounded p-4 bg-body-secondary">
<h1 class="display-3">{{ vars.partdb_title() }}</h1> <h1 class="display-3">{{ vars.partdb_title() }}</h1>
{% if settings_instance('customization').showVersionOnHomepage %}
<h4> <h4>
{% trans %}version.caption{% endtrans %}: {{ shivas_app_version }} {% trans %}version.caption{% endtrans %}: {{ shivas_app_version }}
{% if git_branch is not empty or git_commit is not empty %} {% if git_branch is not empty or git_commit is not empty %}
({{ git_branch ?? '' }}/{{ git_commit ?? '' }}) ({{ git_branch ?? '' }}/{{ git_commit ?? '' }})
{% endif %} {% endif %}
</h4> </h4>
{% endif %}
{% if banner is not empty %} {% if banner is not empty %}
<hr> <hr>
<div class="latex" data-controller="common--latex"> <div class="latex" data-controller="common--latex">
@ -31,9 +28,11 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %}
{% block item_first_steps %}
{% if show_first_steps %} {% if show_first_steps %}
<div class="card border-info mt-3"> <div class="card border-info">
<div class="card-header bg-info "> <div class="card-header bg-info ">
<h4><i class="fa fa-circle-play fa-fw " aria-hidden="true"></i> {% trans %}homepage.first_steps.title{% endtrans %}</h4> <h4><i class="fa fa-circle-play fa-fw " aria-hidden="true"></i> {% trans %}homepage.first_steps.title{% endtrans %}</h4>
</div> </div>
@ -51,8 +50,10 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endblock %}
<div class="card border-primary mt-3"> {% block item_license %}
<div class="card border-primary">
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
<h4><i class="fa fa-book fa-fw" aria-hidden="true"></i> {% trans %}homepage.license{% endtrans %}</h4> <h4><i class="fa fa-book fa-fw" aria-hidden="true"></i> {% trans %}homepage.license{% endtrans %}</h4>
</div> </div>
@ -68,9 +69,11 @@
<strong><i class="fas fa-comments fa-fw"></i> {% trans %}homepage.forum.caption{% endtrans %}:</strong> {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server/discussions'}%}homepage.forum.text{% endtrans %}<br> <strong><i class="fas fa-comments fa-fw"></i> {% trans %}homepage.forum.caption{% endtrans %}:</strong> {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server/discussions'}%}homepage.forum.text{% endtrans %}<br>
</div> </div>
</div> </div>
{% endblock %}
{% block item_last_activity %}
{% if datatable is not null %} {% if datatable is not null %}
<div class="card mt-3"> <div class="card">
<div class="card-header"><i class="fas fa-fw fa-history"></i> {% trans %}homepage.last_activity{% endtrans %}</div> <div class="card-header"><i class="fas fa-fw fa-history"></i> {% trans %}homepage.last_activity{% endtrans %}</div>
<div class="card-body"> <div class="card-body">
{% import "components/history_log_macros.html.twig" as log %} {% import "components/history_log_macros.html.twig" as log %}
@ -79,3 +82,22 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %}
{% if is_granted('@system.show_updates') %}
{{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
{% endif %}
{% for item in settings_instance('customization').homepageitems %}
{% if block('item_' ~ item.value) is defined %}
{{ block('item_' ~ item.value) }}
<div class="mb-2"></div>
{% else %}
<div class="alert alert-warning mt-3" role="alert">
Alert: The homepage item "{{ item.value }}" is not defined!
</div>
{% endif %}
{% endfor %}
{% endblock %}

View file

@ -23,7 +23,7 @@
</div> </div>
<div class="col-6"> <div class="col-6">
{% if provider.providerInfo.settings_class is defined %} {% if provider.providerInfo.settings_class is defined %}
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm" <a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm {% if not is_granted('@config.change_system_settings') %}disabled{% endif %}"
title="{% trans %}info_providers.settings.title{% endtrans %}" title="{% trans %}info_providers.settings.title{% endtrans %}"
><i class="fa-solid fa-cog"></i></a> ><i class="fa-solid fa-cog"></i></a>
{% endif %} {% endif %}

View file

@ -100,6 +100,10 @@
</div> </div>
{% endif %} {% endif %}
{% if form.update_profile is defined %}
{{ form_row(form.update_profile) }}
{% endif %}
<div class="form-group row"> <div class="form-group row">
<div class="offset-sm-3 col-sm-9"> <div class="offset-sm-3 col-sm-9">
<div class="input-group"> <div class="input-group">

View file

@ -0,0 +1,186 @@
{# BOM Validation Results Component #}
{#
Usage:
{% include 'projects/_bom_validation_results.html.twig' with {
validation_result: validation_result,
show_summary: true,
show_details: true
} %}
#}
{% if validation_result is defined and validation_result is not empty %}
{% set stats = validation_result %}
{# Validation Summary #}
{% if show_summary is defined and show_summary %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa-solid fa-chart-bar fa-fw"></i>
{% trans %}project.bom_import.validation.summary{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-primary">{{ stats.total_entries }}</div>
<small class="text-muted">{% trans %}project.bom_import.validation.total_entries{% endtrans %}</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-success">{{ stats.valid_entries }}</div>
<small class="text-muted">{% trans %}project.bom_import.validation.valid_entries{% endtrans %}</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-warning">{{ stats.invalid_entries }}</div>
<small class="text-muted">{% trans %}project.bom_import.validation.invalid_entries{% endtrans %}</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-info">
{% if stats.total_entries > 0 %}
{{ ((stats.valid_entries / stats.total_entries) * 100) | round(1) }}%
{% else %}
0%
{% endif %}
</div>
<small class="text-muted">{% trans %}project.bom_import.validation.success_rate{% endtrans %}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# Validation Messages #}
{% if validation_result.errors is defined and validation_result.errors is not empty %}
<div class="alert alert-danger">
<h4><i class="fa-solid fa-exclamation-triangle fa-fw"></i> {% trans %}project.bom_import.validation.errors.title{% endtrans %}</h4>
<p class="mb-2">{% trans %}project.bom_import.validation.errors.description{% endtrans %}</p>
<ul class="mb-0">
{% for error in validation_result.errors %}
<li>{{ error|raw }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if validation_result.warnings is defined and validation_result.warnings is not empty %}
<div class="alert alert-warning">
<h4><i class="fa-solid fa-exclamation-circle fa-fw"></i> {% trans %}project.bom_import.validation.warnings.title{% endtrans %}</h4>
<p class="mb-2">{% trans %}project.bom_import.validation.warnings.description{% endtrans %}</p>
<ul class="mb-0">
{% for warning in validation_result.warnings %}
<li>{{ warning|raw }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if validation_result.info is defined and validation_result.info is not empty %}
<div class="alert alert-info">
<h4><i class="fa-solid fa-info-circle fa-fw"></i> {% trans %}project.bom_import.validation.info.title{% endtrans %}</h4>
<ul class="mb-0">
{% for info in validation_result.info %}
<li>{{ info|raw }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Detailed Line-by-Line Results #}
{% if show_details is defined and show_details and validation_result.line_results is defined %}
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa-solid fa-list fa-fw"></i>
{% trans %}project.bom_import.validation.details.title{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans %}project.bom_import.validation.details.line{% endtrans %}</th>
<th>{% trans %}project.bom_import.validation.details.status{% endtrans %}</th>
<th>{% trans %}project.bom_import.validation.details.messages{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for line_result in validation_result.line_results %}
<tr class="{% if line_result.is_valid %}table-success{% else %}table-danger{% endif %}">
<td>
<strong>{{ line_result.line_number }}</strong>
</td>
<td>
{% if line_result.is_valid %}
<span class="badge bg-success">
<i class="fa-solid fa-check fa-fw"></i>
{% trans %}project.bom_import.validation.details.valid{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger">
<i class="fa-solid fa-times fa-fw"></i>
{% trans %}project.bom_import.validation.details.invalid{% endtrans %}
</span>
{% endif %}
</td>
<td>
{% if line_result.errors is not empty %}
<div class="text-danger">
{% for error in line_result.errors %}
<div><i class="fa-solid fa-exclamation-triangle fa-fw"></i> {{ error|raw }}</div>
{% endfor %}
</div>
{% endif %}
{% if line_result.warnings is not empty %}
<div class="text-warning">
{% for warning in line_result.warnings %}
<div><i class="fa-solid fa-exclamation-circle fa-fw"></i> {{ warning|raw }}</div>
{% endfor %}
</div>
{% endif %}
{% if line_result.info is not empty %}
<div class="text-info">
{% for info in line_result.info %}
<div><i class="fa-solid fa-info-circle fa-fw"></i> {{ info|raw }}</div>
{% endfor %}
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# Action Buttons #}
{% if validation_result.is_valid is defined %}
<div class="mt-3">
{% if validation_result.is_valid %}
<div class="alert alert-success">
<i class="fa-solid fa-check-circle fa-fw"></i>
{% trans %}project.bom_import.validation.all_valid{% endtrans %}
</div>
{% else %}
<div class="alert alert-danger">
<i class="fa-solid fa-exclamation-triangle fa-fw"></i>
{% trans %}project.bom_import.validation.fix_errors{% endtrans %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,204 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}project.bom_import.map_fields{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fa-solid fa-arrows-left-right fa-fw"></i>
{% trans %}project.bom_import.map_fields{% endtrans %}{% if project %}: <i>{{ project.name }}</i>{% endif %}
{% endblock %}
{% block card_content %}
{% if validation_result is defined %}
{% include 'projects/_bom_validation_results.html.twig' with {
validation_result: validation_result,
show_summary: true,
show_details: false
} %}
{% endif %}
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info">
<i class="fa-solid fa-info-circle fa-fw"></i>
{% trans %}project.bom_import.map_fields.help{% endtrans %}
</div>
<div class="alert alert-warning">
<i class="fa-solid fa-lightbulb fa-fw"></i>
{% trans %}project.bom_import.field_mapping.priority_note{% endtrans %}
</div>
</div>
</div>
{{ form_start(form) }}
<div class="row mb-3">
<div class="col-md-6">
{{ form_row(form.delimiter) }}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa-solid fa-table-columns fa-fw"></i>
{% trans %}project.bom_import.field_mapping.title{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{% trans %}project.bom_import.field_mapping.csv_field{% endtrans %}</th>
<th>{% trans %}project.bom_import.field_mapping.maps_to{% endtrans %}</th>
<th>{% trans %}project.bom_import.field_mapping.suggestion{% endtrans %}</th>
<th>{% trans %}project.bom_import.field_mapping.priority{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for field in detected_fields %}
<tr>
<td>
<code>{{ field }}</code>
</td>
<td>
{{ form_widget(form['mapping_' ~ field_name_mapping[field]], {
'attr': {
'class': 'form-select field-mapping-select',
'data-field': field
}
}) }}
</td>
<td>
{% if suggested_mapping[field] is defined %}
<span class="badge bg-success">
<i class="fa-solid fa-magic fa-fw"></i>
{{ suggested_mapping[field] }}
</span>
{% else %}
<span class="text-muted">
<i class="fa-solid fa-question fa-fw"></i>
{% trans %}project.bom_import.field_mapping.no_suggestion{% endtrans %}
</span>
{% endif %}
</td>
<td>
<input type="number"
class="form-control form-control-sm priority-input"
min="1"
value="10"
style="width: 80px;"
data-field="{{ field }}"
title="{% trans %}project.bom_import.field_mapping.priority_help{% endtrans %}">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3">
<h6>{% trans %}project.bom_import.field_mapping.summary{% endtrans %}:</h6>
<div id="mapping-summary" class="alert alert-info">
<i class="fa-solid fa-info-circle fa-fw"></i>
{% trans %}project.bom_import.field_mapping.select_to_see_summary{% endtrans %}
</div>
</div>
</div>
</div>
<div class="mt-3">
{{ form_widget(form.submit, {
'attr': {
'class': 'btn btn-primary'
}
}) }}
<a href="{{ path('project_import_bom', {'id': project.id}) }}" class="btn btn-secondary">
<i class="fa-solid fa-arrow-left fa-fw"></i>
{% trans %}common.back{% endtrans %}
</a>
</div>
{{ form_end(form) }}
<script nonce="{{ csp_nonce('script') }}">
// Function to initialize the field mapping page
function initializeFieldMapping() {
const suggestions = {{ suggested_mapping|json_encode|raw }};
const fieldNameMapping = {{ field_name_mapping|json_encode|raw }};
Object.keys(suggestions).forEach(function(field) {
// Use the sanitized field name from the server-side mapping
const sanitizedField = fieldNameMapping[field];
const select = document.querySelector('[name="form[mapping_' + sanitizedField + ']"]');
if (select && !select.value) {
select.value = suggestions[field];
}
});
// Update mapping summary
updateMappingSummary();
// Add event listeners for dynamic updates
document.querySelectorAll('.field-mapping-select').forEach(function(select) {
select.addEventListener('change', updateMappingSummary);
});
document.querySelectorAll('.priority-input').forEach(function(input) {
input.addEventListener('change', updateMappingSummary);
});
}
// Initialize on both DOMContentLoaded and Turbo events
document.addEventListener('DOMContentLoaded', initializeFieldMapping);
document.addEventListener('turbo:load', initializeFieldMapping);
document.addEventListener('turbo:frame-load', function(event) {
// Only initialize if this frame contains our field mapping content
if (event.target.id === 'content' || event.target.closest('#content')) {
initializeFieldMapping();
}
});
function updateMappingSummary() {
const summary = document.getElementById('mapping-summary');
const mappings = {};
const priorities = {};
// Collect all mappings and priorities
document.querySelectorAll('.field-mapping-select').forEach(function(select) {
const field = select.getAttribute('data-field');
const target = select.value;
const priorityInput = document.querySelector('.priority-input[data-field="' + field + '"]');
const priority = priorityInput ? parseInt(priorityInput.value) || 10 : 10;
if (target && target !== '') {
if (!mappings[target]) {
mappings[target] = [];
}
mappings[target].push({
field: field,
priority: priority
});
}
});
// Sort by priority and build summary
let summaryHtml = '<div class="row">';
Object.keys(mappings).forEach(function(target) {
const fieldMappings = mappings[target].sort((a, b) => a.priority - b.priority);
const fieldList = fieldMappings.map(m => m.field + ' (' + '{{ "project.bom_import.field_mapping.priority_short"|trans }}' + m.priority + ')').join(', ');
summaryHtml += '<div class="col-md-6 mb-2">';
summaryHtml += '<strong>' + target + ':</strong> ' + fieldList;
summaryHtml += '</div>';
});
summaryHtml += '</div>';
if (Object.keys(mappings).length === 0) {
summary.innerHTML = '<i class="fa-solid fa-info-circle fa-fw"></i> {{ "project.bom_import.field_mapping.select_to_see_summary"|trans }}';
} else {
summary.innerHTML = summaryHtml;
}
}
</script>
{% endblock %}

View file

@ -36,7 +36,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
{ {
$this->_testGetCollection(); $this->_testGetCollection();
self::assertJsonContains([ self::assertJsonContains([
'hydra:totalItems' => 0, 'hydra:totalItems' => 4, //The 4 currencies from our fixtures
]); ]);
} }
@ -45,7 +45,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
{ {
$this->_testPostItem([ $this->_testPostItem([
'name' => 'Test API', 'name' => 'Test API',
'iso_code' => 'USD', 'iso_code' => 'CAD',
]); ]);
} }

View file

@ -0,0 +1,35 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 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\Tests\Controller\AdminPages;
use App\Entity\PriceInformations\Currency;
use PHPUnit\Framework\Attributes\Group;
use App\Entity\Parts\Manufacturer;
#[Group('slow')]
#[Group('DB')]
class CurrencyController extends AbstractAdminController
{
protected static string $base_path = '/en/currency';
protected static string $entity_class = Currency::class;
}

View file

@ -22,9 +22,12 @@ declare(strict_types=1);
*/ */
namespace App\Tests\Services\ImportExportSystem; namespace App\Tests\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ImportExportSystem\BOMImporter; use App\Services\ImportExportSystem\BOMImporter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\File;
@ -36,11 +39,17 @@ class BOMImporterTest extends WebTestCase
*/ */
protected $service; protected $service;
/**
* @var EntityManagerInterface
*/
protected $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
//Get a service instance. //Get a service instance.
self::bootKernel(); self::bootKernel();
$this->service = self::getContainer()->get(BOMImporter::class); $this->service = self::getContainer()->get(BOMImporter::class);
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
} }
public function testImportFileIntoProject(): void public function testImportFileIntoProject(): void
@ -119,4 +128,489 @@ class BOMImporterTest extends WebTestCase
$this->service->stringToBOMEntries($input, ['type' => 'kicad_pcbnew']); $this->service->stringToBOMEntries($input, ['type' => 'kicad_pcbnew']);
} }
public function testDetectFields(): void
{
$input = <<<CSV
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
CSV;
$fields = $this->service->detectFields($input);
$this->assertIsArray($fields);
$this->assertCount(8, $fields);
$this->assertContains('Reference', $fields);
$this->assertContains('Value', $fields);
$this->assertContains('Footprint', $fields);
$this->assertContains('Quantity', $fields);
$this->assertContains('MPN', $fields);
$this->assertContains('Manufacturer', $fields);
$this->assertContains('LCSC SPN', $fields);
$this->assertContains('Mouser SPN', $fields);
}
public function testDetectFieldsWithQuotes(): void
{
$input = <<<CSV
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
CSV;
$fields = $this->service->detectFields($input);
$this->assertIsArray($fields);
$this->assertCount(8, $fields);
$this->assertEquals('Reference', $fields[0]);
$this->assertEquals('Value', $fields[1]);
}
public function testDetectFieldsWithSemicolon(): void
{
$input = <<<CSV
"Reference";"Value";"Footprint";"Quantity";"MPN";"Manufacturer";"LCSC SPN";"Mouser SPN"
CSV;
$fields = $this->service->detectFields($input, ';');
$this->assertIsArray($fields);
$this->assertCount(8, $fields);
$this->assertEquals('Reference', $fields[0]);
$this->assertEquals('Value', $fields[1]);
}
public function testGetAvailableFieldTargets(): void
{
$targets = $this->service->getAvailableFieldTargets();
$this->assertIsArray($targets);
$this->assertArrayHasKey('Designator', $targets);
$this->assertArrayHasKey('Quantity', $targets);
$this->assertArrayHasKey('Value', $targets);
$this->assertArrayHasKey('Package', $targets);
$this->assertArrayHasKey('MPN', $targets);
$this->assertArrayHasKey('Manufacturer', $targets);
$this->assertArrayHasKey('Part-DB ID', $targets);
$this->assertArrayHasKey('Comment', $targets);
// Check structure of a target
$this->assertArrayHasKey('label', $targets['Designator']);
$this->assertArrayHasKey('description', $targets['Designator']);
$this->assertArrayHasKey('required', $targets['Designator']);
$this->assertArrayHasKey('multiple', $targets['Designator']);
$this->assertTrue($targets['Designator']['required']);
$this->assertTrue($targets['Quantity']['required']);
$this->assertFalse($targets['Value']['required']);
}
public function testGetAvailableFieldTargetsWithSuppliers(): void
{
// Create test suppliers
$supplier1 = new Supplier();
$supplier1->setName('LCSC');
$supplier2 = new Supplier();
$supplier2->setName('Mouser');
$this->entityManager->persist($supplier1);
$this->entityManager->persist($supplier2);
$this->entityManager->flush();
$targets = $this->service->getAvailableFieldTargets();
$this->assertArrayHasKey('LCSC SPN', $targets);
$this->assertArrayHasKey('Mouser SPN', $targets);
$this->assertEquals('LCSC SPN', $targets['LCSC SPN']['label']);
$this->assertEquals('Mouser SPN', $targets['Mouser SPN']['label']);
$this->assertFalse($targets['LCSC SPN']['required']);
$this->assertTrue($targets['LCSC SPN']['multiple']);
// Clean up
$this->entityManager->remove($supplier1);
$this->entityManager->remove($supplier2);
$this->entityManager->flush();
}
public function testGetSuggestedFieldMapping(): void
{
$detected_fields = [
'Reference',
'Value',
'Footprint',
'Quantity',
'MPN',
'Manufacturer',
'LCSC',
'Mouser',
'Part-DB ID',
'Comment'
];
$suggestions = $this->service->getSuggestedFieldMapping($detected_fields);
$this->assertIsArray($suggestions);
$this->assertEquals('Designator', $suggestions['Reference']);
$this->assertEquals('Value', $suggestions['Value']);
$this->assertEquals('Package', $suggestions['Footprint']);
$this->assertEquals('Quantity', $suggestions['Quantity']);
$this->assertEquals('MPN', $suggestions['MPN']);
$this->assertEquals('Manufacturer', $suggestions['Manufacturer']);
$this->assertEquals('Part-DB ID', $suggestions['Part-DB ID']);
$this->assertEquals('Comment', $suggestions['Comment']);
}
public function testGetSuggestedFieldMappingWithSuppliers(): void
{
// Create test suppliers
$supplier1 = new Supplier();
$supplier1->setName('LCSC');
$supplier2 = new Supplier();
$supplier2->setName('Mouser');
$this->entityManager->persist($supplier1);
$this->entityManager->persist($supplier2);
$this->entityManager->flush();
$detected_fields = [
'Reference',
'LCSC',
'Mouser',
'lcsc_part',
'mouser_spn'
];
$suggestions = $this->service->getSuggestedFieldMapping($detected_fields);
$this->assertIsArray($suggestions);
$this->assertEquals('Designator', $suggestions['Reference']);
// Note: The exact mapping depends on the pattern matching logic
// We just check that supplier fields are mapped to something
$this->assertArrayHasKey('LCSC', $suggestions);
$this->assertArrayHasKey('Mouser', $suggestions);
$this->assertArrayHasKey('lcsc_part', $suggestions);
$this->assertArrayHasKey('mouser_spn', $suggestions);
// Clean up
$this->entityManager->remove($supplier1);
$this->entityManager->remove($supplier2);
$this->entityManager->flush();
}
public function testValidateFieldMappingValid(): void
{
$field_mapping = [
'Reference' => 'Designator',
'Quantity' => 'Quantity',
'Value' => 'Value'
];
$detected_fields = ['Reference', 'Quantity', 'Value', 'MPN'];
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('errors', $result);
$this->assertArrayHasKey('warnings', $result);
$this->assertArrayHasKey('is_valid', $result);
$this->assertTrue($result['is_valid']);
$this->assertEmpty($result['errors']);
$this->assertNotEmpty($result['warnings']); // Should warn about unmapped MPN
}
public function testValidateFieldMappingMissingRequired(): void
{
$field_mapping = [
'Value' => 'Value',
'MPN' => 'MPN'
];
$detected_fields = ['Value', 'MPN'];
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
$this->assertFalse($result['is_valid']);
$this->assertNotEmpty($result['errors']);
$this->assertContains("Required field 'Designator' is not mapped from any CSV column.", $result['errors']);
$this->assertContains("Required field 'Quantity' is not mapped from any CSV column.", $result['errors']);
}
public function testValidateFieldMappingInvalidTarget(): void
{
$field_mapping = [
'Reference' => 'Designator',
'Quantity' => 'Quantity',
'Value' => 'InvalidTarget'
];
$detected_fields = ['Reference', 'Quantity', 'Value'];
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
$this->assertFalse($result['is_valid']);
$this->assertNotEmpty($result['errors']);
$this->assertContains("Invalid target field 'InvalidTarget' for CSV field 'Value'.", $result['errors']);
}
public function testStringToBOMEntriesKiCADSchematic(): void
{
$input = <<<CSV
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
"R1,R2","10k","R_0805_2012Metric",2,"CRCW080510K0FKEA","Vishay","C123456","123-M10K"
"C1","100nF","C_0805_2012Metric",1,"CL21A104KOCLRNC","Samsung","C789012","80-CL21A104KOCLRNC"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Footprint' => 'Package',
'Quantity' => 'Quantity',
'MPN' => 'MPN',
'Manufacturer' => 'Manufacturer',
'LCSC SPN' => 'LCSC SPN',
'Mouser SPN' => 'Mouser SPN'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(2, $bom_entries);
// Check first entry
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
$this->assertEquals('CRCW080510K0FKEA (R_0805_2012Metric)', $bom_entries[0]->getName());
$this->assertStringContainsString('Value: 10k', $bom_entries[0]->getComment());
$this->assertStringContainsString('MPN: CRCW080510K0FKEA', $bom_entries[0]->getComment());
$this->assertStringContainsString('Manf: Vishay', $bom_entries[0]->getComment());
// Check second entry
$this->assertEquals('C1', $bom_entries[1]->getMountnames());
$this->assertEquals(1.0, $bom_entries[1]->getQuantity());
}
public function testStringToBOMEntriesKiCADSchematicWithPriority(): void
{
$input = <<<CSV
"Reference","Value","MPN1","MPN2","Quantity"
"R1,R2","10k","CRCW080510K0FKEA","","2"
"C1","100nF","","CL21A104KOCLRNC","1"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'MPN1' => 'MPN',
'MPN2' => 'MPN',
'Quantity' => 'Quantity'
];
$field_priorities = [
'MPN1' => 1,
'MPN2' => 2
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(2, $bom_entries);
// First entry should use MPN1 (higher priority)
$this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName());
// Second entry should use MPN2 (MPN1 is empty)
$this->assertEquals('CL21A104KOCLRNC', $bom_entries[1]->getName());
}
public function testStringToBOMEntriesKiCADSchematicWithPartDBID(): void
{
// Create a test part with required fields
$part = new Part();
$part->setName('Test Part');
$part->setCategory($this->getDefaultCategory($this->entityManager));
$this->entityManager->persist($part);
$this->entityManager->flush();
$input = <<<CSV
"Reference","Value","Part-DB ID","Quantity"
"R1,R2","10k","{$part->getID()}","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Part-DB ID' => 'Part-DB ID',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
$this->assertEquals('Test Part', $bom_entries[0]->getName());
$this->assertSame($part, $bom_entries[0]->getPart());
$this->assertStringContainsString("Part-DB ID: {$part->getID()}", $bom_entries[0]->getComment());
// Clean up
$this->entityManager->remove($part);
$this->entityManager->flush();
}
public function testStringToBOMEntriesKiCADSchematicWithInvalidPartDBID(): void
{
$input = <<<CSV
"Reference","Value","Part-DB ID","Quantity"
"R1,R2","10k","99999","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Part-DB ID' => 'Part-DB ID',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
$this->assertEquals('10k', $bom_entries[0]->getName()); // Should use Value as name
$this->assertNull($bom_entries[0]->getPart()); // Should not link to part
$this->assertStringContainsString("Part-DB ID: 99999 (NOT FOUND)", $bom_entries[0]->getComment());
}
public function testStringToBOMEntriesKiCADSchematicMergeDuplicates(): void
{
$input = <<<CSV
"Reference","Value","MPN","Quantity"
"R1","10k","CRCW080510K0FKEA","1"
"R2","10k","CRCW080510K0FKEA","1"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'MPN' => 'MPN',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries); // Should merge into one entry
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
$this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName());
}
public function testStringToBOMEntriesKiCADSchematicMissingRequired(): void
{
$input = <<<CSV
"Value","MPN"
"10k","CRCW080510K0FKEA"
CSV;
$field_mapping = [
'Value' => 'Value',
'MPN' => 'MPN'
];
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Required field "Designator" is missing or empty');
$this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
}
public function testStringToBOMEntriesKiCADSchematicQuantityMismatch(): void
{
$input = <<<CSV
"Reference","Value","Quantity"
"R1,R2,R3","10k","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Quantity' => 'Quantity'
];
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Mismatch between quantity and component references');
$this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
}
public function testStringToBOMEntriesKiCADSchematicWithBOM(): void
{
// Test with BOM (Byte Order Mark)
$input = "\xEF\xBB\xBF" . <<<CSV
"Reference","Value","Quantity"
"R1,R2","10k","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
}
private function getDefaultCategory(EntityManagerInterface $entityManager)
{
// Get the first available category or create a default one
$categoryRepo = $entityManager->getRepository(\App\Entity\Parts\Category::class);
$categories = $categoryRepo->findAll();
if (empty($categories)) {
// Create a default category if none exists
$category = new \App\Entity\Parts\Category();
$category->setName('Default Category');
$entityManager->persist($category);
$entityManager->flush();
return $category;
}
return $categories[0];
}
} }

View file

@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Services\ImportExportSystem\BOMValidationService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Services\ImportExportSystem\BOMValidationService
*/
class BOMValidationServiceTest extends WebTestCase
{
private BOMValidationService $validationService;
private EntityManagerInterface $entityManager;
private TranslatorInterface $translator;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->translator = self::getContainer()->get(TranslatorInterface::class);
$this->validationService = new BOMValidationService($this->entityManager, $this->translator);
}
public function testValidateBOMEntryWithValidData(): void
{
$entry = [
'Designator' => 'R1,C2,R3',
'Quantity' => '3',
'MPN' => 'RES-10K',
'Package' => '0603',
'Value' => '10k',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']);
$this->assertEmpty($result['errors']);
$this->assertEquals(1, $result['line_number']);
}
public function testValidateBOMEntryWithMissingRequiredFields(): void
{
$entry = [
'MPN' => 'RES-10K',
'Package' => '0603',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertCount(2, $result['errors']);
$this->assertStringContainsString('Designator', (string) $result['errors'][0]);
$this->assertStringContainsString('Quantity', (string) $result['errors'][1]);
}
public function testValidateBOMEntryWithQuantityMismatch(): void
{
$entry = [
'Designator' => 'R1,C2,R3,C4',
'Quantity' => '3',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertCount(1, $result['errors']);
$this->assertStringContainsString('Mismatch between quantity and component references', (string) $result['errors'][0]);
}
public function testValidateBOMEntryWithInvalidQuantity(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => 'abc',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('not a valid number', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithZeroQuantity(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '0',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('must be greater than 0', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithDuplicateDesignators(): void
{
$entry = [
'Designator' => 'R1,R1,C2',
'Quantity' => '3',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertCount(1, $result['errors']);
$this->assertStringContainsString('Duplicate component references', (string) $result['errors'][0]);
}
public function testValidateBOMEntryWithInvalidDesignatorFormat(): void
{
$entry = [
'Designator' => 'R1,invalid,C2',
'Quantity' => '3',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('unusual format', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithEmptyDesignator(): void
{
$entry = [
'Designator' => '',
'Quantity' => '1',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('Required field "Designator" is missing or empty', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithInvalidPartDBID(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Part-DB ID' => 'abc',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('not a valid number', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithNonExistentPartDBID(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Part-DB ID' => '999999', // Use very high ID that doesn't exist
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('not found in database', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithNoComponentName(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'Package' => '0603',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('No component name/designation', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithLongPackageName(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Package' => str_repeat('A', 150), // Very long package name
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('unusually long', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithLibraryPrefix(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Package' => 'Resistor_SMD:R_0603_1608Metric',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']);
$this->assertCount(1, $result['info']);
$this->assertStringContainsString('library prefix', $result['info'][0]);
}
public function testValidateBOMEntriesWithMultipleEntries(): void
{
$entries = [
[
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
],
[
'Designator' => 'C1,C2',
'Quantity' => '2',
'MPN' => 'CAP-100nF',
],
];
$result = $this->validationService->validateBOMEntries($entries);
$this->assertTrue($result['is_valid']);
$this->assertEquals(2, $result['total_entries']);
$this->assertEquals(2, $result['valid_entries']);
$this->assertEquals(0, $result['invalid_entries']);
$this->assertCount(2, $result['line_results']);
}
public function testValidateBOMEntriesWithMixedResults(): void
{
$entries = [
[
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
],
[
'Designator' => 'C1,C2',
'Quantity' => '1', // Mismatch
'MPN' => 'CAP-100nF',
],
];
$result = $this->validationService->validateBOMEntries($entries);
$this->assertFalse($result['is_valid']);
$this->assertEquals(2, $result['total_entries']);
$this->assertEquals(1, $result['valid_entries']);
$this->assertEquals(1, $result['invalid_entries']);
$this->assertCount(1, $result['errors']);
}
public function testGetValidationStats(): void
{
$validation_result = [
'total_entries' => 10,
'valid_entries' => 8,
'invalid_entries' => 2,
'errors' => ['Error 1', 'Error 2'],
'warnings' => ['Warning 1'],
'info' => ['Info 1', 'Info 2'],
];
$stats = $this->validationService->getValidationStats($validation_result);
$this->assertEquals(10, $stats['total_entries']);
$this->assertEquals(8, $stats['valid_entries']);
$this->assertEquals(2, $stats['invalid_entries']);
$this->assertEquals(2, $stats['error_count']);
$this->assertEquals(1, $stats['warning_count']);
$this->assertEquals(2, $stats['info_count']);
$this->assertEquals(80.0, $stats['success_rate']);
}
public function testGetErrorMessage(): void
{
$validation_result = [
'is_valid' => false,
'errors' => ['Error 1', 'Error 2'],
'warnings' => ['Warning 1'],
];
$message = $this->validationService->getErrorMessage($validation_result);
$this->assertStringContainsString('Errors:', $message);
$this->assertStringContainsString('• Error 1', $message);
$this->assertStringContainsString('• Error 2', $message);
$this->assertStringContainsString('Warnings:', $message);
$this->assertStringContainsString('• Warning 1', $message);
}
public function testGetErrorMessageWithValidResult(): void
{
$validation_result = [
'is_valid' => true,
'errors' => [],
'warnings' => [],
];
$message = $this->validationService->getErrorMessage($validation_result);
$this->assertEquals('', $message);
}
}

View file

@ -60,26 +60,29 @@ class TimestampableElementProviderTest extends WebTestCase
protected function setUp(): void protected function setUp(): void
{ {
self::bootKernel(); self::bootKernel();
\Locale::setDefault('en'); \Locale::setDefault('en_US');
$this->service = self::getContainer()->get(TimestampableElementProvider::class); $this->service = self::getContainer()->get(TimestampableElementProvider::class);
$this->target = new class () implements TimeStampableInterface { $this->target = new class () implements TimeStampableInterface {
public function getLastModified(): ?DateTime public function getLastModified(): ?DateTime
{ {
return new \DateTime('2000-01-01'); return new DateTime('2000-01-01');
} }
public function getAddedDate(): ?DateTime public function getAddedDate(): ?DateTime
{ {
return new \DateTime('2000-01-01'); return new DateTime('2000-01-01');
} }
}; };
} }
public static function dataProvider(): \Iterator public static function dataProvider(): \Iterator
{ {
\Locale::setDefault('en'); \Locale::setDefault('en_US');
yield ['1/1/00, 12:00 AM', '[[LAST_MODIFIED]]']; // Use IntlDateFormatter like the actual service does
yield ['1/1/00, 12:00 AM', '[[CREATION_DATE]]']; $formatter = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
$expectedFormat = $formatter->format(new DateTime('2000-01-01'));
yield [$expectedFormat, '[[LAST_MODIFIED]]'];
yield [$expectedFormat, '[[CREATION_DATE]]'];
} }
#[DataProvider('dataProvider')] #[DataProvider('dataProvider')]

View file

@ -580,7 +580,7 @@
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>storelocation.new</source> <source>storelocation.new</source>
<target>Nové místo skladování</target> <target>Nové umístění</target>
</segment> </segment>
</unit> </unit>
<unit id="Rt3eY_7" name="supplier.caption"> <unit id="Rt3eY_7" name="supplier.caption">
@ -913,7 +913,7 @@ Související prvky budou přesunuty nahoru.</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>edit.log_comment</source> <source>edit.log_comment</source>
<target>Změnit komentář</target> <target>Komentář ke změně</target>
</segment> </segment>
</unit> </unit>
<unit id="ZMmz8UB" name="entity.delete.recursive"> <unit id="ZMmz8UB" name="entity.delete.recursive">
@ -2502,7 +2502,7 @@ Související prvky budou přesunuty nahoru.</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>part.needs_review.badge</source> <source>part.needs_review.badge</source>
<target>Potřeba revize</target> <target>Vyžaduje kontrolu</target>
</segment> </segment>
</unit> </unit>
<unit id="IttGv57" name="part.favorite.badge"> <unit id="IttGv57" name="part.favorite.badge">
@ -4019,7 +4019,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>search.regexmatching</source> <source>search.regexmatching</source>
<target>RegEx. shoda</target> <target>Reg.Ex. shoda</target>
</segment> </segment>
</unit> </unit>
<unit id="U5IhkwB" name="search.submit"> <unit id="U5IhkwB" name="search.submit">
@ -4858,7 +4858,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>part.table.needsReview</source> <source>part.table.needsReview</source>
<target>Potřeba revize</target> <target>Vyžaduje kontrolu</target>
</segment> </segment>
</unit> </unit>
<unit id="AtzzLFz" name="part.table.favorite"> <unit id="AtzzLFz" name="part.table.favorite">
@ -5662,7 +5662,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>part.edit.needs_review</source> <source>part.edit.needs_review</source>
<target>Potřeba revize</target> <target>Vyžaduje kontrolu</target>
</segment> </segment>
</unit> </unit>
<unit id="TQbwkUd" name="part.edit.is_favorite"> <unit id="TQbwkUd" name="part.edit.is_favorite">
@ -6357,7 +6357,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>user.theme.label</source> <source>user.theme.label</source>
<target>Téma</target> <target>Vzhled</target>
</segment> </segment>
</unit> </unit>
<unit id="LQ7ihIX" name="user_settings.theme.placeholder"> <unit id="LQ7ihIX" name="user_settings.theme.placeholder">
@ -6368,7 +6368,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>user_settings.theme.placeholder</source> <source>user_settings.theme.placeholder</source>
<target>Serverové téma</target> <target>Vzhled pro celý server</target>
</segment> </segment>
</unit> </unit>
<unit id="ZkXKucz" name="log.user_login.ip"> <unit id="ZkXKucz" name="log.user_login.ip">
@ -8551,16 +8551,6 @@ Element 3</target>
<target>Authenticator app</target> <target>Authenticator app</target>
</segment> </segment>
</unit> </unit>
<unit id="fGkpjYW" name="Login successful">
<notes>
<note priority="1">obsolete</note>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<source>Login successful</source>
<target>Přihlášení bylo úspěšné</target>
</segment>
</unit>
<unit id="KSHVrbr" name="log.type.exception"> <unit id="KSHVrbr" name="log.type.exception">
<notes> <notes>
<note priority="1">obsolete</note> <note priority="1">obsolete</note>
@ -8686,15 +8676,6 @@ Element 3</target>
<target>Bezpečnostní klíč byl úspěšně přidán.</target> <target>Bezpečnostní klíč byl úspěšně přidán.</target>
</segment> </segment>
</unit> </unit>
<unit id="VhxhtYo" name="Username">
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<source>Username</source>
<target>Uživatelské jméno</target>
</segment>
</unit>
<unit id="gDVCAxj" name="log.type.security.google_disabled"> <unit id="gDVCAxj" name="log.type.security.google_disabled">
<notes> <notes>
<note category="state" priority="1">obsolete</note> <note category="state" priority="1">obsolete</note>
@ -9010,7 +8991,7 @@ Element 3</target>
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>%count% dílů vybráno!</target> <target>%count% dílů vybráno</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">
@ -9718,7 +9699,7 @@ Element 3</target>
<unit id="OEVzOkv" name="part_list.action.action.group.needs_review"> <unit id="OEVzOkv" name="part_list.action.action.group.needs_review">
<segment state="translated"> <segment state="translated">
<source>part_list.action.action.group.needs_review</source> <source>part_list.action.action.group.needs_review</source>
<target>Potřeba revize</target> <target>Vyžaduje kontrolu</target>
</segment> </segment>
</unit> </unit>
<unit id="nkoTW_w" name="part_list.action.action.set_needs_review"> <unit id="nkoTW_w" name="part_list.action.action.set_needs_review">
@ -10678,7 +10659,7 @@ Element 3</target>
<unit id="VraT.Lo" name="log.element_edited.changed_fields.theme"> <unit id="VraT.Lo" name="log.element_edited.changed_fields.theme">
<segment state="translated"> <segment state="translated">
<source>log.element_edited.changed_fields.theme</source> <source>log.element_edited.changed_fields.theme</source>
<target>Téma</target> <target>Vzhled</target>
</segment> </segment>
</unit> </unit>
<unit id="QFESysH" name="log.element_edited.changed_fields.timezone"> <unit id="QFESysH" name="log.element_edited.changed_fields.timezone">
@ -10774,7 +10755,7 @@ Element 3</target>
<unit id="iF9ovqi" name="log.element_edited.changed_fields.needs_review"> <unit id="iF9ovqi" name="log.element_edited.changed_fields.needs_review">
<segment state="translated"> <segment state="translated">
<source>log.element_edited.changed_fields.needs_review</source> <source>log.element_edited.changed_fields.needs_review</source>
<target>Potřeba revize</target> <target>Vyžaduje kontrolu</target>
</segment> </segment>
</unit> </unit>
<unit id="wgJmpYG" name="log.element_edited.changed_fields.tags"> <unit id="wgJmpYG" name="log.element_edited.changed_fields.tags">
@ -10984,7 +10965,7 @@ Element 3</target>
<unit id="awbvhVq" name="parts.import.help"> <unit id="awbvhVq" name="parts.import.help">
<segment state="translated"> <segment state="translated">
<source>parts.import.help</source> <source>parts.import.help</source>
<target>Pomocí tohoto nástroje můžete importovat díly z existujících souborů. Díly budou zapsány přímo do databáze, proto před nahráním souboru sem zkontrolujte, zda je jeho obsah správný.</target> <target>Pomocí tohoto nástroje můžete importovat součásti z existujících souborů. Součásti budou přímo zapsány do databáze, proto před nahráním souboru zkontrolujte jeho správný obsah.</target>
</segment> </segment>
</unit> </unit>
<unit id="5.sq5ns" name="parts.import.flash.success"> <unit id="5.sq5ns" name="parts.import.flash.success">
@ -11014,7 +10995,7 @@ Element 3</target>
<unit id="7dsEiOg" name="parts.import.part_needs_review.help"> <unit id="7dsEiOg" name="parts.import.part_needs_review.help">
<segment state="translated"> <segment state="translated">
<source>parts.import.part_needs_review.help</source> <source>parts.import.part_needs_review.help</source>
<target>Pokud je tato možnost vybrána, budou všechny díly označeny jako "Potřeba revize" bez ohledu na to, co bylo nastaveno v údajích.</target> <target>Pokud je tato možnost vybrána, budou všechny díly označeny jako "Vyžaduje kontrolu" bez ohledu na to, co bylo nastaveno v údajích.</target>
</segment> </segment>
</unit> </unit>
<unit id="Ie9LLKJ" name="project.bom_import.flash.success"> <unit id="Ie9LLKJ" name="project.bom_import.flash.success">
@ -12060,7 +12041,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="duBTELg" name="part.info.withdraw_modal.delete_lot_if_empty"> <unit id="duBTELg" name="part.info.withdraw_modal.delete_lot_if_empty">
<segment state="translated"> <segment state="translated">
<source>part.info.withdraw_modal.delete_lot_if_empty</source> <source>part.info.withdraw_modal.delete_lot_if_empty</source>
<target>Vymazat tento inventář, až se vyprázdní</target> <target>Smazat tuto položku, pokud se vyprázdní</target>
</segment> </segment>
</unit> </unit>
<unit id="SMclulD" name="info_providers.search.error.client_exception"> <unit id="SMclulD" name="info_providers.search.error.client_exception">
@ -12516,7 +12497,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id=".OyihML" name="settings.system.attachments.downloadByDefault"> <unit id=".OyihML" name="settings.system.attachments.downloadByDefault">
<segment state="translated"> <segment state="translated">
<source>settings.system.attachments.downloadByDefault</source> <source>settings.system.attachments.downloadByDefault</source>
<target>Ve výchozím nastavení stahovat nové adresy URL příloh</target> <target>Ve výchozím nastavení stahovat URL adresu pro nové přílohy</target>
</segment> </segment>
</unit> </unit>
<unit id="UuDCaUI" name="settings.system.customization"> <unit id="UuDCaUI" name="settings.system.customization">
@ -12528,7 +12509,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="VYMqQr5" name="settings.system.customization.instanceName"> <unit id="VYMqQr5" name="settings.system.customization.instanceName">
<segment state="translated"> <segment state="translated">
<source>settings.system.customization.instanceName</source> <source>settings.system.customization.instanceName</source>
<target>Instance name</target> <target>Název instance</target>
</segment> </segment>
</unit> </unit>
<unit id="0YFxSHZ" name="settings.system.customization.instanceName.help"> <unit id="0YFxSHZ" name="settings.system.customization.instanceName.help">
@ -12576,7 +12557,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="pinyqu2" name="settings.system.customization.theme"> <unit id="pinyqu2" name="settings.system.customization.theme">
<segment state="translated"> <segment state="translated">
<source>settings.system.customization.theme</source> <source>settings.system.customization.theme</source>
<target>Globální téma</target> <target>Globální vzhed</target>
</segment> </segment>
</unit> </unit>
<unit id="Aky9nXE" name="settings.system.history.enforceComments"> <unit id="Aky9nXE" name="settings.system.history.enforceComments">
@ -12642,7 +12623,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="8IszKgp" name="settings.system.privacy.useGravatar.description"> <unit id="8IszKgp" name="settings.system.privacy.useGravatar.description">
<segment state="translated"> <segment state="translated">
<source>settings.system.privacy.useGravatar.description</source> <source>settings.system.privacy.useGravatar.description</source>
<target>Pokud uživatel nemá nastavený avatar, použijte avatar z Gravataru na základě e-mailové adresy uživatele. To způsobí, že prohlížeč načte obrázky od třetí strany!</target> <target>Pokud uživatel nemá zadaný obrázek avatara, použije se avatar z Gravataru na základě e-mailu uživatele. To způsobí, že prohlížeč načte obrázky ze třetí strany!</target>
</segment> </segment>
</unit> </unit>
<unit id="rxHBzbv" name="settings.system.privacy.checkForUpdates"> <unit id="rxHBzbv" name="settings.system.privacy.checkForUpdates">
@ -12691,7 +12672,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="cvpTUeY" name="settings.system.privacy"> <unit id="cvpTUeY" name="settings.system.privacy">
<segment state="translated"> <segment state="translated">
<source>settings.system.privacy</source> <source>settings.system.privacy</source>
<target>Ochrana osobních údajů</target> <target>Soukromí</target>
</segment> </segment>
</unit> </unit>
<unit id="TVAVZUl" name="settings.title"> <unit id="TVAVZUl" name="settings.title">
@ -13054,5 +13035,449 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<target>Z bezpečnostních důvodů redigováno</target> <target>Z bezpečnostních důvodů redigováno</target>
</segment> </segment>
</unit> </unit>
<unit id="J716Oh4" name="project.bom_import.map_fields">
<segment state="translated">
<source>project.bom_import.map_fields</source>
<target>Mapa polí</target>
</segment>
</unit>
<unit id="wvT5Xvn" name="project.bom_import.map_fields.help">
<segment state="translated">
<source>project.bom_import.map_fields.help</source>
<target>Nakonfigurujte, jak se sloupce CSV mapují na pole kusovníku</target>
</segment>
</unit>
<unit id="nh7uLPe" name="project.bom_import.delimiter">
<segment state="translated">
<source>project.bom_import.delimiter</source>
<target>Oddělovač</target>
</segment>
</unit>
<unit id="MSlSeE4" name="project.bom_import.delimiter.comma">
<segment state="translated">
<source>project.bom_import.delimiter.comma</source>
<target>Čárka (,)</target>
</segment>
</unit>
<unit id="UdDSB0h" name="project.bom_import.delimiter.semicolon">
<segment state="translated">
<source>project.bom_import.delimiter.semicolon</source>
<target>Středník (;)</target>
</segment>
</unit>
<unit id="Wpa4Gmf" name="project.bom_import.delimiter.tab">
<segment state="translated">
<source>project.bom_import.delimiter.tab</source>
<target>Tabulátor</target>
</segment>
</unit>
<unit id="tjrG_vU" name="project.bom_import.field_mapping.title">
<segment state="translated">
<source>project.bom_import.field_mapping.title</source>
<target>Mapování pole</target>
</segment>
</unit>
<unit id="nWu8B7R" name="project.bom_import.field_mapping.csv_field">
<segment state="translated">
<source>project.bom_import.field_mapping.csv_field</source>
<target>CSV pole</target>
</segment>
</unit>
<unit id="D0KlFqm" name="project.bom_import.field_mapping.maps_to">
<segment state="translated">
<source>project.bom_import.field_mapping.maps_to</source>
<target>Mapy do</target>
</segment>
</unit>
<unit id="rhUpmxC" name="project.bom_import.field_mapping.suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.suggestion</source>
<target>Návrh</target>
</segment>
</unit>
<unit id="TM0mgKr" name="project.bom_import.field_mapping.priority">
<segment state="translated">
<source>project.bom_import.field_mapping.priority</source>
<target>Priorita</target>
</segment>
</unit>
<unit id="xY8NSRr" name="project.bom_import.field_mapping.priority_help">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_help</source>
<target>Priorita (nižší číslo = vyšší priorita)</target>
</segment>
</unit>
<unit id="CuXg8WU" name="project.bom_import.field_mapping.priority_short">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_short</source>
<target>P</target>
</segment>
</unit>
<unit id="jOHOShN" name="project.bom_import.field_mapping.priority_note">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_note</source>
<target>Tip k prioritám: Čím nižší číslo, tím vyšší priorita. Výchozí priorita je 10. Pro nejdůležitější pole použijte priority 1-9, pro normální prioritu 10+.</target>
</segment>
</unit>
<unit id="pvmv3OT" name="project.bom_import.field_mapping.summary">
<segment state="translated">
<source>project.bom_import.field_mapping.summary</source>
<target>Shrnutí mapování polí</target>
</segment>
</unit>
<unit id="VjLf1Pf" name="project.bom_import.field_mapping.select_to_see_summary">
<segment state="translated">
<source>project.bom_import.field_mapping.select_to_see_summary</source>
<target>Vyberte mapování polí, abyste viděli souhrn</target>
</segment>
</unit>
<unit id="JOPny6T" name="project.bom_import.field_mapping.no_suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.no_suggestion</source>
<target>Žádný návrh</target>
</segment>
</unit>
<unit id="1LWEtqL" name="project.bom_import.preview">
<segment state="translated">
<source>project.bom_import.preview</source>
<target>Náhled</target>
</segment>
</unit>
<unit id="1CJA0aY" name="project.bom_import.flash.session_expired">
<segment state="translated">
<source>project.bom_import.flash.session_expired</source>
<target>Importní relace vypršela. Nahrajte soubor znovu.</target>
</segment>
</unit>
<unit id="BLN7XRN" name="project.bom_import.field_mapping.ignore">
<segment state="translated">
<source>project.bom_import.field_mapping.ignore</source>
<target>Ignorovat</target>
</segment>
</unit>
<unit id="yiIB3yV" name="project.bom_import.type.kicad_schematic">
<segment state="translated">
<source>project.bom_import.type.kicad_schematic</source>
<target>KiCAD schéma BOM (soubor CSV)</target>
</segment>
</unit>
<unit id="ltE6xPP" name="common.back">
<segment state="translated">
<source>common.back</source>
<target>Zpět</target>
</segment>
</unit>
<unit id="HCTqjZO" name="project.bom_import.validation.errors.required_field_missing">
<segment state="translated">
<source>project.bom_import.validation.errors.required_field_missing</source>
<target>Řádek %line%: Povinné pole "%field%" chybí nebo je prázdné. Ujistěte se, že je toto pole namapováno a obsahuje data.</target>
</segment>
</unit>
<unit id="_ua5YM7" name="project.bom_import.validation.errors.no_valid_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.no_valid_designators</source>
<target>Řádek %line%: Pole označení neobsahuje žádné platné odkazy na komponenty. Očekávaný formát: „R1,C2,U3“ nebo „R1, C2, U3“.</target>
</segment>
</unit>
<unit id="xpkq3rW" name="project.bom_import.validation.warnings.unusual_designator_format">
<segment state="translated">
<source>project.bom_import.validation.warnings.unusual_designator_format</source>
<target>Řádek %line%: Některé odkazy na komponenty mohou mít neobvyklý formát: %designators%. Očekávaný formát: "R1", "C2", "U3" atd.</target>
</segment>
</unit>
<unit id="M54Ud5d" name="project.bom_import.validation.errors.duplicate_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.duplicate_designators</source>
<target>Řádek %line%: Nalezeny duplicitní odkazy na komponenty: %designators%. Každá komponenta by měla být v každém řádku uvedena pouze jednou.</target>
</segment>
</unit>
<unit id="ZULFZh2" name="project.bom_import.validation.errors.invalid_quantity">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_quantity</source>
<target>Řádek %line%: Množství „%quantity%“ není platné číslo. Zadejte číselnou hodnotu (např. 1, 2.5, 10).</target>
</segment>
</unit>
<unit id="3WbRgsl" name="project.bom_import.validation.errors.quantity_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_zero_or_negative</source>
<target>Řádek %line%: Množství musí být větší než 0, máte %quantity%.</target>
</segment>
</unit>
<unit id="_nWGsSU" name="project.bom_import.validation.warnings.quantity_unusually_high">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_unusually_high</source>
<target>Řádek %line%: Množství %quantity% se jeví jako neobvykle vysoké. Ověřte prosím, zda je správné.</target>
</segment>
</unit>
<unit id="iJWw39f" name="project.bom_import.validation.warnings.quantity_not_whole_number">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_not_whole_number</source>
<target>Řádek %line%: Množství %quantity% není celé číslo, ale máte %count% odkazů na komponenty. To může znamenat nesoulad.</target>
</segment>
</unit>
<unit id="Zffmgvv" name="project.bom_import.validation.errors.quantity_designator_mismatch">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_designator_mismatch</source>
<target>Řádek %line%: Nesoulad mezi množstvím a odkazy na součásti. Množství: %quantity%, odkazy: %count% (%designators%). Tyto hodnoty by se měly shodovat. Upravte množství nebo zkontrolujte odkazy na součásti.</target>
</segment>
</unit>
<unit id="JqHpDIo" name="project.bom_import.validation.errors.invalid_partdb_id">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_partdb_id</source>
<target>Řádek %line%: ID části databáze „%id%“ není platné číslo. Zadejte číselné ID.</target>
</segment>
</unit>
<unit id="Mr7W6r0" name="project.bom_import.validation.errors.partdb_id_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.partdb_id_zero_or_negative</source>
<target>Řádek %line%: ID části DB musí být větší než 0, nalezeno %id%.</target>
</segment>
</unit>
<unit id="gRH6IeR" name="project.bom_import.validation.warnings.partdb_id_not_found">
<segment state="translated">
<source>project.bom_import.validation.warnings.partdb_id_not_found</source>
<target>Řádek %line%: ID dílu %id% nebylo nalezeno v databázi. Díl bude importována bez propojení s existujícím dílem.</target>
</segment>
</unit>
<unit id="eMfaBYo" name="project.bom_import.validation.info.partdb_link_success">
<segment state="translated">
<source>project.bom_import.validation.info.partdb_link_success</source>
<target>Řádek %line%: Úspěšně propojeno s částí Part-DB „%name%“ (ID: %id%).</target>
</segment>
</unit>
<unit id="b_m9p.f" name="project.bom_import.validation.warnings.no_component_name">
<segment state="translated">
<source>project.bom_import.validation.warnings.no_component_name</source>
<target>Řádek %line%: Není uvedeno žádné jméno/označení komponenty (MPN, označení nebo hodnota). Komponenta bude pojmenována "Neznámá komponenta".</target>
</segment>
</unit>
<unit id=".LAGdMe" name="project.bom_import.validation.warnings.package_name_too_long">
<segment state="translated">
<source>project.bom_import.validation.warnings.package_name_too_long</source>
<target>Řádek %line%: Název balíčku „%package%“ je neobvykle dlouhý. Zkontrolujte, zda je správný.</target>
</segment>
</unit>
<unit id="wFF49kl" name="project.bom_import.validation.info.library_prefix_detected">
<segment state="translated">
<source>project.bom_import.validation.info.library_prefix_detected</source>
<target>Řádek %line%: Balíček „%package%“ obsahuje předponu knihovny. Ta bude během importu automaticky odstraněna.</target>
</segment>
</unit>
<unit id="hEO2sxC" name="project.bom_import.validation.errors.non_numeric_field">
<segment state="translated">
<source>project.bom_import.validation.errors.non_numeric_field</source>
<target>Řádek %line%: Pole "%field%" obsahuje nečíselnou hodnotu "%value%". Zadejte platné číslo.</target>
</segment>
</unit>
<unit id="Ear_sUX" name="project.bom_import.validation.info.import_summary">
<segment state="translated">
<source>project.bom_import.validation.info.import_summary</source>
<target>Souhrn importu: %total% celkový počet záznamů, %valid% platných, %invalid% s problémy.</target>
</segment>
</unit>
<unit id="SqJZ97A" name="project.bom_import.validation.errors.summary">
<segment state="translated">
<source>project.bom_import.validation.errors.summary</source>
<target>Bylo nalezeno %count% chyb ověření, které je nutné opravit, než bude možné pokračovat v importu.</target>
</segment>
</unit>
<unit id="s7dzFnp" name="project.bom_import.validation.warnings.summary">
<segment state="translated">
<source>project.bom_import.validation.warnings.summary</source>
<target>Nalezeno %count% varování. Před pokračováním prosím zkontrolujte tyto problémy.</target>
</segment>
</unit>
<unit id="2KaAHmS" name="project.bom_import.validation.info.all_valid">
<segment state="translated">
<source>project.bom_import.validation.info.all_valid</source>
<target>Všechny záznamy úspěšně prošly validací!</target>
</segment>
</unit>
<unit id="Lyb3BjK" name="project.bom_import.validation.summary">
<segment state="translated">
<source>project.bom_import.validation.summary</source>
<target>Souhrn validace</target>
</segment>
</unit>
<unit id="DUM8GoE" name="project.bom_import.validation.total_entries">
<segment state="translated">
<source>project.bom_import.validation.total_entries</source>
<target>Celkový počet záznamů</target>
</segment>
</unit>
<unit id="AtLGGU2" name="project.bom_import.validation.valid_entries">
<segment state="translated">
<source>project.bom_import.validation.valid_entries</source>
<target>Platné záznamy</target>
</segment>
</unit>
<unit id="mgdn5v3" name="project.bom_import.validation.invalid_entries">
<segment state="translated">
<source>project.bom_import.validation.invalid_entries</source>
<target>Neplatné záznamy</target>
</segment>
</unit>
<unit id="qmJ5BfF" name="project.bom_import.validation.success_rate">
<segment state="translated">
<source>project.bom_import.validation.success_rate</source>
<target>Success Rate</target>
</segment>
</unit>
<unit id="QyDsrFV" name="project.bom_import.validation.errors.title">
<segment state="translated">
<source>project.bom_import.validation.errors.title</source>
<target>Úspěšnost</target>
</segment>
</unit>
<unit id="d.Mvu0a" name="project.bom_import.validation.errors.description">
<segment state="translated">
<source>project.bom_import.validation.errors.description</source>
<target>Před pokračováním v importu je nutné opravit následující chyby:</target>
</segment>
</unit>
<unit id="MGFfKr." name="project.bom_import.validation.warnings.title">
<segment state="translated">
<source>project.bom_import.validation.warnings.title</source>
<target>Výstrahy validace</target>
</segment>
</unit>
<unit id="tt4VxY6" name="project.bom_import.validation.warnings.description">
<segment state="translated">
<source>project.bom_import.validation.warnings.description</source>
<target>Před pokračováním si přečtěte následující varování:</target>
</segment>
</unit>
<unit id="YuAdYeh" name="project.bom_import.validation.info.title">
<segment state="translated">
<source>project.bom_import.validation.info.title</source>
<target>Informace</target>
</segment>
</unit>
<unit id="1pUfi7Q" name="project.bom_import.validation.details.title">
<segment state="translated">
<source>project.bom_import.validation.details.title</source>
<target>Podrobné výsledky ověření</target>
</segment>
</unit>
<unit id="4Vv0Xns" name="project.bom_import.validation.details.line">
<segment state="translated">
<source>project.bom_import.validation.details.line</source>
<target>Řádek</target>
</segment>
</unit>
<unit id="RbvD2zF" name="project.bom_import.validation.details.status">
<segment state="translated">
<source>project.bom_import.validation.details.status</source>
<target>Stav</target>
</segment>
</unit>
<unit id="iGtKkH_" name="project.bom_import.validation.details.messages">
<segment state="translated">
<source>project.bom_import.validation.details.messages</source>
<target>Zprávy</target>
</segment>
</unit>
<unit id="IgdgZd8" name="project.bom_import.validation.details.valid">
<segment state="translated">
<source>project.bom_import.validation.details.valid</source>
<target>Platný</target>
</segment>
</unit>
<unit id="lWQEtkq" name="project.bom_import.validation.details.invalid">
<segment state="translated">
<source>project.bom_import.validation.details.invalid</source>
<target>Neplatný</target>
</segment>
</unit>
<unit id="tPn6Ind" name="project.bom_import.validation.all_valid">
<segment state="translated">
<source>project.bom_import.validation.all_valid</source>
<target>Všechny položky jsou platné a připravené k importu!</target>
</segment>
</unit>
<unit id="fzoGCaf" name="project.bom_import.validation.fix_errors">
<segment state="translated">
<source>project.bom_import.validation.fix_errors</source>
<target>Před pokračováním v importu opravte chyby ověření.</target>
</segment>
</unit>
<unit id="nc9MqUc" name="project.bom_import.type.generic_csv">
<segment state="translated">
<source>project.bom_import.type.generic_csv</source>
<target>Obecný CSV</target>
</segment>
</unit>
<unit id=".N35Pvs" name="label_generator.update_profile">
<segment state="translated">
<source>label_generator.update_profile</source>
<target>Aktualizovat profil s aktuálním nastavením</target>
</segment>
</unit>
<unit id="ulTo6Aa" name="label_generator.profile_updated">
<segment state="translated">
<source>label_generator.profile_updated</source>
<target>Profil štítku byl úspěšně aktualizován.</target>
</segment>
</unit>
<unit id="7lgFa7I" name="settings.behavior.hompepage.items">
<segment state="translated">
<source>settings.behavior.hompepage.items</source>
<target>Položky na domovské stránce</target>
</segment>
</unit>
<unit id="FsrRdkp" name="settings.behavior.homepage.items.help">
<segment state="translated">
<source>settings.behavior.homepage.items.help</source>
<target>Položky, které se mají zobrazit na domovské stránce. Pořadí lze změnit pomocí funkce drag &amp; drop.</target>
</segment>
</unit>
<unit id="CYw3_pS" name="settings.system.customization.showVersionOnHomepage">
<segment state="translated">
<source>settings.system.customization.showVersionOnHomepage</source>
<target>Zobrazit verzi Part-DB na domovské stránce</target>
</segment>
</unit>
<unit id="GLYhV9m" name="settings.behavior.part_info.extract_params_from_description">
<segment state="translated">
<source>settings.behavior.part_info.extract_params_from_description</source>
<target>Extrahovat parametry z popisu dílu</target>
</segment>
</unit>
<unit id="aYOedkN" name="settings.behavior.part_info.extract_params_from_notes">
<segment state="translated">
<source>settings.behavior.part_info.extract_params_from_notes</source>
<target>Extrahovat parametry z poznámek k dílům</target>
</segment>
</unit>
<unit id="nCH2MW6" name="settings.ips.default_providers">
<segment state="translated">
<source>settings.ips.default_providers</source>
<target>Výchozí poskytovatelé vyhledávání</target>
</segment>
</unit>
<unit id="TLNoCLT" name="settings.ips.general">
<segment state="translated">
<source>settings.ips.general</source>
<target>Obecná nastavení</target>
</segment>
</unit>
<unit id="IDs2sXK" name="settings.ips.default_providers.help">
<segment state="translated">
<source>settings.ips.default_providers.help</source>
<target>Tito poskytovatelé budou předem vybráni pro vyhledávání v části poskytovatelé.</target>
</segment>
</unit>
<unit id="dv6eslZ" name="settings.behavior.table.preview_image_max_width">
<segment state="translated">
<source>settings.behavior.table.preview_image_max_width</source>
<target>Maximální šířka náhledu (px)</target>
</segment>
</unit>
<unit id="5bOoqEL" name="settings.behavior.table.preview_image_min_width">
<segment state="translated">
<source>settings.behavior.table.preview_image_min_width</source>
<target>Minimální šířka náhledu (px)</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>

View file

@ -6504,7 +6504,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>flash.password_change_needed</source> <source>flash.password_change_needed</source>
<target>Ihr Password muss geändert werden!</target> <target>Ihr Passwort muss geändert werden!</target>
</segment> </segment>
</unit> </unit>
<unit id="8I8zHPK" name="attachment.table.type"> <unit id="8I8zHPK" name="attachment.table.type">
@ -7157,8 +7157,14 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
<segment state="translated"> <segment state="translated">
<source>mass_creation.lines.placeholder</source> <source>mass_creation.lines.placeholder</source>
<target>Element 1 <target>Element 1
Element 1.1
Element 1.1.1
Element 1.2
Element 2 Element 2
Element 3</target> Element 3
Element 1 -&gt; Element 1.1
Element 1 -&gt; Element 1.2</target>
</segment> </segment>
</unit> </unit>
<unit id="TWSqPFi" name="entity.mass_creation.btn"> <unit id="TWSqPFi" name="entity.mass_creation.btn">
@ -8547,16 +8553,6 @@ Element 3</target>
<target>Authenticator App</target> <target>Authenticator App</target>
</segment> </segment>
</unit> </unit>
<unit id="fGkpjYW" name="Login successful">
<notes>
<note priority="1">obsolete</note>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<source>Login successful</source>
<target>Login erfolgreich.</target>
</segment>
</unit>
<unit id="KSHVrbr" name="log.type.exception"> <unit id="KSHVrbr" name="log.type.exception">
<notes> <notes>
<note priority="1">obsolete</note> <note priority="1">obsolete</note>
@ -8682,15 +8678,6 @@ Element 3</target>
<target>Sicherheitsschlüssel erfolgreich hinzugefügt.</target> <target>Sicherheitsschlüssel erfolgreich hinzugefügt.</target>
</segment> </segment>
</unit> </unit>
<unit id="VhxhtYo" name="Username">
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<source>Username</source>
<target>Benutzername</target>
</segment>
</unit>
<unit id="gDVCAxj" name="log.type.security.google_disabled"> <unit id="gDVCAxj" name="log.type.security.google_disabled">
<notes> <notes>
<note category="state" priority="1">obsolete</note> <note category="state" priority="1">obsolete</note>
@ -9006,7 +8993,7 @@ Element 3</target>
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>%count% Bauteile ausgewählt!</target> <target>%count% Bauteile ausgewählt</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">
@ -12921,7 +12908,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity"> <unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
<segment state="translated"> <segment state="translated">
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source> <source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>
<target>Wurzelknoten leitet zur Erstellung eines neuen Elements weiter</target> <target>Stammknoten leitet zur Erstellung eines neuen Elements weiter</target>
</segment> </segment>
</unit> </unit>
<unit id="j7HiQ80" name="settings.ips.digikey"> <unit id="j7HiQ80" name="settings.ips.digikey">
@ -13050,5 +13037,449 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<target>Aus Sicherheitsgründen ausgeblendet</target> <target>Aus Sicherheitsgründen ausgeblendet</target>
</segment> </segment>
</unit> </unit>
<unit id="J716Oh4" name="project.bom_import.map_fields">
<segment state="translated">
<source>project.bom_import.map_fields</source>
<target>Spalten zuordnen</target>
</segment>
</unit>
<unit id="wvT5Xvn" name="project.bom_import.map_fields.help">
<segment state="translated">
<source>project.bom_import.map_fields.help</source>
<target>Wählen Sie aus, wie CSV Spalten auf BOM Felder gemappt werden</target>
</segment>
</unit>
<unit id="nh7uLPe" name="project.bom_import.delimiter">
<segment state="translated">
<source>project.bom_import.delimiter</source>
<target>Trennzeichen</target>
</segment>
</unit>
<unit id="MSlSeE4" name="project.bom_import.delimiter.comma">
<segment state="translated">
<source>project.bom_import.delimiter.comma</source>
<target>Komma (,)</target>
</segment>
</unit>
<unit id="UdDSB0h" name="project.bom_import.delimiter.semicolon">
<segment state="translated">
<source>project.bom_import.delimiter.semicolon</source>
<target>Semikolon (;)</target>
</segment>
</unit>
<unit id="Wpa4Gmf" name="project.bom_import.delimiter.tab">
<segment state="translated">
<source>project.bom_import.delimiter.tab</source>
<target>Tab</target>
</segment>
</unit>
<unit id="tjrG_vU" name="project.bom_import.field_mapping.title">
<segment state="translated">
<source>project.bom_import.field_mapping.title</source>
<target>Spaltenzuordnung</target>
</segment>
</unit>
<unit id="nWu8B7R" name="project.bom_import.field_mapping.csv_field">
<segment state="translated">
<source>project.bom_import.field_mapping.csv_field</source>
<target>CSV Spalte</target>
</segment>
</unit>
<unit id="D0KlFqm" name="project.bom_import.field_mapping.maps_to">
<segment state="translated">
<source>project.bom_import.field_mapping.maps_to</source>
<target>Mappt auf</target>
</segment>
</unit>
<unit id="rhUpmxC" name="project.bom_import.field_mapping.suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.suggestion</source>
<target>Vorschlag</target>
</segment>
</unit>
<unit id="TM0mgKr" name="project.bom_import.field_mapping.priority">
<segment state="translated">
<source>project.bom_import.field_mapping.priority</source>
<target>Priorität</target>
</segment>
</unit>
<unit id="xY8NSRr" name="project.bom_import.field_mapping.priority_help">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_help</source>
<target>Priorität (kleinere Nummer = höhere Priorität)</target>
</segment>
</unit>
<unit id="CuXg8WU" name="project.bom_import.field_mapping.priority_short">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_short</source>
<target>P</target>
</segment>
</unit>
<unit id="jOHOShN" name="project.bom_import.field_mapping.priority_note">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_note</source>
<target>Prioritätstipp: Niedrigere Zahlen = höhere Priorität. Die Standardpriorität ist 10. Verwenden Sie die Prioritäten 19 für die wichtigsten Felder und 10+ für normale Priorität.</target>
</segment>
</unit>
<unit id="pvmv3OT" name="project.bom_import.field_mapping.summary">
<segment state="translated">
<source>project.bom_import.field_mapping.summary</source>
<target>Zusammenfassung der Zuordnung</target>
</segment>
</unit>
<unit id="VjLf1Pf" name="project.bom_import.field_mapping.select_to_see_summary">
<segment state="translated">
<source>project.bom_import.field_mapping.select_to_see_summary</source>
<target>Wählen Sie Zuordnungen aus, um eine Zusammenfassung anzuzeigen.</target>
</segment>
</unit>
<unit id="JOPny6T" name="project.bom_import.field_mapping.no_suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.no_suggestion</source>
<target>Kein Vorschlag</target>
</segment>
</unit>
<unit id="1LWEtqL" name="project.bom_import.preview">
<segment state="translated">
<source>project.bom_import.preview</source>
<target>Vorschau</target>
</segment>
</unit>
<unit id="1CJA0aY" name="project.bom_import.flash.session_expired">
<segment state="translated">
<source>project.bom_import.flash.session_expired</source>
<target>Die Import-Sitzung ist abgelaufen. Bitte laden Sie Ihre Datei erneut hoch.</target>
</segment>
</unit>
<unit id="BLN7XRN" name="project.bom_import.field_mapping.ignore">
<segment state="translated">
<source>project.bom_import.field_mapping.ignore</source>
<target>Ignorieren</target>
</segment>
</unit>
<unit id="yiIB3yV" name="project.bom_import.type.kicad_schematic">
<segment state="translated">
<source>project.bom_import.type.kicad_schematic</source>
<target>KiCAD Schaltplaneditor BOM (CSV Datei)</target>
</segment>
</unit>
<unit id="ltE6xPP" name="common.back">
<segment state="translated">
<source>common.back</source>
<target>Zurück</target>
</segment>
</unit>
<unit id="HCTqjZO" name="project.bom_import.validation.errors.required_field_missing">
<segment state="translated">
<source>project.bom_import.validation.errors.required_field_missing</source>
<target>Zeile %line%: Das Pflichtfeld „%field%“ fehlt oder ist leer. Bitte stellen Sie sicher, dass dieses Feld zugeordnet ist und Daten enthält.</target>
</segment>
</unit>
<unit id="_ua5YM7" name="project.bom_import.validation.errors.no_valid_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.no_valid_designators</source>
<target>Zeile %line%: Das Bezeichnungsfeld enthält keine gültigen Komponentenreferenzen. Erwartetes Format: „R1,C2,U3“ oder „R1, C2, U3“.</target>
</segment>
</unit>
<unit id="xpkq3rW" name="project.bom_import.validation.warnings.unusual_designator_format">
<segment state="translated">
<source>project.bom_import.validation.warnings.unusual_designator_format</source>
<target>Zeile %line%: Einige Komponentenreferenzen haben möglicherweise ein ungewöhnliches Format: %designators%. Erwartetes Format: „R1“, „C2“, „U3“ usw.</target>
</segment>
</unit>
<unit id="M54Ud5d" name="project.bom_import.validation.errors.duplicate_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.duplicate_designators</source>
<target>Zeile %line%: Doppelte Komponentenreferenzen gefunden: %designators%. Jede Komponente sollte nur einmal pro Zeile referenziert werden.</target>
</segment>
</unit>
<unit id="ZULFZh2" name="project.bom_import.validation.errors.invalid_quantity">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_quantity</source>
<target>Zeile %line%: Die Menge „%quantity%“ ist keine gültige Zahl. Bitte geben Sie einen numerischen Wert ein (z. B. 1, 2,5, 10).</target>
</segment>
</unit>
<unit id="3WbRgsl" name="project.bom_import.validation.errors.quantity_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_zero_or_negative</source>
<target>Zeile %line%: Die Menge muss größer als 0 sein, erhaltene Menge %quantity%.</target>
</segment>
</unit>
<unit id="_nWGsSU" name="project.bom_import.validation.warnings.quantity_unusually_high">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_unusually_high</source>
<target>Zeile %line%: Die Menge %quantity% erscheint ungewöhnlich hoch. Bitte überprüfen Sie, ob dies korrekt ist.</target>
</segment>
</unit>
<unit id="iJWw39f" name="project.bom_import.validation.warnings.quantity_not_whole_number">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_not_whole_number</source>
<target>Zeile %line%: Die Menge %quantity% ist keine ganze Zahl, aber Sie haben %count% Komponentenreferenzen. Dies kann auf eine Nichtübereinstimmung hindeuten.</target>
</segment>
</unit>
<unit id="Zffmgvv" name="project.bom_import.validation.errors.quantity_designator_mismatch">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_designator_mismatch</source>
<target>Zeile %line%: Diskrepanz zwischen Menge und Komponentenreferenzen. Menge: %quantity%, Referenzen: %count% (%designators%). Diese sollten übereinstimmen. Passen Sie entweder die Menge an oder überprüfen Sie Ihre Komponentenreferenzen.</target>
</segment>
</unit>
<unit id="JqHpDIo" name="project.bom_import.validation.errors.invalid_partdb_id">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_partdb_id</source>
<target>Zeile %line%: Part-DB ID „%id%“ ist keine gültige Zahl. Bitte geben Sie eine numerische ID ein.</target>
</segment>
</unit>
<unit id="Mr7W6r0" name="project.bom_import.validation.errors.partdb_id_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.partdb_id_zero_or_negative</source>
<target>Zeile %line%: Die Part-DB ID muss größer als 0 sein, erhaltene ID lautet %id%.</target>
</segment>
</unit>
<unit id="gRH6IeR" name="project.bom_import.validation.warnings.partdb_id_not_found">
<segment state="translated">
<source>project.bom_import.validation.warnings.partdb_id_not_found</source>
<target>Zeile %line%: Teil-DB-ID %id% nicht in der Datenbank gefunden. Die Komponente wird ohne Verknüpfung mit einem vorhandenen Teil importiert.</target>
</segment>
</unit>
<unit id="eMfaBYo" name="project.bom_import.validation.info.partdb_link_success">
<segment state="translated">
<source>project.bom_import.validation.info.partdb_link_success</source>
<target>Zeile %line%: Erfolgreich mit dem Bauteil „%name%“ (ID: %id%) verknüpft.</target>
</segment>
</unit>
<unit id="b_m9p.f" name="project.bom_import.validation.warnings.no_component_name">
<segment state="translated">
<source>project.bom_import.validation.warnings.no_component_name</source>
<target>Zeile %line%: Kein Komponentenname/keine Komponentenbezeichnung angegeben (MPN, Bezeichnung oder Wert). Die Komponente wird als „Unbekanntes Bauteil” bezeichnet.</target>
</segment>
</unit>
<unit id=".LAGdMe" name="project.bom_import.validation.warnings.package_name_too_long">
<segment state="translated">
<source>project.bom_import.validation.warnings.package_name_too_long</source>
<target>Zeile %line%: Der Footprintname „%package%“ ist ungewöhnlich lang. Bitte überprüfen Sie, ob er korrekt ist.</target>
</segment>
</unit>
<unit id="wFF49kl" name="project.bom_import.validation.info.library_prefix_detected">
<segment state="translated">
<source>project.bom_import.validation.info.library_prefix_detected</source>
<target>Zeile %line%: Das Footprint „%package%“ enthält ein Bibliothekspräfix. Dieses wird beim Import automatisch entfernt.</target>
</segment>
</unit>
<unit id="hEO2sxC" name="project.bom_import.validation.errors.non_numeric_field">
<segment state="translated">
<source>project.bom_import.validation.errors.non_numeric_field</source>
<target>Zeile %line%: Das Feld „%field%“ enthält den nicht numerischen Wert „%value%“. Bitte geben Sie eine gültige Zahl ein.</target>
</segment>
</unit>
<unit id="Ear_sUX" name="project.bom_import.validation.info.import_summary">
<segment state="translated">
<source>project.bom_import.validation.info.import_summary</source>
<target>Importübersicht: %total% Einträge insgesamt, %valid% gültig, %invalid% mit Problemen.</target>
</segment>
</unit>
<unit id="SqJZ97A" name="project.bom_import.validation.errors.summary">
<segment state="translated">
<source>project.bom_import.validation.errors.summary</source>
<target>Es wurden %count% Validierungsfehler gefunden, die behoben werden müssen, bevor der Import fortgesetzt werden kann.</target>
</segment>
</unit>
<unit id="s7dzFnp" name="project.bom_import.validation.warnings.summary">
<segment state="translated">
<source>project.bom_import.validation.warnings.summary</source>
<target>Es wurden %count% Warnungen gefunden. Bitte überprüfen Sie diese Probleme, bevor Sie fortfahren.</target>
</segment>
</unit>
<unit id="2KaAHmS" name="project.bom_import.validation.info.all_valid">
<segment state="translated">
<source>project.bom_import.validation.info.all_valid</source>
<target>Alle Einträge haben die Validierung erfolgreich bestanden!</target>
</segment>
</unit>
<unit id="Lyb3BjK" name="project.bom_import.validation.summary">
<segment state="translated">
<source>project.bom_import.validation.summary</source>
<target>Validierungsübersicht</target>
</segment>
</unit>
<unit id="DUM8GoE" name="project.bom_import.validation.total_entries">
<segment state="translated">
<source>project.bom_import.validation.total_entries</source>
<target>Gesamtzahl der Einträge</target>
</segment>
</unit>
<unit id="AtLGGU2" name="project.bom_import.validation.valid_entries">
<segment state="translated">
<source>project.bom_import.validation.valid_entries</source>
<target>Gültige Einträge</target>
</segment>
</unit>
<unit id="mgdn5v3" name="project.bom_import.validation.invalid_entries">
<segment state="translated">
<source>project.bom_import.validation.invalid_entries</source>
<target>Ungültige Einträge</target>
</segment>
</unit>
<unit id="qmJ5BfF" name="project.bom_import.validation.success_rate">
<segment state="translated">
<source>project.bom_import.validation.success_rate</source>
<target>Erfolgsquote</target>
</segment>
</unit>
<unit id="QyDsrFV" name="project.bom_import.validation.errors.title">
<segment state="translated">
<source>project.bom_import.validation.errors.title</source>
<target>Validierungsfehler</target>
</segment>
</unit>
<unit id="d.Mvu0a" name="project.bom_import.validation.errors.description">
<segment state="translated">
<source>project.bom_import.validation.errors.description</source>
<target>Die folgenden Fehler müssen behoben werden, bevor der Import fortgesetzt werden kann:</target>
</segment>
</unit>
<unit id="MGFfKr." name="project.bom_import.validation.warnings.title">
<segment state="translated">
<source>project.bom_import.validation.warnings.title</source>
<target>Validierungswarnungen</target>
</segment>
</unit>
<unit id="tt4VxY6" name="project.bom_import.validation.warnings.description">
<segment state="translated">
<source>project.bom_import.validation.warnings.description</source>
<target>Die folgenden Warnhinweise sollten vor dem Fortfahren gelesen werden:</target>
</segment>
</unit>
<unit id="YuAdYeh" name="project.bom_import.validation.info.title">
<segment state="translated">
<source>project.bom_import.validation.info.title</source>
<target>Informationen</target>
</segment>
</unit>
<unit id="1pUfi7Q" name="project.bom_import.validation.details.title">
<segment state="translated">
<source>project.bom_import.validation.details.title</source>
<target>Detaillierte Validierungsergebnisse</target>
</segment>
</unit>
<unit id="4Vv0Xns" name="project.bom_import.validation.details.line">
<segment state="translated">
<source>project.bom_import.validation.details.line</source>
<target>Zeile</target>
</segment>
</unit>
<unit id="RbvD2zF" name="project.bom_import.validation.details.status">
<segment state="translated">
<source>project.bom_import.validation.details.status</source>
<target>Status</target>
</segment>
</unit>
<unit id="iGtKkH_" name="project.bom_import.validation.details.messages">
<segment state="translated">
<source>project.bom_import.validation.details.messages</source>
<target>Meldungen</target>
</segment>
</unit>
<unit id="IgdgZd8" name="project.bom_import.validation.details.valid">
<segment state="translated">
<source>project.bom_import.validation.details.valid</source>
<target>Gültig</target>
</segment>
</unit>
<unit id="lWQEtkq" name="project.bom_import.validation.details.invalid">
<segment state="translated">
<source>project.bom_import.validation.details.invalid</source>
<target>Ungültig</target>
</segment>
</unit>
<unit id="tPn6Ind" name="project.bom_import.validation.all_valid">
<segment state="translated">
<source>project.bom_import.validation.all_valid</source>
<target>Alle Einträge sind gültig und bereit zum Import!</target>
</segment>
</unit>
<unit id="fzoGCaf" name="project.bom_import.validation.fix_errors">
<segment state="translated">
<source>project.bom_import.validation.fix_errors</source>
<target>Bitte beheben Sie die Validierungsfehler, bevor Sie mit dem Import fortfahren.</target>
</segment>
</unit>
<unit id="nc9MqUc" name="project.bom_import.type.generic_csv">
<segment state="translated">
<source>project.bom_import.type.generic_csv</source>
<target>Generische CSV-Datei</target>
</segment>
</unit>
<unit id=".N35Pvs" name="label_generator.update_profile">
<segment state="translated">
<source>label_generator.update_profile</source>
<target>Profil mit aktuellen Einstellungen aktualisieren</target>
</segment>
</unit>
<unit id="ulTo6Aa" name="label_generator.profile_updated">
<segment state="translated">
<source>label_generator.profile_updated</source>
<target>Labelprofil aktualisiert</target>
</segment>
</unit>
<unit id="7lgFa7I" name="settings.behavior.hompepage.items">
<segment state="translated">
<source>settings.behavior.hompepage.items</source>
<target>Startseiten-Elemente</target>
</segment>
</unit>
<unit id="FsrRdkp" name="settings.behavior.homepage.items.help">
<segment state="translated">
<source>settings.behavior.homepage.items.help</source>
<target>Die Elemente, die auf der Startseite angezeigt werden sollen. Die Reihenfolge kann per Drag &amp; Drop geändert werden.</target>
</segment>
</unit>
<unit id="CYw3_pS" name="settings.system.customization.showVersionOnHomepage">
<segment state="translated">
<source>settings.system.customization.showVersionOnHomepage</source>
<target>Part-DB-Version auf der Startseite anzeigen</target>
</segment>
</unit>
<unit id="GLYhV9m" name="settings.behavior.part_info.extract_params_from_description">
<segment state="translated">
<source>settings.behavior.part_info.extract_params_from_description</source>
<target>Parameter aus der Bauteilebeschreibung extrahieren</target>
</segment>
</unit>
<unit id="aYOedkN" name="settings.behavior.part_info.extract_params_from_notes">
<segment state="translated">
<source>settings.behavior.part_info.extract_params_from_notes</source>
<target>Parameter aus der Bauteilenotiz extrahieren</target>
</segment>
</unit>
<unit id="nCH2MW6" name="settings.ips.default_providers">
<segment state="translated">
<source>settings.ips.default_providers</source>
<target>Standard-Suchquellen</target>
</segment>
</unit>
<unit id="TLNoCLT" name="settings.ips.general">
<segment state="translated">
<source>settings.ips.general</source>
<target>Allgemeine Einstellungen</target>
</segment>
</unit>
<unit id="IDs2sXK" name="settings.ips.default_providers.help">
<segment state="translated">
<source>settings.ips.default_providers.help</source>
<target>Diese Anbieter werden für die Suche in Informationsquellen vorausgewählt.</target>
</segment>
</unit>
<unit id="dv6eslZ" name="settings.behavior.table.preview_image_max_width">
<segment state="translated">
<source>settings.behavior.table.preview_image_max_width</source>
<target>Max. Vorschaubilde-Breite (px)</target>
</segment>
</unit>
<unit id="5bOoqEL" name="settings.behavior.table.preview_image_min_width">
<segment state="translated">
<source>settings.behavior.table.preview_image_min_width</source>
<target>Min. Vorschaubilde-Breite (px)</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>

View file

@ -242,7 +242,7 @@
</notes> </notes>
<segment state="final"> <segment state="final">
<source>part.info.timetravel_hint</source> <source>part.info.timetravel_hint</source>
<target><![CDATA[This is how the part appeared before %timestamp%. <i>Please note that this feature is experimental, so the info may not be correct.</i>]]></target> <target>This is how the part appeared before %timestamp%. &lt;i&gt;Please note that this feature is experimental, so the info may not be correct.&lt;/i&gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="3exvSpl" name="standard.label"> <unit id="3exvSpl" name="standard.label">
@ -731,10 +731,10 @@
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>user.edit.tfa.disable_tfa_message</source> <source>user.edit.tfa.disable_tfa_message</source>
<target><![CDATA[This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>! <target>This will disable &lt;b&gt;all active two-factor authentication methods of the user&lt;/b&gt; and delete the &lt;b&gt;backup codes&lt;/b&gt;!
<br> &lt;br&gt;
The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br> The user will have to set up all two-factor authentication methods again and print new backup codes! &lt;br&gt;&lt;br&gt;
<b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b>]]></target> &lt;b&gt;Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!&lt;/b&gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="APsHYu0" name="user.edit.tfa.disable_tfa.btn"> <unit id="APsHYu0" name="user.edit.tfa.disable_tfa.btn">
@ -885,9 +885,9 @@ The user will have to set up all two-factor authentication methods again and pri
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>entity.delete.message</source> <source>entity.delete.message</source>
<target><![CDATA[This can not be undone! <target>This can not be undone!
<br> &lt;br&gt;
Sub elements will be moved upwards.]]></target> Sub elements will be moved upwards.</target>
</segment> </segment>
</unit> </unit>
<unit id="2tKAqHw" name="entity.delete"> <unit id="2tKAqHw" name="entity.delete">
@ -1441,7 +1441,7 @@ Sub elements will be moved upwards.]]></target>
</notes> </notes>
<segment state="final"> <segment state="final">
<source>homepage.github.text</source> <source>homepage.github.text</source>
<target><![CDATA[Source, downloads, bug reports, to-do-list etc. can be found on <a href="%href%" class="link-external" target="_blank">GitHub project page</a>]]></target> <target>Source, downloads, bug reports, to-do-list etc. can be found on &lt;a href="%href%" class="link-external" target="_blank"&gt;GitHub project page&lt;/a&gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="D5OKsgU" name="homepage.help.caption"> <unit id="D5OKsgU" name="homepage.help.caption">
@ -1463,7 +1463,7 @@ Sub elements will be moved upwards.]]></target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>homepage.help.text</source> <source>homepage.help.text</source>
<target><![CDATA[Help and tips can be found in Wiki the <a href="%href%" class="link-external" target="_blank">GitHub page</a>]]></target> <target>Help and tips can be found in Wiki the &lt;a href="%href%" class="link-external" target="_blank"&gt;GitHub page&lt;/a&gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="dnirx4v" name="homepage.forum.caption"> <unit id="dnirx4v" name="homepage.forum.caption">
@ -1705,7 +1705,7 @@ Sub elements will be moved upwards.]]></target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>email.pw_reset.fallback</source> <source>email.pw_reset.fallback</source>
<target><![CDATA[If this does not work for you, go to <a href="%url%">%url%</a> and enter the following info]]></target> <target>If this does not work for you, go to &lt;a href="%url%"&gt;%url%&lt;/a&gt; and enter the following info</target>
</segment> </segment>
</unit> </unit>
<unit id="DduL9Hu" name="email.pw_reset.username"> <unit id="DduL9Hu" name="email.pw_reset.username">
@ -1735,7 +1735,7 @@ Sub elements will be moved upwards.]]></target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>email.pw_reset.valid_unit %date%</source> <source>email.pw_reset.valid_unit %date%</source>
<target><![CDATA[The reset token will be valid until <i>%date%</i>.]]></target> <target>The reset token will be valid until &lt;i&gt;%date%&lt;/i&gt;.</target>
</segment> </segment>
</unit> </unit>
<unit id="8sBnjRy" name="orderdetail.delete"> <unit id="8sBnjRy" name="orderdetail.delete">
@ -3578,8 +3578,8 @@ Sub elements will be moved upwards.]]></target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>tfa_google.disable.confirm_message</source> <source>tfa_google.disable.confirm_message</source>
<target><![CDATA[If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.<br> <target>If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.&lt;br&gt;
Also note that without two-factor authentication, your account is no longer as well protected against attackers!]]></target> Also note that without two-factor authentication, your account is no longer as well protected against attackers!</target>
</segment> </segment>
</unit> </unit>
<unit id="yu9MSt5" name="tfa_google.disabled_message"> <unit id="yu9MSt5" name="tfa_google.disabled_message">
@ -3599,7 +3599,7 @@ Also note that without two-factor authentication, your account is no longer as w
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>tfa_google.step.download</source> <source>tfa_google.step.download</source>
<target><![CDATA[Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)]]></target> <target>Download an authenticator app (e.g. &lt;a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2"&gt;Google Authenticator&lt;/a&gt; oder &lt;a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp"&gt;FreeOTP Authenticator&lt;/a&gt;)</target>
</segment> </segment>
</unit> </unit>
<unit id="eriwJoR" name="tfa_google.step.scan"> <unit id="eriwJoR" name="tfa_google.step.scan">
@ -3841,8 +3841,8 @@ Also note that without two-factor authentication, your account is no longer as w
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>tfa_trustedDevices.explanation</source> <source>tfa_trustedDevices.explanation</source>
<target><![CDATA[When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed. <target>When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed.
If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of <i>all </i>computers here.]]></target> If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of &lt;i&gt;all &lt;/i&gt;computers here.</target>
</segment> </segment>
</unit> </unit>
<unit id="FZINq8z" name="tfa_trustedDevices.invalidate.confirm_title"> <unit id="FZINq8z" name="tfa_trustedDevices.invalidate.confirm_title">
@ -5313,7 +5313,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>label_options.lines_mode.help</source> <source>label_options.lines_mode.help</source>
<target><![CDATA[If you select Twig here, the content field is interpreted as Twig template. See <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig documentation</a> and <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a> for more information.]]></target> <target>If you select Twig here, the content field is interpreted as Twig template. See &lt;a href="https://twig.symfony.com/doc/3.x/templates.html"&gt;Twig documentation&lt;/a&gt; and &lt;a href="https://docs.part-db.de/usage/labels.html#twig-mode"&gt;Wiki&lt;/a&gt; for more information.</target>
</segment> </segment>
</unit> </unit>
<unit id="isvxbiX" name="label_options.page_size.label"> <unit id="isvxbiX" name="label_options.page_size.label">
@ -7157,7 +7157,7 @@ Exampletown</target>
</notes> </notes>
<segment state="translated"> <segment state="translated">
<source>mass_creation.lines.placeholder</source> <source>mass_creation.lines.placeholder</source>
<target><![CDATA[Element 1 <target>Element 1
Element 1.1 Element 1.1
Element 1.1.1 Element 1.1.1
Element 1.2 Element 1.2
@ -7165,7 +7165,7 @@ Element 2
Element 3 Element 3
Element 1 -&gt; Element 1.1 Element 1 -&gt; Element 1.1
Element 1 -&gt; Element 1.2]]></target> Element 1 -&gt; Element 1.2</target>
</segment> </segment>
</unit> </unit>
<unit id="TWSqPFi" name="entity.mass_creation.btn"> <unit id="TWSqPFi" name="entity.mass_creation.btn">
@ -8554,16 +8554,6 @@ Element 1 -&gt; Element 1.2]]></target>
<target>Authenticator app</target> <target>Authenticator app</target>
</segment> </segment>
</unit> </unit>
<unit id="fGkpjYW" name="Login successful">
<notes>
<note priority="1">obsolete</note>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<source>Login successful</source>
<target>Login successful</target>
</segment>
</unit>
<unit id="KSHVrbr" name="log.type.exception"> <unit id="KSHVrbr" name="log.type.exception">
<notes> <notes>
<note priority="1">obsolete</note> <note priority="1">obsolete</note>
@ -8689,15 +8679,6 @@ Element 1 -&gt; Element 1.2]]></target>
<target>Security key added successfully.</target> <target>Security key added successfully.</target>
</segment> </segment>
</unit> </unit>
<unit id="VhxhtYo" name="Username">
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<source>Username</source>
<target>Username</target>
</segment>
</unit>
<unit id="gDVCAxj" name="log.type.security.google_disabled"> <unit id="gDVCAxj" name="log.type.security.google_disabled">
<notes> <notes>
<note category="state" priority="1">obsolete</note> <note category="state" priority="1">obsolete</note>
@ -9013,7 +8994,7 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>%count% parts selected!</target> <target>%count% parts selected</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">
@ -9391,25 +9372,25 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="r4vDLAt" name="filter.parameter_value_constraint.operator.&lt;"> <unit id="r4vDLAt" name="filter.parameter_value_constraint.operator.&lt;">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&lt;</source> <source>filter.parameter_value_constraint.operator.&lt;</source>
<target><![CDATA[Typ. Value <]]></target> <target>Typ. Value &lt;</target>
</segment> </segment>
</unit> </unit>
<unit id="X9SA3UP" name="filter.parameter_value_constraint.operator.&gt;"> <unit id="X9SA3UP" name="filter.parameter_value_constraint.operator.&gt;">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&gt;</source> <source>filter.parameter_value_constraint.operator.&gt;</source>
<target><![CDATA[Typ. Value >]]></target> <target>Typ. Value &gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="BQGaoQS" name="filter.parameter_value_constraint.operator.&lt;="> <unit id="BQGaoQS" name="filter.parameter_value_constraint.operator.&lt;=">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&lt;=</source> <source>filter.parameter_value_constraint.operator.&lt;=</source>
<target><![CDATA[Typ. Value <=]]></target> <target>Typ. Value &lt;=</target>
</segment> </segment>
</unit> </unit>
<unit id="2ha3P6g" name="filter.parameter_value_constraint.operator.&gt;="> <unit id="2ha3P6g" name="filter.parameter_value_constraint.operator.&gt;=">
<segment state="translated"> <segment state="translated">
<source>filter.parameter_value_constraint.operator.&gt;=</source> <source>filter.parameter_value_constraint.operator.&gt;=</source>
<target><![CDATA[Typ. Value >=]]></target> <target>Typ. Value &gt;=</target>
</segment> </segment>
</unit> </unit>
<unit id="4DaBace" name="filter.parameter_value_constraint.operator.BETWEEN"> <unit id="4DaBace" name="filter.parameter_value_constraint.operator.BETWEEN">
@ -9517,7 +9498,7 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="4tHhDtU" name="parts_list.search.searching_for"> <unit id="4tHhDtU" name="parts_list.search.searching_for">
<segment state="translated"> <segment state="translated">
<source>parts_list.search.searching_for</source> <source>parts_list.search.searching_for</source>
<target><![CDATA[Searching parts with keyword <b>%keyword%</b>]]></target> <target>Searching parts with keyword &lt;b&gt;%keyword%&lt;/b&gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="4vomKLa" name="parts_list.search_options.caption"> <unit id="4vomKLa" name="parts_list.search_options.caption">
@ -10177,13 +10158,13 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="NdZ1t7a" name="project.builds.number_of_builds_possible"> <unit id="NdZ1t7a" name="project.builds.number_of_builds_possible">
<segment state="translated"> <segment state="translated">
<source>project.builds.number_of_builds_possible</source> <source>project.builds.number_of_builds_possible</source>
<target><![CDATA[You have enough stocked to build <b>%max_builds%</b> builds of this project.]]></target> <target>You have enough stocked to build &lt;b&gt;%max_builds%&lt;/b&gt; builds of this project.</target>
</segment> </segment>
</unit> </unit>
<unit id="iuSpPbg" name="project.builds.check_project_status"> <unit id="iuSpPbg" name="project.builds.check_project_status">
<segment state="translated"> <segment state="translated">
<source>project.builds.check_project_status</source> <source>project.builds.check_project_status</source>
<target><![CDATA[The current project status is <b>"%project_status%"</b>. You should check if you really want to build the project with this status!]]></target> <target>The current project status is &lt;b&gt;"%project_status%"&lt;/b&gt;. You should check if you really want to build the project with this status!</target>
</segment> </segment>
</unit> </unit>
<unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n"> <unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n">
@ -10285,7 +10266,7 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="GzqIwHH" name="entity.select.add_hint"> <unit id="GzqIwHH" name="entity.select.add_hint">
<segment state="translated"> <segment state="translated">
<source>entity.select.add_hint</source> <source>entity.select.add_hint</source>
<target><![CDATA[Use -> to create nested structures, e.g. "Node 1->Node 1.1"]]></target> <target>Use -&gt; to create nested structures, e.g. "Node 1-&gt;Node 1.1"</target>
</segment> </segment>
</unit> </unit>
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB"> <unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
@ -10309,13 +10290,13 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="XLnXtsR" name="homepage.first_steps.introduction"> <unit id="XLnXtsR" name="homepage.first_steps.introduction">
<segment state="translated"> <segment state="translated">
<source>homepage.first_steps.introduction</source> <source>homepage.first_steps.introduction</source>
<target><![CDATA[Your database is still empty. You might want to read the <a href="%url%">documentation</a> or start to creating the following data structures:]]></target> <target>Your database is still empty. You might want to read the &lt;a href="%url%"&gt;documentation&lt;/a&gt; or start to creating the following data structures:</target>
</segment> </segment>
</unit> </unit>
<unit id="Q79MOIk" name="homepage.first_steps.create_part"> <unit id="Q79MOIk" name="homepage.first_steps.create_part">
<segment state="translated"> <segment state="translated">
<source>homepage.first_steps.create_part</source> <source>homepage.first_steps.create_part</source>
<target><![CDATA[Or you can directly <a href="%url%">create a new part</a>.]]></target> <target>Or you can directly &lt;a href="%url%"&gt;create a new part&lt;/a&gt;.</target>
</segment> </segment>
</unit> </unit>
<unit id="vplYq4f" name="homepage.first_steps.hide_hint"> <unit id="vplYq4f" name="homepage.first_steps.hide_hint">
@ -10327,7 +10308,7 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="MJoZl4f" name="homepage.forum.text"> <unit id="MJoZl4f" name="homepage.forum.text">
<segment state="translated"> <segment state="translated">
<source>homepage.forum.text</source> <source>homepage.forum.text</source>
<target><![CDATA[For questions about Part-DB use the <a href="%href%" class="link-external" target="_blank">discussion forum</a>]]></target> <target>For questions about Part-DB use the &lt;a href="%href%" class="link-external" target="_blank"&gt;discussion forum&lt;/a&gt;</target>
</segment> </segment>
</unit> </unit>
<unit id="YsukbnK" name="log.element_edited.changed_fields.category"> <unit id="YsukbnK" name="log.element_edited.changed_fields.category">
@ -10981,7 +10962,7 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="p_IxB9K" name="parts.import.help_documentation"> <unit id="p_IxB9K" name="parts.import.help_documentation">
<segment state="translated"> <segment state="translated">
<source>parts.import.help_documentation</source> <source>parts.import.help_documentation</source>
<target><![CDATA[See the <a href="%link%">documentation</a> for more information on the file format.]]></target> <target>See the &lt;a href="%link%"&gt;documentation&lt;/a&gt; for more information on the file format.</target>
</segment> </segment>
</unit> </unit>
<unit id="awbvhVq" name="parts.import.help"> <unit id="awbvhVq" name="parts.import.help">
@ -11161,7 +11142,7 @@ Element 1 -&gt; Element 1.2]]></target>
<unit id="o5u.Nnz" name="part.filter.lessThanDesired"> <unit id="o5u.Nnz" name="part.filter.lessThanDesired">
<segment state="translated"> <segment state="translated">
<source>part.filter.lessThanDesired</source> <source>part.filter.lessThanDesired</source>
<target><![CDATA[In stock less than desired (total amount < min. amount)]]></target> <target>In stock less than desired (total amount &lt; min. amount)</target>
</segment> </segment>
</unit> </unit>
<unit id="YN9eLcZ" name="part.filter.lotOwner"> <unit id="YN9eLcZ" name="part.filter.lotOwner">
@ -11973,13 +11954,13 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="i68lU5x" name="part.merge.confirm.title"> <unit id="i68lU5x" name="part.merge.confirm.title">
<segment state="translated"> <segment state="translated">
<source>part.merge.confirm.title</source> <source>part.merge.confirm.title</source>
<target><![CDATA[Do you really want to merge <b>%other%</b> into <b>%target%</b>?]]></target> <target>Do you really want to merge &lt;b&gt;%other%&lt;/b&gt; into &lt;b&gt;%target%&lt;/b&gt;?</target>
</segment> </segment>
</unit> </unit>
<unit id="k0anzYV" name="part.merge.confirm.message"> <unit id="k0anzYV" name="part.merge.confirm.message">
<segment state="translated"> <segment state="translated">
<source>part.merge.confirm.message</source> <source>part.merge.confirm.message</source>
<target><![CDATA[<b>%other%</b> will be deleted, and the part will be saved with the shown information.]]></target> <target>&lt;b&gt;%other%&lt;/b&gt; will be deleted, and the part will be saved with the shown information.</target>
</segment> </segment>
</unit> </unit>
<unit id="mmW5Yl1" name="part.info.merge_modal.title"> <unit id="mmW5Yl1" name="part.info.merge_modal.title">
@ -13057,5 +13038,449 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Redacted for security reasons</target> <target>Redacted for security reasons</target>
</segment> </segment>
</unit> </unit>
<unit id="J716Oh4" name="project.bom_import.map_fields">
<segment state="translated">
<source>project.bom_import.map_fields</source>
<target>Map Fields</target>
</segment>
</unit>
<unit id="wvT5Xvn" name="project.bom_import.map_fields.help">
<segment state="translated">
<source>project.bom_import.map_fields.help</source>
<target>Configure how CSV columns map to BOM fields</target>
</segment>
</unit>
<unit id="nh7uLPe" name="project.bom_import.delimiter">
<segment state="translated">
<source>project.bom_import.delimiter</source>
<target>Delimiter</target>
</segment>
</unit>
<unit id="MSlSeE4" name="project.bom_import.delimiter.comma">
<segment state="translated">
<source>project.bom_import.delimiter.comma</source>
<target>Comma (,)</target>
</segment>
</unit>
<unit id="UdDSB0h" name="project.bom_import.delimiter.semicolon">
<segment state="translated">
<source>project.bom_import.delimiter.semicolon</source>
<target>Semicolon (;)</target>
</segment>
</unit>
<unit id="Wpa4Gmf" name="project.bom_import.delimiter.tab">
<segment state="translated">
<source>project.bom_import.delimiter.tab</source>
<target>Tab</target>
</segment>
</unit>
<unit id="tjrG_vU" name="project.bom_import.field_mapping.title">
<segment state="translated">
<source>project.bom_import.field_mapping.title</source>
<target>Field Mapping</target>
</segment>
</unit>
<unit id="nWu8B7R" name="project.bom_import.field_mapping.csv_field">
<segment state="translated">
<source>project.bom_import.field_mapping.csv_field</source>
<target>CSV Field</target>
</segment>
</unit>
<unit id="D0KlFqm" name="project.bom_import.field_mapping.maps_to">
<segment state="translated">
<source>project.bom_import.field_mapping.maps_to</source>
<target>Maps To</target>
</segment>
</unit>
<unit id="rhUpmxC" name="project.bom_import.field_mapping.suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.suggestion</source>
<target>Suggestion</target>
</segment>
</unit>
<unit id="TM0mgKr" name="project.bom_import.field_mapping.priority">
<segment state="translated">
<source>project.bom_import.field_mapping.priority</source>
<target>Priority</target>
</segment>
</unit>
<unit id="xY8NSRr" name="project.bom_import.field_mapping.priority_help">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_help</source>
<target>Priority (lower number = higher priority)</target>
</segment>
</unit>
<unit id="CuXg8WU" name="project.bom_import.field_mapping.priority_short">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_short</source>
<target>P</target>
</segment>
</unit>
<unit id="jOHOShN" name="project.bom_import.field_mapping.priority_note">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_note</source>
<target>Priority Tip: Lower numbers = higher priority. Default priority is 10. Use priorities 1-9 for most important fields, 10+ for normal priority.</target>
</segment>
</unit>
<unit id="pvmv3OT" name="project.bom_import.field_mapping.summary">
<segment state="translated">
<source>project.bom_import.field_mapping.summary</source>
<target>Field Mapping Summary</target>
</segment>
</unit>
<unit id="VjLf1Pf" name="project.bom_import.field_mapping.select_to_see_summary">
<segment state="translated">
<source>project.bom_import.field_mapping.select_to_see_summary</source>
<target>Select field mappings to see summary</target>
</segment>
</unit>
<unit id="JOPny6T" name="project.bom_import.field_mapping.no_suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.no_suggestion</source>
<target>No suggestion</target>
</segment>
</unit>
<unit id="1LWEtqL" name="project.bom_import.preview">
<segment state="translated">
<source>project.bom_import.preview</source>
<target>Preview</target>
</segment>
</unit>
<unit id="1CJA0aY" name="project.bom_import.flash.session_expired">
<segment state="translated">
<source>project.bom_import.flash.session_expired</source>
<target>Import session has expired. Please upload your file again.</target>
</segment>
</unit>
<unit id="BLN7XRN" name="project.bom_import.field_mapping.ignore">
<segment state="translated">
<source>project.bom_import.field_mapping.ignore</source>
<target>Ignore</target>
</segment>
</unit>
<unit id="yiIB3yV" name="project.bom_import.type.kicad_schematic">
<segment state="translated">
<source>project.bom_import.type.kicad_schematic</source>
<target>KiCAD Schematic BOM (CSV file)</target>
</segment>
</unit>
<unit id="ltE6xPP" name="common.back">
<segment state="translated">
<source>common.back</source>
<target>Back</target>
</segment>
</unit>
<unit id="HCTqjZO" name="project.bom_import.validation.errors.required_field_missing">
<segment state="translated">
<source>project.bom_import.validation.errors.required_field_missing</source>
<target>Line %line%: Required field "%field%" is missing or empty. Please ensure this field is mapped and contains data.</target>
</segment>
</unit>
<unit id="_ua5YM7" name="project.bom_import.validation.errors.no_valid_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.no_valid_designators</source>
<target>Line %line%: Designator field contains no valid component references. Expected format: "R1,C2,U3" or "R1, C2, U3".</target>
</segment>
</unit>
<unit id="xpkq3rW" name="project.bom_import.validation.warnings.unusual_designator_format">
<segment state="translated">
<source>project.bom_import.validation.warnings.unusual_designator_format</source>
<target>Line %line%: Some component references may have unusual format: %designators%. Expected format: "R1", "C2", "U3", etc.</target>
</segment>
</unit>
<unit id="M54Ud5d" name="project.bom_import.validation.errors.duplicate_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.duplicate_designators</source>
<target>Line %line%: Duplicate component references found: %designators%. Each component should be referenced only once per line.</target>
</segment>
</unit>
<unit id="ZULFZh2" name="project.bom_import.validation.errors.invalid_quantity">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_quantity</source>
<target>Line %line%: Quantity "%quantity%" is not a valid number. Please enter a numeric value (e.g., 1, 2.5, 10).</target>
</segment>
</unit>
<unit id="3WbRgsl" name="project.bom_import.validation.errors.quantity_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_zero_or_negative</source>
<target>Line %line%: Quantity must be greater than 0, got %quantity%.</target>
</segment>
</unit>
<unit id="_nWGsSU" name="project.bom_import.validation.warnings.quantity_unusually_high">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_unusually_high</source>
<target>Line %line%: Quantity %quantity% seems unusually high. Please verify this is correct.</target>
</segment>
</unit>
<unit id="iJWw39f" name="project.bom_import.validation.warnings.quantity_not_whole_number">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_not_whole_number</source>
<target>Line %line%: Quantity %quantity% is not a whole number, but you have %count% component references. This may indicate a mismatch.</target>
</segment>
</unit>
<unit id="Zffmgvv" name="project.bom_import.validation.errors.quantity_designator_mismatch">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_designator_mismatch</source>
<target>Line %line%: Mismatch between quantity and component references. Quantity: %quantity%, References: %count% (%designators%). These should match. Either adjust the quantity or check your component references.</target>
</segment>
</unit>
<unit id="JqHpDIo" name="project.bom_import.validation.errors.invalid_partdb_id">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_partdb_id</source>
<target>Line %line%: Part-DB ID "%id%" is not a valid number. Please enter a numeric ID.</target>
</segment>
</unit>
<unit id="Mr7W6r0" name="project.bom_import.validation.errors.partdb_id_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.partdb_id_zero_or_negative</source>
<target>Line %line%: Part-DB ID must be greater than 0, got %id%.</target>
</segment>
</unit>
<unit id="gRH6IeR" name="project.bom_import.validation.warnings.partdb_id_not_found">
<segment state="translated">
<source>project.bom_import.validation.warnings.partdb_id_not_found</source>
<target>Line %line%: Part-DB ID %id% not found in database. The component will be imported without linking to an existing part.</target>
</segment>
</unit>
<unit id="eMfaBYo" name="project.bom_import.validation.info.partdb_link_success">
<segment state="translated">
<source>project.bom_import.validation.info.partdb_link_success</source>
<target>Line %line%: Successfully linked to Part-DB part "%name%" (ID: %id%).</target>
</segment>
</unit>
<unit id="b_m9p.f" name="project.bom_import.validation.warnings.no_component_name">
<segment state="translated">
<source>project.bom_import.validation.warnings.no_component_name</source>
<target>Line %line%: No component name/designation provided (MPN, Designation, or Value). Component will be named "Unknown Component".</target>
</segment>
</unit>
<unit id=".LAGdMe" name="project.bom_import.validation.warnings.package_name_too_long">
<segment state="translated">
<source>project.bom_import.validation.warnings.package_name_too_long</source>
<target>Line %line%: Package name "%package%" is unusually long. Please verify this is correct.</target>
</segment>
</unit>
<unit id="wFF49kl" name="project.bom_import.validation.info.library_prefix_detected">
<segment state="translated">
<source>project.bom_import.validation.info.library_prefix_detected</source>
<target>Line %line%: Package "%package%" contains library prefix. This will be automatically removed during import.</target>
</segment>
</unit>
<unit id="hEO2sxC" name="project.bom_import.validation.errors.non_numeric_field">
<segment state="translated">
<source>project.bom_import.validation.errors.non_numeric_field</source>
<target>Line %line%: Field "%field%" contains non-numeric value "%value%". Please enter a valid number.</target>
</segment>
</unit>
<unit id="Ear_sUX" name="project.bom_import.validation.info.import_summary">
<segment state="translated">
<source>project.bom_import.validation.info.import_summary</source>
<target>Import summary: %total% total entries, %valid% valid, %invalid% with issues.</target>
</segment>
</unit>
<unit id="SqJZ97A" name="project.bom_import.validation.errors.summary">
<segment state="translated">
<source>project.bom_import.validation.errors.summary</source>
<target>Found %count% validation error(s) that must be fixed before import can proceed.</target>
</segment>
</unit>
<unit id="s7dzFnp" name="project.bom_import.validation.warnings.summary">
<segment state="translated">
<source>project.bom_import.validation.warnings.summary</source>
<target>Found %count% warning(s). Please review these issues before proceeding.</target>
</segment>
</unit>
<unit id="2KaAHmS" name="project.bom_import.validation.info.all_valid">
<segment state="translated">
<source>project.bom_import.validation.info.all_valid</source>
<target>All entries passed validation successfully!</target>
</segment>
</unit>
<unit id="Lyb3BjK" name="project.bom_import.validation.summary">
<segment state="translated">
<source>project.bom_import.validation.summary</source>
<target>Validation Summary</target>
</segment>
</unit>
<unit id="DUM8GoE" name="project.bom_import.validation.total_entries">
<segment state="translated">
<source>project.bom_import.validation.total_entries</source>
<target>Total Entries</target>
</segment>
</unit>
<unit id="AtLGGU2" name="project.bom_import.validation.valid_entries">
<segment state="translated">
<source>project.bom_import.validation.valid_entries</source>
<target>Valid Entries</target>
</segment>
</unit>
<unit id="mgdn5v3" name="project.bom_import.validation.invalid_entries">
<segment state="translated">
<source>project.bom_import.validation.invalid_entries</source>
<target>Invalid Entries</target>
</segment>
</unit>
<unit id="qmJ5BfF" name="project.bom_import.validation.success_rate">
<segment state="translated">
<source>project.bom_import.validation.success_rate</source>
<target>Success Rate</target>
</segment>
</unit>
<unit id="QyDsrFV" name="project.bom_import.validation.errors.title">
<segment state="translated">
<source>project.bom_import.validation.errors.title</source>
<target>Validation Errors</target>
</segment>
</unit>
<unit id="d.Mvu0a" name="project.bom_import.validation.errors.description">
<segment state="translated">
<source>project.bom_import.validation.errors.description</source>
<target>The following errors must be fixed before the import can proceed:</target>
</segment>
</unit>
<unit id="MGFfKr." name="project.bom_import.validation.warnings.title">
<segment state="translated">
<source>project.bom_import.validation.warnings.title</source>
<target>Validation Warnings</target>
</segment>
</unit>
<unit id="tt4VxY6" name="project.bom_import.validation.warnings.description">
<segment state="translated">
<source>project.bom_import.validation.warnings.description</source>
<target>The following warnings should be reviewed before proceeding:</target>
</segment>
</unit>
<unit id="YuAdYeh" name="project.bom_import.validation.info.title">
<segment state="translated">
<source>project.bom_import.validation.info.title</source>
<target>Information</target>
</segment>
</unit>
<unit id="1pUfi7Q" name="project.bom_import.validation.details.title">
<segment state="translated">
<source>project.bom_import.validation.details.title</source>
<target>Detailed Validation Results</target>
</segment>
</unit>
<unit id="4Vv0Xns" name="project.bom_import.validation.details.line">
<segment state="translated">
<source>project.bom_import.validation.details.line</source>
<target>Line</target>
</segment>
</unit>
<unit id="RbvD2zF" name="project.bom_import.validation.details.status">
<segment state="translated">
<source>project.bom_import.validation.details.status</source>
<target>Status</target>
</segment>
</unit>
<unit id="iGtKkH_" name="project.bom_import.validation.details.messages">
<segment state="translated">
<source>project.bom_import.validation.details.messages</source>
<target>Messages</target>
</segment>
</unit>
<unit id="IgdgZd8" name="project.bom_import.validation.details.valid">
<segment state="translated">
<source>project.bom_import.validation.details.valid</source>
<target>Valid</target>
</segment>
</unit>
<unit id="lWQEtkq" name="project.bom_import.validation.details.invalid">
<segment state="translated">
<source>project.bom_import.validation.details.invalid</source>
<target>Invalid</target>
</segment>
</unit>
<unit id="tPn6Ind" name="project.bom_import.validation.all_valid">
<segment state="translated">
<source>project.bom_import.validation.all_valid</source>
<target>All entries are valid and ready for import!</target>
</segment>
</unit>
<unit id="fzoGCaf" name="project.bom_import.validation.fix_errors">
<segment state="translated">
<source>project.bom_import.validation.fix_errors</source>
<target>Please fix the validation errors before proceeding with the import.</target>
</segment>
</unit>
<unit id="nc9MqUc" name="project.bom_import.type.generic_csv">
<segment state="translated">
<source>project.bom_import.type.generic_csv</source>
<target>Generic CSV</target>
</segment>
</unit>
<unit id=".N35Pvs" name="label_generator.update_profile">
<segment state="translated">
<source>label_generator.update_profile</source>
<target>Update profile with current settings</target>
</segment>
</unit>
<unit id="ulTo6Aa" name="label_generator.profile_updated">
<segment state="translated">
<source>label_generator.profile_updated</source>
<target>Label profile updated successfully.</target>
</segment>
</unit>
<unit id="7lgFa7I" name="settings.behavior.hompepage.items">
<segment state="translated">
<source>settings.behavior.hompepage.items</source>
<target>Homepage items</target>
</segment>
</unit>
<unit id="FsrRdkp" name="settings.behavior.homepage.items.help">
<segment state="translated">
<source>settings.behavior.homepage.items.help</source>
<target>The items to show at the homepage. Order can be changed via drag &amp; drop.</target>
</segment>
</unit>
<unit id="CYw3_pS" name="settings.system.customization.showVersionOnHomepage">
<segment state="translated">
<source>settings.system.customization.showVersionOnHomepage</source>
<target>Show Part-DB version on homepage</target>
</segment>
</unit>
<unit id="GLYhV9m" name="settings.behavior.part_info.extract_params_from_description">
<segment state="translated">
<source>settings.behavior.part_info.extract_params_from_description</source>
<target>Extract parameters from part description</target>
</segment>
</unit>
<unit id="aYOedkN" name="settings.behavior.part_info.extract_params_from_notes">
<segment state="translated">
<source>settings.behavior.part_info.extract_params_from_notes</source>
<target>Extract parameters from part notes</target>
</segment>
</unit>
<unit id="nCH2MW6" name="settings.ips.default_providers">
<segment state="translated">
<source>settings.ips.default_providers</source>
<target>Default search providers</target>
</segment>
</unit>
<unit id="TLNoCLT" name="settings.ips.general">
<segment state="translated">
<source>settings.ips.general</source>
<target>General settings</target>
</segment>
</unit>
<unit id="IDs2sXK" name="settings.ips.default_providers.help">
<segment state="translated">
<source>settings.ips.default_providers.help</source>
<target>These providers will be preselected for searches in part providers.</target>
</segment>
</unit>
<unit id="dv6eslZ" name="settings.behavior.table.preview_image_max_width">
<segment state="translated">
<source>settings.behavior.table.preview_image_max_width</source>
<target>Preview image max width (px)</target>
</segment>
</unit>
<unit id="5bOoqEL" name="settings.behavior.table.preview_image_min_width">
<segment state="translated">
<source>settings.behavior.table.preview_image_min_width</source>
<target>Preview image min width (px)</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>

View file

@ -9009,7 +9009,7 @@ Elemento 3</target>
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>¡%count% componentes seleccionadas!</target> <target>¡%count% componentes seleccionadas</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">

View file

@ -8932,7 +8932,7 @@ exemple de ville</target>
<unit id="qKDo_nI" name="part_list.action.part_count"> <unit id="qKDo_nI" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>%count% composants sélectionnés !</target> <target>%count% composants sélectionnés</target>
</segment> </segment>
</unit> </unit>
<unit id="ssOogP5" name="company.edit.quick.website"> <unit id="ssOogP5" name="company.edit.quick.website">

View file

@ -9011,7 +9011,7 @@ Element 3</target>
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>%count% componenti selezionati !</target> <target>%count% componenti selezionati</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">

View file

@ -9014,7 +9014,7 @@ Element 3</target>
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>Wybrano %count% części!</target> <target>Wybrano %count% części</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">

View file

@ -9018,7 +9018,7 @@
<unit id="gaoMsrY" name="part_list.action.part_count"> <unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated"> <segment state="translated">
<source>part_list.action.part_count</source> <source>part_list.action.part_count</source>
<target>%count% компонентов выбрано!</target> <target>%count% компонентов выбрано</target>
</segment> </segment>
</unit> </unit>
<unit id="FhdheZY" name="company.edit.quick.website"> <unit id="FhdheZY" name="company.edit.quick.website">

1518
yarn.lock

File diff suppressed because it is too large Load diff