diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js index 0175b284..f8bc301e 100644 --- a/assets/controllers/elements/attachment_autocomplete_controller.js +++ b/assets/controllers/elements/attachment_autocomplete_controller.js @@ -42,7 +42,6 @@ export default class extends Controller { selectOnTab: true, //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', - dropdownParent: 'body', render: { item: (data, escape) => { return '' + escape(data.label) + ''; diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index 0658f4b4..5abd5ba3 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -16,7 +16,6 @@ export default class extends Controller { searchField: ["name", "description", "category", "footprint"], valueField: "id", labelField: "name", - dropdownParent: 'body', preload: "focus", render: { item: (data, escape) => { @@ -72,4 +71,4 @@ export default class extends Controller { //Destroy the TomSelect instance this._tomSelect.destroy(); } -} +} \ No newline at end of file diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js index f933731a..cdafe4d0 100644 --- a/assets/controllers/elements/select_controller.js +++ b/assets/controllers/elements/select_controller.js @@ -44,7 +44,6 @@ export default class extends Controller { allowEmptyOption: true, selectOnTab: true, maxOptions: null, - dropdownParent: 'body', render: { item: this.renderItem.bind(this), @@ -109,4 +108,4 @@ export default class extends Controller { //Destroy the TomSelect instance this._tomSelect.destroy(); } -} +} \ No newline at end of file diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js index daa6b0a1..df37871d 100644 --- a/assets/controllers/elements/select_multiple_controller.js +++ b/assets/controllers/elements/select_multiple_controller.js @@ -29,7 +29,6 @@ export default class extends Controller { this._tomSelect = new TomSelect(this.element, { maxItems: 1000, allowEmptyOption: true, - dropdownParent: 'body', plugins: ['remove_button'], }); } @@ -40,4 +39,4 @@ export default class extends Controller { this._tomSelect.destroy(); } -} +} \ No newline at end of file diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js index 0421a26d..31ca0314 100644 --- a/assets/controllers/elements/static_file_autocomplete_controller.js +++ b/assets/controllers/elements/static_file_autocomplete_controller.js @@ -50,7 +50,6 @@ export default class extends Controller { valueField: 'text', searchField: 'text', orderField: 'text', - dropdownParent: 'body', //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', diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 5c6f9490..a1114a97 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -54,7 +54,6 @@ export default class extends Controller { maxItems: 1, delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$", splitOn: null, - dropdownParent: 'body', searchField: [ {field: "text", weight : 2}, diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js index 53bf7608..1f10c457 100644 --- a/assets/controllers/elements/tagsinput_controller.js +++ b/assets/controllers/elements/tagsinput_controller.js @@ -43,7 +43,6 @@ export default class extends Controller { selectOnTab: true, createOnBlur: true, create: true, - dropdownParent: 'body', }; if(this.element.dataset.autocomplete) { @@ -74,4 +73,4 @@ export default class extends Controller { //Destroy the TomSelect instance this._tomSelect.destroy(); } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index 1f67b80f..6de15830 100644 --- a/composer.lock +++ b/composer.lock @@ -7513,16 +7513,16 @@ }, { "name": "part-db/label-fonts", - "version": "v1.2.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/Part-DB/label-fonts.git", - "reference": "c85aeb051d6492961a2c59bc291979f15ce60e88" + "reference": "77c84b70ed3bb005df15f30ff835ddec490394b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Part-DB/label-fonts/zipball/c85aeb051d6492961a2c59bc291979f15ce60e88", - "reference": "c85aeb051d6492961a2c59bc291979f15ce60e88", + "url": "https://api.github.com/repos/Part-DB/label-fonts/zipball/77c84b70ed3bb005df15f30ff835ddec490394b9", + "reference": "77c84b70ed3bb005df15f30ff835ddec490394b9", "shasum": "" }, "type": "library", @@ -7545,9 +7545,9 @@ ], "support": { "issues": "https://github.com/Part-DB/label-fonts/issues", - "source": "https://github.com/Part-DB/label-fonts/tree/v1.2.0" + "source": "https://github.com/Part-DB/label-fonts/tree/v1.1.0" }, - "time": "2025-09-07T15:42:51+00:00" + "time": "2024-02-08T21:44:38+00:00" }, { "name": "part-db/swap", @@ -17883,16 +17883,16 @@ }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.5", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865" + "reference": "6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/eeff19808f8ae3a6f7c4e43e388a2848eb2b0865", - "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8", + "reference": "6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8", "shasum": "" }, "require": { @@ -17949,9 +17949,9 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.5" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.4" }, - "time": "2025-09-07T11:52:30+00:00" + "time": "2025-07-17T11:57:55+00:00" }, { "name": "phpstan/phpstan-strict-rules", @@ -18003,16 +18003,16 @@ }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.8", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "8820c22d785c235f69bb48da3d41e688bc8a1796" + "reference": "392f7ab8f52a0a776977be4e62535358c28e1b15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/8820c22d785c235f69bb48da3d41e688bc8a1796", - "reference": "8820c22d785c235f69bb48da3d41e688bc8a1796", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/392f7ab8f52a0a776977be4e62535358c28e1b15", + "reference": "392f7ab8f52a0a776977be4e62535358c28e1b15", "shasum": "" }, "require": { @@ -18068,9 +18068,9 @@ "description": "Symfony Framework extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.8" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.7" }, - "time": "2025-09-07T06:55:50+00:00" + "time": "2025-07-22T09:40:57+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index c283cd8e..1cb74da7 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -69,3 +69,9 @@ nelmio_security: - 'data:' 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 + +when@dev: + # disables the Content-Security-Policy header + nelmio_security: + csp: + enabled: false \ No newline at end of file diff --git a/config/packages/settings.yaml b/config/packages/settings.yaml index c16d1804..05e21636 100644 --- a/config/packages/settings.yaml +++ b/config/packages/settings.yaml @@ -5,11 +5,4 @@ jbtronics_settings: default_cacheable: true orm_storage: - default_entity_class: App\Entity\SettingsEntry - - -# Disable caching for development environment -when@dev: - jbtronics_settings: - cache: - default_cacheable: false + default_entity_class: App\Entity\SettingsEntry \ No newline at end of file diff --git a/docs/usage/bom_import.md b/docs/usage/bom_import.md index b4bcb2be..94a06d55 100644 --- a/docs/usage/bom_import.md +++ b/docs/usage/bom_import.md @@ -34,12 +34,3 @@ 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. 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. -* **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. diff --git a/makefile b/makefile deleted file mode 100644 index 9041ba0f..00000000 --- a/makefile +++ /dev/null @@ -1,112 +0,0 @@ -# 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!" \ No newline at end of file diff --git a/src/Controller/LabelController.php b/src/Controller/LabelController.php index 90a6715b..4950628b 100644 --- a/src/Controller/LabelController.php +++ b/src/Controller/LabelController.php @@ -58,15 +58,12 @@ use Symfony\Component\Form\FormError; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Translation\TranslatorInterface; #[Route(path: '/label')] class LabelController extends AbstractController { - public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator, - private readonly ValidatorInterface $validator - ) + public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator) { } @@ -88,7 +85,6 @@ class LabelController extends AbstractController $form = $this->createForm(LabelDialogType::class, null, [ 'disable_options' => $disable_options, - 'profile' => $profile ]); //Try to parse given target_type and target_id @@ -124,49 +120,12 @@ class LabelController extends AbstractController goto render; } - $new_profile = new LabelProfile(); - $new_profile->setName($form->get('save_profile_name')->getData()); - $new_profile->setOptions($form_options); - - //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->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 = new LabelProfile(); + $profile->setName($form->get('save_profile_name')->getData()); $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'); + $this->addFlash('success', 'label_generator.profile_saved'); return $this->redirectToRoute('label_dialog_profile', [ 'profile' => $profile->getID(), diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 2a6d19ee..a64c1851 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -36,7 +36,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use League\Csv\SyntaxError; use Omines\DataTablesBundle\DataTableFactory; -use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -103,14 +102,9 @@ class ProjectController extends AbstractController $this->addFlash('success', 'project.build.flash.success'); return $this->redirect( - $request->get( - '_redirect', - $this->generateUrl( - 'project_info', - ['id' => $project->getID()] - ) - ) - ); + $request->get('_redirect', + $this->generateUrl('project_info', ['id' => $project->getID()] + ))); } $this->addFlash('error', 'project.build.flash.invalid_input'); @@ -126,13 +120,9 @@ class ProjectController extends AbstractController } #[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])] - public function importBOM( - Request $request, - EntityManagerInterface $entityManager, - Project $project, - BOMImporter $BOMImporter, - ValidatorInterface $validator - ): Response { + public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project, + BOMImporter $BOMImporter, ValidatorInterface $validator): Response + { $this->denyAccessUnlessGranted('edit', $project); $builder = $this->createFormBuilder(); @@ -148,8 +138,6 @@ class ProjectController extends AbstractController 'required' => true, 'choices' => [ '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, [ @@ -173,40 +161,25 @@ class ProjectController extends AbstractController $entityManager->flush(); } - $import_type = $form->get('type')->getData(); - 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, [ - 'type' => $import_type, + 'type' => $form->get('type')->getData(), ]); - // Validate the project entries + //Validate the project entries $errors = $validator->validateProperty($project, 'bom_entries'); - // If no validation errors occurred, save the changes and redirect to edit page - if (count($errors) === 0) { + //If no validation errors occured, save the changes and redirect to edit page + if (count ($errors) === 0) { $this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)])); $entityManager->flush(); return $this->redirectToRoute('project_edit', ['id' => $project->getID()]); } - // When we get here, there were validation errors + //When we get here, there were validation errors $this->addFlash('error', t('project.bom_import.flash.invalid_entries')); - } catch (\UnexpectedValueException | SyntaxError $e) { + } catch (\UnexpectedValueException|SyntaxError $e) { $this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()])); } } @@ -218,267 +191,11 @@ 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: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])] public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response { - if ($project instanceof Project) { + if($project instanceof Project) { $this->denyAccessUnlessGranted('edit', $project); } else { $this->denyAccessUnlessGranted('@projects.edit'); @@ -525,7 +242,7 @@ class ProjectController extends AbstractController $data = $form->getData(); $bom_entries = $data['bom_entries']; - foreach ($bom_entries as $bom_entry) { + foreach ($bom_entries as $bom_entry){ $target_project->addBOMEntry($bom_entry); } diff --git a/src/Form/LabelSystem/LabelDialogType.php b/src/Form/LabelSystem/LabelDialogType.php index d79d01f6..f2710b19 100644 --- a/src/Form/LabelSystem/LabelDialogType.php +++ b/src/Form/LabelSystem/LabelDialogType.php @@ -87,16 +87,6 @@ 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, [ 'label' => 'label_generator.update', ]); @@ -107,6 +97,5 @@ class LabelDialogType extends AbstractType parent::configureOptions($resolver); $resolver->setDefault('mapped', false); $resolver->setDefault('disable_options', false); - $resolver->setDefault('profile', null); } } diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index 862fa463..d4876445 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -22,13 +22,10 @@ declare(strict_types=1); */ namespace App\Services\ImportExportSystem; -use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; -use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use League\Csv\Reader; -use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -47,25 +44,14 @@ class BOMImporter 5 => 'Supplier and ref', ]; - public function __construct( - private readonly EntityManagerInterface $entityManager, - private readonly LoggerInterface $logger, - private readonly BOMValidationService $validationService - ) { + public function __construct() + { } protected function configureOptions(OptionsResolver $resolver): OptionsResolver { $resolver->setRequired('type'); - $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'); + $resolver->setAllowedValues('type', ['kicad_pcbnew']); return $resolver; } @@ -96,23 +82,6 @@ class BOMImporter 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. * @param string $data The data to import @@ -126,13 +95,12 @@ class BOMImporter $options = $resolver->resolve($options); return match ($options['type']) { - 'kicad_pcbnew' => $this->parseKiCADPCB($data), - 'kicad_schematic' => $this->parseKiCADSchematic($data, $options), + 'kicad_pcbnew' => $this->parseKiCADPCB($data, $options), default => throw new InvalidArgumentException('Invalid import type!'), }; } - private function parseKiCADPCB(string $data): array + private function parseKiCADPCB(string $data, array $options = []): array { $csv = Reader::createFromString($data); $csv->setDelimiter(';'); @@ -145,17 +113,17 @@ class BOMImporter $entry = $this->normalizeColumnNames($entry); //Ensure that the entry has all required fields - if (!isset($entry['Designator'])) { - throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!'); + if (!isset ($entry['Designator'])) { + throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!'); } - if (!isset($entry['Package'])) { - throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!'); + if (!isset ($entry['Package'])) { + throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!'); } - if (!isset($entry['Designation'])) { - throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!'); + if (!isset ($entry['Designation'])) { + throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!'); } - if (!isset($entry['Quantity'])) { - throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!'); + if (!isset ($entry['Quantity'])) { + throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!'); } $bom_entry = new ProjectBOMEntry(); @@ -170,63 +138,6 @@ class BOMImporter 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. * @param array $entry @@ -249,482 +160,4 @@ class BOMImporter 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); - } } diff --git a/src/Services/ImportExportSystem/BOMValidationService.php b/src/Services/ImportExportSystem/BOMValidationService.php deleted file mode 100644 index 74f81fe3..00000000 --- a/src/Services/ImportExportSystem/BOMValidationService.php +++ /dev/null @@ -1,476 +0,0 @@ -. - */ -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, - ]; - } -} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index b74e0365..55fa335a 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -158,8 +158,7 @@ class PollinProvider implements InfoProviderInterface category: $this->parseCategory($dom), manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null, preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'), - //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')), + manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')), provider_url: $productPageUrl, notes: $this->parseNotes($dom), datasheets: $this->parseDatasheets($dom), diff --git a/src/Settings/SystemSettings/CustomizationSettings.php b/src/Settings/SystemSettings/CustomizationSettings.php index 623e6187..d7e92a51 100644 --- a/src/Settings/SystemSettings/CustomizationSettings.php +++ b/src/Settings/SystemSettings/CustomizationSettings.php @@ -28,13 +28,10 @@ use App\Form\Type\ThemeChoiceType; use App\Settings\SettingsIcon; use App\Validator\Constraints\ValidTheme; use Jbtronics\SettingsBundle\Metadata\EnvVarMode; -use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; -use Jbtronics\SettingsBundle\ParameterTypes\EnumType; use Jbtronics\SettingsBundle\Settings\Settings; use Jbtronics\SettingsBundle\Settings\SettingsParameter; use Jbtronics\SettingsBundle\Settings\SettingsTrait; use Symfony\Component\Translation\TranslatableMessage as TM; -use Symfony\Component\Validator\Constraints as Assert; #[Settings(name: "customization", label: new TM("settings.system.customization"))] #[SettingsIcon("fa-paint-roller")] @@ -49,13 +46,6 @@ class CustomizationSettings )] 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( label: new TM("settings.system.customization.banner"), formType: RichTextEditorType::class, formOptions: ['mode' => 'markdown-full'], @@ -63,22 +53,10 @@ class CustomizationSettings )] public ?string $banner = null; - /** - * @var HomepageItems[] The items to show in the sidebar. - */ - #[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] - )] - #[Assert\NotBlank()] - #[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") + label: new TM("settings.system.customization.theme"), + formType: ThemeChoiceType::class, formOptions: ['placeholder' => false] )] - public bool $showVersionOnHomepage = true; + #[ValidTheme] + public string $theme = 'bootstrap'; } diff --git a/src/Settings/SystemSettings/HomepageItems.php b/src/Settings/SystemSettings/HomepageItems.php deleted file mode 100644 index 7366dfa2..00000000 --- a/src/Settings/SystemSettings/HomepageItems.php +++ /dev/null @@ -1,51 +0,0 @@ -. - */ - -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); - } -} diff --git a/templates/homepage.html.twig b/templates/homepage.html.twig index 6e7aa360..3f820a53 100644 --- a/templates/homepage.html.twig +++ b/templates/homepage.html.twig @@ -4,23 +4,26 @@ {% import "components/search.macro.html.twig" as search %} {% import "vars.macro.twig" as vars %} -{% block item_search %} +{% block content %} + + {% if is_granted('@system.show_updates') %} + {{ nv.new_version_alert(new_version_available, new_version, new_version_url) }} + {% endif %} + {% if is_granted('@parts.read') %} {{ search.search_form("standalone") }} +
{% endif %} -{% endblock %} -{% block item_banner %} +{% trans %}project.bom_import.validation.errors.description{% endtrans %}
-{% trans %}project.bom_import.validation.warnings.description{% endtrans %}
-| {% trans %}project.bom_import.validation.details.line{% endtrans %} | -{% trans %}project.bom_import.validation.details.status{% endtrans %} | -{% trans %}project.bom_import.validation.details.messages{% endtrans %} | -
|---|---|---|
| - {{ line_result.line_number }} - | -- {% if line_result.is_valid %} - - - {% trans %}project.bom_import.validation.details.valid{% endtrans %} - - {% else %} - - - {% trans %}project.bom_import.validation.details.invalid{% endtrans %} - - {% endif %} - | -
- {% if line_result.errors is not empty %}
-
- {% for error in line_result.errors %}
-
- {% endif %}
- {% if line_result.warnings is not empty %}
- {{ error|raw }}
- {% endfor %}
-
- {% for warning in line_result.warnings %}
-
- {% endif %}
- {% if line_result.info is not empty %}
- {{ warning|raw }}
- {% endfor %}
-
- {% for info in line_result.info %}
-
- {% endif %}
- {{ info|raw }}
- {% endfor %}
- |
-
| {% trans %}project.bom_import.field_mapping.csv_field{% endtrans %} | -{% trans %}project.bom_import.field_mapping.maps_to{% endtrans %} | -{% trans %}project.bom_import.field_mapping.suggestion{% endtrans %} | -{% trans %}project.bom_import.field_mapping.priority{% endtrans %} | -
|---|---|---|---|
- {{ field }}
- |
- - {{ form_widget(form['mapping_' ~ field_name_mapping[field]], { - 'attr': { - 'class': 'form-select field-mapping-select', - 'data-field': field - } - }) }} - | -- {% if suggested_mapping[field] is defined %} - - - {{ suggested_mapping[field] }} - - {% else %} - - - {% trans %}project.bom_import.field_mapping.no_suggestion{% endtrans %} - - {% endif %} - | -- - | -