diff --git a/.github/workflows/assets_artifact_build.yml b/.github/workflows/assets_artifact_build.yml
index c950375b..447f95bf 100644
--- a/.github/workflows/assets_artifact_build.yml
+++ b/.github/workflows/assets_artifact_build.yml
@@ -60,7 +60,7 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '20'
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 66e2f40c..c7c0965b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php-versions: ['8.2', '8.3', '8.4' ]
+ php-versions: ['8.2', '8.3', '8.4', '8.5' ]
db-type: [ 'mysql', 'sqlite', 'postgres' ]
env:
@@ -104,7 +104,7 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '20'
diff --git a/.gitignore b/.gitignore
index 76655919..dd5c43db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,6 @@ yarn-error.log
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
+
+.claude/
+CLAUDE.md
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..bc4d0bf3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,91 @@
+# PartDB Makefile for Test Environment Management
+
+.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
+test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
+section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
+
+# Default target
+help: ## Show this help
+ @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+# Dependencies
+deps-install: ## Install PHP dependencies with unlimited memory
+ @echo "📦 Installing PHP dependencies..."
+ COMPOSER_MEMORY_LIMIT=-1 composer install
+ yarn install
+ @echo "✅ Dependencies installed"
+
+# Complete test environment setup
+test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
+ @echo "✅ Test environment setup complete!"
+
+# Clean test environment
+test-clean: ## Clean test cache and database files
+ @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: ## Create test database (if not exists)
+ @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: ## Run database migrations for test environment
+ @echo "🔄 Running database migrations..."
+ COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
+
+# Clear test cache
+test-cache-clear: ## Clear test cache
+ @echo "🗑️ Clearing test cache..."
+ rm -rf var/cache/test
+ @echo "✅ Test cache cleared"
+
+# Load test fixtures
+test-fixtures: ## Load test fixtures
+ @echo "📦 Loading test fixtures..."
+ php bin/console partdb:fixtures:load -n --env test
+
+# Run PHPUnit tests
+test-run: ## Run PHPUnit tests
+ @echo "🧪 Running tests..."
+ php bin/phpunit
+
+# Quick test reset (clean + migrate + fixtures, skip DB creation)
+test-reset: test-cache-clear test-db-migrate test-fixtures
+ @echo "✅ Test environment reset complete!"
+
+test-typecheck: ## Run static analysis (PHPStan)
+ @echo "🧪 Running type checks..."
+ COMPOSER_MEMORY_LIMIT=-1 composer phpstan
+
+# Development helpers
+dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
+ @echo "✅ Development environment setup complete!"
+
+dev-clean: ## Clean development cache and database files
+ @echo "🧹 Cleaning development environment..."
+ rm -rf var/cache/dev
+ rm -f var/app_dev.db
+ @echo "✅ Development environment cleaned"
+
+dev-db-create: ## Create development database (if not exists)
+ @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: ## Run database migrations for development environment
+ @echo "🔄 Running database migrations..."
+ COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
+
+dev-cache-clear: ## Clear development cache
+ @echo "🗑️ Clearing development cache..."
+ rm -rf var/cache/dev
+ @echo "✅ Development cache cleared"
+
+dev-warmup: ## Warm up development cache
+ @echo "🔥 Warming up development cache..."
+ COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
+
+dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
+ @echo "✅ Development environment reset complete!"
\ No newline at end of file
diff --git a/VERSION b/VERSION
index eca07e4c..c043eea7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.1.2
+2.2.1
diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js
index 095b0d25..bb9fcd1f 100644
--- a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js
+++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js
@@ -20,11 +20,12 @@
import {Plugin} from 'ckeditor5';
require('./lang/de.js');
+require('./lang/en.js');
import { addListToDropdown, createDropdown } from 'ckeditor5';
import {Collection} from 'ckeditor5';
-import {Model} from 'ckeditor5';
+import {UIModel} from 'ckeditor5';
export default class PartDBLabelUI extends Plugin {
init() {
@@ -151,18 +152,28 @@ const PLACEHOLDERS = [
function getDropdownItemsDefinitions(t) {
const itemDefinitions = new Collection();
+ let first = true;
+
for ( const group of PLACEHOLDERS) {
+
//Add group header
- itemDefinitions.add({
- 'type': 'separator',
- model: new Model( {
- withText: true,
- })
- });
+
+ //Skip separator for first group
+ if (!first) {
+
+ itemDefinitions.add({
+ 'type': 'separator',
+ model: new UIModel( {
+ withText: true,
+ })
+ });
+ } else {
+ first = false;
+ }
itemDefinitions.add({
type: 'button',
- model: new Model( {
+ model: new UIModel( {
label: t(group.label),
withText: true,
isEnabled: false,
@@ -173,7 +184,7 @@ function getDropdownItemsDefinitions(t) {
for ( const entry of group.entries) {
const definition = {
type: 'button',
- model: new Model( {
+ model: new UIModel( {
commandParam: entry[0],
label: t(entry[1]),
tooltip: entry[0],
diff --git a/assets/ckeditor/plugins/PartDBLabel/lang/de.js b/assets/ckeditor/plugins/PartDBLabel/lang/de.js
index 748b1607..e0ca0521 100644
--- a/assets/ckeditor/plugins/PartDBLabel/lang/de.js
+++ b/assets/ckeditor/plugins/PartDBLabel/lang/de.js
@@ -17,15 +17,9 @@
* along with this program. If not, see .
*/
-// Make sure that the global object is defined. If not, define it.
-window.CKEDITOR_TRANSLATIONS = window.CKEDITOR_TRANSLATIONS || {};
+import {add} from "ckeditor5";
-// Make sure that the dictionary for Polish translations exist.
-window.CKEDITOR_TRANSLATIONS[ 'de' ] = window.CKEDITOR_TRANSLATIONS[ 'de' ] || {};
-window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary = window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary || {};
-
-// Extend the dictionary for Polish translations with your translations:
-Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
+add( "de", {
'Label Placeholder': 'Label Platzhalter',
'Part': 'Bauteil',
@@ -88,5 +82,4 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'Instance name': 'Instanzname',
'Target type': 'Zieltyp',
'URL of this Part-DB instance': 'URL dieser Part-DB Instanz',
-
-} );
\ No newline at end of file
+});
diff --git a/assets/ckeditor/plugins/PartDBLabel/lang/en.js b/assets/ckeditor/plugins/PartDBLabel/lang/en.js
new file mode 100644
index 00000000..8f77aaf1
--- /dev/null
+++ b/assets/ckeditor/plugins/PartDBLabel/lang/en.js
@@ -0,0 +1,84 @@
+/*
+ * 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 .
+ */
+
+import {add} from "ckeditor5";
+
+add( "en", {
+ 'Label Placeholder': 'Label placeholder',
+ 'Part': 'Part',
+
+ 'Database ID': 'Database ID',
+ 'Part name': 'Part name',
+ 'Category': 'Category',
+ 'Category (Full path)': 'Category (full path)',
+ 'Manufacturer': 'Manufacturer',
+ 'Manufacturer (Full path)': 'Manufacturer (full path)',
+ 'Footprint': 'Footprint',
+ 'Footprint (Full path)': 'Footprint (full path)',
+ 'Mass': 'Mass',
+ 'Manufacturer Product Number (MPN)': 'Manufacturer Product Number (MPN)',
+ 'Internal Part Number (IPN)': 'Internal Part Number (IPN)',
+ 'Tags': 'Tags',
+ 'Manufacturing status': 'Manufacturing status',
+ 'Description': 'Description',
+ 'Description (plain text)': 'Description (plain text)',
+ 'Comment': 'Comment',
+ 'Comment (plain text)': 'Comment (plain text)',
+ 'Last modified datetime': 'Last modified datetime',
+ 'Creation datetime': 'Creation datetime',
+ 'IPN as QR code': 'IPN as QR code',
+ 'IPN as Code 128 barcode': 'IPN as Code 128 barcode',
+ 'IPN as Code 39 barcode': 'IPN as Code 39 barcode',
+
+ 'Lot ID': 'Lot ID',
+ 'Lot name': 'Lot name',
+ 'Lot comment': 'Lot comment',
+ 'Lot expiration date': 'Lot expiration date',
+ 'Lot amount': 'Lot amount',
+ 'Storage location': 'Storage location',
+ 'Storage location (Full path)': 'Storage location (full path)',
+ 'Full name of the lot owner': 'Full name of the lot owner',
+ 'Username of the lot owner': 'Username of the lot owner',
+
+ 'Barcodes': 'Barcodes',
+ 'Content of the 1D barcodes (like Code 39)': 'Content of the 1D barcodes (like Code 39)',
+ 'Content of the 2D barcodes (QR codes)': 'Content of the 2D barcodes (QR codes)',
+ 'QR code linking to this element': 'QR code linking to this element',
+ 'Code 128 barcode linking to this element': 'Code 128 barcode linking to this element',
+ 'Code 39 barcode linking to this element': 'Code 39 barcode linking to this element',
+ 'Code 93 barcode linking to this element': 'Code 93 barcode linking to this element',
+ 'Datamatrix code linking to this element': 'Datamatrix code linking to this element',
+
+ 'Location ID': 'Location ID',
+ 'Name': 'Name',
+ 'Full path': 'Full path',
+ 'Parent name': 'Parent name',
+ 'Parent full path': 'Parent full path',
+ 'Full name of the location owner': 'Full name of the location owner',
+ 'Username of the location owner': 'Username of the location owner',
+
+ 'Username': 'Username',
+ 'Username (including name)': 'Username (including name)',
+ 'Current datetime': 'Current datetime',
+ 'Current date': 'Current date',
+ 'Current time': 'Current time',
+ 'Instance name': 'Instance name',
+ 'Target type': 'Target type',
+ 'URL of this Part-DB instance': 'URL of this Part-DB instance',
+} );
diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js
new file mode 100644
index 00000000..49e4d60f
--- /dev/null
+++ b/assets/controllers/bulk_import_controller.js
@@ -0,0 +1,359 @@
+import { Controller } from "@hotwired/stimulus"
+import { generateCsrfHeaders } from "./csrf_protection_controller"
+
+export default class extends Controller {
+ static targets = ["progressBar", "progressText"]
+ static values = {
+ jobId: Number,
+ partId: Number,
+ researchUrl: String,
+ researchAllUrl: String,
+ markCompletedUrl: String,
+ markSkippedUrl: String,
+ markPendingUrl: String
+ }
+
+ connect() {
+ // Auto-refresh progress if job is in progress
+ if (this.hasProgressBarTarget) {
+ this.startProgressUpdates()
+ }
+
+ // Restore scroll position after page reload (if any)
+ this.restoreScrollPosition()
+ }
+
+ getHeaders() {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+
+ // Add CSRF headers if available
+ const form = document.querySelector('form')
+ if (form) {
+ const csrfHeaders = generateCsrfHeaders(form)
+ Object.assign(headers, csrfHeaders)
+ }
+
+ return headers
+ }
+
+ async fetchWithErrorHandling(url, options = {}, timeout = 30000) {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ headers: { ...this.getHeaders(), ...options.headers },
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Server error (${response.status}): ${errorText}`)
+ }
+
+ return await response.json()
+ } catch (error) {
+ clearTimeout(timeoutId)
+
+ if (error.name === 'AbortError') {
+ throw new Error('Request timed out. Please try again.')
+ } else if (error.message.includes('Failed to fetch')) {
+ throw new Error('Network error. Please check your connection and try again.')
+ } else {
+ throw error
+ }
+ }
+ }
+
+ disconnect() {
+ if (this.progressInterval) {
+ clearInterval(this.progressInterval)
+ }
+ }
+
+ startProgressUpdates() {
+ // Progress updates are handled via page reload for better reliability
+ // No need for periodic updates since state changes trigger page refresh
+ }
+
+ restoreScrollPosition() {
+ const savedPosition = sessionStorage.getItem('bulkImportScrollPosition')
+ if (savedPosition) {
+ // Restore scroll position after a small delay to ensure page is fully loaded
+ setTimeout(() => {
+ window.scrollTo(0, parseInt(savedPosition))
+ // Clear the saved position so it doesn't interfere with normal navigation
+ sessionStorage.removeItem('bulkImportScrollPosition')
+ }, 100)
+ }
+ }
+
+ async markCompleted(event) {
+ const partId = event.currentTarget.dataset.partId
+
+ try {
+ const url = this.markCompletedUrlValue.replace('__PART_ID__', partId)
+ const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
+
+ if (data.success) {
+ this.updateProgressDisplay(data)
+ this.markRowAsCompleted(partId)
+
+ if (data.job_completed) {
+ this.showJobCompletedMessage()
+ }
+ } else {
+ this.showErrorMessage(data.error || 'Failed to mark part as completed')
+ }
+ } catch (error) {
+ console.error('Error marking part as completed:', error)
+ this.showErrorMessage(error.message || 'Failed to mark part as completed')
+ }
+ }
+
+ async markSkipped(event) {
+ const partId = event.currentTarget.dataset.partId
+ const reason = prompt('Reason for skipping (optional):') || ''
+
+ try {
+ const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
+ const data = await this.fetchWithErrorHandling(url, {
+ method: 'POST',
+ body: JSON.stringify({ reason })
+ })
+
+ if (data.success) {
+ this.updateProgressDisplay(data)
+ this.markRowAsSkipped(partId)
+ } else {
+ this.showErrorMessage(data.error || 'Failed to mark part as skipped')
+ }
+ } catch (error) {
+ console.error('Error marking part as skipped:', error)
+ this.showErrorMessage(error.message || 'Failed to mark part as skipped')
+ }
+ }
+
+ async markPending(event) {
+ const partId = event.currentTarget.dataset.partId
+
+ try {
+ const url = this.markPendingUrlValue.replace('__PART_ID__', partId)
+ const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
+
+ if (data.success) {
+ this.updateProgressDisplay(data)
+ this.markRowAsPending(partId)
+ } else {
+ this.showErrorMessage(data.error || 'Failed to mark part as pending')
+ }
+ } catch (error) {
+ console.error('Error marking part as pending:', error)
+ this.showErrorMessage(error.message || 'Failed to mark part as pending')
+ }
+ }
+
+ updateProgressDisplay(data) {
+ if (this.hasProgressBarTarget) {
+ this.progressBarTarget.style.width = `${data.progress}%`
+ this.progressBarTarget.setAttribute('aria-valuenow', data.progress)
+ }
+
+ if (this.hasProgressTextTarget) {
+ this.progressTextTarget.textContent = `${data.completed_count} / ${data.total_count} completed`
+ }
+ }
+
+ markRowAsCompleted(partId) {
+ // Save scroll position and refresh page to show updated state
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ }
+
+ markRowAsSkipped(partId) {
+ // Save scroll position and refresh page to show updated state
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ }
+
+ markRowAsPending(partId) {
+ // Save scroll position and refresh page to show updated state
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ }
+
+ showJobCompletedMessage() {
+ const alert = document.createElement('div')
+ alert.className = 'alert alert-success alert-dismissible fade show'
+ alert.innerHTML = `
+
+ Job completed! All parts have been processed.
+
+ `
+
+ const container = document.querySelector('.card-body')
+ container.insertBefore(alert, container.firstChild)
+ }
+
+ async researchPart(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const partId = event.currentTarget.dataset.partId
+ const spinner = event.currentTarget.querySelector(`[data-research-spinner="${partId}"]`)
+ const button = event.currentTarget
+
+ // Show loading state
+ if (spinner) {
+ spinner.style.display = 'inline-block'
+ }
+ button.disabled = true
+
+ try {
+ const url = this.researchUrlValue.replace('__PART_ID__', partId)
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Server error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ if (data.success) {
+ this.showSuccessMessage(`Research completed for part. Found ${data.results_count} results.`)
+ // Save scroll position and reload to show updated results
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ } else {
+ this.showErrorMessage(data.error || 'Research failed')
+ }
+ } catch (error) {
+ console.error('Error researching part:', error)
+
+ if (error.name === 'AbortError') {
+ this.showErrorMessage('Research timed out. Please try again.')
+ } else if (error.message.includes('Failed to fetch')) {
+ this.showErrorMessage('Network error. Please check your connection and try again.')
+ } else {
+ this.showErrorMessage(error.message || 'Research failed due to an unexpected error')
+ }
+ } finally {
+ // Hide loading state
+ if (spinner) {
+ spinner.style.display = 'none'
+ }
+ button.disabled = false
+ }
+ }
+
+ async researchAllParts(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const spinner = document.getElementById('research-all-spinner')
+ const button = event.currentTarget
+
+ // Show loading state
+ if (spinner) {
+ spinner.style.display = 'inline-block'
+ }
+ button.disabled = true
+
+ try {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 120000) // 2 minute timeout for bulk operations
+
+ const response = await fetch(this.researchAllUrlValue, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Server error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ if (data.success) {
+ this.showSuccessMessage(`Research completed for ${data.researched_count} parts.`)
+ // Save scroll position and reload to show updated results
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ } else {
+ this.showErrorMessage(data.error || 'Bulk research failed')
+ }
+ } catch (error) {
+ console.error('Error researching all parts:', error)
+
+ if (error.name === 'AbortError') {
+ this.showErrorMessage('Bulk research timed out. This may happen with large batches. Please try again or process smaller batches.')
+ } else if (error.message.includes('Failed to fetch')) {
+ this.showErrorMessage('Network error. Please check your connection and try again.')
+ } else {
+ this.showErrorMessage(error.message || 'Bulk research failed due to an unexpected error')
+ }
+ } finally {
+ // Hide loading state
+ if (spinner) {
+ spinner.style.display = 'none'
+ }
+ button.disabled = false
+ }
+ }
+
+ showSuccessMessage(message) {
+ this.showToast('success', message)
+ }
+
+ showErrorMessage(message) {
+ this.showToast('error', message)
+ }
+
+ showToast(type, message) {
+ // Create a simple alert that doesn't disrupt layout
+ const alertId = 'alert-' + Date.now()
+ const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle'
+ const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'
+
+ const alertHTML = `
+
+
+ ${message}
+
+
+ `
+
+ // Add alert to body
+ document.body.insertAdjacentHTML('beforeend', alertHTML)
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => {
+ const alertElement = document.getElementById(alertId)
+ if (alertElement) {
+ alertElement.remove()
+ }
+ }, 5000)
+ }
+}
\ No newline at end of file
diff --git a/assets/controllers/bulk_job_manage_controller.js b/assets/controllers/bulk_job_manage_controller.js
new file mode 100644
index 00000000..c26e37c6
--- /dev/null
+++ b/assets/controllers/bulk_job_manage_controller.js
@@ -0,0 +1,92 @@
+import { Controller } from "@hotwired/stimulus"
+import { generateCsrfHeaders } from "./csrf_protection_controller"
+
+export default class extends Controller {
+ static values = {
+ deleteUrl: String,
+ stopUrl: String,
+ deleteConfirmMessage: String,
+ stopConfirmMessage: String
+ }
+
+ connect() {
+ // Controller initialized
+ }
+ getHeaders() {
+ const headers = {
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+
+ // Add CSRF headers if available
+ const form = document.querySelector('form')
+ if (form) {
+ const csrfHeaders = generateCsrfHeaders(form)
+ Object.assign(headers, csrfHeaders)
+ }
+
+ return headers
+ }
+ async deleteJob(event) {
+ const jobId = event.currentTarget.dataset.jobId
+ const confirmMessage = this.deleteConfirmMessageValue || 'Are you sure you want to delete this job?'
+
+ if (confirm(confirmMessage)) {
+ try {
+ const deleteUrl = this.deleteUrlValue.replace('__JOB_ID__', jobId)
+
+ const response = await fetch(deleteUrl, {
+ method: 'DELETE',
+ headers: this.getHeaders()
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`HTTP ${response.status}: ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ if (data.success) {
+ location.reload()
+ } else {
+ alert('Error deleting job: ' + (data.error || 'Unknown error'))
+ }
+ } catch (error) {
+ console.error('Error deleting job:', error)
+ alert('Error deleting job: ' + error.message)
+ }
+ }
+ }
+
+ async stopJob(event) {
+ const jobId = event.currentTarget.dataset.jobId
+ const confirmMessage = this.stopConfirmMessageValue || 'Are you sure you want to stop this job?'
+
+ if (confirm(confirmMessage)) {
+ try {
+ const stopUrl = this.stopUrlValue.replace('__JOB_ID__', jobId)
+
+ const response = await fetch(stopUrl, {
+ method: 'POST',
+ headers: this.getHeaders()
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`HTTP ${response.status}: ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ if (data.success) {
+ location.reload()
+ } else {
+ alert('Error stopping job: ' + (data.error || 'Unknown error'))
+ }
+ } catch (error) {
+ console.error('Error stopping job:', error)
+ alert('Error stopping job: ' + error.message)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js
index 0175b284..94b01136 100644
--- a/assets/controllers/elements/attachment_autocomplete_controller.js
+++ b/assets/controllers/elements/attachment_autocomplete_controller.js
@@ -34,6 +34,11 @@ export default class extends Controller {
connect() {
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
+
let settings = {
persistent: false,
create: true,
@@ -42,7 +47,7 @@ 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',
+ dropdownParent: dropdownParent,
render: {
item: (data, escape) => {
return '' + escape(data.label) + '';
diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js
index 62a48b15..46e78fd5 100644
--- a/assets/controllers/elements/ckeditor_controller.js
+++ b/assets/controllers/elements/ckeditor_controller.js
@@ -28,6 +28,27 @@ import {EditorWatchdog} from 'ckeditor5';
import "ckeditor5/ckeditor5.css";;
import "../../css/components/ckeditor.css";
+const translationContext = require.context(
+ 'ckeditor5/translations',
+ false,
+ //Only load the translation files we will really need
+ /(de|it|fr|ru|ja|cs|da|zh|pl|hu)\.js$/
+);
+
+function loadTranslation(language) {
+ if (!language || language === 'en') {
+ return null;
+ }
+ const lang = language.slice(0, 2);
+ const path = `./${lang}.js`;
+ if (translationContext.keys().includes(path)) {
+ const module = translationContext(path);
+ return module.default;
+ } else {
+ return null;
+ }
+}
+
/* stimulusFetch: 'lazy' */
export default class extends Controller {
connect() {
@@ -63,6 +84,13 @@ export default class extends Controller {
}
}
+ //Load translations if not english
+ let translations = loadTranslation(language);
+ if (translations) {
+ //Keep existing translations (e.g. from other plugins), if any
+ config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
+ }
+
const watchdog = new EditorWatchdog();
watchdog.setCreator((elementOrData, editorConfig) => {
return EDITOR_TYPE.create(elementOrData, editorConfig)
diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js
index 0658f4b4..8a4e19b8 100644
--- a/assets/controllers/elements/part_select_controller.js
+++ b/assets/controllers/elements/part_select_controller.js
@@ -10,13 +10,19 @@ export default class extends Controller {
connect() {
+ //Check if tomselect is inside an modal and do not attach the dropdown to body in that case (as it breaks the modal)
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
+
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
- dropdownParent: 'body',
+ dropdownParent: dropdownParent,
preload: "focus",
render: {
item: (data, escape) => {
diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js
index f933731a..d70e588c 100644
--- a/assets/controllers/elements/select_controller.js
+++ b/assets/controllers/elements/select_controller.js
@@ -38,13 +38,17 @@ export default class extends Controller {
this._emptyMessage = this.element.getAttribute('title');
}
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
let settings = {
plugins: ["clear_button"],
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,
- dropdownParent: 'body',
+ dropdownParent: dropdownParent,
render: {
item: this.renderItem.bind(this),
diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js
index daa6b0a1..17e85fae 100644
--- a/assets/controllers/elements/select_multiple_controller.js
+++ b/assets/controllers/elements/select_multiple_controller.js
@@ -26,10 +26,15 @@ export default class extends Controller {
_tomSelect;
connect() {
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
+
this._tomSelect = new TomSelect(this.element, {
maxItems: 1000,
allowEmptyOption: true,
- dropdownParent: 'body',
+ dropdownParent: dropdownParent,
plugins: ['remove_button'],
});
}
diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js
index 0421a26d..9703c618 100644
--- a/assets/controllers/elements/static_file_autocomplete_controller.js
+++ b/assets/controllers/elements/static_file_autocomplete_controller.js
@@ -40,6 +40,11 @@ export default class extends Controller {
connect() {
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
+
let settings = {
persistent: false,
create: true,
@@ -50,7 +55,7 @@ export default class extends Controller {
valueField: 'text',
searchField: 'text',
orderField: 'text',
- dropdownParent: 'body',
+ dropdownParent: dropdownParent,
//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..4b220d5b 100644
--- a/assets/controllers/elements/structural_entity_select_controller.js
+++ b/assets/controllers/elements/structural_entity_select_controller.js
@@ -40,7 +40,10 @@ export default class extends Controller {
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
const addHint = this.element.getAttribute("data-add-hint") ?? "";
-
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
let settings = {
@@ -54,7 +57,7 @@ export default class extends Controller {
maxItems: 1,
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
- dropdownParent: 'body',
+ dropdownParent: dropdownParent,
searchField: [
{field: "text", weight : 2},
diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js
index 53bf7608..14725227 100644
--- a/assets/controllers/elements/tagsinput_controller.js
+++ b/assets/controllers/elements/tagsinput_controller.js
@@ -33,6 +33,11 @@ export default class extends Controller {
_tomSelect;
connect() {
+ let dropdownParent = "body";
+ if (this.element.closest('.modal')) {
+ dropdownParent = null
+ }
+
let settings = {
plugins: {
remove_button:{},
@@ -43,7 +48,7 @@ export default class extends Controller {
selectOnTab: true,
createOnBlur: true,
create: true,
- dropdownParent: 'body',
+ dropdownParent: dropdownParent,
};
if(this.element.dataset.autocomplete) {
diff --git a/assets/controllers/field_mapping_controller.js b/assets/controllers/field_mapping_controller.js
new file mode 100644
index 00000000..9c9c8ac6
--- /dev/null
+++ b/assets/controllers/field_mapping_controller.js
@@ -0,0 +1,136 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["tbody", "addButton", "submitButton"]
+ static values = {
+ mappingIndex: Number,
+ maxMappings: Number,
+ prototype: String,
+ maxMappingsReachedMessage: String
+ }
+
+ connect() {
+ this.updateAddButtonState()
+ this.updateFieldOptions()
+ this.attachEventListeners()
+ }
+
+ attachEventListeners() {
+ // Add event listeners to existing field selects
+ const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]')
+ fieldSelects.forEach(select => {
+ select.addEventListener('change', this.updateFieldOptions.bind(this))
+ })
+
+ // Note: Add button click is handled by Stimulus action in template (data-action="click->field-mapping#addMapping")
+ // No manual event listener needed
+
+ // Form submit handler
+ const form = this.element.querySelector('form')
+ if (form && this.hasSubmitButtonTarget) {
+ form.addEventListener('submit', this.handleFormSubmit.bind(this))
+ }
+ }
+
+ addMapping() {
+ const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length
+
+ if (currentMappings >= this.maxMappingsValue) {
+ alert(this.maxMappingsReachedMessageValue)
+ return
+ }
+
+ const newRowHtml = this.prototypeValue.replace(/__name__/g, this.mappingIndexValue)
+ const tempDiv = document.createElement('div')
+ tempDiv.innerHTML = newRowHtml
+
+ const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0]
+ const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1]
+ const priorityWidget = tempDiv.querySelector('input[name*="[priority]"]') || tempDiv.children[2]
+
+ const newRow = document.createElement('tr')
+ newRow.className = 'mapping-row'
+ newRow.innerHTML = `
+
${fieldWidget ? fieldWidget.outerHTML : ''}
+
${providerWidget ? providerWidget.outerHTML : ''}
+
${priorityWidget ? priorityWidget.outerHTML : ''}
+
+
+
+ `
+
+ this.tbodyTarget.appendChild(newRow)
+ this.mappingIndexValue++
+
+ const newFieldSelect = newRow.querySelector('select[name*="[field]"]')
+ if (newFieldSelect) {
+ newFieldSelect.value = ''
+ newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
+ }
+
+ this.updateFieldOptions()
+ this.updateAddButtonState()
+ }
+
+ removeMapping(event) {
+ const row = event.target.closest('tr')
+ row.remove()
+ this.updateFieldOptions()
+ this.updateAddButtonState()
+ }
+
+ updateFieldOptions() {
+ const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]')
+
+ const selectedFields = Array.from(fieldSelects)
+ .map(select => select.value)
+ .filter(value => value && value !== '')
+
+ fieldSelects.forEach(select => {
+ Array.from(select.options).forEach(option => {
+ const isCurrentValue = option.value === select.value
+ const isEmptyOption = !option.value || option.value === ''
+ const isAlreadySelected = selectedFields.includes(option.value)
+
+ if (!isEmptyOption && isAlreadySelected && !isCurrentValue) {
+ option.disabled = true
+ option.style.display = 'none'
+ } else {
+ option.disabled = false
+ option.style.display = ''
+ }
+ })
+ })
+ }
+
+ updateAddButtonState() {
+ const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length
+
+ if (this.hasAddButtonTarget) {
+ if (currentMappings >= this.maxMappingsValue) {
+ this.addButtonTarget.disabled = true
+ this.addButtonTarget.title = this.maxMappingsReachedMessageValue
+ } else {
+ this.addButtonTarget.disabled = false
+ this.addButtonTarget.title = ''
+ }
+ }
+ }
+
+ handleFormSubmit(event) {
+ if (this.hasSubmitButtonTarget) {
+ this.submitButtonTarget.disabled = true
+
+ // Disable the entire form to prevent changes during processing
+ const form = event.target
+ const formElements = form.querySelectorAll('input, select, textarea, button')
+ formElements.forEach(element => {
+ if (element !== this.submitButtonTarget) {
+ element.disabled = true
+ }
+ })
+ }
+ }
+}
\ No newline at end of file
diff --git a/assets/css/app/tables.css b/assets/css/app/tables.css
index 8d4b200c..b2d8882c 100644
--- a/assets/css/app/tables.css
+++ b/assets/css/app/tables.css
@@ -94,6 +94,11 @@ th.select-checkbox {
display: inline-flex;
}
+/** Add spacing between column visibility button and length menu */
+.buttons-colvis {
+ margin-right: 0.2em !important;
+}
+
/** Fix datatables select-checkbox position */
table.dataTable tr.selected td.select-checkbox:after
{
diff --git a/composer.json b/composer.json
index 80b413f8..f53130d4 100644
--- a/composer.json
+++ b/composer.json
@@ -24,8 +24,7 @@
"doctrine/doctrine-bundle": "^2.0",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^3.2.0",
- "dompdf/dompdf": "^v3.0.0",
- "part-db/swap-bundle": "^6.0.0",
+ "dompdf/dompdf": "^3.1.2",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"jbtronics/2fa-webauthn": "^3.0.0",
@@ -37,6 +36,7 @@
"league/csv": "^9.8.0",
"league/html-to-markdown": "^5.0.1",
"liip/imagine-bundle": "^2.2",
+ "maennchen/zipstream-php": "2.1",
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
"nelexa/zip": "^4.0",
"nelmio/cors-bundle": "^2.3",
@@ -45,6 +45,8 @@
"omines/datatables-bundle": "^0.10.0",
"paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0",
+ "part-db/swap-bundle": "^6.0.0",
+ "phpoffice/phpspreadsheet": "^5.0.0",
"rhukster/dom-sanitizer": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1",
@@ -157,7 +159,7 @@
"post-update-cmd": [
"@auto-scripts"
],
- "phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G"
+ "phpstan": "php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5"
},
"conflict": {
"symfony/symfony": "*"
diff --git a/composer.lock b/composer.lock
index 1f67b80f..72e83e0f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fe6dfc229f551945cfa6be8ca26a437e",
+ "content-hash": "3b5a603cc4c289262a2e58b0f37ee42e",
"packages": [
{
"name": "amphp/amp",
@@ -968,16 +968,16 @@
},
{
"name": "api-platform/doctrine-common",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/doctrine-common.git",
- "reference": "e0ef3f5d1c4a9d023da519ea120a1d7732e0b1a7"
+ "reference": "8acbed7c2768f7c15a5b030018132e454f895e55"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/e0ef3f5d1c4a9d023da519ea120a1d7732e0b1a7",
- "reference": "e0ef3f5d1c4a9d023da519ea120a1d7732e0b1a7",
+ "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/8acbed7c2768f7c15a5b030018132e454f895e55",
+ "reference": "8acbed7c2768f7c15a5b030018132e454f895e55",
"shasum": ""
},
"require": {
@@ -995,7 +995,8 @@
"doctrine/mongodb-odm": "^2.10",
"doctrine/orm": "^2.17 || ^3.0",
"phpspec/prophecy-phpunit": "^2.2",
- "phpunit/phpunit": "11.5.x-dev"
+ "phpunit/phpunit": "11.5.x-dev",
+ "symfony/type-info": "^7.3"
},
"suggest": {
"api-platform/graphql": "For GraphQl mercure subscriptions.",
@@ -1017,7 +1018,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1050,31 +1052,31 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/doctrine-common/tree/v4.1.23"
+ "source": "https://github.com/api-platform/doctrine-common/tree/v4.2.2"
},
- "time": "2025-08-18T13:30:43+00:00"
+ "time": "2025-08-27T12:34:14+00:00"
},
{
"name": "api-platform/doctrine-orm",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/doctrine-orm.git",
- "reference": "61a199da6f6014dba2da43ea1a66b2c9dda27263"
+ "reference": "d35d97423f7b399117ee033ecc886b3ed9dc2e23"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/61a199da6f6014dba2da43ea1a66b2c9dda27263",
- "reference": "61a199da6f6014dba2da43ea1a66b2c9dda27263",
+ "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/d35d97423f7b399117ee033ecc886b3ed9dc2e23",
+ "reference": "d35d97423f7b399117ee033ecc886b3ed9dc2e23",
"shasum": ""
},
"require": {
- "api-platform/doctrine-common": "^4.1.11",
+ "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha",
"api-platform/metadata": "^4.1.11",
"api-platform/state": "^4.1.11",
"doctrine/orm": "^2.17 || ^3.0",
"php": ">=8.2",
- "symfony/property-info": "^6.4 || ^7.1"
+ "symfony/type-info": "^7.3"
},
"require-dev": {
"doctrine/doctrine-bundle": "^2.11",
@@ -1085,6 +1087,7 @@
"symfony/cache": "^6.4 || ^7.0",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/property-access": "^6.4 || ^7.0",
+ "symfony/property-info": "^6.4 || ^7.1",
"symfony/serializer": "^6.4 || ^7.0",
"symfony/uid": "^6.4 || ^7.0",
"symfony/validator": "^6.4 || ^7.0",
@@ -1102,7 +1105,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1135,22 +1139,22 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/doctrine-orm/tree/v4.1.23"
+ "source": "https://github.com/api-platform/doctrine-orm/tree/v4.2.2"
},
- "time": "2025-06-06T14:56:47+00:00"
+ "time": "2025-10-07T13:54:25+00:00"
},
{
"name": "api-platform/documentation",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/documentation.git",
- "reference": "1a0ac988d659008ef8667d05bc9978863026bab8"
+ "reference": "c5a54336d8c51271aa5d54e57147cdee7162ab3a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/documentation/zipball/1a0ac988d659008ef8667d05bc9978863026bab8",
- "reference": "1a0ac988d659008ef8667d05bc9978863026bab8",
+ "url": "https://api.github.com/repos/api-platform/documentation/zipball/c5a54336d8c51271aa5d54e57147cdee7162ab3a",
+ "reference": "c5a54336d8c51271aa5d54e57147cdee7162ab3a",
"shasum": ""
},
"require": {
@@ -1172,7 +1176,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1197,22 +1202,22 @@
],
"description": "API Platform documentation controller.",
"support": {
- "source": "https://github.com/api-platform/documentation/tree/v4.2.0-alpha.1"
+ "source": "https://github.com/api-platform/documentation/tree/v4.2.2"
},
- "time": "2025-06-06T14:56:47+00:00"
+ "time": "2025-08-19T08:04:29+00:00"
},
{
"name": "api-platform/http-cache",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/http-cache.git",
- "reference": "f65f092c90311a87ebb6dda87db3ca08b57c10d6"
+ "reference": "aef434b026b861ea451d814c86838b5470b8bfb4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/http-cache/zipball/f65f092c90311a87ebb6dda87db3ca08b57c10d6",
- "reference": "f65f092c90311a87ebb6dda87db3ca08b57c10d6",
+ "url": "https://api.github.com/repos/api-platform/http-cache/zipball/aef434b026b861ea451d814c86838b5470b8bfb4",
+ "reference": "aef434b026b861ea451d814c86838b5470b8bfb4",
"shasum": ""
},
"require": {
@@ -1226,7 +1231,8 @@
"phpspec/prophecy-phpunit": "^2.2",
"phpunit/phpunit": "11.5.x-dev",
"symfony/dependency-injection": "^6.4 || ^7.0",
- "symfony/http-client": "^6.4 || ^7.0"
+ "symfony/http-client": "^6.4 || ^7.0",
+ "symfony/type-info": "^7.3"
},
"type": "library",
"extra": {
@@ -1240,7 +1246,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1275,32 +1282,33 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/http-cache/tree/v4.1.23"
+ "source": "https://github.com/api-platform/http-cache/tree/v4.2.2"
},
- "time": "2025-06-06T14:56:47+00:00"
+ "time": "2025-09-16T12:51:08+00:00"
},
{
"name": "api-platform/hydra",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/hydra.git",
- "reference": "8c75b814af143c95ffc1857565169ff5b6f1b421"
+ "reference": "bfbe928e6a3999433ef11afc267e591152b17cc3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/hydra/zipball/8c75b814af143c95ffc1857565169ff5b6f1b421",
- "reference": "8c75b814af143c95ffc1857565169ff5b6f1b421",
+ "url": "https://api.github.com/repos/api-platform/hydra/zipball/bfbe928e6a3999433ef11afc267e591152b17cc3",
+ "reference": "bfbe928e6a3999433ef11afc267e591152b17cc3",
"shasum": ""
},
"require": {
- "api-platform/documentation": "^4.1.11",
- "api-platform/json-schema": "^4.1.11",
- "api-platform/jsonld": "^4.1.11",
- "api-platform/metadata": "^4.1.11",
- "api-platform/serializer": "^4.1.11",
- "api-platform/state": "^4.1.11",
+ "api-platform/documentation": "^4.1",
+ "api-platform/json-schema": "^4.2@beta",
+ "api-platform/jsonld": "^4.1",
+ "api-platform/metadata": "^4.2@beta",
+ "api-platform/serializer": "^4.1",
+ "api-platform/state": "^4.1.8",
"php": ">=8.2",
+ "symfony/type-info": "^7.3",
"symfony/web-link": "^6.4 || ^7.1"
},
"require-dev": {
@@ -1323,7 +1331,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1360,38 +1369,40 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/hydra/tree/v4.1.23"
+ "source": "https://github.com/api-platform/hydra/tree/v4.2.2"
},
- "time": "2025-07-15T14:10:59+00:00"
+ "time": "2025-10-07T13:39:38+00:00"
},
{
"name": "api-platform/json-api",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/json-api.git",
- "reference": "7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4"
+ "reference": "e8da698d55fb1702b25c63d7c821d1760159912e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/json-api/zipball/7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4",
- "reference": "7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4",
+ "url": "https://api.github.com/repos/api-platform/json-api/zipball/e8da698d55fb1702b25c63d7c821d1760159912e",
+ "reference": "e8da698d55fb1702b25c63d7c821d1760159912e",
"shasum": ""
},
"require": {
"api-platform/documentation": "^4.1.11",
- "api-platform/json-schema": "^4.1.11",
- "api-platform/metadata": "^4.1.11",
+ "api-platform/json-schema": "^4.2@beta",
+ "api-platform/metadata": "^4.2@beta",
"api-platform/serializer": "^4.1.11",
"api-platform/state": "^4.1.11",
"php": ">=8.2",
"symfony/error-handler": "^6.4 || ^7.0",
- "symfony/http-foundation": "^6.4 || ^7.0"
+ "symfony/http-foundation": "^6.4 || ^7.0",
+ "symfony/type-info": "^7.3"
},
"require-dev": {
"phpspec/prophecy": "^1.19",
"phpspec/prophecy-phpunit": "^2.2",
- "phpunit/phpunit": "11.5.x-dev"
+ "phpunit/phpunit": "11.5.x-dev",
+ "symfony/type-info": "^7.3"
},
"type": "library",
"extra": {
@@ -1405,7 +1416,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1439,30 +1451,31 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/json-api/tree/v4.1.23"
+ "source": "https://github.com/api-platform/json-api/tree/v4.2.2"
},
- "time": "2025-08-06T07:56:58+00:00"
+ "time": "2025-09-16T12:49:22+00:00"
},
{
"name": "api-platform/json-schema",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/json-schema.git",
- "reference": "1d1c6eaa4841f3989e2bec4cdf8167fb0ca42a8f"
+ "reference": "ec81bdd09375067d7d2555c10ec3dfc697874327"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/json-schema/zipball/1d1c6eaa4841f3989e2bec4cdf8167fb0ca42a8f",
- "reference": "1d1c6eaa4841f3989e2bec4cdf8167fb0ca42a8f",
+ "url": "https://api.github.com/repos/api-platform/json-schema/zipball/ec81bdd09375067d7d2555c10ec3dfc697874327",
+ "reference": "ec81bdd09375067d7d2555c10ec3dfc697874327",
"shasum": ""
},
"require": {
- "api-platform/metadata": "^4.1.11",
+ "api-platform/metadata": "^4.2",
"php": ">=8.2",
"symfony/console": "^6.4 || ^7.0",
"symfony/property-info": "^6.4 || ^7.1",
"symfony/serializer": "^6.4 || ^7.0",
+ "symfony/type-info": "^7.3",
"symfony/uid": "^6.4 || ^7.0"
},
"require-dev": {
@@ -1481,7 +1494,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1518,22 +1532,22 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/json-schema/tree/v4.1.23"
+ "source": "https://github.com/api-platform/json-schema/tree/v4.2.2"
},
- "time": "2025-06-29T12:24:14+00:00"
+ "time": "2025-10-07T09:45:59+00:00"
},
{
"name": "api-platform/jsonld",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/jsonld.git",
- "reference": "e122bf1f04f895e80e6469e0f09d1f06f7508ca6"
+ "reference": "774b460273177983c52540a479ea9e9f940d7a1b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/jsonld/zipball/e122bf1f04f895e80e6469e0f09d1f06f7508ca6",
- "reference": "e122bf1f04f895e80e6469e0f09d1f06f7508ca6",
+ "url": "https://api.github.com/repos/api-platform/jsonld/zipball/774b460273177983c52540a479ea9e9f940d7a1b",
+ "reference": "774b460273177983c52540a479ea9e9f940d7a1b",
"shasum": ""
},
"require": {
@@ -1543,7 +1557,8 @@
"php": ">=8.2"
},
"require-dev": {
- "phpunit/phpunit": "11.5.x-dev"
+ "phpunit/phpunit": "11.5.x-dev",
+ "symfony/type-info": "^7.3"
},
"type": "library",
"extra": {
@@ -1557,7 +1572,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1596,22 +1612,22 @@
"rest"
],
"support": {
- "source": "https://github.com/api-platform/jsonld/tree/v4.1.23"
+ "source": "https://github.com/api-platform/jsonld/tree/v4.2.2"
},
- "time": "2025-07-25T10:05:30+00:00"
+ "time": "2025-09-25T19:30:56+00:00"
},
{
"name": "api-platform/metadata",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/metadata.git",
- "reference": "58b25f9a82c12727afab09b5a311828aacff8e88"
+ "reference": "5aeba910cb6352068664ca11cd9ee0dc208894c0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/metadata/zipball/58b25f9a82c12727afab09b5a311828aacff8e88",
- "reference": "58b25f9a82c12727afab09b5a311828aacff8e88",
+ "url": "https://api.github.com/repos/api-platform/metadata/zipball/5aeba910cb6352068664ca11cd9ee0dc208894c0",
+ "reference": "5aeba910cb6352068664ca11cd9ee0dc208894c0",
"shasum": ""
},
"require": {
@@ -1653,7 +1669,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1693,40 +1710,42 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/metadata/tree/v4.1.23"
+ "source": "https://github.com/api-platform/metadata/tree/v4.2.2"
},
- "time": "2025-09-05T09:06:52+00:00"
+ "time": "2025-10-08T08:36:37+00:00"
},
{
"name": "api-platform/openapi",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/openapi.git",
- "reference": "793b53e51a5c24076d4024b6aa77de29e74015cd"
+ "reference": "2545f2be06eed0f9a121d631b8f1db22212a7826"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/openapi/zipball/793b53e51a5c24076d4024b6aa77de29e74015cd",
- "reference": "793b53e51a5c24076d4024b6aa77de29e74015cd",
+ "url": "https://api.github.com/repos/api-platform/openapi/zipball/2545f2be06eed0f9a121d631b8f1db22212a7826",
+ "reference": "2545f2be06eed0f9a121d631b8f1db22212a7826",
"shasum": ""
},
"require": {
- "api-platform/json-schema": "^4.1.11",
- "api-platform/metadata": "^4.1.11",
- "api-platform/state": "^4.1.11",
+ "api-platform/json-schema": "^4.2@beta",
+ "api-platform/metadata": "^4.2@beta",
+ "api-platform/state": "^4.2@beta",
"php": ">=8.2",
"symfony/console": "^6.4 || ^7.0",
"symfony/filesystem": "^6.4 || ^7.0",
"symfony/property-access": "^6.4 || ^7.0",
- "symfony/serializer": "^6.4 || ^7.0"
+ "symfony/serializer": "^6.4 || ^7.0",
+ "symfony/type-info": "^7.3"
},
"require-dev": {
"api-platform/doctrine-common": "^4.1",
"api-platform/doctrine-odm": "^4.1",
"api-platform/doctrine-orm": "^4.1",
"phpspec/prophecy-phpunit": "^2.2",
- "phpunit/phpunit": "11.5.x-dev"
+ "phpunit/phpunit": "11.5.x-dev",
+ "symfony/type-info": "^7.3"
},
"type": "library",
"extra": {
@@ -1740,7 +1759,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1780,26 +1800,26 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/openapi/tree/v4.1.23"
+ "source": "https://github.com/api-platform/openapi/tree/v4.2.2"
},
- "time": "2025-07-29T08:53:27+00:00"
+ "time": "2025-09-30T12:06:50+00:00"
},
{
"name": "api-platform/serializer",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/serializer.git",
- "reference": "70dbdeac9584870be444d78c1a796b6edb9e46a5"
+ "reference": "58c1378af6429049ee62951b838f6b645706c3a3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/serializer/zipball/70dbdeac9584870be444d78c1a796b6edb9e46a5",
- "reference": "70dbdeac9584870be444d78c1a796b6edb9e46a5",
+ "url": "https://api.github.com/repos/api-platform/serializer/zipball/58c1378af6429049ee62951b838f6b645706c3a3",
+ "reference": "58c1378af6429049ee62951b838f6b645706c3a3",
"shasum": ""
},
"require": {
- "api-platform/metadata": "^4.1.11",
+ "api-platform/metadata": "^4.2.0",
"api-platform/state": "^4.1.11",
"php": ">=8.2",
"symfony/property-access": "^6.4 || ^7.0",
@@ -1817,6 +1837,7 @@
"phpspec/prophecy-phpunit": "^2.2",
"phpunit/phpunit": "11.5.x-dev",
"symfony/mercure-bundle": "*",
+ "symfony/type-info": "^7.3",
"symfony/var-dumper": "^6.4 || ^7.0",
"symfony/yaml": "^6.4 || ^7.0"
},
@@ -1836,7 +1857,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1871,22 +1893,22 @@
"serializer"
],
"support": {
- "source": "https://github.com/api-platform/serializer/tree/v4.1.23"
+ "source": "https://github.com/api-platform/serializer/tree/v4.2.2"
},
- "time": "2025-08-29T15:13:26+00:00"
+ "time": "2025-10-03T08:13:34+00:00"
},
{
"name": "api-platform/state",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/state.git",
- "reference": "056b07285cdc904984fb44c2614f7df8f4620a95"
+ "reference": "7dc3dfbafa4627cc789bd8bcd4da46e6c37f6b26"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/state/zipball/056b07285cdc904984fb44c2614f7df8f4620a95",
- "reference": "056b07285cdc904984fb44c2614f7df8f4620a95",
+ "url": "https://api.github.com/repos/api-platform/state/zipball/7dc3dfbafa4627cc789bd8bcd4da46e6c37f6b26",
+ "reference": "7dc3dfbafa4627cc789bd8bcd4da46e6c37f6b26",
"shasum": ""
},
"require": {
@@ -1898,9 +1920,12 @@
"symfony/translation-contracts": "^3.0"
},
"require-dev": {
+ "api-platform/serializer": "^4.1",
"api-platform/validator": "^4.1",
"phpunit/phpunit": "11.5.x-dev",
"symfony/http-foundation": "^6.4 || ^7.0",
+ "symfony/object-mapper": "^7.3",
+ "symfony/type-info": "^7.3",
"symfony/web-link": "^6.4 || ^7.1",
"willdurand/negotiation": "^3.1"
},
@@ -1923,7 +1948,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -1963,22 +1989,22 @@
"swagger"
],
"support": {
- "source": "https://github.com/api-platform/state/tree/v4.1.23"
+ "source": "https://github.com/api-platform/state/tree/v4.2.2"
},
- "time": "2025-07-16T14:01:52+00:00"
+ "time": "2025-10-09T08:33:56+00:00"
},
{
"name": "api-platform/symfony",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/symfony.git",
- "reference": "e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1"
+ "reference": "44bb117df1cd5695203ec6a97999cabc85257780"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/symfony/zipball/e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1",
- "reference": "e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1",
+ "url": "https://api.github.com/repos/api-platform/symfony/zipball/44bb117df1cd5695203ec6a97999cabc85257780",
+ "reference": "44bb117df1cd5695203ec6a97999cabc85257780",
"shasum": ""
},
"require": {
@@ -1987,12 +2013,13 @@
"api-platform/hydra": "^4.1.11",
"api-platform/json-schema": "^4.1.11",
"api-platform/jsonld": "^4.1.11",
- "api-platform/metadata": "^4.1.11",
+ "api-platform/metadata": "^4.2@beta",
"api-platform/openapi": "^4.1.11",
"api-platform/serializer": "^4.1.11",
- "api-platform/state": "^4.1.11",
+ "api-platform/state": "^4.2@beta",
"api-platform/validator": "^4.1.11",
"php": ">=8.2",
+ "symfony/finder": "^6.4 || ^7.0",
"symfony/property-access": "^6.4 || ^7.0",
"symfony/property-info": "^6.4 || ^7.1",
"symfony/security-core": "^6.4 || ^7.0",
@@ -2009,8 +2036,11 @@
"phpspec/prophecy-phpunit": "^2.2",
"phpunit/phpunit": "11.5.x-dev",
"symfony/expression-language": "^6.4 || ^7.0",
+ "symfony/intl": "^6.4 || ^7.0",
"symfony/mercure-bundle": "*",
+ "symfony/object-mapper": "^7.0",
"symfony/routing": "^6.4 || ^7.0",
+ "symfony/type-info": "^7.3",
"symfony/validator": "^6.4 || ^7.0",
"webonyx/graphql-php": "^15.0"
},
@@ -2019,8 +2049,8 @@
"api-platform/doctrine-orm": "To support Doctrine ORM.",
"api-platform/elasticsearch": "To support Elasticsearch.",
"api-platform/graphql": "To support GraphQL.",
+ "api-platform/hal": "to support the HAL format",
"api-platform/ramsey-uuid": "To support Ramsey's UUID identifiers.",
- "ocramius/package-versions": "To display the API Platform's version in the debug bar.",
"phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.",
"psr/cache-implementation": "To use metadata caching.",
"symfony/cache": "To have metadata caching when using Symfony integration.",
@@ -2046,7 +2076,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -2087,22 +2118,22 @@
"symfony"
],
"support": {
- "source": "https://github.com/api-platform/symfony/tree/v4.1.23"
+ "source": "https://github.com/api-platform/symfony/tree/v4.2.2"
},
- "time": "2025-09-05T07:30:37+00:00"
+ "time": "2025-10-09T08:33:56+00:00"
},
{
"name": "api-platform/validator",
- "version": "v4.1.23",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/api-platform/validator.git",
- "reference": "9f0bde95dccf1d86e6a6165543d601a4a46eaa9a"
+ "reference": "9986e84b27e3de7f87c7c23f238430a572f87f67"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/api-platform/validator/zipball/9f0bde95dccf1d86e6a6165543d601a4a46eaa9a",
- "reference": "9f0bde95dccf1d86e6a6165543d601a4a46eaa9a",
+ "url": "https://api.github.com/repos/api-platform/validator/zipball/9986e84b27e3de7f87c7c23f238430a572f87f67",
+ "reference": "9986e84b27e3de7f87c7c23f238430a572f87f67",
"shasum": ""
},
"require": {
@@ -2110,7 +2141,7 @@
"php": ">=8.2",
"symfony/http-kernel": "^6.4 || ^7.1",
"symfony/serializer": "^6.4 || ^7.1",
- "symfony/type-info": "^7.2",
+ "symfony/type-info": "^7.3",
"symfony/validator": "^6.4 || ^7.1",
"symfony/web-link": "^6.4 || ^7.1"
},
@@ -2130,7 +2161,8 @@
"branch-alias": {
"dev-3.4": "3.4.x-dev",
"dev-4.1": "4.1.x-dev",
- "dev-main": "4.2.x-dev"
+ "dev-4.2": "4.2.x-dev",
+ "dev-main": "4.3.x-dev"
}
},
"autoload": {
@@ -2162,9 +2194,9 @@
"validator"
],
"support": {
- "source": "https://github.com/api-platform/validator/tree/v4.1.23"
+ "source": "https://github.com/api-platform/validator/tree/v4.2.2"
},
- "time": "2025-07-16T14:01:52+00:00"
+ "time": "2025-09-29T15:36:04+00:00"
},
{
"name": "beberlei/assert",
@@ -2500,6 +2532,85 @@
],
"time": "2022-01-17T14:14:24+00:00"
},
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
{
"name": "daverandom/libdns",
"version": "v2.1.0",
@@ -2798,16 +2909,16 @@
},
{
"name": "doctrine/data-fixtures",
- "version": "2.1.0",
+ "version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/data-fixtures.git",
- "reference": "f161e20f04ba5440a09330e156b40f04dd70d47f"
+ "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/f161e20f04ba5440a09330e156b40f04dd70d47f",
- "reference": "f161e20f04ba5440a09330e156b40f04dd70d47f",
+ "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
+ "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
"shasum": ""
},
"require": {
@@ -2821,14 +2932,14 @@
"doctrine/phpcr-odm": "<1.3.0"
},
"require-dev": {
- "doctrine/coding-standard": "^13",
+ "doctrine/coding-standard": "^14",
"doctrine/dbal": "^3.5 || ^4",
"doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
"doctrine/orm": "^2.14 || ^3",
"ext-sqlite3": "*",
"fig/log-test": "^1",
- "phpstan/phpstan": "2.1.17",
- "phpunit/phpunit": "10.5.45",
+ "phpstan/phpstan": "2.1.31",
+ "phpunit/phpunit": "10.5.45 || 12.4.0",
"symfony/cache": "^6.4 || ^7",
"symfony/var-exporter": "^6.4 || ^7"
},
@@ -2861,7 +2972,7 @@
],
"support": {
"issues": "https://github.com/doctrine/data-fixtures/issues",
- "source": "https://github.com/doctrine/data-fixtures/tree/2.1.0"
+ "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
},
"funding": [
{
@@ -2877,20 +2988,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-08T17:48:20+00:00"
+ "time": "2025-10-17T20:06:20+00:00"
},
{
"name": "doctrine/dbal",
- "version": "4.3.3",
+ "version": "4.3.4",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
- "reference": "231959669bb2173194c95636eae7f1b41b2a8b19"
+ "reference": "1a2fbd0e93b8dec7c3d1ac2b6396a7b929b130dc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/dbal/zipball/231959669bb2173194c95636eae7f1b41b2a8b19",
- "reference": "231959669bb2173194c95636eae7f1b41b2a8b19",
+ "url": "https://api.github.com/repos/doctrine/dbal/zipball/1a2fbd0e93b8dec7c3d1ac2b6396a7b929b130dc",
+ "reference": "1a2fbd0e93b8dec7c3d1ac2b6396a7b929b130dc",
"shasum": ""
},
"require": {
@@ -2900,15 +3011,15 @@
"psr/log": "^1|^2|^3"
},
"require-dev": {
- "doctrine/coding-standard": "13.0.1",
+ "doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.2",
- "phpstan/phpstan": "2.1.22",
- "phpstan/phpstan-phpunit": "2.0.6",
+ "phpstan/phpstan": "2.1.30",
+ "phpstan/phpstan-phpunit": "2.0.7",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "11.5.23",
- "slevomat/coding-standard": "8.16.2",
- "squizlabs/php_codesniffer": "3.13.1",
+ "slevomat/coding-standard": "8.24.0",
+ "squizlabs/php_codesniffer": "4.0.0",
"symfony/cache": "^6.3.8|^7.0",
"symfony/console": "^5.4|^6.3|^7.0"
},
@@ -2967,7 +3078,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
- "source": "https://github.com/doctrine/dbal/tree/4.3.3"
+ "source": "https://github.com/doctrine/dbal/tree/4.3.4"
},
"funding": [
{
@@ -2983,7 +3094,7 @@
"type": "tidelift"
}
],
- "time": "2025-09-04T23:52:42+00:00"
+ "time": "2025-10-09T09:11:36+00:00"
},
{
"name": "doctrine/deprecations",
@@ -3035,20 +3146,21 @@
},
{
"name": "doctrine/doctrine-bundle",
- "version": "2.16.1",
+ "version": "2.18.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineBundle.git",
- "reference": "152d5083f0cd205a278131dc4351a8c94d007fe1"
+ "reference": "cd5d4da6a5f7cf3d8708e17211234657b5eb4e95"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/152d5083f0cd205a278131dc4351a8c94d007fe1",
- "reference": "152d5083f0cd205a278131dc4351a8c94d007fe1",
+ "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/cd5d4da6a5f7cf3d8708e17211234657b5eb4e95",
+ "reference": "cd5d4da6a5f7cf3d8708e17211234657b5eb4e95",
"shasum": ""
},
"require": {
"doctrine/dbal": "^3.7.0 || ^4.0",
+ "doctrine/deprecations": "^1.0",
"doctrine/persistence": "^3.1 || ^4",
"doctrine/sql-formatter": "^1.0.1",
"php": "^8.1",
@@ -3056,7 +3168,6 @@
"symfony/config": "^6.4 || ^7.0",
"symfony/console": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
- "symfony/deprecation-contracts": "^2.1 || ^3",
"symfony/doctrine-bridge": "^6.4.3 || ^7.0.3",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/service-contracts": "^2.5 || ^3"
@@ -3071,19 +3182,17 @@
"require-dev": {
"doctrine/annotations": "^1 || ^2",
"doctrine/cache": "^1.11 || ^2.0",
- "doctrine/coding-standard": "^13",
- "doctrine/deprecations": "^1.0",
+ "doctrine/coding-standard": "^14",
"doctrine/orm": "^2.17 || ^3.1",
"friendsofphp/proxy-manager-lts": "^1.0",
"phpstan/phpstan": "2.1.1",
"phpstan/phpstan-phpunit": "2.0.3",
"phpstan/phpstan-strict-rules": "^2",
- "phpunit/phpunit": "^9.6.22",
+ "phpunit/phpunit": "^10.5.53 || ^12.3.10",
"psr/log": "^1.1.4 || ^2.0 || ^3.0",
"symfony/doctrine-messenger": "^6.4 || ^7.0",
"symfony/expression-language": "^6.4 || ^7.0",
"symfony/messenger": "^6.4 || ^7.0",
- "symfony/phpunit-bridge": "^7.2",
"symfony/property-info": "^6.4 || ^7.0",
"symfony/security-bundle": "^6.4 || ^7.0",
"symfony/stopwatch": "^6.4 || ^7.0",
@@ -3093,7 +3202,7 @@
"symfony/var-exporter": "^6.4.1 || ^7.0.1",
"symfony/web-profiler-bundle": "^6.4 || ^7.0",
"symfony/yaml": "^6.4 || ^7.0",
- "twig/twig": "^2.13 || ^3.0.4"
+ "twig/twig": "^2.14.7 || ^3.0.4"
},
"suggest": {
"doctrine/orm": "The Doctrine ORM integration is optional in the bundle.",
@@ -3138,7 +3247,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
- "source": "https://github.com/doctrine/DoctrineBundle/tree/2.16.1"
+ "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.0"
},
"funding": [
{
@@ -3154,24 +3263,24 @@
"type": "tidelift"
}
],
- "time": "2025-09-05T15:24:53+00:00"
+ "time": "2025-10-11T04:43:27+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
- "version": "3.4.2",
+ "version": "3.5.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineMigrationsBundle.git",
- "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9"
+ "reference": "71c81279ca0e907c3edc718418b93fd63074856c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/5a6ac7120c2924c4c070a869d08b11ccf9e277b9",
- "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9",
+ "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/71c81279ca0e907c3edc718418b93fd63074856c",
+ "reference": "71c81279ca0e907c3edc718418b93fd63074856c",
"shasum": ""
},
"require": {
- "doctrine/doctrine-bundle": "^2.4",
+ "doctrine/doctrine-bundle": "^2.4 || ^3.0",
"doctrine/migrations": "^3.2",
"php": "^7.2 || ^8.0",
"symfony/deprecation-contracts": "^2.1 || ^3",
@@ -3179,7 +3288,7 @@
},
"require-dev": {
"composer/semver": "^3.0",
- "doctrine/coding-standard": "^12",
+ "doctrine/coding-standard": "^12 || ^14",
"doctrine/orm": "^2.6 || ^3",
"phpstan/phpstan": "^1.4 || ^2",
"phpstan/phpstan-deprecation-rules": "^1 || ^2",
@@ -3223,7 +3332,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues",
- "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.4.2"
+ "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.5.0"
},
"funding": [
{
@@ -3239,7 +3348,7 @@
"type": "tidelift"
}
],
- "time": "2025-03-11T17:36:26+00:00"
+ "time": "2025-10-12T17:06:40+00:00"
},
{
"name": "doctrine/event-manager",
@@ -3764,16 +3873,16 @@
},
{
"name": "doctrine/persistence",
- "version": "4.1.0",
+ "version": "4.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/persistence.git",
- "reference": "dcbdfe4b211ae09478e192289cae7ab0987b29a4"
+ "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/persistence/zipball/dcbdfe4b211ae09478e192289cae7ab0987b29a4",
- "reference": "dcbdfe4b211ae09478e192289cae7ab0987b29a4",
+ "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09",
+ "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09",
"shasum": ""
},
"require": {
@@ -3782,11 +3891,11 @@
"psr/cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
- "doctrine/coding-standard": "^12",
- "phpstan/phpstan": "1.12.7",
- "phpstan/phpstan-phpunit": "^1",
- "phpstan/phpstan-strict-rules": "^1.6",
- "phpunit/phpunit": "^9.6",
+ "doctrine/coding-standard": "^14",
+ "phpstan/phpstan": "2.1.30",
+ "phpstan/phpstan-phpunit": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^10.5.58 || ^12",
"symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0",
"symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0"
},
@@ -3837,7 +3946,7 @@
],
"support": {
"issues": "https://github.com/doctrine/persistence/issues",
- "source": "https://github.com/doctrine/persistence/tree/4.1.0"
+ "source": "https://github.com/doctrine/persistence/tree/4.1.1"
},
"funding": [
{
@@ -3853,7 +3962,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-21T16:00:31+00:00"
+ "time": "2025-10-16T20:13:18+00:00"
},
{
"name": "doctrine/sql-formatter",
@@ -3912,16 +4021,16 @@
},
{
"name": "dompdf/dompdf",
- "version": "v3.1.0",
+ "version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
- "reference": "a51bd7a063a65499446919286fb18b518177155a"
+ "reference": "baed300e4fb8226359c04395518059a136e2a2e2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a",
- "reference": "a51bd7a063a65499446919286fb18b518177155a",
+ "url": "https://api.github.com/repos/dompdf/dompdf/zipball/baed300e4fb8226359c04395518059a136e2a2e2",
+ "reference": "baed300e4fb8226359c04395518059a136e2a2e2",
"shasum": ""
},
"require": {
@@ -3970,9 +4079,9 @@
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
- "source": "https://github.com/dompdf/dompdf/tree/v3.1.0"
+ "source": "https://github.com/dompdf/dompdf/tree/v3.1.3"
},
- "time": "2025-01-15T14:09:04+00:00"
+ "time": "2025-10-14T13:10:17+00:00"
},
{
"name": "dompdf/php-font-lib",
@@ -4651,16 +4760,16 @@
},
{
"name": "hshn/base64-encoded-file",
- "version": "v5.0.2",
+ "version": "v5.0.3",
"source": {
"type": "git",
"url": "https://github.com/hshn/base64-encoded-file.git",
- "reference": "49e38d27fcf01a2f5b142886d6ef20fa62132a2d"
+ "reference": "74984c7e69fbed9378dbf1d64e632522cc1b6d95"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/hshn/base64-encoded-file/zipball/49e38d27fcf01a2f5b142886d6ef20fa62132a2d",
- "reference": "49e38d27fcf01a2f5b142886d6ef20fa62132a2d",
+ "url": "https://api.github.com/repos/hshn/base64-encoded-file/zipball/74984c7e69fbed9378dbf1d64e632522cc1b6d95",
+ "reference": "74984c7e69fbed9378dbf1d64e632522cc1b6d95",
"shasum": ""
},
"require": {
@@ -4707,9 +4816,9 @@
"description": "Provides handling base64 encoded files, and the integration of symfony/form",
"support": {
"issues": "https://github.com/hshn/base64-encoded-file/issues",
- "source": "https://github.com/hshn/base64-encoded-file/tree/v5.0.2"
+ "source": "https://github.com/hshn/base64-encoded-file/tree/v5.0.3"
},
- "time": "2025-07-06T05:52:34+00:00"
+ "time": "2025-10-06T10:34:52+00:00"
},
{
"name": "imagine/imagine",
@@ -4835,16 +4944,16 @@
},
{
"name": "jbtronics/dompdf-font-loader-bundle",
- "version": "v1.1.4",
+ "version": "v1.1.5",
"source": {
"type": "git",
"url": "https://github.com/jbtronics/dompdf-font-loader-bundle.git",
- "reference": "1b41014a2dd9e82ba6a62e61deeebe3cdc1eaf1f"
+ "reference": "83a0e50ecceefea0a63915dae758e00788fd067e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/jbtronics/dompdf-font-loader-bundle/zipball/1b41014a2dd9e82ba6a62e61deeebe3cdc1eaf1f",
- "reference": "1b41014a2dd9e82ba6a62e61deeebe3cdc1eaf1f",
+ "url": "https://api.github.com/repos/jbtronics/dompdf-font-loader-bundle/zipball/83a0e50ecceefea0a63915dae758e00788fd067e",
+ "reference": "83a0e50ecceefea0a63915dae758e00788fd067e",
"shasum": ""
},
"require": {
@@ -4884,22 +4993,22 @@
],
"support": {
"issues": "https://github.com/jbtronics/dompdf-font-loader-bundle/issues",
- "source": "https://github.com/jbtronics/dompdf-font-loader-bundle/tree/v1.1.4"
+ "source": "https://github.com/jbtronics/dompdf-font-loader-bundle/tree/v1.1.5"
},
- "time": "2025-07-07T20:39:34+00:00"
+ "time": "2025-07-25T20:29:05+00:00"
},
{
"name": "jbtronics/settings-bundle",
- "version": "v3.0.1",
+ "version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/jbtronics/settings-bundle.git",
- "reference": "9103bd7f78f0b223d1c7167feb824004fc2a9f07"
+ "reference": "1067dd3d816cd0a6be7ac3d3989587ea05040bd4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/9103bd7f78f0b223d1c7167feb824004fc2a9f07",
- "reference": "9103bd7f78f0b223d1c7167feb824004fc2a9f07",
+ "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/1067dd3d816cd0a6be7ac3d3989587ea05040bd4",
+ "reference": "1067dd3d816cd0a6be7ac3d3989587ea05040bd4",
"shasum": ""
},
"require": {
@@ -4960,7 +5069,7 @@
],
"support": {
"issues": "https://github.com/jbtronics/settings-bundle/issues",
- "source": "https://github.com/jbtronics/settings-bundle/tree/v3.0.1"
+ "source": "https://github.com/jbtronics/settings-bundle/tree/v3.1.1"
},
"funding": [
{
@@ -4972,7 +5081,7 @@
"type": "github"
}
],
- "time": "2025-08-24T21:20:15+00:00"
+ "time": "2025-09-22T22:00:15+00:00"
},
{
"name": "jfcherng/php-color-output",
@@ -5271,16 +5380,16 @@
},
{
"name": "knpuniversity/oauth2-client-bundle",
- "version": "v2.18.4",
+ "version": "v2.19.0",
"source": {
"type": "git",
"url": "https://github.com/knpuniversity/oauth2-client-bundle.git",
- "reference": "2f48e1ff7969ef0252482d0f6af874eca639ea2d"
+ "reference": "cd1cb6945a46df81be6e94944872546ca4bf335c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/2f48e1ff7969ef0252482d0f6af874eca639ea2d",
- "reference": "2f48e1ff7969ef0252482d0f6af874eca639ea2d",
+ "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/cd1cb6945a46df81be6e94944872546ca4bf335c",
+ "reference": "cd1cb6945a46df81be6e94944872546ca4bf335c",
"shasum": ""
},
"require": {
@@ -5324,9 +5433,9 @@
],
"support": {
"issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues",
- "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.18.4"
+ "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.19.0"
},
- "time": "2025-08-18T15:33:00+00:00"
+ "time": "2025-09-17T15:00:36+00:00"
},
{
"name": "lcobucci/clock",
@@ -5394,22 +5503,22 @@
},
{
"name": "lcobucci/jwt",
- "version": "5.5.0",
+ "version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
- "reference": "a835af59b030d3f2967725697cf88300f579088e"
+ "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a835af59b030d3f2967725697cf88300f579088e",
- "reference": "a835af59b030d3f2967725697cf88300f579088e",
+ "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
+ "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
- "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
@@ -5451,7 +5560,7 @@
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
- "source": "https://github.com/lcobucci/jwt/tree/5.5.0"
+ "source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
@@ -5463,7 +5572,7 @@
"type": "patreon"
}
],
- "time": "2025-01-26T21:29:45+00:00"
+ "time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/commonmark",
@@ -5656,16 +5765,16 @@
},
{
"name": "league/csv",
- "version": "9.24.1",
+ "version": "9.27.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/csv.git",
- "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8"
+ "reference": "cb491b1ba3c42ff2bcd0113814f4256b42bae845"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/csv/zipball/e0221a3f16aa2a823047d59fab5809d552e29bc8",
- "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8",
+ "url": "https://api.github.com/repos/thephpleague/csv/zipball/cb491b1ba3c42ff2bcd0113814f4256b42bae845",
+ "reference": "cb491b1ba3c42ff2bcd0113814f4256b42bae845",
"shasum": ""
},
"require": {
@@ -5681,7 +5790,7 @@
"phpstan/phpstan-deprecation-rules": "^1.2.1",
"phpstan/phpstan-phpunit": "^1.4.2",
"phpstan/phpstan-strict-rules": "^1.6.2",
- "phpunit/phpunit": "^10.5.16 || ^11.5.22",
+ "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.3.6",
"symfony/var-dumper": "^6.4.8 || ^7.3.0"
},
"suggest": {
@@ -5743,7 +5852,7 @@
"type": "github"
}
],
- "time": "2025-06-25T14:53:51+00:00"
+ "time": "2025-10-16T08:22:09+00:00"
},
{
"name": "league/html-to-markdown",
@@ -6157,16 +6266,16 @@
},
{
"name": "liip/imagine-bundle",
- "version": "2.14.0",
+ "version": "2.15.0",
"source": {
"type": "git",
"url": "https://github.com/liip/LiipImagineBundle.git",
- "reference": "f80dc13e9a454682b8c2255b3487829d2f8a7fe4"
+ "reference": "f8c98a5a962806f26571db219412b64266c763d8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/f80dc13e9a454682b8c2255b3487829d2f8a7fe4",
- "reference": "f80dc13e9a454682b8c2255b3487829d2f8a7fe4",
+ "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/f8c98a5a962806f26571db219412b64266c763d8",
+ "reference": "f8c98a5a962806f26571db219412b64266c763d8",
"shasum": ""
},
"require": {
@@ -6258,9 +6367,9 @@
],
"support": {
"issues": "https://github.com/liip/LiipImagineBundle/issues",
- "source": "https://github.com/liip/LiipImagineBundle/tree/2.14.0"
+ "source": "https://github.com/liip/LiipImagineBundle/tree/2.15.0"
},
- "time": "2025-09-03T06:33:10+00:00"
+ "time": "2025-10-09T06:49:28+00:00"
},
{
"name": "lorenzo/pinky",
@@ -6315,6 +6424,188 @@
},
"time": "2023-07-31T13:36:50+00:00"
},
+ {
+ "name": "maennchen/zipstream-php",
+ "version": "2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/maennchen/ZipStream-PHP.git",
+ "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/c4c5803cc1f93df3d2448478ef79394a5981cc58",
+ "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58",
+ "shasum": ""
+ },
+ "require": {
+ "myclabs/php-enum": "^1.5",
+ "php": ">= 7.1",
+ "psr/http-message": "^1.0",
+ "symfony/polyfill-mbstring": "^1.0"
+ },
+ "require-dev": {
+ "ext-zip": "*",
+ "guzzlehttp/guzzle": ">= 6.3",
+ "mikey179/vfsstream": "^1.6",
+ "phpunit/phpunit": ">= 7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ZipStream\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paul Duncan",
+ "email": "pabs@pablotron.org"
+ },
+ {
+ "name": "Jonatan Männchen",
+ "email": "jonatan@maennchen.ch"
+ },
+ {
+ "name": "Jesse Donat",
+ "email": "donatj@gmail.com"
+ },
+ {
+ "name": "András Kolesár",
+ "email": "kolesar@kolesar.hu"
+ }
+ ],
+ "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
+ "keywords": [
+ "stream",
+ "zip"
+ ],
+ "support": {
+ "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
+ "source": "https://github.com/maennchen/ZipStream-PHP/tree/2.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/maennchen",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/zipstream",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2020-05-30T13:11:16+00:00"
+ },
+ {
+ "name": "markbaker/complex",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPComplex.git",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Complex\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@lange.demon.co.uk"
+ }
+ ],
+ "description": "PHP Class for working with complex numbers",
+ "homepage": "https://github.com/MarkBaker/PHPComplex",
+ "keywords": [
+ "complex",
+ "mathematics"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPComplex/issues",
+ "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
+ },
+ "time": "2022-12-06T16:21:08+00:00"
+ },
+ {
+ "name": "markbaker/matrix",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPMatrix.git",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpdocumentor/phpdocumentor": "2.*",
+ "phploc/phploc": "^4.0",
+ "phpmd/phpmd": "2.*",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "sebastian/phpcpd": "^4.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Matrix\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@demon-angel.eu"
+ }
+ ],
+ "description": "PHP Class for working with matrices",
+ "homepage": "https://github.com/MarkBaker/PHPMatrix",
+ "keywords": [
+ "mathematics",
+ "matrix",
+ "vector"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
+ "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
+ },
+ "time": "2022-12-02T22:17:43+00:00"
+ },
{
"name": "masterminds/html5",
"version": "2.10.0",
@@ -6486,17 +6777,80 @@
"time": "2025-03-24T10:02:05+00:00"
},
{
- "name": "nbgrp/onelogin-saml-bundle",
- "version": "v2.0.2",
+ "name": "myclabs/php-enum",
+ "version": "1.8.5",
"source": {
"type": "git",
- "url": "https://github.com/nbgrp/onelogin-saml-bundle.git",
- "reference": "d2feeb7de6ab5b98e69deeea31ad0ceb20a1c4dc"
+ "url": "https://github.com/myclabs/php-enum.git",
+ "reference": "e7be26966b7398204a234f8673fdad5ac6277802"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nbgrp/onelogin-saml-bundle/zipball/d2feeb7de6ab5b98e69deeea31ad0ceb20a1c4dc",
- "reference": "d2feeb7de6ab5b98e69deeea31ad0ceb20a1c4dc",
+ "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802",
+ "reference": "e7be26966b7398204a234f8673fdad5ac6277802",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "php": "^7.3 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "squizlabs/php_codesniffer": "1.*",
+ "vimeo/psalm": "^4.6.2 || ^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "MyCLabs\\Enum\\": "src/"
+ },
+ "classmap": [
+ "stubs/Stringable.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP Enum contributors",
+ "homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
+ }
+ ],
+ "description": "PHP Enum implementation",
+ "homepage": "https://github.com/myclabs/php-enum",
+ "keywords": [
+ "enum"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/php-enum/issues",
+ "source": "https://github.com/myclabs/php-enum/tree/1.8.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mnapoli",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-01-14T11:49:03+00:00"
+ },
+ {
+ "name": "nbgrp/onelogin-saml-bundle",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nbgrp/onelogin-saml-bundle.git",
+ "reference": "087402c69ef87e0a34d9b708661deecd00fd190a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nbgrp/onelogin-saml-bundle/zipball/087402c69ef87e0a34d9b708661deecd00fd190a",
+ "reference": "087402c69ef87e0a34d9b708661deecd00fd190a",
"shasum": ""
},
"require": {
@@ -6516,7 +6870,7 @@
},
"require-dev": {
"doctrine/orm": "^2.3 || ^3",
- "phpunit/phpunit": "^11.2",
+ "phpunit/phpunit": "^11",
"symfony/event-dispatcher": "^7"
},
"type": "symfony-bundle",
@@ -6544,9 +6898,9 @@
],
"support": {
"issues": "https://github.com/nbgrp/onelogin-saml-bundle/issues",
- "source": "https://github.com/nbgrp/onelogin-saml-bundle/tree/v2.0.2"
+ "source": "https://github.com/nbgrp/onelogin-saml-bundle/tree/v2.1.0"
},
- "time": "2024-09-01T22:16:27+00:00"
+ "time": "2025-09-26T08:45:17+00:00"
},
{
"name": "nelexa/zip",
@@ -6685,16 +7039,16 @@
},
{
"name": "nelmio/security-bundle",
- "version": "v3.5.1",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/nelmio/NelmioSecurityBundle.git",
- "reference": "b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7"
+ "reference": "f3a7bf628a0873788172a0d05d20c0224080f5eb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7",
- "reference": "b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7",
+ "url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/f3a7bf628a0873788172a0d05d20c0224080f5eb",
+ "reference": "f3a7bf628a0873788172a0d05d20c0224080f5eb",
"shasum": ""
},
"require": {
@@ -6753,9 +7107,9 @@
],
"support": {
"issues": "https://github.com/nelmio/NelmioSecurityBundle/issues",
- "source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.5.1"
+ "source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.6.0"
},
- "time": "2025-03-13T09:17:16+00:00"
+ "time": "2025-09-19T08:24:46+00:00"
},
{
"name": "nette/schema",
@@ -7233,24 +7587,26 @@
},
{
"name": "paragonie/constant_time_encoding",
- "version": "v3.0.0",
+ "version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
- "reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
- "reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
+ "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
- "phpunit/phpunit": "^9",
- "vimeo/psalm": "^4|^5"
+ "infection/infection": "^0",
+ "nikic/php-fuzzer": "^0",
+ "phpunit/phpunit": "^9|^10|^11",
+ "vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
@@ -7296,7 +7652,7 @@
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
- "time": "2024-05-08T12:36:18+00:00"
+ "time": "2025-09-24T15:06:41+00:00"
},
{
"name": "paragonie/random_compat",
@@ -7350,16 +7706,16 @@
},
{
"name": "paragonie/sodium_compat",
- "version": "v1.21.1",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/sodium_compat.git",
- "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37"
+ "reference": "b938a5c6844d222a26d46a6c7b80291e4cd8cfab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/bb312875dcdd20680419564fe42ba1d9564b9e37",
- "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37",
+ "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/b938a5c6844d222a26d46a6c7b80291e4cd8cfab",
+ "reference": "b938a5c6844d222a26d46a6c7b80291e4cd8cfab",
"shasum": ""
},
"require": {
@@ -7430,9 +7786,9 @@
],
"support": {
"issues": "https://github.com/paragonie/sodium_compat/issues",
- "source": "https://github.com/paragonie/sodium_compat/tree/v1.21.1"
+ "source": "https://github.com/paragonie/sodium_compat/tree/v1.23.0"
},
- "time": "2024-04-22T22:05:04+00:00"
+ "time": "2025-10-06T08:53:07+00:00"
},
{
"name": "part-db/exchanger",
@@ -8110,6 +8466,112 @@
},
"time": "2024-11-09T15:12:26+00:00"
},
+ {
+ "name": "phpoffice/phpspreadsheet",
+ "version": "5.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
+ "reference": "fd26e45a814e94ae2aad0df757d9d1739c4bf2e0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fd26e45a814e94ae2aad0df757d9d1739c4bf2e0",
+ "reference": "fd26e45a814e94ae2aad0df757d9d1739c4bf2e0",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1||^2||^3",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-fileinfo": "*",
+ "ext-gd": "*",
+ "ext-iconv": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-simplexml": "*",
+ "ext-xml": "*",
+ "ext-xmlreader": "*",
+ "ext-xmlwriter": "*",
+ "ext-zip": "*",
+ "ext-zlib": "*",
+ "maennchen/zipstream-php": "^2.1 || ^3.0",
+ "markbaker/complex": "^3.0",
+ "markbaker/matrix": "^3.0",
+ "php": "^8.1",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.0",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
+ "dompdf/dompdf": "^2.0 || ^3.0",
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "mitoteam/jpgraph": "^10.3",
+ "mpdf/mpdf": "^8.1.1",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpstan/phpstan": "^1.1 || ^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+ "phpunit/phpunit": "^10.5",
+ "squizlabs/php_codesniffer": "^3.7",
+ "tecnickcom/tcpdf": "^6.5"
+ },
+ "suggest": {
+ "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
+ "ext-intl": "PHP Internationalization Functions",
+ "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
+ "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
+ "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Maarten Balliauw",
+ "homepage": "https://blog.maartenballiauw.be"
+ },
+ {
+ "name": "Mark Baker",
+ "homepage": "https://markbakeruk.net"
+ },
+ {
+ "name": "Franck Lefevre",
+ "homepage": "https://rootslabs.net"
+ },
+ {
+ "name": "Erik Tilt"
+ },
+ {
+ "name": "Adrien Crivelli"
+ }
+ ],
+ "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
+ "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
+ "keywords": [
+ "OpenXML",
+ "excel",
+ "gnumeric",
+ "ods",
+ "php",
+ "spreadsheet",
+ "xls",
+ "xlsx"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.1.0"
+ },
+ "time": "2025-09-04T05:34:49+00:00"
+ },
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.0",
@@ -8466,16 +8928,16 @@
},
{
"name": "psr/http-message",
- "version": "2.0",
+ "version": "1.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
+ "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
"shasum": ""
},
"require": {
@@ -8484,7 +8946,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0.x-dev"
+ "dev-master": "1.1.x-dev"
}
},
"autoload": {
@@ -8499,7 +8961,7 @@
"authors": [
{
"name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
+ "homepage": "http://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
@@ -8513,9 +8975,9 @@
"response"
],
"support": {
- "source": "https://github.com/php-fig/http-message/tree/2.0"
+ "source": "https://github.com/php-fig/http-message/tree/1.1"
},
- "time": "2023-04-04T09:54:51+00:00"
+ "time": "2023-04-04T09:50:52+00:00"
},
{
"name": "psr/link",
@@ -9870,16 +10332,16 @@
},
{
"name": "symfony/cache",
- "version": "v7.3.2",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache.git",
- "reference": "6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6"
+ "reference": "bf8afc8ffd4bfd3d9c373e417f041d9f1e5b863f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/cache/zipball/6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6",
- "reference": "6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6",
+ "url": "https://api.github.com/repos/symfony/cache/zipball/bf8afc8ffd4bfd3d9c373e417f041d9f1e5b863f",
+ "reference": "bf8afc8ffd4bfd3d9c373e417f041d9f1e5b863f",
"shasum": ""
},
"require": {
@@ -9948,7 +10410,7 @@
"psr6"
],
"support": {
- "source": "https://github.com/symfony/cache/tree/v7.3.2"
+ "source": "https://github.com/symfony/cache/tree/v7.3.4"
},
"funding": [
{
@@ -9968,7 +10430,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-30T17:13:41+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/cache-contracts",
@@ -10122,16 +10584,16 @@
},
{
"name": "symfony/config",
- "version": "v7.3.2",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/config.git",
- "reference": "faef36e271bbeb74a9d733be4b56419b157762e2"
+ "reference": "8a09223170046d2cfda3d2e11af01df2c641e961"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/config/zipball/faef36e271bbeb74a9d733be4b56419b157762e2",
- "reference": "faef36e271bbeb74a9d733be4b56419b157762e2",
+ "url": "https://api.github.com/repos/symfony/config/zipball/8a09223170046d2cfda3d2e11af01df2c641e961",
+ "reference": "8a09223170046d2cfda3d2e11af01df2c641e961",
"shasum": ""
},
"require": {
@@ -10177,7 +10639,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/config/tree/v7.3.2"
+ "source": "https://github.com/symfony/config/tree/v7.3.4"
},
"funding": [
{
@@ -10197,20 +10659,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-26T13:55:06+00:00"
+ "time": "2025-09-22T12:46:16+00:00"
},
{
"name": "symfony/console",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7"
+ "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7",
- "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7",
+ "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db",
+ "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db",
"shasum": ""
},
"require": {
@@ -10275,7 +10737,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.3.3"
+ "source": "https://github.com/symfony/console/tree/v7.3.4"
},
"funding": [
{
@@ -10295,7 +10757,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-25T06:35:40+00:00"
+ "time": "2025-09-22T15:31:00+00:00"
},
{
"name": "symfony/css-selector",
@@ -10364,16 +10826,16 @@
},
{
"name": "symfony/dependency-injection",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
- "reference": "ab6c38dad5da9b15b1f7afb2f5c5814112e70261"
+ "reference": "82119812ab0bf3425c1234d413efd1b19bb92ae4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/ab6c38dad5da9b15b1f7afb2f5c5814112e70261",
- "reference": "ab6c38dad5da9b15b1f7afb2f5c5814112e70261",
+ "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/82119812ab0bf3425c1234d413efd1b19bb92ae4",
+ "reference": "82119812ab0bf3425c1234d413efd1b19bb92ae4",
"shasum": ""
},
"require": {
@@ -10424,7 +10886,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/dependency-injection/tree/v7.3.3"
+ "source": "https://github.com/symfony/dependency-injection/tree/v7.3.4"
},
"funding": [
{
@@ -10444,7 +10906,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-14T09:54:27+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -10515,16 +10977,16 @@
},
{
"name": "symfony/doctrine-bridge",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/doctrine-bridge.git",
- "reference": "b371ded46da25415e1a3a7422e4acd2ec34214c5"
+ "reference": "21cd48c34a47a0d0e303a590a67c3450fde55888"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/b371ded46da25415e1a3a7422e4acd2ec34214c5",
- "reference": "b371ded46da25415e1a3a7422e4acd2ec34214c5",
+ "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/21cd48c34a47a0d0e303a590a67c3450fde55888",
+ "reference": "21cd48c34a47a0d0e303a590a67c3450fde55888",
"shasum": ""
},
"require": {
@@ -10604,7 +11066,7 @@
"description": "Provides integration for Doctrine with various Symfony components",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.3"
+ "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.4"
},
"funding": [
{
@@ -10624,7 +11086,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-18T13:10:53+00:00"
+ "time": "2025-09-24T09:56:23+00:00"
},
{
"name": "symfony/dom-crawler",
@@ -10777,16 +11239,16 @@
},
{
"name": "symfony/error-handler",
- "version": "v7.3.2",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
- "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3"
+ "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
- "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
+ "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
+ "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
"shasum": ""
},
"require": {
@@ -10834,7 +11296,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/error-handler/tree/v7.3.2"
+ "source": "https://github.com/symfony/error-handler/tree/v7.3.4"
},
"funding": [
{
@@ -10854,7 +11316,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-07T08:17:57+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/event-dispatcher",
@@ -11296,16 +11758,16 @@
},
{
"name": "symfony/form",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/form.git",
- "reference": "f151b4a027fa67769268b80111f7fdb63edb5e79"
+ "reference": "7b3eee0f4d4dfd1ff1be70a27474197330c61736"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/form/zipball/f151b4a027fa67769268b80111f7fdb63edb5e79",
- "reference": "f151b4a027fa67769268b80111f7fdb63edb5e79",
+ "url": "https://api.github.com/repos/symfony/form/zipball/7b3eee0f4d4dfd1ff1be70a27474197330c61736",
+ "reference": "7b3eee0f4d4dfd1ff1be70a27474197330c61736",
"shasum": ""
},
"require": {
@@ -11373,7 +11835,7 @@
"description": "Allows to easily create, process and reuse HTML forms",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/form/tree/v7.3.3"
+ "source": "https://github.com/symfony/form/tree/v7.3.4"
},
"funding": [
{
@@ -11393,20 +11855,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-18T13:10:53+00:00"
+ "time": "2025-09-22T15:31:00+00:00"
},
{
"name": "symfony/framework-bundle",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/framework-bundle.git",
- "reference": "19ec4ab6be90322ed190e041e2404a976ed22571"
+ "reference": "b13e7cec5a144c8dba6f4233a2c53c00bc29e140"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/19ec4ab6be90322ed190e041e2404a976ed22571",
- "reference": "19ec4ab6be90322ed190e041e2404a976ed22571",
+ "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/b13e7cec5a144c8dba6f4233a2c53c00bc29e140",
+ "reference": "b13e7cec5a144c8dba6f4233a2c53c00bc29e140",
"shasum": ""
},
"require": {
@@ -11531,7 +11993,7 @@
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/framework-bundle/tree/v7.3.3"
+ "source": "https://github.com/symfony/framework-bundle/tree/v7.3.4"
},
"funding": [
{
@@ -11551,20 +12013,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-27T07:45:05+00:00"
+ "time": "2025-09-17T05:51:54+00:00"
},
{
"name": "symfony/http-client",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019"
+ "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019",
- "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62",
+ "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62",
"shasum": ""
},
"require": {
@@ -11631,7 +12093,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.3.3"
+ "source": "https://github.com/symfony/http-client/tree/v7.3.4"
},
"funding": [
{
@@ -11651,7 +12113,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-27T07:45:05+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/http-client-contracts",
@@ -11733,16 +12195,16 @@
},
{
"name": "symfony/http-foundation",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00"
+ "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00",
- "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6",
+ "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6",
"shasum": ""
},
"require": {
@@ -11792,7 +12254,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.3.3"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.3.4"
},
"funding": [
{
@@ -11812,20 +12274,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-20T08:04:18+00:00"
+ "time": "2025-09-16T08:38:17+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b"
+ "reference": "b796dffea7821f035047235e076b60ca2446e3cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b",
- "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf",
+ "reference": "b796dffea7821f035047235e076b60ca2446e3cf",
"shasum": ""
},
"require": {
@@ -11910,7 +12372,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v7.3.3"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.3.4"
},
"funding": [
{
@@ -11930,20 +12392,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-29T08:23:45+00:00"
+ "time": "2025-09-27T12:32:17+00:00"
},
{
"name": "symfony/intl",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/intl.git",
- "reference": "754d5ad02c889e380efc5a74fa3f6cfe56b7454d"
+ "reference": "e6db84864655885d9dac676a9d7dde0d904fda54"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/intl/zipball/754d5ad02c889e380efc5a74fa3f6cfe56b7454d",
- "reference": "754d5ad02c889e380efc5a74fa3f6cfe56b7454d",
+ "url": "https://api.github.com/repos/symfony/intl/zipball/e6db84864655885d9dac676a9d7dde0d904fda54",
+ "reference": "e6db84864655885d9dac676a9d7dde0d904fda54",
"shasum": ""
},
"require": {
@@ -12000,7 +12462,7 @@
"localization"
],
"support": {
- "source": "https://github.com/symfony/intl/tree/v7.3.3"
+ "source": "https://github.com/symfony/intl/tree/v7.3.4"
},
"funding": [
{
@@ -12020,20 +12482,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-19T14:06:46+00:00"
+ "time": "2025-09-08T14:11:30+00:00"
},
{
"name": "symfony/mailer",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575"
+ "reference": "ab97ef2f7acf0216955f5845484235113047a31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575",
- "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d",
+ "reference": "ab97ef2f7acf0216955f5845484235113047a31d",
"shasum": ""
},
"require": {
@@ -12084,7 +12546,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v7.3.3"
+ "source": "https://github.com/symfony/mailer/tree/v7.3.4"
},
"funding": [
{
@@ -12104,20 +12566,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2025-09-17T05:51:54+00:00"
},
{
"name": "symfony/mime",
- "version": "v7.3.2",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1"
+ "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1",
- "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35",
+ "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35",
"shasum": ""
},
"require": {
@@ -12172,7 +12634,7 @@
"mime-type"
],
"support": {
- "source": "https://github.com/symfony/mime/tree/v7.3.2"
+ "source": "https://github.com/symfony/mime/tree/v7.3.4"
},
"funding": [
{
@@ -12192,20 +12654,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T13:41:35+00:00"
+ "time": "2025-09-16T08:38:17+00:00"
},
{
"name": "symfony/monolog-bridge",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
- "reference": "6f3745e887659b46a8b7bb5ade8356a41700f095"
+ "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/6f3745e887659b46a8b7bb5ade8356a41700f095",
- "reference": "6f3745e887659b46a8b7bb5ade8356a41700f095",
+ "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7acf2abe23e5019451399ba69fc8ed3d61d4d8f0",
+ "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0",
"shasum": ""
},
"require": {
@@ -12254,7 +12716,7 @@
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.3"
+ "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.4"
},
"funding": [
{
@@ -12274,7 +12736,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2025-09-24T16:45:39+00:00"
},
{
"name": "symfony/monolog-bundle",
@@ -13419,16 +13881,16 @@
},
{
"name": "symfony/process",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "32241012d521e2e8a9d713adb0812bb773b907f1"
+ "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1",
- "reference": "32241012d521e2e8a9d713adb0812bb773b907f1",
+ "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b",
+ "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b",
"shasum": ""
},
"require": {
@@ -13460,7 +13922,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v7.3.3"
+ "source": "https://github.com/symfony/process/tree/v7.3.4"
},
"funding": [
{
@@ -13480,7 +13942,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-18T09:42:54+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/property-access",
@@ -13564,16 +14026,16 @@
},
{
"name": "symfony/property-info",
- "version": "v7.3.1",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/property-info.git",
- "reference": "90586acbf2a6dd13bee4f09f09111c8bd4773970"
+ "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/property-info/zipball/90586acbf2a6dd13bee4f09f09111c8bd4773970",
- "reference": "90586acbf2a6dd13bee4f09f09111c8bd4773970",
+ "url": "https://api.github.com/repos/symfony/property-info/zipball/7b6db23f23d13ada41e1cb484748a8ec028fbace",
+ "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace",
"shasum": ""
},
"require": {
@@ -13630,7 +14092,7 @@
"validator"
],
"support": {
- "source": "https://github.com/symfony/property-info/tree/v7.3.1"
+ "source": "https://github.com/symfony/property-info/tree/v7.3.4"
},
"funding": [
{
@@ -13641,12 +14103,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-27T19:55:54+00:00"
+ "time": "2025-09-15T13:55:54+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
@@ -13807,16 +14273,16 @@
},
{
"name": "symfony/routing",
- "version": "v7.3.2",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
- "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4"
+ "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
- "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c",
+ "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c",
"shasum": ""
},
"require": {
@@ -13868,7 +14334,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v7.3.2"
+ "source": "https://github.com/symfony/routing/tree/v7.3.4"
},
"funding": [
{
@@ -13888,20 +14354,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:36:08+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/runtime",
- "version": "v7.3.1",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/runtime.git",
- "reference": "9516056d432f8acdac9458eb41b80097da7a05c9"
+ "reference": "3550e2711e30bfa5d808514781cd52d1cc1d9e9f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/runtime/zipball/9516056d432f8acdac9458eb41b80097da7a05c9",
- "reference": "9516056d432f8acdac9458eb41b80097da7a05c9",
+ "url": "https://api.github.com/repos/symfony/runtime/zipball/3550e2711e30bfa5d808514781cd52d1cc1d9e9f",
+ "reference": "3550e2711e30bfa5d808514781cd52d1cc1d9e9f",
"shasum": ""
},
"require": {
@@ -13951,7 +14417,7 @@
"runtime"
],
"support": {
- "source": "https://github.com/symfony/runtime/tree/v7.3.1"
+ "source": "https://github.com/symfony/runtime/tree/v7.3.4"
},
"funding": [
{
@@ -13962,25 +14428,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-13T07:48:40+00:00"
+ "time": "2025-09-11T15:31:28+00:00"
},
{
"name": "symfony/security-bundle",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-bundle.git",
- "reference": "fbecca9a10af8d886e116f74e860e19b7583689c"
+ "reference": "f750d9abccbeaa433c56f6a4eb2073166476a75a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/security-bundle/zipball/fbecca9a10af8d886e116f74e860e19b7583689c",
- "reference": "fbecca9a10af8d886e116f74e860e19b7583689c",
+ "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f750d9abccbeaa433c56f6a4eb2073166476a75a",
+ "reference": "f750d9abccbeaa433c56f6a4eb2073166476a75a",
"shasum": ""
},
"require": {
@@ -14057,7 +14527,7 @@
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/security-bundle/tree/v7.3.3"
+ "source": "https://github.com/symfony/security-bundle/tree/v7.3.4"
},
"funding": [
{
@@ -14077,20 +14547,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-06T08:34:58+00:00"
+ "time": "2025-09-22T15:31:00+00:00"
},
{
"name": "symfony/security-core",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-core.git",
- "reference": "4465a3b9cefbaebaeeeb98c2becfdb4b59d22488"
+ "reference": "68b9d3ca57615afde6152a1e1441fa035bea43f8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/security-core/zipball/4465a3b9cefbaebaeeeb98c2becfdb4b59d22488",
- "reference": "4465a3b9cefbaebaeeeb98c2becfdb4b59d22488",
+ "url": "https://api.github.com/repos/symfony/security-core/zipball/68b9d3ca57615afde6152a1e1441fa035bea43f8",
+ "reference": "68b9d3ca57615afde6152a1e1441fa035bea43f8",
"shasum": ""
},
"require": {
@@ -14148,7 +14618,7 @@
"description": "Symfony Security Component - Core Library",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/security-core/tree/v7.3.3"
+ "source": "https://github.com/symfony/security-core/tree/v7.3.4"
},
"funding": [
{
@@ -14168,7 +14638,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-25T06:35:40+00:00"
+ "time": "2025-09-24T14:32:13+00:00"
},
{
"name": "symfony/security-csrf",
@@ -14242,16 +14712,16 @@
},
{
"name": "symfony/security-http",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-http.git",
- "reference": "1bf0dc10f27d4776c47f18f98236c619793a9260"
+ "reference": "1cf54d0648ebab23bf9b8972617b79f1995e13a9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/security-http/zipball/1bf0dc10f27d4776c47f18f98236c619793a9260",
- "reference": "1bf0dc10f27d4776c47f18f98236c619793a9260",
+ "url": "https://api.github.com/repos/symfony/security-http/zipball/1cf54d0648ebab23bf9b8972617b79f1995e13a9",
+ "reference": "1cf54d0648ebab23bf9b8972617b79f1995e13a9",
"shasum": ""
},
"require": {
@@ -14310,7 +14780,7 @@
"description": "Symfony Security Component - HTTP Integration",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/security-http/tree/v7.3.3"
+ "source": "https://github.com/symfony/security-http/tree/v7.3.4"
},
"funding": [
{
@@ -14330,20 +14800,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-25T06:35:40+00:00"
+ "time": "2025-09-09T17:06:44+00:00"
},
{
"name": "symfony/serializer",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/serializer.git",
- "reference": "5608b04d8daaf29432d76ecc618b0fac169c2dfb"
+ "reference": "0df5af266c6fe9a855af7db4fea86e13b9ca3ab1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/serializer/zipball/5608b04d8daaf29432d76ecc618b0fac169c2dfb",
- "reference": "5608b04d8daaf29432d76ecc618b0fac169c2dfb",
+ "url": "https://api.github.com/repos/symfony/serializer/zipball/0df5af266c6fe9a855af7db4fea86e13b9ca3ab1",
+ "reference": "0df5af266c6fe9a855af7db4fea86e13b9ca3ab1",
"shasum": ""
},
"require": {
@@ -14413,7 +14883,7 @@
"description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/serializer/tree/v7.3.3"
+ "source": "https://github.com/symfony/serializer/tree/v7.3.4"
},
"funding": [
{
@@ -14433,7 +14903,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-27T11:34:33+00:00"
+ "time": "2025-09-15T13:39:02+00:00"
},
{
"name": "symfony/service-contracts",
@@ -14655,16 +15125,16 @@
},
{
"name": "symfony/string",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c"
+ "reference": "f96476035142921000338bad71e5247fbc138872"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c",
- "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c",
+ "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872",
+ "reference": "f96476035142921000338bad71e5247fbc138872",
"shasum": ""
},
"require": {
@@ -14679,7 +15149,6 @@
},
"require-dev": {
"symfony/emoji": "^7.1",
- "symfony/error-handler": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/intl": "^6.4|^7.0",
"symfony/translation-contracts": "^2.5|^3.0",
@@ -14722,7 +15191,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v7.3.3"
+ "source": "https://github.com/symfony/string/tree/v7.3.4"
},
"funding": [
{
@@ -14742,20 +15211,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-25T06:35:40+00:00"
+ "time": "2025-09-11T14:36:48+00:00"
},
{
"name": "symfony/translation",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "e0837b4cbcef63c754d89a4806575cada743a38d"
+ "reference": "ec25870502d0c7072d086e8ffba1420c85965174"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d",
- "reference": "e0837b4cbcef63c754d89a4806575cada743a38d",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174",
+ "reference": "ec25870502d0c7072d086e8ffba1420c85965174",
"shasum": ""
},
"require": {
@@ -14822,7 +15291,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v7.3.3"
+ "source": "https://github.com/symfony/translation/tree/v7.3.4"
},
"funding": [
{
@@ -14842,7 +15311,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-01T21:02:37+00:00"
+ "time": "2025-09-07T11:39:36+00:00"
},
{
"name": "symfony/translation-contracts",
@@ -15039,16 +15508,16 @@
},
{
"name": "symfony/twig-bundle",
- "version": "v7.3.2",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/twig-bundle.git",
- "reference": "5d85220df4d8d79e6a9ca57eea6f70004de39657"
+ "reference": "da5c778a8416fcce5318737c4d944f6fa2bb3f81"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/5d85220df4d8d79e6a9ca57eea6f70004de39657",
- "reference": "5d85220df4d8d79e6a9ca57eea6f70004de39657",
+ "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/da5c778a8416fcce5318737c4d944f6fa2bb3f81",
+ "reference": "da5c778a8416fcce5318737c4d944f6fa2bb3f81",
"shasum": ""
},
"require": {
@@ -15103,7 +15572,7 @@
"description": "Provides a tight integration of Twig into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/twig-bundle/tree/v7.3.2"
+ "source": "https://github.com/symfony/twig-bundle/tree/v7.3.4"
},
"funding": [
{
@@ -15123,20 +15592,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-10T08:47:49+00:00"
+ "time": "2025-09-10T12:00:31+00:00"
},
{
"name": "symfony/type-info",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/type-info.git",
- "reference": "aa64b58ed04517d4d730202dd035895743c23273"
+ "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/type-info/zipball/aa64b58ed04517d4d730202dd035895743c23273",
- "reference": "aa64b58ed04517d4d730202dd035895743c23273",
+ "url": "https://api.github.com/repos/symfony/type-info/zipball/d34eaeb57f39c8a9c97eb72a977c423207dfa35b",
+ "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b",
"shasum": ""
},
"require": {
@@ -15186,7 +15655,7 @@
"type"
],
"support": {
- "source": "https://github.com/symfony/type-info/tree/v7.3.3"
+ "source": "https://github.com/symfony/type-info/tree/v7.3.4"
},
"funding": [
{
@@ -15206,7 +15675,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-28T09:38:04+00:00"
+ "time": "2025-09-11T15:33:27+00:00"
},
{
"name": "symfony/uid",
@@ -15468,16 +15937,16 @@
},
{
"name": "symfony/validator",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
- "reference": "a2f26d7c122393db75a2d41435ad8251250f8bc6"
+ "reference": "5e29a348b5fac2227b6938a54db006d673bb813a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/validator/zipball/a2f26d7c122393db75a2d41435ad8251250f8bc6",
- "reference": "a2f26d7c122393db75a2d41435ad8251250f8bc6",
+ "url": "https://api.github.com/repos/symfony/validator/zipball/5e29a348b5fac2227b6938a54db006d673bb813a",
+ "reference": "5e29a348b5fac2227b6938a54db006d673bb813a",
"shasum": ""
},
"require": {
@@ -15546,7 +16015,7 @@
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/validator/tree/v7.3.3"
+ "source": "https://github.com/symfony/validator/tree/v7.3.4"
},
"funding": [
{
@@ -15566,20 +16035,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-27T11:34:33+00:00"
+ "time": "2025-09-24T06:32:27+00:00"
},
{
"name": "symfony/var-dumper",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f"
+ "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f",
- "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
+ "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
"shasum": ""
},
"require": {
@@ -15633,7 +16102,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v7.3.3"
+ "source": "https://github.com/symfony/var-dumper/tree/v7.3.4"
},
"funding": [
{
@@ -15653,20 +16122,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/var-exporter",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
- "reference": "d4dfcd2a822cbedd7612eb6fbd260e46f87b7137"
+ "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d4dfcd2a822cbedd7612eb6fbd260e46f87b7137",
- "reference": "d4dfcd2a822cbedd7612eb6fbd260e46f87b7137",
+ "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0f020b544a30a7fe8ba972e53ee48a74c0bc87f4",
+ "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4",
"shasum": ""
},
"require": {
@@ -15714,7 +16183,7 @@
"serialize"
],
"support": {
- "source": "https://github.com/symfony/var-exporter/tree/v7.3.3"
+ "source": "https://github.com/symfony/var-exporter/tree/v7.3.4"
},
"funding": [
{
@@ -15734,7 +16203,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-18T13:10:53+00:00"
+ "time": "2025-09-11T10:12:26+00:00"
},
{
"name": "symfony/web-link",
@@ -15973,16 +16442,16 @@
},
{
"name": "symplify/easy-coding-standard",
- "version": "12.5.24",
+ "version": "12.6.0",
"source": {
"type": "git",
"url": "https://github.com/easy-coding-standard/easy-coding-standard.git",
- "reference": "4b90f2b6efed9508000968eac2397ac7aff34354"
+ "reference": "781e6124dc7e14768ae999a8f5309566bbe62004"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/4b90f2b6efed9508000968eac2397ac7aff34354",
- "reference": "4b90f2b6efed9508000968eac2397ac7aff34354",
+ "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/781e6124dc7e14768ae999a8f5309566bbe62004",
+ "reference": "781e6124dc7e14768ae999a8f5309566bbe62004",
"shasum": ""
},
"require": {
@@ -16018,7 +16487,7 @@
],
"support": {
"issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues",
- "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.5.24"
+ "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.6.0"
},
"funding": [
{
@@ -16030,7 +16499,7 @@
"type": "github"
}
],
- "time": "2025-08-21T06:57:14+00:00"
+ "time": "2025-09-10T14:21:58+00:00"
},
{
"name": "tecnickcom/tc-lib-barcode",
@@ -17250,25 +17719,25 @@
"packages-dev": [
{
"name": "dama/doctrine-test-bundle",
- "version": "v8.3.1",
+ "version": "v8.4.0",
"source": {
"type": "git",
"url": "https://github.com/dmaicher/doctrine-test-bundle.git",
- "reference": "9bc47e02a0d67cbfef6773837249f71e65c95bf6"
+ "reference": "ce7cd44126c36694e2f2d92c4aedd4fc5b0874f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/9bc47e02a0d67cbfef6773837249f71e65c95bf6",
- "reference": "9bc47e02a0d67cbfef6773837249f71e65c95bf6",
+ "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/ce7cd44126c36694e2f2d92c4aedd4fc5b0874f2",
+ "reference": "ce7cd44126c36694e2f2d92c4aedd4fc5b0874f2",
"shasum": ""
},
"require": {
"doctrine/dbal": "^3.3 || ^4.0",
- "doctrine/doctrine-bundle": "^2.11.0",
+ "doctrine/doctrine-bundle": "^2.11.0 || ^3.0",
"php": ">= 8.1",
"psr/cache": "^2.0 || ^3.0",
- "symfony/cache": "^6.4 || ^7.2 || ^8.0",
- "symfony/framework-bundle": "^6.4 || ^7.2 || ^8.0"
+ "symfony/cache": "^6.4 || ^7.3 || ^8.0",
+ "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<10.0"
@@ -17277,9 +17746,9 @@
"behat/behat": "^3.0",
"friendsofphp/php-cs-fixer": "^3.27",
"phpstan/phpstan": "^2.0",
- "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
- "symfony/process": "^6.4 || ^7.2 || ^8.0",
- "symfony/yaml": "^6.4 || ^7.2 || ^8.0"
+ "phpunit/phpunit": "^10.5.57 || ^11.5.41|| ^12.3.14",
+ "symfony/dotenv": "^6.4 || ^7.3 || ^8.0",
+ "symfony/process": "^6.4 || ^7.3 || ^8.0"
},
"type": "symfony-bundle",
"extra": {
@@ -17313,27 +17782,27 @@
],
"support": {
"issues": "https://github.com/dmaicher/doctrine-test-bundle/issues",
- "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.3.1"
+ "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.4.0"
},
- "time": "2025-08-05T17:55:02+00:00"
+ "time": "2025-10-11T15:24:02+00:00"
},
{
"name": "doctrine/doctrine-fixtures-bundle",
- "version": "4.1.0",
+ "version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
- "reference": "a06db6b81ff20a2980bf92063d80c013bb8b4b7c"
+ "reference": "cd58d7738fe1fea1dbfd3e3f3bb421ee92d45e10"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/a06db6b81ff20a2980bf92063d80c013bb8b4b7c",
- "reference": "a06db6b81ff20a2980bf92063d80c013bb8b4b7c",
+ "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/cd58d7738fe1fea1dbfd3e3f3bb421ee92d45e10",
+ "reference": "cd58d7738fe1fea1dbfd3e3f3bb421ee92d45e10",
"shasum": ""
},
"require": {
"doctrine/data-fixtures": "^2.0",
- "doctrine/doctrine-bundle": "^2.2",
+ "doctrine/doctrine-bundle": "^2.2 || ^3.0",
"doctrine/orm": "^2.14.0 || ^3.0",
"doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
"php": "^8.1",
@@ -17349,7 +17818,7 @@
"doctrine/dbal": "< 3"
},
"require-dev": {
- "doctrine/coding-standard": "13.0.0",
+ "doctrine/coding-standard": "14.0.0",
"phpstan/phpstan": "2.1.11",
"phpunit/phpunit": "^10.5.38 || 11.4.14"
},
@@ -17385,7 +17854,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
- "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.1.0"
+ "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.2.0"
},
"funding": [
{
@@ -17401,7 +17870,7 @@
"type": "tidelift"
}
],
- "time": "2025-03-26T10:56:26+00:00"
+ "time": "2025-10-12T16:50:54+00:00"
},
{
"name": "ekino/phpstan-banned-code",
@@ -17825,16 +18294,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.22",
- "source": {
- "type": "git",
- "url": "https://github.com/phpstan/phpstan.git",
- "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4"
- },
+ "version": "2.1.31",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4",
- "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96",
+ "reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96",
"shasum": ""
},
"require": {
@@ -17879,20 +18343,20 @@
"type": "github"
}
],
- "time": "2025-08-04T19:17:37+00:00"
+ "time": "2025-10-10T14:14:11+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
- "version": "2.0.5",
+ "version": "2.0.10",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
- "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865"
+ "reference": "5eaf37b87288474051469aee9f937fc9d862f330"
},
"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/5eaf37b87288474051469aee9f937fc9d862f330",
+ "reference": "5eaf37b87288474051469aee9f937fc9d862f330",
"shasum": ""
},
"require": {
@@ -17926,7 +18390,8 @@
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6.20",
"ramsey/uuid": "^4.2",
- "symfony/cache": "^5.4"
+ "symfony/cache": "^5.4",
+ "symfony/uid": "^5.4 || ^6.4 || ^7.3"
},
"type": "phpstan-extension",
"extra": {
@@ -17949,27 +18414,27 @@
"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.10"
},
- "time": "2025-09-07T11:52:30+00:00"
+ "time": "2025-10-06T10:01:02+00:00"
},
{
"name": "phpstan/phpstan-strict-rules",
- "version": "2.0.6",
+ "version": "2.0.7",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-strict-rules.git",
- "reference": "f9f77efa9de31992a832ff77ea52eb42d675b094"
+ "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/f9f77efa9de31992a832ff77ea52eb42d675b094",
- "reference": "f9f77efa9de31992a832ff77ea52eb42d675b094",
+ "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538",
+ "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
- "phpstan/phpstan": "^2.0.4"
+ "phpstan/phpstan": "^2.1.29"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
@@ -17997,9 +18462,9 @@
"description": "Extra strict and opinionated rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-strict-rules/issues",
- "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.6"
+ "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7"
},
- "time": "2025-07-21T12:19:29+00:00"
+ "time": "2025-09-26T11:19:08+00:00"
},
{
"name": "phpstan/phpstan-symfony",
@@ -18409,16 +18874,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "11.5.36",
+ "version": "11.5.42",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "264a87c7ef68b1ab9af7172357740dc266df5957"
+ "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/264a87c7ef68b1ab9af7172357740dc266df5957",
- "reference": "264a87c7ef68b1ab9af7172357740dc266df5957",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c",
+ "reference": "1c6cb5dfe412af3d0dfd414cfd110e3b9cfdbc3c",
"shasum": ""
},
"require": {
@@ -18442,7 +18907,7 @@
"sebastian/comparator": "^6.3.2",
"sebastian/diff": "^6.0.2",
"sebastian/environment": "^7.2.1",
- "sebastian/exporter": "^6.3.0",
+ "sebastian/exporter": "^6.3.2",
"sebastian/global-state": "^7.0.2",
"sebastian/object-enumerator": "^6.0.1",
"sebastian/type": "^5.1.3",
@@ -18490,7 +18955,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.36"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.42"
},
"funding": [
{
@@ -18514,25 +18979,25 @@
"type": "tidelift"
}
],
- "time": "2025-09-03T06:24:17+00:00"
+ "time": "2025-09-28T12:09:13+00:00"
},
{
"name": "rector/rector",
- "version": "2.1.6",
+ "version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
- "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3"
+ "reference": "d27f976a332a87b5d03553c2e6f04adbe5da034f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/rectorphp/rector/zipball/729aabc0ec66e700ef164e26454a1357f222a2f3",
- "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/d27f976a332a87b5d03553c2e6f04adbe5da034f",
+ "reference": "d27f976a332a87b5d03553c2e6f04adbe5da034f",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0",
- "phpstan/phpstan": "^2.1.18"
+ "phpstan/phpstan": "^2.1.26"
},
"conflict": {
"rector/rector-doctrine": "*",
@@ -18566,7 +19031,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
- "source": "https://github.com/rectorphp/rector/tree/2.1.6"
+ "source": "https://github.com/rectorphp/rector/tree/2.2.3"
},
"funding": [
{
@@ -18574,7 +19039,7 @@
"type": "github"
}
],
- "time": "2025-09-05T15:43:08+00:00"
+ "time": "2025-10-11T21:50:23+00:00"
},
{
"name": "roave/security-advisories",
@@ -18582,12 +19047,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d"
+ "reference": "7a8f128281289412092c450a5eb3df5cabbc89e1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/dc5c4ede5c331ae21fb68947ff89672df9b7cc7d",
- "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/7a8f128281289412092c450a5eb3df5cabbc89e1",
+ "reference": "7a8f128281289412092c450a5eb3df5cabbc89e1",
"shasum": ""
},
"conflict": {
@@ -18606,6 +19071,7 @@
"akaunting/akaunting": "<2.1.13",
"akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53",
"alextselegidis/easyappointments": "<1.5.2.0-beta1",
+ "alt-design/alt-redirect": "<1.6.4",
"alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1",
"amazing/media2click": ">=1,<1.3.3",
"ameos/ameos_tarteaucitron": "<1.2.23",
@@ -18629,10 +19095,10 @@
"athlon1600/php-proxy-app": "<=3",
"athlon1600/youtube-downloader": "<=4",
"austintoddj/canvas": "<=3.4.2",
- "auth0/auth0-php": ">=8.0.0.0-beta1,<8.14",
- "auth0/login": "<7.17",
- "auth0/symfony": "<5.4",
- "auth0/wordpress": "<5.3",
+ "auth0/auth0-php": ">=3.3,<=8.16",
+ "auth0/login": "<=7.18",
+ "auth0/symfony": "<=5.4.1",
+ "auth0/wordpress": "<=5.3",
"automad/automad": "<2.0.0.0-alpha5",
"automattic/jetpack": "<9.8",
"awesome-support/awesome-support": "<=6.0.7",
@@ -18644,7 +19110,7 @@
"backpack/filemanager": "<2.0.2|>=3,<3.0.9",
"bacula-web/bacula-web": "<9.7.1",
"badaso/core": "<=2.9.11",
- "bagisto/bagisto": "<2.1",
+ "bagisto/bagisto": "<=2.3.7",
"barrelstrength/sprout-base-email": "<1.2.7",
"barrelstrength/sprout-forms": "<3.9",
"barryvdh/laravel-translation-manager": "<0.6.8",
@@ -18725,6 +19191,7 @@
"dapphp/securimage": "<3.6.6",
"darylldoyle/safe-svg": "<1.9.10",
"datadog/dd-trace": ">=0.30,<0.30.2",
+ "datahihi1/tiny-env": "<1.0.3|>=1.0.9,<1.0.11",
"datatables/datatables": "<1.10.10",
"david-garcia/phpwhois": "<=4.3.1",
"dbrisinajumi/d2files": "<1",
@@ -18748,9 +19215,10 @@
"doctrine/mongodb-odm": "<1.0.2",
"doctrine/mongodb-odm-bundle": "<3.0.1",
"doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4",
- "dolibarr/dolibarr": "<=21.0.2",
+ "dolibarr/dolibarr": "<21.0.3",
"dompdf/dompdf": "<2.0.4",
"doublethreedigital/guest-entries": "<3.1.2",
+ "drupal-pattern-lab/unified-twig-extensions": "<=0.1",
"drupal/admin_audit_trail": "<1.0.5",
"drupal/ai": "<1.0.5",
"drupal/alogin": "<2.0.6",
@@ -18799,7 +19267,7 @@
"ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1-dev",
"ezsystems/ezfind-ls": ">=5.3,<5.3.6.1-dev|>=5.4,<5.4.11.1-dev|>=2017.12,<2017.12.0.1-dev",
"ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24",
- "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.38|>=3.3,<3.3.39",
+ "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.39|>=3.3,<3.3.39",
"ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1|>=5.3.0.0-beta1,<5.3.5",
"ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12",
"ezsystems/ezplatform-http-cache": "<2.3.16",
@@ -18874,6 +19342,7 @@
"gogentooss/samlbase": "<1.2.7",
"google/protobuf": "<3.4",
"gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3",
+ "gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
"grumpydictator/firefly-iii": "<6.1.17",
@@ -18892,15 +19361,15 @@
"hov/jobfair": "<1.0.13|>=2,<2.0.2",
"httpsoft/http-message": "<1.0.12",
"hyn/multi-tenant": ">=5.6,<5.7.2",
- "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6,<4.6.21",
+ "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6,<4.6.25|>=5,<5.0.3",
"ibexa/admin-ui-assets": ">=4.6.0.0-alpha1,<4.6.21",
"ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2",
- "ibexa/fieldtype-richtext": ">=4.6,<4.6.21",
+ "ibexa/fieldtype-richtext": ">=4.6,<4.6.25|>=5,<5.0.3",
"ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3",
"ibexa/http-cache": ">=4.6,<4.6.14",
"ibexa/post-install": "<1.0.16|>=4.6,<4.6.14",
"ibexa/solr": ">=4.5,<4.5.4",
- "ibexa/user": ">=4,<4.4.3",
+ "ibexa/user": ">=4,<4.4.3|>=5,<5.0.3",
"icecoder/icecoder": "<=8.1",
"idno/known": "<=1.3.1",
"ilicmiljan/secure-props": ">=1.2,<1.2.2",
@@ -18936,7 +19405,7 @@
"joomla/archive": "<1.1.12|>=2,<2.0.1",
"joomla/database": ">=1,<2.2|>=3,<3.4",
"joomla/filesystem": "<1.6.2|>=2,<2.0.1",
- "joomla/filter": "<1.4.4|>=2,<2.0.1",
+ "joomla/filter": "<2.0.6|>=3,<3.0.5|==4",
"joomla/framework": "<1.5.7|>=2.5.4,<=3.8.12",
"joomla/input": ">=2,<2.0.2",
"joomla/joomla-cms": "<3.9.12|>=4,<4.4.13|>=5,<5.2.6",
@@ -18975,6 +19444,7 @@
"laravel/socialite": ">=1,<2.0.10",
"latte/latte": "<2.10.8",
"lavalite/cms": "<=9|==10.1",
+ "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2",
"lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5",
"league/commonmark": "<2.7",
"league/flysystem": "<1.1.4|>=2,<2.1.1",
@@ -18996,13 +19466,14 @@
"luyadev/yii-helpers": "<1.2.1",
"macropay-solutions/laravel-crud-wizard-free": "<3.4.17",
"maestroerror/php-heic-to-jpg": "<1.0.5",
- "magento/community-edition": "<2.4.5.0-patch14|==2.4.6|>=2.4.6.0-patch1,<2.4.6.0-patch12|>=2.4.7.0-beta1,<2.4.7.0-patch7|>=2.4.8.0-beta1,<2.4.8.0-patch1",
+ "magento/community-edition": "<=2.4.5.0-patch14|==2.4.6|>=2.4.6.0-patch1,<=2.4.6.0-patch12|>=2.4.7.0-beta1,<=2.4.7.0-patch7|>=2.4.8.0-beta1,<=2.4.8.0-patch2|>=2.4.9.0-alpha1,<=2.4.9.0-alpha2|==2.4.9",
"magento/core": "<=1.9.4.5",
"magento/magento1ce": "<1.9.4.3-dev",
"magento/magento1ee": ">=1,<1.14.4.3-dev",
"magento/product-community-edition": "<2.4.4.0-patch9|>=2.4.5,<2.4.5.0-patch8|>=2.4.6,<2.4.6.0-patch6|>=2.4.7,<2.4.7.0-patch1",
"magento/project-community-edition": "<=2.0.2",
"magneto/core": "<1.9.4.4-dev",
+ "mahocommerce/maho": "<25.9",
"maikuolan/phpmussel": ">=1,<1.6",
"mainwp/mainwp": "<=4.4.3.3",
"manogi/nova-tiptap": "<=3.2.6",
@@ -19023,7 +19494,9 @@
"mediawiki/semantic-media-wiki": "<4.0.2",
"mehrwert/phpmyadmin": "<3.2",
"melisplatform/melis-asset-manager": "<5.0.1",
- "melisplatform/melis-cms": "<5.0.1",
+ "melisplatform/melis-cms": "<5.3.4",
+ "melisplatform/melis-cms-slider": "<5.3.1",
+ "melisplatform/melis-core": "<5.3.11",
"melisplatform/melis-front": "<5.0.1",
"mezzio/mezzio-swoole": "<3.7|>=4,<4.3",
"mgallegos/laravel-jqgrid": "<=1.3",
@@ -19074,6 +19547,7 @@
"notrinos/notrinos-erp": "<=0.7",
"noumo/easyii": "<=0.9",
"novaksolutions/infusionsoft-php-sdk": "<1",
+ "novosga/novosga": "<=2.2.12",
"nukeviet/nukeviet": "<4.5.02",
"nyholm/psr7": "<1.6.1",
"nystudio107/craft-seomatic": "<3.4.12",
@@ -19088,7 +19562,7 @@
"omeka/omeka-s": "<4.0.3",
"onelogin/php-saml": "<2.10.4",
"oneup/uploader-bundle": ">=1,<1.9.3|>=2,<2.1.5",
- "open-web-analytics/open-web-analytics": "<1.7.4",
+ "open-web-analytics/open-web-analytics": "<1.8.1",
"opencart/opencart": ">=0",
"openid/php-openid": "<2.3",
"openmage/magento-lts": "<20.12.3",
@@ -19167,6 +19641,7 @@
"prestashop/gamification": "<2.3.2",
"prestashop/prestashop": "<8.2.3",
"prestashop/productcomments": "<5.0.2",
+ "prestashop/ps_checkout": "<4.4.1|>=5,<5.0.5",
"prestashop/ps_contactinfo": "<=3.3.2",
"prestashop/ps_emailsubscription": "<2.6.1",
"prestashop/ps_facetedsearch": "<3.4.1",
@@ -19204,7 +19679,7 @@
"roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11",
"rudloff/alltube": "<3.0.3",
"rudloff/rtmpdump-bin": "<=2.3.1",
- "s-cart/core": "<6.9",
+ "s-cart/core": "<=9.0.5",
"s-cart/s-cart": "<6.9",
"sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1",
"sabre/dav": ">=1.6,<1.7.11|>=1.8,<1.8.9",
@@ -19215,10 +19690,10 @@
"setasign/fpdi": "<2.6.4",
"sfroemken/url_redirect": "<=1.2.1",
"sheng/yiicms": "<1.2.1",
- "shopware/core": "<6.5.8.18-dev|>=6.6,<6.6.10.3-dev|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev",
+ "shopware/core": "<6.5.8.18-dev|>=6.6,<6.6.10.3-dev|>=6.7,<6.7.2.1-dev",
"shopware/platform": "<=6.6.10.4|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev",
"shopware/production": "<=6.3.5.2",
- "shopware/shopware": "<=5.7.17",
+ "shopware/shopware": "<=5.7.17|>=6.7,<6.7.2.1-dev",
"shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev",
"shopxo/shopxo": "<=6.4",
"showdoc/showdoc": "<2.10.4",
@@ -19260,7 +19735,7 @@
"slim/slim": "<2.6",
"slub/slub-events": "<3.0.3",
"smarty/smarty": "<4.5.3|>=5,<5.1.1",
- "snipe/snipe-it": "<8.1",
+ "snipe/snipe-it": "<8.1.18",
"socalnick/scn-social-auth": "<1.15.2",
"socialiteproviders/steam": "<1.1",
"solspace/craft-freeform": ">=5,<5.10.16",
@@ -19276,6 +19751,7 @@
"starcitizentools/citizen-skin": ">=1.9.4,<3.4",
"starcitizentools/short-description": ">=4,<4.0.1",
"starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1",
+ "starcitizenwiki/embedvideo": "<=4",
"statamic/cms": "<=5.16",
"stormpath/sdk": "<9.9.99",
"studio-42/elfinder": "<=2.1.64",
@@ -19350,7 +19826,7 @@
"thelia/thelia": ">=2.1,<2.1.3",
"theonedemon/phpwhois": "<=4.2.5",
"thinkcmf/thinkcmf": "<6.0.8",
- "thorsten/phpmyfaq": "<=4.0.1",
+ "thorsten/phpmyfaq": "<=4.0.1|>=4.0.7,<4.0.13",
"tikiwiki/tiki-manager": "<=17.1",
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
"tinymce/tinymce": "<7.2",
@@ -19366,14 +19842,14 @@
"tribalsystems/zenario": "<=9.7.61188",
"truckersmp/phpwhois": "<=4.3.1",
"ttskch/pagination-service-provider": "<1",
- "twbs/bootstrap": "<=3.4.1|>=4,<=4.6.2",
+ "twbs/bootstrap": "<3.4.1|>=4,<4.3.1",
"twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19",
"typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2",
- "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<10.4.46|>=11,<11.5.40|>=12,<=12.4.30|>=13,<=13.4.11",
+ "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
- "typo3/cms-beuser": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
- "typo3/cms-core": "<=8.7.56|>=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11",
- "typo3/cms-dashboard": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
+ "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
+ "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
+ "typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1",
"typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
"typo3/cms-felogin": ">=4.2,<4.2.3",
@@ -19383,10 +19859,13 @@
"typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
"typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2",
"typo3/cms-lowlevel": ">=11,<=11.5.41",
+ "typo3/cms-recordlist": ">=11,<11.5.48",
+ "typo3/cms-recycler": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30",
"typo3/cms-scheduler": ">=11,<=11.5.41",
"typo3/cms-setup": ">=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11",
"typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11",
+ "typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6",
"typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3",
"typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3",
@@ -19427,6 +19906,7 @@
"webklex/laravel-imap": "<5.3",
"webklex/php-imap": "<5.3",
"webpa/webpa": "<3.1.2",
+ "webreinvent/vaahcms": "<=2.3.1",
"wikibase/wikibase": "<=1.39.3",
"wikimedia/parsoid": "<0.12.2",
"willdurand/js-translation-bundle": "<2.1.1",
@@ -19447,7 +19927,7 @@
"xataface/xataface": "<3",
"xpressengine/xpressengine": "<3.0.15",
"yab/quarx": "<2.4.5",
- "yeswiki/yeswiki": "<4.5.4",
+ "yeswiki/yeswiki": "<=4.5.4",
"yetiforce/yetiforce-crm": "<6.5",
"yidashi/yii2cmf": "<=2",
"yii2mod/yii2-cms": "<1.9.2",
@@ -19539,7 +20019,7 @@
"type": "tidelift"
}
],
- "time": "2025-09-04T20:05:35+00:00"
+ "time": "2025-10-17T18:06:27+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -20006,16 +20486,16 @@
},
{
"name": "sebastian/exporter",
- "version": "6.3.0",
+ "version": "6.3.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3"
+ "reference": "70a298763b40b213ec087c51c739efcaa90bcd74"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3",
- "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74",
+ "reference": "70a298763b40b213ec087c51c739efcaa90bcd74",
"shasum": ""
},
"require": {
@@ -20029,7 +20509,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.1-dev"
+ "dev-main": "6.3-dev"
}
},
"autoload": {
@@ -20072,15 +20552,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
}
],
- "time": "2024-12-05T09:17:50+00:00"
+ "time": "2025-09-24T06:12:51+00:00"
},
{
"name": "sebastian/global-state",
@@ -20641,16 +21133,16 @@
},
{
"name": "symfony/debug-bundle",
- "version": "v7.3.0",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/debug-bundle.git",
- "reference": "781acc90f31f5fe18915f9276890864ebbbe3da8"
+ "reference": "30f922edd53dd85238f1f26dbb68a044109f8f0e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/781acc90f31f5fe18915f9276890864ebbbe3da8",
- "reference": "781acc90f31f5fe18915f9276890864ebbbe3da8",
+ "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/30f922edd53dd85238f1f26dbb68a044109f8f0e",
+ "reference": "30f922edd53dd85238f1f26dbb68a044109f8f0e",
"shasum": ""
},
"require": {
@@ -20692,7 +21184,7 @@
"description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/debug-bundle/tree/v7.3.0"
+ "source": "https://github.com/symfony/debug-bundle/tree/v7.3.4"
},
"funding": [
{
@@ -20703,12 +21195,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-05-04T13:21:13+00:00"
+ "time": "2025-09-10T12:00:31+00:00"
},
{
"name": "symfony/maker-bundle",
@@ -20805,16 +21301,16 @@
},
{
"name": "symfony/phpunit-bridge",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/phpunit-bridge.git",
- "reference": "7954e563ed14f924593169f6c4645d58d9d9ac77"
+ "reference": "ed77a629c13979e051b7000a317966474d566398"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/7954e563ed14f924593169f6c4645d58d9d9ac77",
- "reference": "7954e563ed14f924593169f6c4645d58d9d9ac77",
+ "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/ed77a629c13979e051b7000a317966474d566398",
+ "reference": "ed77a629c13979e051b7000a317966474d566398",
"shasum": ""
},
"require": {
@@ -20870,7 +21366,7 @@
"testing"
],
"support": {
- "source": "https://github.com/symfony/phpunit-bridge/tree/v7.3.3"
+ "source": "https://github.com/symfony/phpunit-bridge/tree/v7.3.4"
},
"funding": [
{
@@ -20890,20 +21386,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-04T15:15:28+00:00"
+ "time": "2025-09-12T12:18:52+00:00"
},
{
"name": "symfony/web-profiler-bundle",
- "version": "v7.3.3",
+ "version": "v7.3.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
- "reference": "6ee224d6e9de787a47622b9ad4880e205ef16ad1"
+ "reference": "f305fa4add690bb7d6b14ab61f37c3bd061a3dd7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/6ee224d6e9de787a47622b9ad4880e205ef16ad1",
- "reference": "6ee224d6e9de787a47622b9ad4880e205ef16ad1",
+ "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f305fa4add690bb7d6b14ab61f37c3bd061a3dd7",
+ "reference": "f305fa4add690bb7d6b14ab61f37c3bd061a3dd7",
"shasum": ""
},
"require": {
@@ -20959,7 +21455,7 @@
"dev"
],
"support": {
- "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.3"
+ "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.4"
},
"funding": [
{
@@ -20979,7 +21475,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-19T13:44:55+00:00"
+ "time": "2025-09-25T08:03:55+00:00"
},
{
"name": "theseer/tokenizer",
@@ -21049,9 +21545,9 @@
"ext-json": "*",
"ext-mbstring": "*"
},
- "platform-dev": [],
+ "platform-dev": {},
"platform-overrides": {
"php": "8.2.0"
},
- "plugin-api-version": "2.3.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/config/packages/doctrine.php b/config/packages/doctrine.php
new file mode 100644
index 00000000..47584ed7
--- /dev/null
+++ b/config/packages/doctrine.php
@@ -0,0 +1,33 @@
+.
+ */
+
+declare(strict_types=1);
+
+/**
+ * This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
+ * We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
+ */
+
+return static function(\Symfony\Config\DoctrineConfig $doctrine) {
+ //On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
+ if (PHP_VERSION_ID >= 80400) {
+ $doctrine->orm()->enableNativeLazyObjects(true);
+ }
+};
diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml
index c283cd8e..6b2b7337 100644
--- a/config/packages/nelmio_security.yaml
+++ b/config/packages/nelmio_security.yaml
@@ -20,12 +20,6 @@ nelmio_security:
- 'digikey.com'
- 'nexar.com'
- # forces Microsoft's XSS-Protection with
- # its block mode
- xss_protection:
- enabled: true
- mode_block: true
-
# Send a full URL in the `Referer` header when performing a same-origin request,
# only send the origin of the document to secure destination (HTTPS->HTTPS),
# and send no header to a less secure destination (HTTPS->HTTP).
diff --git a/config/parameters.yaml b/config/parameters.yaml
index 154fbd8a..d4fe7581 100644
--- a/config/parameters.yaml
+++ b/config/parameters.yaml
@@ -8,7 +8,7 @@ parameters:
# This is used as workaround for places where we can not access the settings directly (like the 2FA application names)
partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage)
- partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] # The languages that are shown in user drop down menu
+ partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl', 'hu'] # The languages that are shown in user drop down menu
partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails
@@ -104,3 +104,9 @@ parameters:
env(SAML_ROLE_MAPPING): '{}'
env(DATABASE_EMULATE_NATURAL_SORT): 0
+
+ ######################################################################################################################
+ # Bulk Info Provider Import Configuration
+ ######################################################################################################################
+ partdb.bulk_import.batch_size: 20 # Number of parts to process in each batch during bulk operations
+ partdb.bulk_import.max_parts_per_operation: 1000 # Maximum number of parts allowed per bulk import operation
diff --git a/docs/assets/usage/import_export/part_import_example.csv b/docs/assets/usage/import_export/part_import_example.csv
index 08701426..14d4500f 100644
--- a/docs/assets/usage/import_export/part_import_example.csv
+++ b/docs/assets/usage/import_export/part_import_example.csv
@@ -1,4 +1,7 @@
-name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status
-BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;;
-BC557;PNP transistor;HTML;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
-Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
\ No newline at end of file
+name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint
+"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric
+"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric
+"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123
+BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
+BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
+Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical
diff --git a/docs/upgrade/1_to_2.md b/docs/upgrade/1_to_2.md
index f5b3b085..c333136a 100644
--- a/docs/upgrade/1_to_2.md
+++ b/docs/upgrade/1_to_2.md
@@ -48,14 +48,15 @@ The upgrade process works very similar to a normal (minor release) upgrade.
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and `.env.local` file.
The `php bin/console partdb:backup` command can help you with this.
2. Pull the v2 version. For git installation you can do this with `git checkout v2.0.0` (or newer version)
-3. Run `composer install --no-dev -o` to update the dependencies.
-4. Run `yarn install` and `yarn build` to update the frontend assets.
-5. Rund `php bin/console doctrine:migrations:migrate` to update the database schema.
-6. Clear the cache with `php bin/console cache:clear`.
-7. Open your Part-DB instance in the browser and log in as an admin user.
-8. Go to the user or group permissions page, and give yourself (and other administrators) the right to change system settings (under "System" and "Configuration").
-9. You can now go to the settings page (under "System" and "Settings") and check if all settings are correct.
-10. Parameters which were previously set via environment variables are greyed out and cannot be changed in the web interface.
+3. Remove the `var/cache/` directory inside the Part-DB installation to ensure that no old cache files remain.
+4. Run `composer install --no-dev -o` to update the dependencies.
+5. Run `yarn install` and `yarn build` to update the frontend assets.
+6. Rund `php bin/console doctrine:migrations:migrate` to update the database schema.
+7. Clear the cache with `php bin/console cache:clear`.
+8. Open your Part-DB instance in the browser and log in as an admin user.
+9. Go to the user or group permissions page, and give yourself (and other administrators) the right to change system settings (under "System" and "Configuration").
+10. You can now go to the settings page (under "System" and "Settings") and check if all settings are correct.
+11. Parameters which were previously set via environment variables are greyed out and cannot be changed in the web interface.
If you want to change them, you must migrate them to the settings interface as described below.
### Docker installation
@@ -87,3 +88,15 @@ After the migration run successfully, the contents of your environment variables
Go through the environment variables listed by the command and remove them from your environment variable configuration (e.g. `.env.local` file or docker compose file), or just comment them out for now.
If you want to keep some environment variables, just leave them as they are, they will still work as before, the migration command only affects the settings stored in the database.
+
+
+## Troubleshooting
+
+### cache:clear fails: You have requested a non-existent parameter "jbtronics.settings.proxy_dir".
+If you receive an error like
+```
+In App_KernelProdContainer.php line 2839:
+You have requested a non-existent parameter "jbtronics.settings.proxy_dir".
+```
+when running `php bin/console cache:clear` or `composer install`. You have to manually delete the `var/cache/`
+directory inside your Part-DB installation and try again.
diff --git a/docs/usage/import_export.md b/docs/usage/import_export.md
index 0534221f..136624e2 100644
--- a/docs/usage/import_export.md
+++ b/docs/usage/import_export.md
@@ -142,6 +142,9 @@ You can select between the following export formats:
efficiently.
* **YAML** (Yet Another Markup Language): Very similar to JSON
* **XML** (Extensible Markup Language): Good support with nested data structures. Similar use cases as JSON and YAML.
+* **Excel**: Similar to CSV, but in a native Excel format. Can be opened in Excel and LibreOffice Calc. Does not support nested
+ data structures or sub-data (like parameters, attachments, etc.), very well (many columns are generated, as every
+ possible sub-data is exported as a separate column).
Also, you can select between the following export levels:
diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md
index 3b016200..96468c1a 100644
--- a/docs/usage/information_provider_system.md
+++ b/docs/usage/information_provider_system.md
@@ -68,6 +68,13 @@ If you already have attachment types for images and datasheets and want the info
can
add the alternative names "Datasheet" and "Image" to the alternative names field of the attachment types.
+## Bulk import
+
+If you want to update the information of multiple parts, you can use the bulk import system: Go to a part table and select
+the parts you want to update. In the bulk actions dropdown select "Bulk info provider import" and click "Apply".
+You will be redirected to a page, where you can select how part fields should be mapped to info provider fields, and the
+results will be shown.
+
## Data providers
The system tries to be as flexible as possible, so many different information sources can be used.
diff --git a/makefile b/makefile
index 9041ba0f..bc4d0bf3 100644
--- a/makefile
+++ b/makefile
@@ -1,112 +1,91 @@
# 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
+.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
+test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
+section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
# 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"
+help: ## Show this help
+ @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
-# Install PHP dependencies with unlimited memory
-deps-install:
+# Dependencies
+deps-install: ## Install PHP dependencies with unlimited memory
@echo "📦 Installing PHP dependencies..."
COMPOSER_MEMORY_LIMIT=-1 composer install
+ yarn install
@echo "✅ Dependencies installed"
# Complete test environment setup
-test-setup: deps-install test-clean test-db-create test-db-migrate test-fixtures
+test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
@echo "✅ Test environment setup complete!"
# Clean test environment
-test-clean:
+test-clean: ## Clean test cache and database files
@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:
+test-db-create: ## Create test database (if not exists)
@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:
+test-db-migrate: ## Run database migrations for test environment
@echo "🔄 Running database migrations..."
- php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test
+ COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
-test-cache-clear:
+test-cache-clear: ## Clear test cache
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
-test-fixtures:
+test-fixtures: ## Load test fixtures
@echo "📦 Loading test fixtures..."
php bin/console partdb:fixtures:load -n --env test
# Run PHPUnit tests
-test-run:
+test-run: ## Run PHPUnit tests
@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!"
+test-typecheck: ## Run static analysis (PHPStan)
+ @echo "🧪 Running type checks..."
+ COMPOSER_MEMORY_LIMIT=-1 composer phpstan
+
# Development helpers
-dev-setup: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup
+dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
@echo "✅ Development environment setup complete!"
-dev-clean:
+dev-clean: ## Clean development cache and database files
@echo "🧹 Cleaning development environment..."
rm -rf var/cache/dev
rm -f var/app_dev.db
@echo "✅ Development environment cleaned"
-dev-db-create:
+dev-db-create: ## Create development database (if not exists)
@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:
+dev-db-migrate: ## Run database migrations for development environment
@echo "🔄 Running database migrations..."
- php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev
+ COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
-dev-cache-clear:
+dev-cache-clear: ## Clear development cache
@echo "🗑️ Clearing development cache..."
- php -d memory_limit=1G bin/console cache:clear --env dev -n
+ rm -rf var/cache/dev
@echo "✅ Development cache cleared"
-dev-warmup:
+dev-warmup: ## Warm up development cache
@echo "🔥 Warming up development cache..."
- php -d memory_limit=1G bin/console cache:warmup --env dev -n
+ COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
-dev-reset: dev-cache-clear dev-db-migrate
+dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
@echo "✅ Development environment reset complete!"
\ No newline at end of file
diff --git a/migrations/Version20250802205143.php b/migrations/Version20250802205143.php
new file mode 100644
index 00000000..5eb09a77
--- /dev/null
+++ b/migrations/Version20250802205143.php
@@ -0,0 +1,70 @@
+addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
+ $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
+
+ $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
+ $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
+ $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
+ $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
+ }
+
+ public function mySQLDown(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
+ $this->addSql('DROP TABLE bulk_info_provider_import_jobs');
+ }
+
+ public function sqLiteUp(Schema $schema): void
+ {
+ $this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
+ $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
+
+ $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
+ $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
+ $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
+ $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
+ }
+
+ public function sqLiteDown(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
+ $this->addSql('DROP TABLE bulk_info_provider_import_jobs');
+ }
+
+ public function postgreSQLUp(Schema $schema): void
+ {
+ $this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
+ $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
+
+ $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
+ $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
+ $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
+ $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
+ }
+
+ public function postgreSQLDown(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
+ $this->addSql('DROP TABLE bulk_info_provider_import_jobs');
+ }
+}
diff --git a/package.json b/package.json
index 7a3efaa4..f2fbebcd 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,7 @@
"bootbox": "^6.0.0",
"bootswatch": "^5.1.3",
"bs-custom-file-input": "^1.3.4",
- "ckeditor5": "^46.0.0",
+ "ckeditor5": "^47.0.0",
"clipboard": "^2.0.4",
"compression-webpack-plugin": "^11.1.0",
"datatables.net": "^2.0.0",
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 4a37b420..3feb4940 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -4,7 +4,7 @@
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
- failOnDeprecation="true"
+ failOnDeprecation="false"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
diff --git a/public/img/calculator/ratio.png b/public/img/calculator/ratio.png
deleted file mode 100644
index d6decff3..00000000
Binary files a/public/img/calculator/ratio.png and /dev/null differ
diff --git a/public/img/calculator/v1.png b/public/img/calculator/v1.png
deleted file mode 100644
index c98d3ad4..00000000
Binary files a/public/img/calculator/v1.png and /dev/null differ
diff --git a/public/img/calculator/v2.png b/public/img/calculator/v2.png
deleted file mode 100644
index 081386fe..00000000
Binary files a/public/img/calculator/v2.png and /dev/null differ
diff --git a/public/img/labels/100.png b/public/img/labels/100.png
deleted file mode 100644
index f68a23a9..00000000
Binary files a/public/img/labels/100.png and /dev/null differ
diff --git a/public/img/labels/1001.png b/public/img/labels/1001.png
deleted file mode 100644
index c87e4ceb..00000000
Binary files a/public/img/labels/1001.png and /dev/null differ
diff --git a/public/img/labels/1002.png b/public/img/labels/1002.png
deleted file mode 100644
index 68b6594c..00000000
Binary files a/public/img/labels/1002.png and /dev/null differ
diff --git a/public/img/labels/1003.png b/public/img/labels/1003.png
deleted file mode 100644
index 2abbd616..00000000
Binary files a/public/img/labels/1003.png and /dev/null differ
diff --git a/public/img/labels/100R.png b/public/img/labels/100R.png
deleted file mode 100644
index 34fb8fa8..00000000
Binary files a/public/img/labels/100R.png and /dev/null differ
diff --git a/public/img/labels/101.png b/public/img/labels/101.png
deleted file mode 100644
index dd07aa39..00000000
Binary files a/public/img/labels/101.png and /dev/null differ
diff --git a/public/img/labels/102.png b/public/img/labels/102.png
deleted file mode 100644
index a54e16b7..00000000
Binary files a/public/img/labels/102.png and /dev/null differ
diff --git a/public/img/labels/10R2.png b/public/img/labels/10R2.png
deleted file mode 100644
index 2b57f7d4..00000000
Binary files a/public/img/labels/10R2.png and /dev/null differ
diff --git a/public/img/labels/220.png b/public/img/labels/220.png
deleted file mode 100644
index 28ede43d..00000000
Binary files a/public/img/labels/220.png and /dev/null differ
diff --git a/public/img/labels/221K.png b/public/img/labels/221K.png
deleted file mode 100644
index 1dbb0c61..00000000
Binary files a/public/img/labels/221K.png and /dev/null differ
diff --git a/public/img/labels/246-20.png b/public/img/labels/246-20.png
deleted file mode 100644
index 590f7c5d..00000000
Binary files a/public/img/labels/246-20.png and /dev/null differ
diff --git a/public/img/labels/3F3.png b/public/img/labels/3F3.png
deleted file mode 100644
index ce85ae97..00000000
Binary files a/public/img/labels/3F3.png and /dev/null differ
diff --git a/public/img/labels/R10.png b/public/img/labels/R10.png
deleted file mode 100644
index 60a90182..00000000
Binary files a/public/img/labels/R10.png and /dev/null differ
diff --git a/public/img/labels/template-c-elko-alu.png b/public/img/labels/template-c-elko-alu.png
deleted file mode 100644
index 24d68d91..00000000
Binary files a/public/img/labels/template-c-elko-alu.png and /dev/null differ
diff --git a/public/img/labels/template-c-elko.png b/public/img/labels/template-c-elko.png
deleted file mode 100644
index 97e3c1ef..00000000
Binary files a/public/img/labels/template-c-elko.png and /dev/null differ
diff --git a/public/img/labels/template-c-tantal.png b/public/img/labels/template-c-tantal.png
deleted file mode 100644
index 3e49efee..00000000
Binary files a/public/img/labels/template-c-tantal.png and /dev/null differ
diff --git a/public/img/labels/template-l.png b/public/img/labels/template-l.png
deleted file mode 100644
index 7e5afd92..00000000
Binary files a/public/img/labels/template-l.png and /dev/null differ
diff --git a/public/img/labels/template-r.png b/public/img/labels/template-r.png
deleted file mode 100644
index 554d2a08..00000000
Binary files a/public/img/labels/template-r.png and /dev/null differ
diff --git a/public/img/partdb/alldatasheet.png b/public/img/partdb/alldatasheet.png
deleted file mode 100644
index d7c1d40f..00000000
Binary files a/public/img/partdb/alldatasheet.png and /dev/null differ
diff --git a/public/img/partdb/dc.png b/public/img/partdb/dc.png
deleted file mode 100644
index 4a9403af..00000000
Binary files a/public/img/partdb/dc.png and /dev/null differ
diff --git a/public/img/partdb/dummytn.png b/public/img/partdb/dummytn.png
deleted file mode 100644
index e63c9248..00000000
Binary files a/public/img/partdb/dummytn.png and /dev/null differ
diff --git a/public/img/partdb/favicon.ico b/public/img/partdb/favicon.ico
deleted file mode 100644
index 1d838794..00000000
Binary files a/public/img/partdb/favicon.ico and /dev/null differ
diff --git a/public/img/partdb/file_all.svg b/public/img/partdb/file_all.svg
deleted file mode 100644
index bb4b4248..00000000
--- a/public/img/partdb/file_all.svg
+++ /dev/null
@@ -1,131 +0,0 @@
-
-
-
-
diff --git a/public/img/partdb/file_dc.svg b/public/img/partdb/file_dc.svg
deleted file mode 100644
index f0039881..00000000
--- a/public/img/partdb/file_dc.svg
+++ /dev/null
@@ -1,90 +0,0 @@
-
-
-
-
diff --git a/public/img/partdb/file_google.svg b/public/img/partdb/file_google.svg
deleted file mode 100644
index 20ea96bf..00000000
--- a/public/img/partdb/file_google.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
diff --git a/public/img/partdb/file_octo.svg b/public/img/partdb/file_octo.svg
deleted file mode 100644
index 307439a5..00000000
--- a/public/img/partdb/file_octo.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
diff --git a/public/img/partdb/file_reichelt.svg b/public/img/partdb/file_reichelt.svg
deleted file mode 100644
index 488dafaa..00000000
--- a/public/img/partdb/file_reichelt.svg
+++ /dev/null
@@ -1,98 +0,0 @@
-
-
-
-
diff --git a/public/img/partdb/help.png b/public/img/partdb/help.png
deleted file mode 100644
index 7cb04978..00000000
Binary files a/public/img/partdb/help.png and /dev/null differ
diff --git a/public/img/partdb/partdb.png b/public/img/partdb/partdb.png
deleted file mode 100644
index 53f51afb..00000000
Binary files a/public/img/partdb/partdb.png and /dev/null differ
diff --git a/public/img/partdb/reichelt.png b/public/img/partdb/reichelt.png
deleted file mode 100644
index fcfcfd49..00000000
Binary files a/public/img/partdb/reichelt.png and /dev/null differ
diff --git a/public/img/partdb/template-pdf.png b/public/img/partdb/template-pdf.png
deleted file mode 100644
index 211bf5a4..00000000
Binary files a/public/img/partdb/template-pdf.png and /dev/null differ
diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php
new file mode 100644
index 00000000..2d3dd7f6
--- /dev/null
+++ b/src/Controller/BulkInfoProviderImportController.php
@@ -0,0 +1,588 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\InfoProviderSystem\BulkImportJobStatus;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\Supplier;
+use App\Entity\UserSystem\User;
+use App\Form\InfoProviderSystem\GlobalFieldMappingType;
+use App\Services\InfoProviderSystem\BulkInfoProviderService;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
+use Doctrine\ORM\EntityManagerInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/tools/bulk_info_provider_import')]
+class BulkInfoProviderImportController extends AbstractController
+{
+ public function __construct(
+ private readonly BulkInfoProviderService $bulkService,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly LoggerInterface $logger,
+ #[Autowire(param: 'partdb.bulk_import.batch_size')]
+ private readonly int $bulkImportBatchSize,
+ #[Autowire(param: 'partdb.bulk_import.max_parts_per_operation')]
+ private readonly int $bulkImportMaxParts
+ ) {
+ }
+
+ /**
+ * Convert field mappings from array format to FieldMappingDTO[].
+ *
+ * @param array $fieldMappings Array of field mapping arrays
+ * @return BulkSearchFieldMappingDTO[] Array of FieldMappingDTO objects
+ */
+ private function convertFieldMappingsToDto(array $fieldMappings): array
+ {
+ $dtos = [];
+ foreach ($fieldMappings as $mapping) {
+ $dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
+ }
+ return $dtos;
+ }
+
+ private function createErrorResponse(string $message, int $statusCode = 400, array $context = []): JsonResponse
+ {
+ $this->logger->warning('Bulk import operation failed', array_merge([
+ 'error' => $message,
+ 'user' => $this->getUser()?->getUserIdentifier(),
+ ], $context));
+
+ return $this->json([
+ 'success' => false,
+ 'error' => $message
+ ], $statusCode);
+ }
+
+ private function validateJobAccess(int $jobId): ?BulkInfoProviderImportJob
+ {
+ $this->denyAccessUnlessGranted('@info_providers.create_parts');
+
+ $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
+
+ if (!$job) {
+ return null;
+ }
+
+ if ($job->getCreatedBy() !== $this->getUser()) {
+ return null;
+ }
+
+ return $job;
+ }
+
+ private function updatePartSearchResults(BulkInfoProviderImportJob $job, ?BulkSearchPartResultsDTO $newResults): void
+ {
+ if ($newResults === null) {
+ return;
+ }
+
+ // Only deserialize and update if we have new results
+ $allResults = $job->getSearchResults($this->entityManager);
+
+ // Find and update the results for this specific part
+ $allResults = $allResults->replaceResultsForPart($newResults);
+
+ // Save updated results back to job
+ $job->setSearchResults($allResults);
+ }
+
+ #[Route('/step1', name: 'bulk_info_provider_step1')]
+ public function step1(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('@info_providers.create_parts');
+
+ set_time_limit(600);
+
+ $ids = $request->query->get('ids');
+ if (!$ids) {
+ $this->addFlash('error', 'No parts selected for bulk import');
+ return $this->redirectToRoute('homepage');
+ }
+
+ $partIds = explode(',', $ids);
+ $partRepository = $this->entityManager->getRepository(Part::class);
+ $parts = $partRepository->getElementsFromIDArray($partIds);
+
+ if (empty($parts)) {
+ $this->addFlash('error', 'No valid parts found for bulk import');
+ return $this->redirectToRoute('homepage');
+ }
+
+ // Validate against configured maximum
+ if (count($parts) > $this->bulkImportMaxParts) {
+ $this->addFlash('error', sprintf(
+ 'Too many parts selected (%d). Maximum allowed is %d parts per operation.',
+ count($parts),
+ $this->bulkImportMaxParts
+ ));
+ return $this->redirectToRoute('homepage');
+ }
+
+ if (count($parts) > ($this->bulkImportMaxParts / 2)) {
+ $this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.');
+ }
+
+ // Generate field choices
+ $fieldChoices = [
+ 'info_providers.bulk_search.field.mpn' => 'mpn',
+ 'info_providers.bulk_search.field.name' => 'name',
+ ];
+
+ // Add dynamic supplier fields
+ $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
+ foreach ($suppliers as $supplier) {
+ $supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
+ $fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn';
+ }
+
+ // Initialize form with useful default mappings
+ $initialData = [
+ 'field_mappings' => [
+ ['field' => 'mpn', 'providers' => [], 'priority' => 1]
+ ],
+ 'prefetch_details' => false
+ ];
+
+ $form = $this->createForm(GlobalFieldMappingType::class, $initialData, [
+ 'field_choices' => $fieldChoices
+ ]);
+ $form->handleRequest($request);
+
+ $searchResults = null;
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $formData = $form->getData();
+ $fieldMappingDtos = $this->convertFieldMappingsToDto($formData['field_mappings']);
+ $prefetchDetails = $formData['prefetch_details'] ?? false;
+
+ $user = $this->getUser();
+ if (!$user instanceof User) {
+ throw new \RuntimeException('User must be authenticated and of type User');
+ }
+
+ // Validate part count against configuration limit
+ if (count($parts) > $this->bulkImportMaxParts) {
+ $this->addFlash('error', "Too many parts selected. Maximum allowed: {$this->bulkImportMaxParts}");
+ $partIds = array_map(fn($part) => $part->getId(), $parts);
+ return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
+ }
+
+ // Create and save the job
+ $job = new BulkInfoProviderImportJob();
+ $job->setFieldMappings($fieldMappingDtos);
+ $job->setPrefetchDetails($prefetchDetails);
+ $job->setCreatedBy($user);
+
+ foreach ($parts as $part) {
+ $jobPart = new BulkInfoProviderImportJobPart($job, $part);
+ $job->addJobPart($jobPart);
+ }
+
+ $this->entityManager->persist($job);
+ $this->entityManager->flush();
+
+ try {
+ $searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails);
+
+ // Save search results to job
+ $job->setSearchResults($searchResultsDto);
+ $job->markAsInProgress();
+ $this->entityManager->flush();
+
+ // Prefetch details if requested
+ if ($prefetchDetails) {
+ $this->bulkService->prefetchDetailsForResults($searchResultsDto);
+ }
+
+ return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]);
+
+ } catch (\Exception $e) {
+ $this->logger->error('Critical error during bulk import search', [
+ 'job_id' => $job->getId(),
+ 'error' => $e->getMessage(),
+ 'exception' => $e
+ ]);
+
+ $this->entityManager->remove($job);
+ $this->entityManager->flush();
+
+ $this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage());
+ $partIds = array_map(fn($part) => $part->getId(), $parts);
+ return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
+ }
+ }
+
+ // Get existing in-progress jobs for current user
+ $existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
+ ->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10);
+
+ return $this->render('info_providers/bulk_import/step1.html.twig', [
+ 'form' => $form,
+ 'parts' => $parts,
+ 'search_results' => $searchResults,
+ 'existing_jobs' => $existingJobs,
+ 'fieldChoices' => $fieldChoices
+ ]);
+ }
+
+ #[Route('/manage', name: 'bulk_info_provider_manage')]
+ public function manageBulkJobs(): Response
+ {
+ $this->denyAccessUnlessGranted('@info_providers.create_parts');
+
+ // Get all jobs for current user
+ $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
+ ->findBy([], ['createdAt' => 'DESC']);
+
+ // Check and auto-complete jobs that should be completed
+ // Also clean up jobs with no results (failed searches)
+ $updatedJobs = false;
+ $jobsToDelete = [];
+
+ foreach ($allJobs as $job) {
+ if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
+ $job->markAsCompleted();
+ $updatedJobs = true;
+ }
+
+ // Mark jobs with no results for deletion (failed searches)
+ if ($job->getResultCount() === 0 && $job->isInProgress()) {
+ $jobsToDelete[] = $job;
+ }
+ }
+
+ // Delete failed jobs
+ foreach ($jobsToDelete as $job) {
+ $this->entityManager->remove($job);
+ $updatedJobs = true;
+ }
+
+ // Flush changes if any jobs were updated
+ if ($updatedJobs) {
+ $this->entityManager->flush();
+
+ if (!empty($jobsToDelete)) {
+ $this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.');
+ }
+ }
+
+ return $this->render('info_providers/bulk_import/manage.html.twig', [
+ 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
+ ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
+ ]);
+ }
+
+ #[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])]
+ public function deleteJob(int $jobId): Response
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ // Only allow deletion of completed, failed, or stopped jobs
+ if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) {
+ return $this->json(['error' => 'Cannot delete active job'], 400);
+ }
+
+ $this->entityManager->remove($job);
+ $this->entityManager->flush();
+
+ return $this->json(['success' => true]);
+ }
+
+ #[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])]
+ public function stopJob(int $jobId): Response
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ // Only allow stopping of pending or in-progress jobs
+ if (!$job->canBeStopped()) {
+ return $this->json(['error' => 'Cannot stop job in current status'], 400);
+ }
+
+ $job->markAsStopped();
+ $this->entityManager->flush();
+
+ return $this->json(['success' => true]);
+ }
+
+
+ #[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')]
+ public function step2(int $jobId): Response
+ {
+ $this->denyAccessUnlessGranted('@info_providers.create_parts');
+
+ $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
+
+ if (!$job) {
+ $this->addFlash('error', 'Bulk import job not found');
+ return $this->redirectToRoute('bulk_info_provider_step1');
+ }
+
+ // Check if user owns this job
+ if ($job->getCreatedBy() !== $this->getUser()) {
+ $this->addFlash('error', 'Access denied to this bulk import job');
+ return $this->redirectToRoute('bulk_info_provider_step1');
+ }
+
+ // Get the parts and deserialize search results
+ $parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray();
+ $searchResults = $job->getSearchResults($this->entityManager);
+
+ return $this->render('info_providers/bulk_import/step2.html.twig', [
+ 'job' => $job,
+ 'parts' => $parts,
+ 'search_results' => $searchResults,
+ ]);
+ }
+
+
+ #[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])]
+ public function markPartCompleted(int $jobId, int $partId): Response
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ $job->markPartAsCompleted($partId);
+
+ // Auto-complete job if all parts are done
+ if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
+ $job->markAsCompleted();
+ }
+
+ $this->entityManager->flush();
+
+ return $this->json([
+ 'success' => true,
+ 'progress' => $job->getProgressPercentage(),
+ 'completed_count' => $job->getCompletedPartsCount(),
+ 'total_count' => $job->getPartCount(),
+ 'job_completed' => $job->isCompleted()
+ ]);
+ }
+
+ #[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])]
+ public function markPartSkipped(int $jobId, int $partId, Request $request): Response
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ $reason = $request->request->get('reason', '');
+ $job->markPartAsSkipped($partId, $reason);
+
+ // Auto-complete job if all parts are done
+ if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
+ $job->markAsCompleted();
+ }
+
+ $this->entityManager->flush();
+
+ return $this->json([
+ 'success' => true,
+ 'progress' => $job->getProgressPercentage(),
+ 'completed_count' => $job->getCompletedPartsCount(),
+ 'skipped_count' => $job->getSkippedPartsCount(),
+ 'total_count' => $job->getPartCount(),
+ 'job_completed' => $job->isCompleted()
+ ]);
+ }
+
+ #[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])]
+ public function markPartPending(int $jobId, int $partId): Response
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ $job->markPartAsPending($partId);
+ $this->entityManager->flush();
+
+ return $this->json([
+ 'success' => true,
+ 'progress' => $job->getProgressPercentage(),
+ 'completed_count' => $job->getCompletedPartsCount(),
+ 'skipped_count' => $job->getSkippedPartsCount(),
+ 'total_count' => $job->getPartCount(),
+ 'job_completed' => $job->isCompleted()
+ ]);
+ }
+
+ #[Route('/job/{jobId}/part/{partId}/research', name: 'bulk_info_provider_research_part', methods: ['POST'])]
+ public function researchPart(int $jobId, int $partId): JsonResponse
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ $part = $this->entityManager->getRepository(Part::class)->find($partId);
+ if (!$part) {
+ return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
+ }
+
+ // Only refresh if the entity might be stale (optional optimization)
+ if ($this->entityManager->getUnitOfWork()->isScheduledForUpdate($part)) {
+ $this->entityManager->refresh($part);
+ }
+
+ try {
+ // Use the job's field mappings to perform the search
+ $fieldMappingDtos = $job->getFieldMappings();
+ $prefetchDetails = $job->isPrefetchDetails();
+
+ try {
+ $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
+ } catch (\Exception $searchException) {
+ // Handle "no search results found" as a normal case, not an error
+ if (str_contains($searchException->getMessage(), 'No search results found')) {
+ $searchResultsDto = null;
+ } else {
+ throw $searchException;
+ }
+ }
+
+ // Update the job's search results for this specific part efficiently
+ $this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
+
+ // Prefetch details if requested
+ if ($prefetchDetails && $searchResultsDto !== null) {
+ $this->bulkService->prefetchDetailsForResults($searchResultsDto);
+ }
+
+ $this->entityManager->flush();
+
+ // Return the new results for this part
+ $newResults = $searchResultsDto[0] ?? null;
+
+ return $this->json([
+ 'success' => true,
+ 'part_id' => $partId,
+ 'results_count' => $newResults ? $newResults->getResultCount() : 0,
+ 'errors_count' => $newResults ? $newResults->getErrorCount() : 0,
+ 'message' => 'Part research completed successfully'
+ ]);
+
+ } catch (\Exception $e) {
+ return $this->createErrorResponse(
+ 'Research failed: ' . $e->getMessage(),
+ 500,
+ [
+ 'job_id' => $jobId,
+ 'part_id' => $partId,
+ 'exception' => $e->getMessage()
+ ]
+ );
+ }
+ }
+
+ #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
+ public function researchAllParts(int $jobId): JsonResponse
+ {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ // Get all parts that are not completed or skipped
+ $parts = [];
+ foreach ($job->getJobParts() as $jobPart) {
+ if (!$jobPart->isCompleted() && !$jobPart->isSkipped()) {
+ $parts[] = $jobPart->getPart();
+ }
+ }
+
+ if (empty($parts)) {
+ return $this->json([
+ 'success' => true,
+ 'message' => 'No parts to research',
+ 'researched_count' => 0
+ ]);
+ }
+
+ try {
+ $fieldMappingDtos = $job->getFieldMappings();
+ $prefetchDetails = $job->isPrefetchDetails();
+
+ // Process in batches to reduce memory usage for large operations
+ $allResults = new BulkSearchResponseDTO(partResults: []);
+ $batches = array_chunk($parts, $this->bulkImportBatchSize);
+
+ foreach ($batches as $batch) {
+ $batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails);
+ $allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto);
+
+ // Properly manage entity manager memory without losing state
+ $jobId = $job->getId();
+ //$this->entityManager->clear(); //TODO: This seems to cause problems with the user relation, when trying to flush later
+ $job = $this->entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ }
+
+ // Update the job's search results
+ $job->setSearchResults($allResults);
+
+ // Prefetch details if requested
+ if ($prefetchDetails) {
+ $this->bulkService->prefetchDetailsForResults($allResults);
+ }
+
+ $this->entityManager->flush();
+
+ return $this->json([
+ 'success' => true,
+ 'researched_count' => count($parts),
+ 'message' => sprintf('Successfully researched %d parts', count($parts))
+ ]);
+
+ } catch (\Exception $e) {
+ return $this->createErrorResponse(
+ 'Bulk research failed: ' . $e->getMessage(),
+ 500,
+ [
+ 'job_id' => $jobId,
+ 'part_count' => count($parts),
+ 'exception' => $e->getMessage()
+ ]
+ );
+ }
+ }
+}
diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php
index dae8213e..b79c307c 100644
--- a/src/Controller/InfoProviderController.php
+++ b/src/Controller/InfoProviderController.php
@@ -25,6 +25,7 @@ namespace App\Controller;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
+use App\Exceptions\OAuthReconnectRequiredException;
use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
@@ -175,8 +176,11 @@ class InfoProviderController extends AbstractController
$this->addFlash('error',$e->getMessage());
//Log the exception
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
+ } catch (OAuthReconnectRequiredException $e) {
+ $this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
}
+
// modify the array to an array of arrays that has a field for a matching local Part
// the advantage to use that format even when we don't look for local parts is that we
// always work with the same interface
diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php
index 6708ed4c..aeb2664e 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -64,14 +64,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/part')]
-class PartController extends AbstractController
+final class PartController extends AbstractController
{
- public function __construct(protected PricedetailHelper $pricedetailHelper,
- protected PartPreviewGenerator $partPreviewGenerator,
+ public function __construct(
+ private readonly PricedetailHelper $pricedetailHelper,
+ private readonly PartPreviewGenerator $partPreviewGenerator,
private readonly TranslatorInterface $translator,
- private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
- protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
- {
+ private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
+ private readonly EntityManagerInterface $em,
+ private readonly EventCommentHelper $commentHelper,
+ private readonly PartInfoSettings $partInfoSettings,
+ ) {
}
/**
@@ -80,9 +83,16 @@ class PartController extends AbstractController
*/
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
- public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
- DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
- {
+ public function show(
+ Part $part,
+ Request $request,
+ TimeTravel $timeTravel,
+ HistoryHelper $historyHelper,
+ DataTableFactory $dataTable,
+ ParameterExtractor $parameterExtractor,
+ PartLotWithdrawAddHelper $withdrawAddHelper,
+ ?string $timestamp = null
+ ): Response {
$this->denyAccessUnlessGranted('read', $part);
$timeTravel_timestamp = null;
@@ -132,7 +142,43 @@ class PartController extends AbstractController
{
$this->denyAccessUnlessGranted('edit', $part);
- return $this->renderPartForm('edit', $request, $part);
+ // Check if this is part of a bulk import job
+ $jobId = $request->query->get('jobId');
+ $bulkJob = null;
+ if ($jobId) {
+ $bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
+ // Verify user owns this job
+ if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
+ $bulkJob = null;
+ }
+ }
+
+ return $this->renderPartForm('edit', $request, $part, [], [
+ 'bulk_job' => $bulkJob
+ ]);
+ }
+
+ #[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])]
+ public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('edit', $part);
+
+ if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) {
+ throw $this->createAccessDeniedException('Invalid CSRF token');
+ }
+
+ $bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
+ if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
+ throw $this->createNotFoundException('Bulk import job not found');
+ }
+
+ $bulkJob->markPartAsCompleted($part->getId());
+ $this->em->persist($bulkJob);
+ $this->em->flush();
+
+ $this->addFlash('success', 'Part marked as completed in bulk import');
+
+ return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]);
}
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
@@ -140,7 +186,7 @@ class PartController extends AbstractController
{
$this->denyAccessUnlessGranted('delete', $part);
- if ($this->isCsrfTokenValid('delete'.$part->getID(), $request->request->get('_token'))) {
+ if ($this->isCsrfTokenValid('delete' . $part->getID(), $request->request->get('_token'))) {
$this->commentHelper->setMessage($request->request->get('log_comment', null));
@@ -159,11 +205,15 @@ class PartController extends AbstractController
#[Route(path: '/new', name: 'part_new')]
#[Route(path: '/{id}/clone', name: 'part_clone')]
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
- public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
- AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
+ public function new(
+ Request $request,
+ EntityManagerInterface $em,
+ TranslatorInterface $translator,
+ AttachmentSubmitHandler $attachmentSubmitHandler,
+ ProjectBuildPartHelper $projectBuildPartHelper,
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
- #[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null): Response
- {
+ #[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null
+ ): Response {
if ($part instanceof Part) {
//Clone part
@@ -258,9 +308,14 @@ class PartController extends AbstractController
}
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
- public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
- PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
- {
+ public function updateFromInfoProvider(
+ Part $part,
+ Request $request,
+ string $providerKey,
+ string $providerId,
+ PartInfoRetriever $infoRetriever,
+ PartMerger $partMerger
+ ): Response {
$this->denyAccessUnlessGranted('edit', $part);
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@@ -274,10 +329,22 @@ class PartController extends AbstractController
$this->addFlash('notice', t('part.merge.flash.please_review'));
+ // Check if this is part of a bulk import job
+ $jobId = $request->query->get('jobId');
+ $bulkJob = null;
+ if ($jobId) {
+ $bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
+ // Verify user owns this job
+ if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
+ $bulkJob = null;
+ }
+ }
+
return $this->renderPartForm('update_from_ip', $request, $part, [
'info_provider_dto' => $dto,
], [
- 'tname_before' => $old_name
+ 'tname_before' => $old_name,
+ 'bulk_job' => $bulkJob
]);
}
@@ -312,7 +379,7 @@ class PartController extends AbstractController
} catch (AttachmentDownloadException $attachmentDownloadException) {
$this->addFlash(
'error',
- $this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage()
+ $this->translator->trans('attachment.download_failed') . ' ' . $attachmentDownloadException->getMessage()
);
}
}
@@ -353,6 +420,12 @@ class PartController extends AbstractController
return $this->redirectToRoute('part_new');
}
+ // Check if we're in bulk import mode and preserve jobId
+ $jobId = $request->query->get('jobId');
+ if ($jobId && isset($merge_infos['bulk_job'])) {
+ return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]);
+ }
+
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]);
}
@@ -371,13 +444,17 @@ class PartController extends AbstractController
$template = 'parts/edit/update_from_ip.html.twig';
}
- return $this->render($template,
+ return $this->render(
+ $template,
[
'part' => $new_part,
'form' => $form,
'merge_old_name' => $merge_infos['tname_before'] ?? null,
- 'merge_other' => $merge_infos['other_part'] ?? null
- ]);
+ 'merge_other' => $merge_infos['other_part'] ?? null,
+ 'bulk_job' => $merge_infos['bulk_job'] ?? null,
+ 'jobId' => $request->query->get('jobId')
+ ]
+ );
}
@@ -387,17 +464,17 @@ class PartController extends AbstractController
if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) {
//Retrieve partlot from the request
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
- if(!$partLot instanceof PartLot) {
+ if (!$partLot instanceof PartLot) {
throw new \RuntimeException('Part lot not found!');
}
//Ensure that the partlot belongs to the part
- if($partLot->getPart() !== $part) {
+ if ($partLot->getPart() !== $part) {
throw new \RuntimeException("The origin partlot does not belong to the part!");
}
//Try to determine the target lot (used for move actions), if the parameter is existing
$targetId = $request->request->get('target_id', null);
- $targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
+ $targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
if ($targetLot && $targetLot->getPart() !== $part) {
throw new \RuntimeException("The target partlot does not belong to the part!");
}
@@ -411,12 +488,12 @@ class PartController extends AbstractController
$timestamp = null;
$timestamp_str = $request->request->getString('timestamp', '');
//Try to parse the timestamp
- if($timestamp_str !== '') {
+ if ($timestamp_str !== '') {
$timestamp = new DateTime($timestamp_str);
}
//Ensure that the timestamp is not in the future
- if($timestamp !== null && $timestamp > new DateTime("+20min")) {
+ if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
throw new \LogicException("The timestamp must not be in the future!");
}
@@ -460,7 +537,7 @@ class PartController extends AbstractController
err:
//If a redirect was passed, then redirect there
- if($request->request->get('_redirect')) {
+ if ($request->request->get('_redirect')) {
return $this->redirect($request->request->get('_redirect'));
}
//Otherwise just redirect to the part page
diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php
index b2df18c1..808b0c5d 100644
--- a/src/Controller/PartListsController.php
+++ b/src/Controller/PartListsController.php
@@ -36,6 +36,7 @@ use App\Exceptions\InvalidRegexException;
use App\Form\Filters\PartFilterType;
use App\Services\Parts\PartsTableActionHandler;
use App\Services\Trees\NodesListBuilder;
+use App\Settings\BehaviorSettings\SidebarSettings;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\EntityManagerInterface;
@@ -56,11 +57,21 @@ class PartListsController extends AbstractController
private readonly NodesListBuilder $nodesListBuilder,
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
- private readonly TableSettings $tableSettings
+ private readonly TableSettings $tableSettings,
+ private readonly SidebarSettings $sidebarSettings,
)
{
}
+ /**
+ * Gets the filter operator to use by default (INCLUDING_CHILDREN or =)
+ * @return string
+ */
+ private function getFilterOperator(): string
+ {
+ return $this->sidebarSettings->dataStructureNodesTableIncludeChildren ? 'INCLUDING_CHILDREN' : '=';
+ }
+
#[Route(path: '/table/action', name: 'table_action', methods: ['POST'])]
public function tableAction(Request $request, PartsTableActionHandler $actionHandler): Response
{
@@ -154,12 +165,17 @@ class PartListsController extends AbstractController
$filter_changer($filter);
}
- $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
- if($form_changer !== null) {
- $form_changer($filterForm);
- }
+ //If we are in a post request for the tables, we only have to apply the filter form if the submit query param was set
+ //This saves us some time from creating this complicated term on simple list pages, where no special filter is applied
+ $filterForm = null;
+ if ($request->getMethod() !== 'POST' || $request->query->has('part_filter')) {
+ $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
+ if ($form_changer !== null) {
+ $form_changer($filterForm);
+ }
- $filterForm->handleRequest($formRequest);
+ $filterForm->handleRequest($formRequest);
+ }
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
['filter' => $filter], $additional_table_vars),
@@ -186,7 +202,7 @@ class PartListsController extends AbstractController
return $this->render($template, array_merge([
'datatable' => $table,
- 'filterForm' => $filterForm->createView(),
+ 'filterForm' => $filterForm?->createView(),
], $additonal_template_vars));
}
@@ -198,7 +214,7 @@ class PartListsController extends AbstractController
return $this->showListWithFilter($request,
'parts/lists/category_list.html.twig',
function (PartFilter $filter) use ($category) {
- $filter->category->setOperator('INCLUDING_CHILDREN')->setValue($category);
+ $filter->category->setOperator($this->getFilterOperator())->setValue($category);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('category')->get('value'));
}, [
@@ -216,7 +232,7 @@ class PartListsController extends AbstractController
return $this->showListWithFilter($request,
'parts/lists/footprint_list.html.twig',
function (PartFilter $filter) use ($footprint) {
- $filter->footprint->setOperator('INCLUDING_CHILDREN')->setValue($footprint);
+ $filter->footprint->setOperator($this->getFilterOperator())->setValue($footprint);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('footprint')->get('value'));
}, [
@@ -234,7 +250,7 @@ class PartListsController extends AbstractController
return $this->showListWithFilter($request,
'parts/lists/manufacturer_list.html.twig',
function (PartFilter $filter) use ($manufacturer) {
- $filter->manufacturer->setOperator('INCLUDING_CHILDREN')->setValue($manufacturer);
+ $filter->manufacturer->setOperator($this->getFilterOperator())->setValue($manufacturer);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('manufacturer')->get('value'));
}, [
@@ -252,7 +268,7 @@ class PartListsController extends AbstractController
return $this->showListWithFilter($request,
'parts/lists/store_location_list.html.twig',
function (PartFilter $filter) use ($storelocation) {
- $filter->storelocation->setOperator('INCLUDING_CHILDREN')->setValue($storelocation);
+ $filter->storelocation->setOperator($this->getFilterOperator())->setValue($storelocation);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('storelocation')->get('value'));
}, [
@@ -270,7 +286,7 @@ class PartListsController extends AbstractController
return $this->showListWithFilter($request,
'parts/lists/supplier_list.html.twig',
function (PartFilter $filter) use ($supplier) {
- $filter->supplier->setOperator('INCLUDING_CHILDREN')->setValue($supplier);
+ $filter->supplier->setOperator($this->getFilterOperator())->setValue($supplier);
}, function (FormInterface $filterForm) {
$this->disableFormFieldAfterCreation($filterForm->get('supplier')->get('value'));
}, [
diff --git a/src/DataTables/Filters/AttachmentFilter.php b/src/DataTables/Filters/AttachmentFilter.php
index d41bbe39..69d2aeac 100644
--- a/src/DataTables/Filters/AttachmentFilter.php
+++ b/src/DataTables/Filters/AttachmentFilter.php
@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
+use Omines\DataTablesBundle\Filter\AbstractFilter;
class AttachmentFilter implements FilterInterface
{
@@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
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->name = new TextConstraint('attachment.name');
$this->targetType = new InstanceOfConstraint('attachment');
diff --git a/src/DataTables/Filters/Constraints/AbstractConstraint.php b/src/DataTables/Filters/Constraints/AbstractConstraint.php
index 7f16511e..c632b2a4 100644
--- a/src/DataTables/Filters/Constraints/AbstractConstraint.php
+++ b/src/DataTables/Filters/Constraints/AbstractConstraint.php
@@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
- /**
- * @var string
- */
- protected string $identifier;
+ protected ?string $identifier;
/**
diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php
index 3260e4e3..2932914a 100644
--- a/src/DataTables/Filters/Constraints/FilterTrait.php
+++ b/src/DataTables/Filters/Constraints/FilterTrait.php
@@ -28,6 +28,7 @@ trait FilterTrait
{
protected bool $useHaving = false;
+ protected static int $parameterCounter = 0;
public function useHaving($value = true): static
{
@@ -50,8 +51,18 @@ trait FilterTrait
{
//Replace all special characters with underscores
$property = preg_replace('/\W/', '_', $property);
- //Add a random number to the end of the property name for uniqueness
- return $property . '_' . uniqid("", false);
+ return $property . '_' . (self::$parameterCounter++) . '_';
+ }
+
+ /**
+ * 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;
}
/**
diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php
new file mode 100644
index 00000000..9d21dd58
--- /dev/null
+++ b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php
@@ -0,0 +1,59 @@
+.
+ */
+
+namespace App\DataTables\Filters\Constraints\Part;
+
+use App\DataTables\Filters\Constraints\BooleanConstraint;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
+use Doctrine\ORM\QueryBuilder;
+
+class BulkImportJobExistsConstraint extends BooleanConstraint
+{
+
+ public function __construct()
+ {
+ parent::__construct('bulk_import_job_exists');
+ }
+
+ public function apply(QueryBuilder $queryBuilder): void
+ {
+ // Do not apply a filter if value is null (filter is set to ignore)
+ if (!$this->isEnabled()) {
+ return;
+ }
+
+ // Use EXISTS subquery to avoid join conflicts
+ $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
+ $existsSubquery->select('1')
+ ->from(BulkInfoProviderImportJobPart::class, 'bip_exists')
+ ->where('bip_exists.part = part.id');
+
+ if ($this->value === true) {
+ // Filter for parts that ARE in bulk import jobs
+ $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
+ } else {
+ // Filter for parts that are NOT in bulk import jobs
+ $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
+ }
+ }
+}
diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php
new file mode 100644
index 00000000..d9451577
--- /dev/null
+++ b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php
@@ -0,0 +1,64 @@
+.
+ */
+
+namespace App\DataTables\Filters\Constraints\Part;
+
+use App\DataTables\Filters\Constraints\AbstractConstraint;
+use App\DataTables\Filters\Constraints\ChoiceConstraint;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
+use Doctrine\ORM\QueryBuilder;
+
+class BulkImportJobStatusConstraint extends ChoiceConstraint
+{
+
+ public function __construct()
+ {
+ parent::__construct('bulk_import_job_status');
+ }
+
+ public function apply(QueryBuilder $queryBuilder): void
+ {
+ // Do not apply a filter if values are empty or operator is null
+ if (!$this->isEnabled()) {
+ return;
+ }
+
+ // Use EXISTS subquery to check if part has a job with the specified status(es)
+ $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
+ $existsSubquery->select('1')
+ ->from(BulkInfoProviderImportJobPart::class, 'bip_status')
+ ->join('bip_status.job', 'job_status')
+ ->where('bip_status.part = part.id');
+
+ // Add status conditions based on operator
+ if ($this->operator === 'ANY') {
+ $existsSubquery->andWhere('job_status.status IN (:job_status_values)');
+ $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
+ $queryBuilder->setParameter('job_status_values', $this->value);
+ } elseif ($this->operator === 'NONE') {
+ $existsSubquery->andWhere('job_status.status IN (:job_status_values)');
+ $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
+ $queryBuilder->setParameter('job_status_values', $this->value);
+ }
+ }
+}
diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php
new file mode 100644
index 00000000..7656a290
--- /dev/null
+++ b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php
@@ -0,0 +1,61 @@
+.
+ */
+
+namespace App\DataTables\Filters\Constraints\Part;
+
+use App\DataTables\Filters\Constraints\ChoiceConstraint;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
+use Doctrine\ORM\QueryBuilder;
+
+class BulkImportPartStatusConstraint extends ChoiceConstraint
+{
+ public function __construct()
+ {
+ parent::__construct('bulk_import_part_status');
+ }
+
+ public function apply(QueryBuilder $queryBuilder): void
+ {
+ // Do not apply a filter if values are empty or operator is null
+ if (!$this->isEnabled()) {
+ return;
+ }
+
+ // Use EXISTS subquery to check if part has the specified status(es)
+ $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
+ $existsSubquery->select('1')
+ ->from(BulkInfoProviderImportJobPart::class, 'bip_part_status')
+ ->where('bip_part_status.part = part.id');
+
+ // Add status conditions based on operator
+ if ($this->operator === 'ANY') {
+ $existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
+ $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
+ $queryBuilder->setParameter('part_status_values', $this->value);
+ } elseif ($this->operator === 'NONE') {
+ $existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
+ $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
+ $queryBuilder->setParameter('part_status_values', $this->value);
+ }
+ }
+}
diff --git a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
index 02eab7a1..2b28e6b4 100644
--- a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
+++ b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
@@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
//Escape any %, _ or \ in the tag
$tag = addcslashes($tag, '%_\\');
- $tag_identifier_prefix = uniqid($this->identifier . '_', false);
+ $tag_identifier_prefix = $this->generateParameterIdentifier('tag');
$expr = $queryBuilder->expr();
diff --git a/src/DataTables/Filters/Constraints/TextConstraint.php b/src/DataTables/Filters/Constraints/TextConstraint.php
index 31b12a5e..c6a6fe19 100644
--- a/src/DataTables/Filters/Constraints/TextConstraint.php
+++ b/src/DataTables/Filters/Constraints/TextConstraint.php
@@ -96,14 +96,15 @@ class TextConstraint extends AbstractConstraint
//The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator, but we have to build the value string differently
$like_value = null;
+ $escaped_value = str_replace(['%', '_'], ['\%', '\_'], $this->value);
if ($this->operator === 'LIKE') {
- $like_value = $this->value;
+ $like_value = $this->value; //Here we do not escape anything, as the user may provide % and _ wildcards
} elseif ($this->operator === 'STARTS') {
- $like_value = $this->value . '%';
+ $like_value = $escaped_value . '%';
} elseif ($this->operator === 'ENDS') {
- $like_value = '%' . $this->value;
+ $like_value = '%' . $escaped_value;
} elseif ($this->operator === 'CONTAINS') {
- $like_value = '%' . $this->value . '%';
+ $like_value = '%' . $escaped_value . '%';
}
if ($like_value !== null) {
diff --git a/src/DataTables/Filters/LogFilter.php b/src/DataTables/Filters/LogFilter.php
index 35d32e74..38dc2191 100644
--- a/src/DataTables/Filters/LogFilter.php
+++ b/src/DataTables/Filters/LogFilter.php
@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
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->dbId = new IntConstraint('log.id');
$this->level = new ChoiceConstraint('log.level');
diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php
index ff98c76f..e44cf69d 100644
--- a/src/DataTables/Filters/PartFilter.php
+++ b/src/DataTables/Filters/PartFilter.php
@@ -22,12 +22,16 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
+use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
+use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
+use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
@@ -101,8 +105,19 @@ class PartFilter implements FilterInterface
public readonly TextConstraint $bomName;
public readonly TextConstraint $bomComment;
+ /*************************************************
+ * Bulk Import Job tab
+ *************************************************/
+
+ public readonly BulkImportJobExistsConstraint $inBulkImportJob;
+ public readonly BulkImportJobStatusConstraint $bulkImportJobStatus;
+ public readonly BulkImportPartStatusConstraint $bulkImportPartStatus;
+
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->description = new TextConstraint('part.description');
$this->comment = new TextConstraint('part.comment');
@@ -126,7 +141,7 @@ class PartFilter implements FilterInterface
*/
$this->amountSum = (new IntConstraint('(
SELECT COALESCE(SUM(__partLot.amount), 0.0)
- FROM '.PartLot::class.' __partLot
+ FROM ' . PartLot::class . ' __partLot
WHERE __partLot.part = part.id
AND __partLot.instock_unknown = false
AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE())
@@ -162,6 +177,11 @@ class PartFilter implements FilterInterface
$this->bomName = new TextConstraint('_projectBomEntries.name');
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
+ // Bulk Import Job filters
+ $this->inBulkImportJob = new BulkImportJobExistsConstraint();
+ $this->bulkImportJobStatus = new BulkImportJobStatusConstraint();
+ $this->bulkImportPartStatus = new BulkImportPartStatusConstraint();
+
}
public function apply(QueryBuilder $queryBuilder): void
diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php
index 6e2e5894..aa8c20f4 100644
--- a/src/DataTables/Filters/PartSearchFilter.php
+++ b/src/DataTables/Filters/PartSearchFilter.php
@@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see .
*/
namespace App\DataTables\Filters;
+use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface
@@ -143,6 +144,8 @@ class PartSearchFilter implements FilterInterface
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
} else {
+ //Escape % and _ characters in the keyword
+ $this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
}
}
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index f0decf27..a97762b1 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -142,23 +142,25 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
- 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
+ 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location')
->add('amount', TextColumn::class, [
'label' => $this->translator->trans('part.table.amount'),
- 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
+ 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
'orderField' => 'amountSum'
])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
- 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
- $context->getPartUnit())),
+ 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
+ $value,
+ $context->getPartUnit()
+ )),
])
->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'),
'orderField' => 'NATSORT(_partUnit.name)',
- 'render' => function($value, Part $context): string {
+ 'render' => function ($value, Part $context): string {
$partUnit = $context->getPartUnit();
if ($partUnit === null) {
return '';
@@ -167,7 +169,7 @@ final class PartsDataTable implements DataTableTypeInterface
$tmp = htmlspecialchars($partUnit->getName());
if ($partUnit->getUnit()) {
- $tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
+ $tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
}
return $tmp;
}
@@ -230,7 +232,7 @@ final class PartsDataTable implements DataTableTypeInterface
}
if (count($projects) > $max) {
- $tmp .= ", + ".(count($projects) - $max);
+ $tmp .= ", + " . (count($projects) - $max);
}
return $tmp;
@@ -366,7 +368,7 @@ final class PartsDataTable implements DataTableTypeInterface
$builder->addSelect(
'(
SELECT COALESCE(SUM(partLot.amount), 0.0)
- FROM '.PartLot::class.' partLot
+ FROM ' . PartLot::class . ' partLot
WHERE partLot.part = part.id
AND partLot.instock_unknown = false
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
@@ -423,6 +425,13 @@ final class PartsDataTable implements DataTableTypeInterface
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
//$builder->addGroupBy('_projectBomEntries');
}
+ if (str_contains($dql, '_jobPart')) {
+ $builder->leftJoin('part.bulkImportJobParts', '_jobPart');
+ $builder->leftJoin('_jobPart.job', '_bulkImportJob');
+ //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
+ //$builder->addGroupBy('_jobPart');
+ //$builder->addGroupBy('_bulkImportJob');
+ }
return $builder;
}
diff --git a/src/Doctrine/Functions/ILike.php b/src/Doctrine/Functions/ILike.php
index 5246220a..ff2d2163 100644
--- a/src/Doctrine/Functions/ILike.php
+++ b/src/Doctrine/Functions/ILike.php
@@ -56,7 +56,6 @@ class ILike extends FunctionNode
{
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
- //
if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
$operator = 'LIKE';
} elseif ($platform instanceof PostgreSQLPlatform) {
@@ -66,6 +65,12 @@ class ILike extends FunctionNode
throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.');
}
- return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')';
+ $escape = "";
+ if ($platform instanceof SQLitePlatform) {
+ //SQLite needs ESCAPE explicitly defined backslash as escape character
+ $escape = " ESCAPE '\\'";
+ }
+
+ return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . $escape . ')';
}
-}
\ No newline at end of file
+}
diff --git a/src/Entity/Base/AbstractCompany.php b/src/Entity/Base/AbstractCompany.php
index 947d1339..57a3f722 100644
--- a/src/Entity/Base/AbstractCompany.php
+++ b/src/Entity/Base/AbstractCompany.php
@@ -81,7 +81,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
/**
* @var string The website of the company
*/
- #[Assert\Url]
+ #[Assert\Url(requireTld: false)]
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)]
diff --git a/src/Entity/InfoProviderSystem/BulkImportJobStatus.php b/src/Entity/InfoProviderSystem/BulkImportJobStatus.php
new file mode 100644
index 00000000..7a88802f
--- /dev/null
+++ b/src/Entity/InfoProviderSystem/BulkImportJobStatus.php
@@ -0,0 +1,35 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Entity\InfoProviderSystem;
+
+use Symfony\Contracts\Translation\TranslatableInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+enum BulkImportJobStatus: string
+{
+ case PENDING = 'pending';
+ case IN_PROGRESS = 'in_progress';
+ case COMPLETED = 'completed';
+ case STOPPED = 'stopped';
+ case FAILED = 'failed';
+}
diff --git a/src/Entity/InfoProviderSystem/BulkImportPartStatus.php b/src/Entity/InfoProviderSystem/BulkImportPartStatus.php
new file mode 100644
index 00000000..0eedc553
--- /dev/null
+++ b/src/Entity/InfoProviderSystem/BulkImportPartStatus.php
@@ -0,0 +1,32 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Entity\InfoProviderSystem;
+
+
+enum BulkImportPartStatus: string
+{
+ case PENDING = 'pending';
+ case COMPLETED = 'completed';
+ case SKIPPED = 'skipped';
+ case FAILED = 'failed';
+}
diff --git a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
new file mode 100644
index 00000000..bc842a26
--- /dev/null
+++ b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
@@ -0,0 +1,449 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Entity\InfoProviderSystem;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Parts\Part;
+use App\Entity\UserSystem\User;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity]
+#[ORM\Table(name: 'bulk_info_provider_import_jobs')]
+class BulkInfoProviderImportJob extends AbstractDBElement
+{
+ #[ORM\Column(type: Types::TEXT)]
+ private string $name = '';
+
+ #[ORM\Column(type: Types::JSON)]
+ private array $fieldMappings = [];
+
+ /**
+ * @var BulkSearchFieldMappingDTO[] The deserialized field mappings DTOs, cached for performance
+ */
+ private ?array $fieldMappingsDTO = null;
+
+ #[ORM\Column(type: Types::JSON)]
+ private array $searchResults = [];
+
+ /**
+ * @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance
+ */
+ private ?BulkSearchResponseDTO $searchResultsDTO = null;
+
+ #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)]
+ private BulkImportJobStatus $status = BulkImportJobStatus::PENDING;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
+ private \DateTimeImmutable $createdAt;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ private ?\DateTimeImmutable $completedAt = null;
+
+ #[ORM\Column(type: Types::BOOLEAN)]
+ private bool $prefetchDetails = false;
+
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: false)]
+ private ?User $createdBy = null;
+
+ /** @var Collection */
+ #[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $jobParts;
+
+ public function __construct()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ $this->jobParts = new ArrayCollection();
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getDisplayNameKey(): string
+ {
+ return 'info_providers.bulk_import.job_name_template';
+ }
+
+ public function getDisplayNameParams(): array
+ {
+ return ['%count%' => $this->getPartCount()];
+ }
+
+ public function getFormattedTimestamp(): string
+ {
+ return $this->createdAt->format('Y-m-d H:i:s');
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getJobParts(): Collection
+ {
+ return $this->jobParts;
+ }
+
+ public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self
+ {
+ if (!$this->jobParts->contains($jobPart)) {
+ $this->jobParts->add($jobPart);
+ $jobPart->setJob($this);
+ }
+ return $this;
+ }
+
+ public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self
+ {
+ if ($this->jobParts->removeElement($jobPart)) {
+ if ($jobPart->getJob() === $this) {
+ $jobPart->setJob(null);
+ }
+ }
+ return $this;
+ }
+
+ public function getPartIds(): array
+ {
+ return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray();
+ }
+
+ public function setPartIds(array $partIds): self
+ {
+ // This method is kept for backward compatibility but should be replaced with addJobPart
+ // Clear existing job parts
+ $this->jobParts->clear();
+
+ // Add new job parts (this would need the actual Part entities, not just IDs)
+ // This is a simplified implementation - in practice, you'd want to pass Part entities
+ return $this;
+ }
+
+ public function addPart(Part $part): self
+ {
+ $jobPart = new BulkInfoProviderImportJobPart($this, $part);
+ $this->addJobPart($jobPart);
+ return $this;
+ }
+
+ /**
+ * @return BulkSearchFieldMappingDTO[] The deserialized field mappings
+ */
+ public function getFieldMappings(): array
+ {
+ if ($this->fieldMappingsDTO === null) {
+ // Lazy load the DTOs from the raw JSON data
+ $this->fieldMappingsDTO = array_map(
+ static fn($data) => BulkSearchFieldMappingDTO::fromSerializableArray($data),
+ $this->fieldMappings
+ );
+ }
+
+ return $this->fieldMappingsDTO;
+ }
+
+ /**
+ * @param BulkSearchFieldMappingDTO[] $fieldMappings
+ * @return $this
+ */
+ public function setFieldMappings(array $fieldMappings): self
+ {
+ //Ensure that we are dealing with the objects here
+ if (count($fieldMappings) > 0 && !$fieldMappings[0] instanceof BulkSearchFieldMappingDTO) {
+ throw new \InvalidArgumentException('Expected an array of FieldMappingDTO objects');
+ }
+
+ $this->fieldMappingsDTO = $fieldMappings;
+
+ $this->fieldMappings = array_map(
+ static fn(BulkSearchFieldMappingDTO $dto) => $dto->toSerializableArray(),
+ $fieldMappings
+ );
+ return $this;
+ }
+
+ public function getSearchResultsRaw(): array
+ {
+ return $this->searchResults;
+ }
+
+ public function setSearchResultsRaw(array $searchResults): self
+ {
+ $this->searchResults = $searchResults;
+ return $this;
+ }
+
+ public function setSearchResults(BulkSearchResponseDTO $searchResponse): self
+ {
+ $this->searchResultsDTO = $searchResponse;
+ $this->searchResults = $searchResponse->toSerializableRepresentation();
+ return $this;
+ }
+
+ public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO
+ {
+ if ($this->searchResultsDTO === null) {
+ // Lazy load the DTO from the raw JSON data
+ $this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager);
+ }
+ return $this->searchResultsDTO;
+ }
+
+ public function hasSearchResults(): bool
+ {
+ return !empty($this->searchResults);
+ }
+
+ public function getStatus(): BulkImportJobStatus
+ {
+ return $this->status;
+ }
+
+ public function setStatus(BulkImportJobStatus $status): self
+ {
+ $this->status = $status;
+ return $this;
+ }
+
+ public function getCreatedAt(): \DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function getCompletedAt(): ?\DateTimeImmutable
+ {
+ return $this->completedAt;
+ }
+
+ public function setCompletedAt(?\DateTimeImmutable $completedAt): self
+ {
+ $this->completedAt = $completedAt;
+ return $this;
+ }
+
+ public function isPrefetchDetails(): bool
+ {
+ return $this->prefetchDetails;
+ }
+
+ public function setPrefetchDetails(bool $prefetchDetails): self
+ {
+ $this->prefetchDetails = $prefetchDetails;
+ return $this;
+ }
+
+ public function getCreatedBy(): User
+ {
+ return $this->createdBy;
+ }
+
+ public function setCreatedBy(User $createdBy): self
+ {
+ $this->createdBy = $createdBy;
+ return $this;
+ }
+
+ public function getProgress(): array
+ {
+ $progress = [];
+ foreach ($this->jobParts as $jobPart) {
+ $progressData = [
+ 'status' => $jobPart->getStatus()->value
+ ];
+
+ // Only include completed_at if it's not null
+ if ($jobPart->getCompletedAt() !== null) {
+ $progressData['completed_at'] = $jobPart->getCompletedAt()->format('c');
+ }
+
+ // Only include reason if it's not null
+ if ($jobPart->getReason() !== null) {
+ $progressData['reason'] = $jobPart->getReason();
+ }
+
+ $progress[$jobPart->getPart()->getId()] = $progressData;
+ }
+ return $progress;
+ }
+
+ public function markAsCompleted(): self
+ {
+ $this->status = BulkImportJobStatus::COMPLETED;
+ $this->completedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ public function markAsFailed(): self
+ {
+ $this->status = BulkImportJobStatus::FAILED;
+ $this->completedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ public function markAsStopped(): self
+ {
+ $this->status = BulkImportJobStatus::STOPPED;
+ $this->completedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ public function markAsInProgress(): self
+ {
+ $this->status = BulkImportJobStatus::IN_PROGRESS;
+ return $this;
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === BulkImportJobStatus::PENDING;
+ }
+
+ public function isInProgress(): bool
+ {
+ return $this->status === BulkImportJobStatus::IN_PROGRESS;
+ }
+
+ public function isCompleted(): bool
+ {
+ return $this->status === BulkImportJobStatus::COMPLETED;
+ }
+
+ public function isFailed(): bool
+ {
+ return $this->status === BulkImportJobStatus::FAILED;
+ }
+
+ public function isStopped(): bool
+ {
+ return $this->status === BulkImportJobStatus::STOPPED;
+ }
+
+ public function canBeStopped(): bool
+ {
+ return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS;
+ }
+
+ public function getPartCount(): int
+ {
+ return $this->jobParts->count();
+ }
+
+ public function getResultCount(): int
+ {
+ $count = 0;
+ foreach ($this->searchResults as $partResult) {
+ $count += count($partResult['search_results'] ?? []);
+ }
+ return $count;
+ }
+
+ public function markPartAsCompleted(int $partId): self
+ {
+ $jobPart = $this->findJobPartByPartId($partId);
+ if ($jobPart) {
+ $jobPart->markAsCompleted();
+ }
+ return $this;
+ }
+
+ public function markPartAsSkipped(int $partId, string $reason = ''): self
+ {
+ $jobPart = $this->findJobPartByPartId($partId);
+ if ($jobPart) {
+ $jobPart->markAsSkipped($reason);
+ }
+ return $this;
+ }
+
+ public function markPartAsPending(int $partId): self
+ {
+ $jobPart = $this->findJobPartByPartId($partId);
+ if ($jobPart) {
+ $jobPart->markAsPending();
+ }
+ return $this;
+ }
+
+ public function isPartCompleted(int $partId): bool
+ {
+ $jobPart = $this->findJobPartByPartId($partId);
+ return $jobPart ? $jobPart->isCompleted() : false;
+ }
+
+ public function isPartSkipped(int $partId): bool
+ {
+ $jobPart = $this->findJobPartByPartId($partId);
+ return $jobPart ? $jobPart->isSkipped() : false;
+ }
+
+ public function getCompletedPartsCount(): int
+ {
+ return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count();
+ }
+
+ public function getSkippedPartsCount(): int
+ {
+ return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count();
+ }
+
+ private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart
+ {
+ foreach ($this->jobParts as $jobPart) {
+ if ($jobPart->getPart()->getId() === $partId) {
+ return $jobPart;
+ }
+ }
+ return null;
+ }
+
+ public function getProgressPercentage(): float
+ {
+ $total = $this->getPartCount();
+ if ($total === 0) {
+ return 100.0;
+ }
+
+ $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
+ return round(($completed / $total) * 100, 1);
+ }
+
+ public function isAllPartsCompleted(): bool
+ {
+ $total = $this->getPartCount();
+ if ($total === 0) {
+ return true;
+ }
+
+ $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
+ return $completed >= $total;
+ }
+}
diff --git a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
new file mode 100644
index 00000000..90519561
--- /dev/null
+++ b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
@@ -0,0 +1,182 @@
+.
+ */
+
+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 .
+ */
+
+namespace App\Entity\InfoProviderSystem;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Parts\Part;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity]
+#[ORM\Table(name: 'bulk_info_provider_import_job_parts')]
+#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])]
+class BulkInfoProviderImportJobPart extends AbstractDBElement
+{
+ #[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')]
+ #[ORM\JoinColumn(nullable: false)]
+ private BulkInfoProviderImportJob $job;
+
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')]
+ #[ORM\JoinColumn(nullable: false)]
+ private Part $part;
+
+ #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)]
+ private BulkImportPartStatus $status = BulkImportPartStatus::PENDING;
+
+ #[ORM\Column(type: Types::TEXT, nullable: true)]
+ private ?string $reason = null;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ private ?\DateTimeImmutable $completedAt = null;
+
+ public function __construct(BulkInfoProviderImportJob $job, Part $part)
+ {
+ $this->job = $job;
+ $this->part = $part;
+ }
+
+ public function getJob(): BulkInfoProviderImportJob
+ {
+ return $this->job;
+ }
+
+ public function setJob(?BulkInfoProviderImportJob $job): self
+ {
+ $this->job = $job;
+ return $this;
+ }
+
+ public function getPart(): Part
+ {
+ return $this->part;
+ }
+
+ public function setPart(?Part $part): self
+ {
+ $this->part = $part;
+ return $this;
+ }
+
+ public function getStatus(): BulkImportPartStatus
+ {
+ return $this->status;
+ }
+
+ public function setStatus(BulkImportPartStatus $status): self
+ {
+ $this->status = $status;
+ return $this;
+ }
+
+ public function getReason(): ?string
+ {
+ return $this->reason;
+ }
+
+ public function setReason(?string $reason): self
+ {
+ $this->reason = $reason;
+ return $this;
+ }
+
+ public function getCompletedAt(): ?\DateTimeImmutable
+ {
+ return $this->completedAt;
+ }
+
+ public function setCompletedAt(?\DateTimeImmutable $completedAt): self
+ {
+ $this->completedAt = $completedAt;
+ return $this;
+ }
+
+ public function markAsCompleted(): self
+ {
+ $this->status = BulkImportPartStatus::COMPLETED;
+ $this->completedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ public function markAsSkipped(string $reason = ''): self
+ {
+ $this->status = BulkImportPartStatus::SKIPPED;
+ $this->reason = $reason;
+ $this->completedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ public function markAsFailed(string $reason = ''): self
+ {
+ $this->status = BulkImportPartStatus::FAILED;
+ $this->reason = $reason;
+ $this->completedAt = new \DateTimeImmutable();
+ return $this;
+ }
+
+ public function markAsPending(): self
+ {
+ $this->status = BulkImportPartStatus::PENDING;
+ $this->reason = null;
+ $this->completedAt = null;
+ return $this;
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === BulkImportPartStatus::PENDING;
+ }
+
+ public function isCompleted(): bool
+ {
+ return $this->status === BulkImportPartStatus::COMPLETED;
+ }
+
+ public function isSkipped(): bool
+ {
+ return $this->status === BulkImportPartStatus::SKIPPED;
+ }
+
+ public function isFailed(): bool
+ {
+ return $this->status === BulkImportPartStatus::FAILED;
+ }
+}
diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php
index 1c6e4f8c..61a2b081 100644
--- a/src/Entity/LogSystem/LogTargetType.php
+++ b/src/Entity/LogSystem/LogTargetType.php
@@ -24,6 +24,8 @@ namespace App\Entity\LogSystem;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
@@ -67,6 +69,8 @@ enum LogTargetType: int
case LABEL_PROFILE = 19;
case PART_ASSOCIATION = 20;
+ case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
+ case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
/**
* Returns the class name of the target type or null if the target type is NONE.
@@ -96,6 +100,8 @@ enum LogTargetType: int
self::PARAMETER => AbstractParameter::class,
self::LABEL_PROFILE => LabelProfile::class,
self::PART_ASSOCIATION => PartAssociation::class,
+ self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class,
+ self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class,
};
}
diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php
index 14a7903f..2f274a8a 100644
--- a/src/Entity/Parts/Part.php
+++ b/src/Entity/Parts/Part.php
@@ -22,8 +22,6 @@ declare(strict_types=1);
namespace App\Entity\Parts;
-use App\ApiPlatform\Filter\TagFilter;
-use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
@@ -40,10 +38,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\Filter\PartStoragelocationFilter;
+use App\ApiPlatform\Filter\TagFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\PartAttachment;
use App\Entity\EDA\EDAPartInfo;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\Parameters\ParametersTrait;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
@@ -59,6 +59,7 @@ use App\Repository\PartRepository;
use App\Validator\Constraints\UniqueObjectCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
+use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
@@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
#[ApiResource(
operations: [
- new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
- 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
+ new Get(normalizationContext: [
+ 'groups' => [
+ 'part:read',
+ 'provider_reference:read',
+ 'api:basic:read',
+ 'part_lot:read',
+ 'orderdetail:read',
+ 'pricedetail:read',
+ 'parameter:read',
+ 'attachment:read',
+ 'eda_info:read'
+ ],
'openapi_definition_name' => 'Read',
], security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
@@ -92,7 +103,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
- normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
+ normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
@@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
#[ApiFilter(TagFilter::class, properties: ["tags"])]
-#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
+#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
@@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
#[Groups(['part:read'])]
protected ?\DateTimeImmutable $lastModified = null;
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
+ protected Collection $bulkImportJobParts;
+
public function __construct()
{
@@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
$this->associated_parts_as_owner = new ArrayCollection();
$this->associated_parts_as_other = new ArrayCollection();
+ $this->bulkImportJobParts = new ArrayCollection();
//By default, the part has no provider
$this->providerReference = InfoProviderReference::noProvider();
@@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
}
}
}
+
+ /**
+ * Get all bulk import job parts for this part
+ * @return Collection
+ */
+ public function getBulkImportJobParts(): Collection
+ {
+ return $this->bulkImportJobParts;
+ }
+
+ /**
+ * Add a bulk import job part to this part
+ */
+ public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
+ {
+ if (!$this->bulkImportJobParts->contains($jobPart)) {
+ $this->bulkImportJobParts->add($jobPart);
+ $jobPart->setPart($this);
+ }
+ return $this;
+ }
+
+ /**
+ * Remove a bulk import job part from this part
+ */
+ public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
+ {
+ if ($this->bulkImportJobParts->removeElement($jobPart)) {
+ if ($jobPart->getPart() === $this) {
+ $jobPart->setPart(null);
+ }
+ }
+ return $this;
+ }
}
diff --git a/src/Entity/Parts/PartTraits/ManufacturerTrait.php b/src/Entity/Parts/PartTraits/ManufacturerTrait.php
index 5d7f8749..911a0806 100644
--- a/src/Entity/Parts/PartTraits/ManufacturerTrait.php
+++ b/src/Entity/Parts/PartTraits/ManufacturerTrait.php
@@ -49,7 +49,7 @@ trait ManufacturerTrait
/**
* @var string The url to the part on the manufacturer's homepage
*/
- #[Assert\Url]
+ #[Assert\Url(requireTld: false)]
#[Groups(['full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $manufacturer_product_url = '';
diff --git a/src/Entity/Parts/PartTraits/ProjectTrait.php b/src/Entity/Parts/PartTraits/ProjectTrait.php
index 45719377..7e1962d3 100644
--- a/src/Entity/Parts/PartTraits/ProjectTrait.php
+++ b/src/Entity/Parts/PartTraits/ProjectTrait.php
@@ -15,7 +15,7 @@ trait ProjectTrait
/**
* @var Collection $project_bom_entries
*/
- #[ORM\OneToMany(mappedBy: 'part', targetEntity: ProjectBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
+ #[ORM\OneToMany(targetEntity: ProjectBOMEntry::class, mappedBy: 'part')]
protected Collection $project_bom_entries;
/**
diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php
index 3709b37d..8ed76a46 100644
--- a/src/Entity/PriceInformations/Orderdetail.php
+++ b/src/Entity/PriceInformations/Orderdetail.php
@@ -124,7 +124,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* @var string The URL to the product on the supplier's website
*/
- #[Assert\Url]
+ #[Assert\Url(requireTld: false)]
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';
diff --git a/src/EntityListeners/PartProjectBOMEntryUnlinkListener.php b/src/EntityListeners/PartProjectBOMEntryUnlinkListener.php
new file mode 100644
index 00000000..08a93f76
--- /dev/null
+++ b/src/EntityListeners/PartProjectBOMEntryUnlinkListener.php
@@ -0,0 +1,59 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EntityListeners;
+
+use App\Entity\Parts\Part;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
+use Doctrine\ORM\Event\PreRemoveEventArgs;
+
+/**
+ * If an part is deleted, this listener makes sure that all ProjectBOMEntries that reference this part, are updated
+ * to not reference the part anymore, but instead store the part name in the name field.
+ */
+#[AsEntityListener(event: "preRemove", entity: Part::class)]
+class PartProjectBOMEntryUnlinkListener
+{
+ public function preRemove(Part $part, PreRemoveEventArgs $event): void
+ {
+ // Iterate over all ProjectBOMEntries that use this part and put the part name into the name field
+ foreach ($part->getProjectBomEntries() as $bom_entry) {
+ $old_name = $bom_entry->getName();
+ if ($old_name === null || trim($old_name) === '') {
+ $bom_entry->setName($part->getName());
+ } else {
+ $bom_entry->setName($old_name . ' (' . $part->getName() . ')');
+ }
+
+ $old_comment = $bom_entry->getComment();
+ if ($old_comment === null || trim($old_comment) === '') {
+ $bom_entry->setComment('Part was deleted: ' . $part->getName());
+ } else {
+ $bom_entry->setComment($old_comment . "\n\n Part was deleted: " . $part->getName());
+ }
+
+ //Remove the part reference
+ $bom_entry->setPart(null);
+ }
+ }
+}
diff --git a/src/EventListener/LogSystem/EventLoggerListener.php b/src/EventListener/LogSystem/EventLoggerListener.php
index 96c6ef51..f5029c28 100644
--- a/src/EventListener/LogSystem/EventLoggerListener.php
+++ b/src/EventListener/LogSystem/EventLoggerListener.php
@@ -170,6 +170,7 @@ class EventLoggerListener
public function hasFieldRestrictions(AbstractDBElement $element): bool
{
foreach (array_keys(static::FIELD_BLACKLIST) as $class) {
+ /** @var string $class */
if ($element instanceof $class) {
return true;
}
@@ -184,6 +185,7 @@ class EventLoggerListener
public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool
{
foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
+ /** @var string $class */
if ($element instanceof $class && in_array($field_name, $blacklist, true)) {
return false;
}
@@ -215,6 +217,7 @@ class EventLoggerListener
$mappings = $metadata->getAssociationMappings();
//Check if class is whitelisted for CollectionElementDeleted entry
foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) {
+ /** @var string $class */
if ($entity instanceof $class) {
//Check names
foreach ($mappings as $field => $mapping) {
diff --git a/src/Exceptions/OAuthReconnectRequiredException.php b/src/Exceptions/OAuthReconnectRequiredException.php
new file mode 100644
index 00000000..97abb19f
--- /dev/null
+++ b/src/Exceptions/OAuthReconnectRequiredException.php
@@ -0,0 +1,48 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Exceptions;
+
+use Throwable;
+
+class OAuthReconnectRequiredException extends \RuntimeException
+{
+ private string $providerName = "unknown";
+
+ public function __construct(string $message = "You need to reconnect the OAuth connection for this provider!", int $code = 0, ?Throwable $previous = null)
+ {
+ parent::__construct($message, $code, $previous);
+ }
+
+ public static function forProvider(string $providerName): self
+ {
+ $exception = new self("You need to reconnect the OAuth connection for the provider '$providerName'!");
+ $exception->providerName = $providerName;
+ return $exception;
+ }
+
+ public function getProviderName(): string
+ {
+ return $this->providerName;
+ }
+}
diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php
index 3e87812c..0bd3cea1 100644
--- a/src/Form/AdminPages/ImportType.php
+++ b/src/Form/AdminPages/ImportType.php
@@ -59,6 +59,8 @@ class ImportType extends AbstractType
'XML' => 'xml',
'CSV' => 'csv',
'YAML' => 'yaml',
+ 'XLSX' => 'xlsx',
+ 'XLS' => 'xls',
],
'label' => 'export.format',
'disabled' => $disabled,
diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php
index 42b367b7..c973ad0f 100644
--- a/src/Form/Filters/LogFilterType.php
+++ b/src/Form/Filters/LogFilterType.php
@@ -100,7 +100,7 @@ class LogFilterType extends AbstractType
]);
$builder->add('user', UserEntityConstraintType::class, [
- 'label' => 'log.user',
+ 'label' => 'log.user',
]);
$builder->add('targetType', EnumConstraintType::class, [
@@ -128,11 +128,13 @@ class LogFilterType extends AbstractType
LogTargetType::PARAMETER => 'parameter.label',
LogTargetType::LABEL_PROFILE => 'label_profile.label',
LogTargetType::PART_ASSOCIATION => 'part_association.label',
+ LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
+ LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
},
]);
$builder->add('targetId', NumberConstraintType::class, [
- 'label' => 'log.target_id',
+ 'label' => 'log.target_id',
'min' => 1,
'step' => 1,
]);
diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php
index dfe449d1..871f9b07 100644
--- a/src/Form/Filters/PartFilterType.php
+++ b/src/Form/Filters/PartFilterType.php
@@ -22,9 +22,12 @@ declare(strict_types=1);
*/
namespace App\Form\Filters;
+use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\PartFilter;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\InfoProviderSystem\BulkImportJobStatus;
+use App\Entity\InfoProviderSystem\BulkImportPartStatus;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@@ -33,8 +36,12 @@ use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\ProjectSystem\Project;
use App\Form\Filters\Constraints\BooleanConstraintType;
+use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType;
+use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType;
+use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType;
use App\Form\Filters\Constraints\ChoiceConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
+use App\Form\Filters\Constraints\EnumConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\ParameterConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
@@ -50,6 +57,8 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
+use function Symfony\Component\Translation\t;
+
class PartFilterType extends AbstractType
{
public function __construct(private readonly Security $security)
@@ -298,6 +307,31 @@ class PartFilterType extends AbstractType
}
+ /**************************************************************************
+ * Bulk Import Job tab
+ **************************************************************************/
+ if ($this->security->isGranted('@info_providers.create_parts')) {
+ $builder
+ ->add('inBulkImportJob', BooleanConstraintType::class, [
+ 'label' => 'part.filter.in_bulk_import_job',
+ ])
+ ->add('bulkImportJobStatus', EnumConstraintType::class, [
+ 'enum_class' => BulkImportJobStatus::class,
+ 'label' => 'part.filter.bulk_import_job_status',
+ 'choice_label' => function (BulkImportJobStatus $value) {
+ return t('bulk_import.status.' . $value->value);
+ },
+ ])
+ ->add('bulkImportPartStatus', EnumConstraintType::class, [
+ 'enum_class' => BulkImportPartStatus::class,
+ 'label' => 'part.filter.bulk_import_part_status',
+ 'choice_label' => function (BulkImportPartStatus $value) {
+ return t('bulk_import.part_status.' . $value->value);
+ },
+ ])
+ ;
+ }
+
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
diff --git a/src/Form/History/EnforceEventCommentTypesType.php b/src/Form/History/EnforceEventCommentTypesType.php
index 8bb095b9..85e43e6e 100644
--- a/src/Form/History/EnforceEventCommentTypesType.php
+++ b/src/Form/History/EnforceEventCommentTypesType.php
@@ -38,7 +38,7 @@ class EnforceEventCommentTypesType extends AbstractType
return EnumType::class;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'multiple' => true,
@@ -46,4 +46,4 @@ class EnforceEventCommentTypesType extends AbstractType
'empty_data' => [],
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/InfoProviderSystem/BulkProviderSearchType.php b/src/Form/InfoProviderSystem/BulkProviderSearchType.php
new file mode 100644
index 00000000..24a3cfb4
--- /dev/null
+++ b/src/Form/InfoProviderSystem/BulkProviderSearchType.php
@@ -0,0 +1,62 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Form\InfoProviderSystem;
+
+use App\Entity\Parts\Part;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class BulkProviderSearchType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $parts = $options['parts'];
+
+ $builder->add('part_configurations', CollectionType::class, [
+ 'entry_type' => PartProviderConfigurationType::class,
+ 'entry_options' => [
+ 'label' => false,
+ ],
+ 'allow_add' => false,
+ 'allow_delete' => false,
+ 'label' => false,
+ ]);
+
+ $builder->add('submit', SubmitType::class, [
+ 'label' => 'info_providers.bulk_search.submit'
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'parts' => [],
+ ]);
+ $resolver->setRequired('parts');
+ }
+}
\ No newline at end of file
diff --git a/src/Form/InfoProviderSystem/FieldToProviderMappingType.php b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php
new file mode 100644
index 00000000..13e9581e
--- /dev/null
+++ b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php
@@ -0,0 +1,75 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Form\InfoProviderSystem;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\IntegerType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class FieldToProviderMappingType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $fieldChoices = $options['field_choices'] ?? [];
+
+ $builder->add('field', ChoiceType::class, [
+ 'label' => 'info_providers.bulk_search.search_field',
+ 'choices' => $fieldChoices,
+ 'expanded' => false,
+ 'multiple' => false,
+ 'required' => false,
+ 'placeholder' => 'info_providers.bulk_search.field.select',
+ ]);
+
+ $builder->add('providers', ProviderSelectType::class, [
+ 'label' => 'info_providers.bulk_search.providers',
+ 'help' => 'info_providers.bulk_search.providers.help',
+ 'required' => false,
+ ]);
+
+ $builder->add('priority', IntegerType::class, [
+ 'label' => 'info_providers.bulk_search.priority',
+ 'help' => 'info_providers.bulk_search.priority.help',
+ 'required' => false,
+ 'data' => 1, // Default priority
+ 'attr' => [
+ 'min' => 1,
+ 'max' => 10,
+ 'class' => 'form-control-sm',
+ 'style' => 'width: 80px;'
+ ],
+ 'constraints' => [
+ new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
+ ],
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'field_choices' => [],
+ ]);
+ }
+}
diff --git a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php
new file mode 100644
index 00000000..ea70284f
--- /dev/null
+++ b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php
@@ -0,0 +1,67 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Form\InfoProviderSystem;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
+use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class GlobalFieldMappingType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $fieldChoices = $options['field_choices'] ?? [];
+
+ $builder->add('field_mappings', CollectionType::class, [
+ 'entry_type' => FieldToProviderMappingType::class,
+ 'entry_options' => [
+ 'label' => false,
+ 'field_choices' => $fieldChoices,
+ ],
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'prototype' => true,
+ 'label' => false,
+ ]);
+
+ $builder->add('prefetch_details', CheckboxType::class, [
+ 'label' => 'info_providers.bulk_import.prefetch_details',
+ 'required' => false,
+ 'help' => 'info_providers.bulk_import.prefetch_details_help',
+ ]);
+
+ $builder->add('submit', SubmitType::class, [
+ 'label' => 'info_providers.bulk_import.search.submit'
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'field_choices' => [],
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/InfoProviderSystem/PartProviderConfigurationType.php b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php
new file mode 100644
index 00000000..cecf62a3
--- /dev/null
+++ b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php
@@ -0,0 +1,55 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Form\InfoProviderSystem;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\FormBuilderInterface;
+
+class PartProviderConfigurationType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add('part_id', HiddenType::class);
+
+ $builder->add('search_field', ChoiceType::class, [
+ 'label' => 'info_providers.bulk_search.search_field',
+ 'choices' => [
+ 'info_providers.bulk_search.field.mpn' => 'mpn',
+ 'info_providers.bulk_search.field.name' => 'name',
+ 'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn',
+ 'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn',
+ 'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn',
+ 'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn',
+ ],
+ 'expanded' => false,
+ 'multiple' => false,
+ ]);
+
+ $builder->add('providers', ProviderSelectType::class, [
+ 'label' => 'info_providers.bulk_search.providers',
+ 'help' => 'info_providers.bulk_search.providers.help',
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Type/LanguageMenuEntriesType.php b/src/Form/Type/LanguageMenuEntriesType.php
new file mode 100644
index 00000000..a3bba77f
--- /dev/null
+++ b/src/Form/Type/LanguageMenuEntriesType.php
@@ -0,0 +1,57 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Type;
+
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\LanguageType;
+use Symfony\Component\Form\Extension\Core\Type\LocaleType;
+use Symfony\Component\Intl\Languages;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class LanguageMenuEntriesType extends AbstractType
+{
+ public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
+ {
+
+ }
+
+ public function getParent(): string
+ {
+ return LanguageType::class;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $choices = [];
+ foreach ($this->preferred_languages as $lang_code) {
+ $choices[Languages::getName($lang_code)] = $lang_code;
+ }
+
+ $resolver->setDefaults([
+ 'choice_loader' => null,
+ 'choices' => $choices,
+ ]);
+ }
+}
diff --git a/src/Form/Type/LocaleSelectType.php b/src/Form/Type/LocaleSelectType.php
index aa7af5c2..d47fb57f 100644
--- a/src/Form/Type/LocaleSelectType.php
+++ b/src/Form/Type/LocaleSelectType.php
@@ -44,10 +44,10 @@ class LocaleSelectType extends AbstractType
return LocaleType::class;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'preferred_choices' => $this->preferred_languages,
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php
index 16afb37e..239a6096 100644
--- a/src/Security/UserChecker.php
+++ b/src/Security/UserChecker.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Security;
use App\Entity\UserSystem\User;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
@@ -51,7 +52,7 @@ final class UserChecker implements UserCheckerInterface
*
* @throws AccountStatusException
*/
- public function checkPostAuth(UserInterface $user): void
+ public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void
{
if (!$user instanceof User) {
return;
diff --git a/src/Security/Voter/BOMEntryVoter.php b/src/Security/Voter/BOMEntryVoter.php
index 121c8172..4ce40d47 100644
--- a/src/Security/Voter/BOMEntryVoter.php
+++ b/src/Security/Voter/BOMEntryVoter.php
@@ -27,6 +27,7 @@ use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -46,7 +47,7 @@ class BOMEntryVoter extends Voter
return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class, true);
}
- protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_a($subject, ProjectBOMEntry::class, true)) {
return false;
@@ -87,4 +88,4 @@ class BOMEntryVoter extends Voter
{
return $subjectType === 'string' || is_a($subjectType, ProjectBOMEntry::class, true);
}
-}
\ No newline at end of file
+}
diff --git a/src/Security/Voter/HasAccessPermissionsVoter.php b/src/Security/Voter/HasAccessPermissionsVoter.php
index bd466d07..9adef977 100644
--- a/src/Security/Voter/HasAccessPermissionsVoter.php
+++ b/src/Security/Voter/HasAccessPermissionsVoter.php
@@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -41,7 +42,7 @@ final class HasAccessPermissionsVoter 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);
return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user);
@@ -56,4 +57,4 @@ final class HasAccessPermissionsVoter extends Voter
{
return $attribute === self::ROLE;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php
index a30163ae..9fbc3fe3 100644
--- a/src/Services/Attachments/AttachmentSubmitHandler.php
+++ b/src/Services/Attachments/AttachmentSubmitHandler.php
@@ -57,6 +57,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
class AttachmentSubmitHandler
{
+ /**
+ * @var array The mapping used to determine which folder will be used for an attachment type
+ */
protected array $folder_mapping;
private ?int $max_upload_size_bytes = null;
@@ -160,6 +163,7 @@ class AttachmentSubmitHandler
} else {
//If not, check for instance of:
foreach ($this->folder_mapping as $class => $folder) {
+ /** @var string $class */
if ($attachment instanceof $class) {
$prefix = $folder;
break;
diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php
index 14247145..326707b7 100644
--- a/src/Services/ElementTypeNameGenerator.php
+++ b/src/Services/ElementTypeNameGenerator.php
@@ -22,13 +22,13 @@ declare(strict_types=1);
namespace App\Services;
-use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
-use App\Entity\Parts\PartAssociation;
-use App\Entity\ProjectSystem\Project;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
+use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
@@ -36,12 +36,14 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
+use App\Entity\Parts\PartAssociation;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
+use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
@@ -79,6 +81,8 @@ class ElementTypeNameGenerator
AbstractParameter::class => $this->translator->trans('parameter.label'),
LabelProfile::class => $this->translator->trans('label_profile.label'),
PartAssociation::class => $this->translator->trans('part_association.label'),
+ BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'),
+ BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'),
];
}
@@ -130,10 +134,10 @@ class ElementTypeNameGenerator
{
$type = $this->getLocalizedTypeLabel($entity);
if ($use_html) {
- return ''.$type.': '.htmlspecialchars($entity->getName());
+ return '' . $type . ': ' . htmlspecialchars($entity->getName());
}
- return $type.': '.$entity->getName();
+ return $type . ': ' . $entity->getName();
}
diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php
index 4ce779e8..01b53e25 100644
--- a/src/Services/EntityMergers/Mergers/PartMerger.php
+++ b/src/Services/EntityMergers/Mergers/PartMerger.php
@@ -100,7 +100,8 @@ class PartMerger implements EntityMergerInterface
return $target;
}
- private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
+ private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool
+ {
//We compare the translation keys, as it contains info about the type and other type info
return $t->getOther() === $o->getOther()
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
@@ -141,40 +142,39 @@ class PartMerger implements EntityMergerInterface
$owner->addAssociatedPartsAsOwner($clone);
}
+ // Merge orderdetails, considering same supplier+part number as duplicates
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
- //First check that the orderdetails infos are equal
- $tmp = $t->getSupplier() === $o->getSupplier()
- && $t->getSupplierPartNr() === $o->getSupplierPartNr()
- && $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
-
- if (!$tmp) {
- return false;
- }
-
- //Check if the pricedetails are equal
- $t_pricedetails = $t->getPricedetails();
- $o_pricedetails = $o->getPricedetails();
- //Ensure that both pricedetails have the same length
- if (count($t_pricedetails) !== count($o_pricedetails)) {
- return false;
- }
-
- //Check if all pricedetails are equal
- for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
- $t_price = $t_pricedetails->get($n);
- $o_price = $o_pricedetails->get($n);
-
- if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
- || $t_price->getCurrency() !== $o_price->getCurrency()
- || $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
- || $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
- ) {
- return false;
+ // If supplier and part number match, merge the orderdetails
+ if ($t->getSupplier() === $o->getSupplier() && $t->getSupplierPartNr() === $o->getSupplierPartNr()) {
+ // Update URL if target doesn't have one
+ if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) {
+ $t->setSupplierProductUrl($o->getSupplierProductUrl(false));
}
+ // Merge price details: add new ones, update empty ones, keep existing non-empty ones
+ foreach ($o->getPricedetails() as $otherPrice) {
+ $found = false;
+ foreach ($t->getPricedetails() as $targetPrice) {
+ if ($targetPrice->getMinDiscountQuantity() === $otherPrice->getMinDiscountQuantity()
+ && $targetPrice->getCurrency() === $otherPrice->getCurrency()) {
+ // Only update price if the existing one is zero/empty (most logical)
+ if ($targetPrice->getPrice()->isZero()) {
+ $targetPrice->setPrice($otherPrice->getPrice());
+ $targetPrice->setPriceRelatedQuantity($otherPrice->getPriceRelatedQuantity());
+ }
+ $found = true;
+ break;
+ }
+ }
+ // Add completely new price tiers
+ if (!$found) {
+ $clonedPrice = clone $otherPrice;
+ $clonedPrice->setOrderdetail($t);
+ $t->addPricedetail($clonedPrice);
+ }
+ }
+ return true; // Consider them equal so the other one gets skipped
}
-
- //If all pricedetails are equal, the orderdetails are equal
- return true;
+ return false; // Different supplier/part number, add as new
});
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
foreach ($target->getOrderdetails() as $orderdetail) {
diff --git a/src/Services/Formatters/SIFormatter.php b/src/Services/Formatters/SIFormatter.php
index a6325987..b83501fa 100644
--- a/src/Services/Formatters/SIFormatter.php
+++ b/src/Services/Formatters/SIFormatter.php
@@ -38,6 +38,11 @@ class SIFormatter
*/
public function getMagnitude(float $value): int
{
+ //Check for zero, as log10(0) is undefined/gives -infinity, which leads to casting issues in PHP8.5+
+ if (0.0 === $value) {
+ return 0;
+ }
+
return (int) floor(log10(abs($value)));
}
diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php
index 271642da..70feb8e6 100644
--- a/src/Services/ImportExportSystem/EntityExporter.php
+++ b/src/Services/ImportExportSystem/EntityExporter.php
@@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface;
use function Symfony\Component\String\u;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use PhpOffice\PhpSpreadsheet\Writer\Xls;
/**
* Use this class to export an entity to multiple file formats.
@@ -52,7 +55,7 @@ class EntityExporter
protected function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('format', 'csv');
- $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
+ $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setDefault('csv_delimiter', ';');
$resolver->setAllowedTypes('csv_delimiter', 'string');
@@ -88,28 +91,35 @@ class EntityExporter
$options = $resolver->resolve($options);
+ //Handle Excel formats by converting from CSV
+ if (in_array($options['format'], ['xlsx', 'xls'], true)) {
+ return $this->exportToExcel($entities, $options);
+ }
+
//If include children is set, then we need to add the include_children group
$groups = [$options['level']];
if ($options['include_children']) {
$groups[] = 'include_children';
}
- return $this->serializer->serialize($entities, $options['format'],
+ return $this->serializer->serialize(
+ $entities,
+ $options['format'],
[
'groups' => $groups,
'as_collection' => true,
'csv_delimiter' => $options['csv_delimiter'],
'xml_root_node_name' => 'PartDBExport',
'partdb_export' => true,
- //Skip the item normalizer, so that we dont get IRIs in the output
+ //Skip the item normalizer, so that we dont get IRIs in the output
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
- //Handle circular references
+ //Handle circular references
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
]
);
}
- private function handleCircularReference(object $object, string $format, array $context): string
+ private function handleCircularReference(object $object): string
{
if ($object instanceof AbstractStructuralDBElement) {
return $object->getFullPath("->");
@@ -119,7 +129,75 @@ class EntityExporter
return $object->__toString();
}
- throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
+ throw new CircularReferenceException('Circular reference detected for object of type ' . get_class($object));
+ }
+
+ /**
+ * Exports entities to Excel format (xlsx or xls).
+ *
+ * @param AbstractNamedDBElement[] $entities The entities to export
+ * @param array $options The export options
+ *
+ * @return string The Excel file content as binary string
+ */
+ protected function exportToExcel(array $entities, array $options): string
+ {
+ //First get CSV data using existing serializer
+ $groups = [$options['level']];
+ if ($options['include_children']) {
+ $groups[] = 'include_children';
+ }
+
+ $csvData = $this->serializer->serialize(
+ $entities,
+ 'csv',
+ [
+ 'groups' => $groups,
+ 'as_collection' => true,
+ 'csv_delimiter' => $options['csv_delimiter'],
+ 'partdb_export' => true,
+ SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
+ AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
+ ]
+ );
+
+ //Convert CSV to Excel
+ $spreadsheet = new Spreadsheet();
+ $worksheet = $spreadsheet->getActiveSheet();
+
+ $rows = explode("\n", $csvData);
+ $rowIndex = 1;
+
+ foreach ($rows as $row) {
+ if (trim($row) === '') {
+ continue;
+ }
+
+ $columns = str_getcsv($row, $options['csv_delimiter'], '"', '\\');
+ $colIndex = 1;
+
+ foreach ($columns as $column) {
+ $cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
+ $worksheet->setCellValue($cellCoordinate, $column);
+ $colIndex++;
+ }
+ $rowIndex++;
+ }
+
+ //Save to memory stream
+ $writer = $options['format'] === 'xlsx' ? new Xlsx($spreadsheet) : new Xls($spreadsheet);
+
+ $memFile = fopen("php://temp", 'r+b');
+ $writer->save($memFile);
+ rewind($memFile);
+ $content = stream_get_contents($memFile);
+ fclose($memFile);
+
+ if ($content === false) {
+ throw new \RuntimeException('Failed to read Excel content from memory stream.');
+ }
+
+ return $content;
}
/**
@@ -156,19 +234,15 @@ class EntityExporter
//Determine the content type for the response
- //Plain text should work for all types
- $content_type = 'text/plain';
-
//Try to use better content types based on the format
$format = $options['format'];
- switch ($format) {
- case 'xml':
- $content_type = 'application/xml';
- break;
- case 'json':
- $content_type = 'application/json';
- break;
- }
+ $content_type = match ($format) {
+ 'xml' => 'application/xml',
+ 'json' => 'application/json',
+ 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'xls' => 'application/vnd.ms-excel',
+ default => 'text/plain',
+ };
$response->headers->set('Content-Type', $content_type);
//If view option is not specified, then download the file.
@@ -186,7 +260,7 @@ class EntityExporter
$level = $options['level'];
- $filename = 'export_'.$entity_name.'_'.$level.'.'.$format;
+ $filename = "export_{$entity_name}_{$level}.{$format}";
//Sanitize the filename
$filename = FilenameSanatizer::sanitizeFilename($filename);
diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php
index 11915cfb..459866ba 100644
--- a/src/Services/ImportExportSystem/EntityImporter.php
+++ b/src/Services/ImportExportSystem/EntityImporter.php
@@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use Psr\Log\LoggerInterface;
/**
* @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
@@ -50,7 +53,7 @@ class EntityImporter
*/
private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
- public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator)
+ public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger)
{
}
@@ -102,7 +105,7 @@ class EntityImporter
foreach ($names as $name) {
//Count indentation level (whitespace characters at the beginning of the line)
- $identSize = strlen($name)-strlen(ltrim($name));
+ $identSize = strlen($name) - strlen(ltrim($name));
//If the line is intended more than the last line, we have a new parent element
if ($identSize > end($indentations)) {
@@ -195,16 +198,20 @@ class EntityImporter
}
//The [] behind class_name denotes that we expect an array.
- $entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'],
+ $entities = $this->serializer->deserialize(
+ $data,
+ $options['class'] . '[]',
+ $options['format'],
[
'groups' => $groups,
'csv_delimiter' => $options['csv_delimiter'],
'create_unknown_datastructures' => $options['create_unknown_datastructures'],
'path_delimiter' => $options['path_delimiter'],
'partdb_import' => true,
- //Disable API Platform normalizer, as we don't want to use it here
+ //Disable API Platform normalizer, as we don't want to use it here
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
- ]);
+ ]
+ );
//Ensure we have an array of entity elements.
if (!is_array($entities)) {
@@ -279,7 +286,7 @@ class EntityImporter
'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element
]);
- $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
+ $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setAllowedTypes('csv_delimiter', 'string');
$resolver->setAllowedTypes('preserve_children', 'bool');
$resolver->setAllowedTypes('class', 'string');
@@ -335,6 +342,33 @@ class EntityImporter
*/
public function importFile(File $file, array $options = [], array &$errors = []): array
{
+ $resolver = new OptionsResolver();
+ $this->configureOptions($resolver);
+ $options = $resolver->resolve($options);
+
+ if (in_array($options['format'], ['xlsx', 'xls'], true)) {
+ $this->logger->info('Converting Excel file to CSV', [
+ 'filename' => $file->getFilename(),
+ 'format' => $options['format'],
+ 'delimiter' => $options['csv_delimiter']
+ ]);
+
+ $csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']);
+ $options['format'] = 'csv';
+
+ $this->logger->debug('Excel to CSV conversion completed', [
+ 'csv_length' => strlen($csvData),
+ 'csv_lines' => substr_count($csvData, "\n") + 1
+ ]);
+
+ // Log the converted CSV for debugging (first 1000 characters)
+ $this->logger->debug('Converted CSV preview', [
+ 'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '')
+ ]);
+
+ return $this->importString($csvData, $options, $errors);
+ }
+
return $this->importString($file->getContent(), $options, $errors);
}
@@ -354,10 +388,103 @@ class EntityImporter
'xml' => 'xml',
'csv', 'tsv' => 'csv',
'yaml', 'yml' => 'yaml',
+ 'xlsx' => 'xlsx',
+ 'xls' => 'xls',
default => null,
};
}
+ /**
+ * Converts Excel file to CSV format using PhpSpreadsheet.
+ *
+ * @param File $file The Excel file to convert
+ * @param string $delimiter The CSV delimiter to use
+ *
+ * @return string The CSV data as string
+ */
+ protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
+ {
+ try {
+ $this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]);
+ $spreadsheet = IOFactory::load($file->getPathname());
+ $worksheet = $spreadsheet->getActiveSheet();
+
+ $csvData = [];
+ $highestRow = $worksheet->getHighestRow();
+ $highestColumn = $worksheet->getHighestColumn();
+
+ $this->logger->debug('Excel file dimensions', [
+ 'rows' => $highestRow,
+ 'columns_detected' => $highestColumn,
+ 'worksheet_title' => $worksheet->getTitle()
+ ]);
+
+ $highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
+
+ for ($row = 1; $row <= $highestRow; $row++) {
+ $rowData = [];
+
+ // Read all columns using numeric index
+ for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
+ $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
+ try {
+ $cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
+ $rowData[] = $cellValue ?? '';
+
+ } catch (\Exception $e) {
+ $this->logger->warning('Error reading cell value', [
+ 'cell' => "{$col}{$row}",
+ 'error' => $e->getMessage()
+ ]);
+ $rowData[] = '';
+ }
+ }
+
+ $csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) {
+ $value = (string) $value;
+ if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) {
+ return '"' . str_replace('"', '""', $value) . '"';
+ }
+ return $value;
+ }, $rowData));
+
+ $csvData[] = $csvRow;
+
+ // Log first few rows for debugging
+ if ($row <= 3) {
+ $this->logger->debug("Row {$row} converted", [
+ 'original_data' => $rowData,
+ 'csv_row' => $csvRow,
+ 'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(),
+ 'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue()
+ ]);
+ }
+ }
+
+ $result = implode("\n", $csvData);
+
+ $this->logger->info('Excel to CSV conversion successful', [
+ 'total_rows' => count($csvData),
+ 'total_characters' => strlen($result)
+ ]);
+
+ $this->logger->debug('Full CSV data', [
+ 'csv_data' => $result
+ ]);
+
+ return $result;
+
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to convert Excel to CSV', [
+ 'file' => $file->getFilename(),
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+ throw $e;
+ }
+ }
+
+
/**
* This functions corrects the parent setting based on the children value of the parent.
*
diff --git a/src/Services/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php
new file mode 100644
index 00000000..586fb873
--- /dev/null
+++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php
@@ -0,0 +1,380 @@
+ Cache for normalized supplier names */
+ private array $supplierCache = [];
+
+ public function __construct(
+ private readonly PartInfoRetriever $infoRetriever,
+ private readonly ExistingPartFinder $existingPartFinder,
+ private readonly ProviderRegistry $providerRegistry,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly LoggerInterface $logger
+ ) {}
+
+ /**
+ * Perform bulk search across multiple parts and providers.
+ *
+ * @param Part[] $parts Array of parts to search for
+ * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mappings defining search strategy
+ * @param bool $prefetchDetails Whether to prefetch detailed information for results
+ * @return BulkSearchResponseDTO Structured response containing all search results
+ * @throws \InvalidArgumentException If no valid parts provided
+ * @throws \RuntimeException If no search results found for any parts
+ */
+ public function performBulkSearch(array $parts, array $fieldMappings, bool $prefetchDetails = false): BulkSearchResponseDTO
+ {
+ if (empty($parts)) {
+ throw new \InvalidArgumentException('No valid parts found for bulk import');
+ }
+
+ $partResults = [];
+ $hasAnyResults = false;
+
+ // Group providers by batch capability
+ $batchProviders = [];
+ $regularProviders = [];
+
+ foreach ($fieldMappings as $mapping) {
+ foreach ($mapping->providers as $providerKey) {
+ if (!is_string($providerKey)) {
+ $this->logger->error('Invalid provider key type', [
+ 'providerKey' => $providerKey,
+ 'type' => gettype($providerKey)
+ ]);
+ continue;
+ }
+
+ $provider = $this->providerRegistry->getProviderByKey($providerKey);
+ if ($provider instanceof BatchInfoProviderInterface) {
+ $batchProviders[$providerKey] = $provider;
+ } else {
+ $regularProviders[$providerKey] = $provider;
+ }
+ }
+ }
+
+ // Process batch providers first (more efficient)
+ $batchResults = $this->processBatchProviders($parts, $fieldMappings, $batchProviders);
+
+ // Process regular providers
+ $regularResults = $this->processRegularProviders($parts, $fieldMappings, $regularProviders, $batchResults);
+
+ // Combine and format results for each part
+ foreach ($parts as $part) {
+ $searchResults = [];
+
+ // Get results from batch and regular processing
+ $allResults = array_merge(
+ $batchResults[$part->getId()] ?? [],
+ $regularResults[$part->getId()] ?? []
+ );
+
+ if (!empty($allResults)) {
+ $hasAnyResults = true;
+ $searchResults = $this->formatSearchResults($allResults);
+ }
+
+ $partResults[] = new BulkSearchPartResultsDTO(
+ part: $part,
+ searchResults: $searchResults,
+ errors: []
+ );
+ }
+
+ if (!$hasAnyResults) {
+ throw new \RuntimeException('No search results found for any of the selected parts');
+ }
+
+ $response = new BulkSearchResponseDTO($partResults);
+
+ // Prefetch details if requested
+ if ($prefetchDetails) {
+ $this->prefetchDetailsForResults($response);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Process parts using batch-capable info providers.
+ *
+ * @param Part[] $parts Array of parts to search for
+ * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations
+ * @param array $batchProviders Batch providers indexed by key
+ * @return array Results indexed by part ID
+ */
+ private function processBatchProviders(array $parts, array $fieldMappings, array $batchProviders): array
+ {
+ $batchResults = [];
+
+ foreach ($batchProviders as $providerKey => $provider) {
+ $keywords = $this->collectKeywordsForProvider($parts, $fieldMappings, $providerKey);
+
+ if (empty($keywords)) {
+ continue;
+ }
+
+ try {
+ $providerResults = $provider->searchByKeywordsBatch($keywords);
+
+ // Map results back to parts
+ foreach ($parts as $part) {
+ foreach ($fieldMappings as $mapping) {
+ if (!in_array($providerKey, $mapping->providers, true)) {
+ continue;
+ }
+
+ $keyword = $this->getKeywordFromField($part, $mapping->field);
+ if ($keyword && isset($providerResults[$keyword])) {
+ foreach ($providerResults[$keyword] as $dto) {
+ $batchResults[$part->getId()][] = new BulkSearchPartResultDTO(
+ searchResult: $dto,
+ sourceField: $mapping->field,
+ sourceKeyword: $keyword,
+ localPart: $this->existingPartFinder->findFirstExisting($dto),
+ priority: $mapping->priority
+ );
+ }
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ $this->logger->error('Batch search failed for provider ' . $providerKey, [
+ 'error' => $e->getMessage(),
+ 'provider' => $providerKey
+ ]);
+ }
+ }
+
+ return $batchResults;
+ }
+
+ /**
+ * Process parts using regular (non-batch) info providers.
+ *
+ * @param Part[] $parts Array of parts to search for
+ * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations
+ * @param array $regularProviders Regular providers indexed by key
+ * @param array $excludeResults Results to exclude (from batch processing)
+ * @return array Results indexed by part ID
+ */
+ private function processRegularProviders(array $parts, array $fieldMappings, array $regularProviders, array $excludeResults): array
+ {
+ $regularResults = [];
+
+ foreach ($parts as $part) {
+ $regularResults[$part->getId()] = [];
+
+ // Skip if we already have batch results for this part
+ if (!empty($excludeResults[$part->getId()] ?? [])) {
+ continue;
+ }
+
+ foreach ($fieldMappings as $mapping) {
+ $providers = array_intersect($mapping->providers, array_keys($regularProviders));
+
+ if (empty($providers)) {
+ continue;
+ }
+
+ $keyword = $this->getKeywordFromField($part, $mapping->field);
+ if (!$keyword) {
+ continue;
+ }
+
+ try {
+ $dtos = $this->infoRetriever->searchByKeyword($keyword, $providers);
+
+ foreach ($dtos as $dto) {
+ $regularResults[$part->getId()][] = new BulkSearchPartResultDTO(
+ searchResult: $dto,
+ sourceField: $mapping->field,
+ sourceKeyword: $keyword,
+ localPart: $this->existingPartFinder->findFirstExisting($dto),
+ priority: $mapping->priority
+ );
+ }
+ } catch (ClientException $e) {
+ $this->logger->error('Regular search failed', [
+ 'part_id' => $part->getId(),
+ 'field' => $mapping->field,
+ 'error' => $e->getMessage()
+ ]);
+ }
+ }
+ }
+
+ return $regularResults;
+ }
+
+ /**
+ * Collect unique keywords for a specific provider from all parts and field mappings.
+ *
+ * @param Part[] $parts Array of parts to collect keywords from
+ * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations
+ * @param string $providerKey The provider key to collect keywords for
+ * @return string[] Array of unique keywords
+ */
+ private function collectKeywordsForProvider(array $parts, array $fieldMappings, string $providerKey): array
+ {
+ $keywords = [];
+
+ foreach ($parts as $part) {
+ foreach ($fieldMappings as $mapping) {
+ if (!in_array($providerKey, $mapping->providers, true)) {
+ continue;
+ }
+
+ $keyword = $this->getKeywordFromField($part, $mapping->field);
+ if ($keyword && !in_array($keyword, $keywords, true)) {
+ $keywords[] = $keyword;
+ }
+ }
+ }
+
+ return $keywords;
+ }
+
+ private function getKeywordFromField(Part $part, string $field): ?string
+ {
+ return match ($field) {
+ 'mpn' => $part->getManufacturerProductNumber(),
+ 'name' => $part->getName(),
+ default => $this->getSupplierPartNumber($part, $field)
+ };
+ }
+
+ private function getSupplierPartNumber(Part $part, string $field): ?string
+ {
+ if (!str_ends_with($field, '_spn')) {
+ return null;
+ }
+
+ $supplierKey = substr($field, 0, -4);
+ $supplier = $this->getSupplierByNormalizedName($supplierKey);
+
+ if (!$supplier) {
+ return null;
+ }
+
+ $orderDetail = $part->getOrderdetails()->filter(
+ fn($od) => $od->getSupplier()?->getId() === $supplier->getId()
+ )->first();
+
+ return $orderDetail !== false ? $orderDetail->getSupplierpartnr() : null;
+ }
+
+ /**
+ * Get supplier by normalized name with caching to prevent N+1 queries.
+ *
+ * @param string $normalizedKey The normalized supplier key to search for
+ * @return Supplier|null The matching supplier or null if not found
+ */
+ private function getSupplierByNormalizedName(string $normalizedKey): ?Supplier
+ {
+ // Check cache first
+ if (isset($this->supplierCache[$normalizedKey])) {
+ return $this->supplierCache[$normalizedKey];
+ }
+
+ // Use efficient database query with PHP normalization
+ // Since DQL doesn't support REPLACE, we'll load all suppliers once and cache the normalization
+ if (empty($this->supplierCache)) {
+ $this->loadSuppliersIntoCache();
+ }
+
+ $supplier = $this->supplierCache[$normalizedKey] ?? null;
+
+ // Cache the result (including null results to prevent repeated queries)
+ $this->supplierCache[$normalizedKey] = $supplier;
+
+ return $supplier;
+ }
+
+ /**
+ * Load all suppliers into cache with normalized names to avoid N+1 queries.
+ */
+ private function loadSuppliersIntoCache(): void
+ {
+ /** @var Supplier[] $suppliers */
+ $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
+
+ foreach ($suppliers as $supplier) {
+ $normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
+ $this->supplierCache[$normalizedName] = $supplier;
+ }
+ }
+
+ /**
+ * Format and deduplicate search results.
+ *
+ * @param BulkSearchPartResultDTO[] $bulkResults Array of bulk search results
+ * @return BulkSearchPartResultDTO[] Array of formatted search results with metadata
+ */
+ private function formatSearchResults(array $bulkResults): array
+ {
+ // Sort by priority and remove duplicates
+ usort($bulkResults, fn($a, $b) => $a->priority <=> $b->priority);
+
+ $uniqueResults = [];
+ $seenKeys = [];
+
+ foreach ($bulkResults as $result) {
+ $key = "{$result->searchResult->provider_key}|{$result->searchResult->provider_id}";
+ if (!in_array($key, $seenKeys, true)) {
+ $seenKeys[] = $key;
+ $uniqueResults[] = $result;
+ }
+ }
+
+ return $uniqueResults;
+ }
+
+ /**
+ * Prefetch detailed information for search results.
+ *
+ * @param BulkSearchResponseDTO $searchResults Search results (supports both new DTO and legacy array format)
+ */
+ public function prefetchDetailsForResults(BulkSearchResponseDTO $searchResults): void
+ {
+ $prefetchCount = 0;
+
+ // Handle both new DTO format and legacy array format for backwards compatibility
+ foreach ($searchResults->partResults as $partResult) {
+ foreach ($partResult->searchResults as $result) {
+ $dto = $result->searchResult;
+
+ try {
+ $this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id);
+ $prefetchCount++;
+ } catch (\Exception $e) {
+ $this->logger->warning('Failed to prefetch details for provider part', [
+ 'provider_key' => $dto->provider_key,
+ 'provider_id' => $dto->provider_id,
+ 'error' => $e->getMessage()
+ ]);
+ }
+ }
+ }
+
+ $this->logger->info("Prefetched details for {$prefetchCount} search results");
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php
new file mode 100644
index 00000000..50b7f4cf
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php
@@ -0,0 +1,91 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Services\InfoProviderSystem\DTOs;
+
+/**
+ * Represents a mapping between a part field and the info providers that should search in that field.
+ */
+readonly class BulkSearchFieldMappingDTO
+{
+ /**
+ * @param string $field The field to search in (e.g., 'mpn', 'name', or supplier-specific fields like 'digikey_spn')
+ * @param string[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell'])
+ * @param int $priority Priority for this field mapping (1-10, lower numbers = higher priority)
+ */
+ public function __construct(
+ public string $field,
+ public array $providers,
+ public int $priority = 1
+ ) {
+ if ($priority < 1 || $priority > 10) {
+ throw new \InvalidArgumentException('Priority must be between 1 and 10');
+ }
+ }
+
+ /**
+ * Create a FieldMappingDTO from legacy array format.
+ * @param array{field: string, providers: string[], priority?: int} $data
+ */
+ public static function fromSerializableArray(array $data): self
+ {
+ return new self(
+ field: $data['field'],
+ providers: $data['providers'] ?? [],
+ priority: $data['priority'] ?? 1
+ );
+ }
+
+ /**
+ * Convert this DTO to the legacy array format for backwards compatibility.
+ * @return array{field: string, providers: string[], priority: int}
+ */
+ public function toSerializableArray(): array
+ {
+ return [
+ 'field' => $this->field,
+ 'providers' => $this->providers,
+ 'priority' => $this->priority,
+ ];
+ }
+
+ /**
+ * Check if this field mapping is for a supplier part number field.
+ */
+ public function isSupplierPartNumberField(): bool
+ {
+ return str_ends_with($this->field, '_spn');
+ }
+
+ /**
+ * Get the supplier key from a supplier part number field.
+ * Returns null if this is not a supplier part number field.
+ */
+ public function getSupplierKey(): ?string
+ {
+ if (!$this->isSupplierPartNumberField()) {
+ return null;
+ }
+
+ return substr($this->field, 0, -4);
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php
new file mode 100644
index 00000000..d46624d4
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php
@@ -0,0 +1,44 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Services\InfoProviderSystem\DTOs;
+
+use App\Entity\Parts\Part;
+
+/**
+ * Represents a single search result from bulk search with additional context information, like how the part was found.
+ */
+readonly class BulkSearchPartResultDTO
+{
+ public function __construct(
+ /** The base search result DTO containing provider data */
+ public SearchResultDTO $searchResult,
+ /** The field that was used to find this result */
+ public ?string $sourceField = null,
+ /** The actual keyword that was searched for */
+ public ?string $sourceKeyword = null,
+ /** Local part that matches this search result, if any */
+ public ?Part $localPart = null,
+ /** Priority for this search result */
+ public int $priority = 1
+ ) {}
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php
new file mode 100644
index 00000000..8614f4ec
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php
@@ -0,0 +1,83 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Services\InfoProviderSystem\DTOs;
+
+use App\Entity\Parts\Part;
+
+/**
+ * Represents the search results for a single part from bulk info provider search.
+ * It contains multiple search results, that match the part.
+ */
+readonly class BulkSearchPartResultsDTO
+{
+ /**
+ * @param Part $part The part that was searched for
+ * @param BulkSearchPartResultDTO[] $searchResults Array of search results found for this part
+ * @param string[] $errors Array of error messages encountered during search
+ */
+ public function __construct(
+ public Part $part,
+ public array $searchResults = [],
+ public array $errors = []
+ ) {}
+
+ /**
+ * Check if this part has any search results.
+ */
+ public function hasResults(): bool
+ {
+ return !empty($this->searchResults);
+ }
+
+ /**
+ * Check if this part has any errors.
+ */
+ public function hasErrors(): bool
+ {
+ return !empty($this->errors);
+ }
+
+ /**
+ * Get the number of search results for this part.
+ */
+ public function getResultCount(): int
+ {
+ return count($this->searchResults);
+ }
+
+ public function getErrorCount(): int
+ {
+ return count($this->errors);
+ }
+
+ /**
+ * Get search results sorted by priority (ascending).
+ * @return BulkSearchPartResultDTO[]
+ */
+ public function getResultsSortedByPriority(): array
+ {
+ $results = $this->searchResults;
+ usort($results, static fn(BulkSearchPartResultDTO $a, BulkSearchPartResultDTO $b) => $a->priority <=> $b->priority);
+ return $results;
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php
new file mode 100644
index 00000000..58e9e240
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php
@@ -0,0 +1,231 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Services\InfoProviderSystem\DTOs;
+
+use App\Entity\Parts\Part;
+use Doctrine\ORM\EntityManagerInterface;
+use Traversable;
+
+/**
+ * Represents the complete response from a bulk info provider search operation.
+ * It contains a list of PartSearchResultDTOs, one for each part searched.
+ */
+readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate
+{
+ /**
+ * @param BulkSearchPartResultsDTO[] $partResults Array of search results for each part
+ */
+ public function __construct(
+ public array $partResults
+ ) {}
+
+ /**
+ * Replaces the search results for a specific part, and returns a new instance.
+ * The part to replaced, is identified by the part property of the new_results parameter.
+ * The original instance remains unchanged.
+ * @param BulkSearchPartResultsDTO $new_results
+ * @return BulkSearchResponseDTO
+ */
+ public function replaceResultsForPart(BulkSearchPartResultsDTO $new_results): self
+ {
+ $array = $this->partResults;
+ $replaced = false;
+ foreach ($array as $index => $partResult) {
+ if ($partResult->part === $new_results->part) {
+ $array[$index] = $new_results;
+ $replaced = true;
+ break;
+ }
+ }
+
+ if (!$replaced) {
+ throw new \InvalidArgumentException("Part not found in existing results.");
+ }
+
+ return new self($array);
+ }
+
+ /**
+ * Check if any parts have search results.
+ */
+ public function hasAnyResults(): bool
+ {
+ foreach ($this->partResults as $partResult) {
+ if ($partResult->hasResults()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the total number of search results across all parts.
+ */
+ public function getTotalResultCount(): int
+ {
+ $count = 0;
+ foreach ($this->partResults as $partResult) {
+ $count += $partResult->getResultCount();
+ }
+ return $count;
+ }
+
+ /**
+ * Get all parts that have search results.
+ * @return BulkSearchPartResultsDTO[]
+ */
+ public function getPartsWithResults(): array
+ {
+ return array_filter($this->partResults, fn($result) => $result->hasResults());
+ }
+
+ /**
+ * Get all parts that have errors.
+ * @return BulkSearchPartResultsDTO[]
+ */
+ public function getPartsWithErrors(): array
+ {
+ return array_filter($this->partResults, fn($result) => $result->hasErrors());
+ }
+
+ /**
+ * Get the number of parts processed.
+ */
+ public function getPartCount(): int
+ {
+ return count($this->partResults);
+ }
+
+ /**
+ * Get the number of parts with successful results.
+ */
+ public function getSuccessfulPartCount(): int
+ {
+ return count($this->getPartsWithResults());
+ }
+
+ /**
+ * Merge multiple BulkSearchResponseDTO instances into one.
+ * @param BulkSearchResponseDTO ...$responses
+ * @return BulkSearchResponseDTO
+ */
+ public static function merge(BulkSearchResponseDTO ...$responses): BulkSearchResponseDTO
+ {
+ $mergedResults = [];
+ foreach ($responses as $response) {
+ foreach ($response->partResults as $partResult) {
+ $mergedResults[] = $partResult;
+ }
+ }
+ return new BulkSearchResponseDTO($mergedResults);
+ }
+
+ /**
+ * Convert this DTO to a serializable representation suitable for storage in the database
+ * @return array
+ */
+ public function toSerializableRepresentation(): array
+ {
+ $serialized = [];
+
+ foreach ($this->partResults as $partResult) {
+ $partData = [
+ 'part_id' => $partResult->part->getId(),
+ 'search_results' => [],
+ 'errors' => $partResult->errors ?? []
+ ];
+
+ foreach ($partResult->searchResults as $result) {
+ $partData['search_results'][] = [
+ 'dto' => $result->searchResult->toNormalizedSearchResultArray(),
+ 'source_field' => $result->sourceField ?? null,
+ 'source_keyword' => $result->sourceKeyword ?? null,
+ 'localPart' => $result->localPart?->getId(),
+ 'priority' => $result->priority
+ ];
+ }
+
+ $serialized[] = $partData;
+ }
+
+ return $serialized;
+ }
+
+ /**
+ * Creates a BulkSearchResponseDTO from a serializable representation.
+ * @param array $data
+ * @param EntityManagerInterface $entityManager
+ * @return BulkSearchResponseDTO
+ * @throws \Doctrine\ORM\Exception\ORMException
+ */
+ public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO
+ {
+ $partResults = [];
+ foreach ($data as $partData) {
+ $partResults[] = new BulkSearchPartResultsDTO(
+ part: $entityManager->getReference(Part::class, $partData['part_id']),
+ searchResults: array_map(fn($result) => new BulkSearchPartResultDTO(
+ searchResult: SearchResultDTO::fromNormalizedSearchResultArray($result['dto']),
+ sourceField: $result['source_field'] ?? null,
+ sourceKeyword: $result['source_keyword'] ?? null,
+ localPart: isset($result['localPart']) ? $entityManager->getReference(Part::class, $result['localPart']) : null,
+ priority: $result['priority'] ?? null
+ ), $partData['search_results'] ?? []),
+ errors: $partData['errors'] ?? []
+ );
+ }
+
+ return new BulkSearchResponseDTO($partResults);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ if (!is_int($offset)) {
+ throw new \InvalidArgumentException("Offset must be an integer.");
+ }
+ return isset($this->partResults[$offset]);
+ }
+
+ public function offsetGet(mixed $offset): ?BulkSearchPartResultsDTO
+ {
+ if (!is_int($offset)) {
+ throw new \InvalidArgumentException("Offset must be an integer.");
+ }
+ return $this->partResults[$offset] ?? null;
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new \LogicException("BulkSearchResponseDTO is immutable.");
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new \LogicException('BulkSearchResponseDTO is immutable.');
+ }
+
+ public function getIterator(): Traversable
+ {
+ return new \ArrayIterator($this->partResults);
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php
index 0d1db76a..84eed0c9 100644
--- a/src/Services/InfoProviderSystem/DTOs/FileDTO.php
+++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php
@@ -28,12 +28,12 @@ namespace App\Services\InfoProviderSystem\DTOs;
* This could be a datasheet, a 3D model, a picture or similar.
* @see \App\Tests\Services\InfoProviderSystem\DTOs\FileDTOTest
*/
-class FileDTO
+readonly class FileDTO
{
/**
* @var string The URL where to get this file
*/
- public readonly string $url;
+ public string $url;
/**
* @param string $url The URL where to get this file
@@ -41,7 +41,7 @@ class FileDTO
*/
public function __construct(
string $url,
- public readonly ?string $name = null,
+ public ?string $name = null,
) {
//Find all occurrences of non URL safe characters and replace them with their URL encoded version.
//We only want to replace characters which can not have a valid meaning in a URL (what would break the URL).
@@ -50,4 +50,4 @@ class FileDTO
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php
index 0b54d1a9..f5868039 100644
--- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php
+++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php
@@ -28,17 +28,17 @@ namespace App\Services\InfoProviderSystem\DTOs;
* This could be a voltage, a current, a temperature or similar.
* @see \App\Tests\Services\InfoProviderSystem\DTOs\ParameterDTOTest
*/
-class ParameterDTO
+readonly class ParameterDTO
{
public function __construct(
- public readonly string $name,
- public readonly ?string $value_text = null,
- public readonly ?float $value_typ = null,
- public readonly ?float $value_min = null,
- public readonly ?float $value_max = null,
- public readonly ?string $unit = null,
- public readonly ?string $symbol = null,
- public readonly ?string $group = null,
+ public string $name,
+ public ?string $value_text = null,
+ public ?float $value_typ = null,
+ public ?float $value_min = null,
+ public ?float $value_max = null,
+ public ?string $unit = null,
+ public ?string $symbol = null,
+ public ?string $group = null,
) {
}
diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php
index 9f365f1e..41d50510 100644
--- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php
+++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php
@@ -70,4 +70,4 @@ class PartDetailDTO extends SearchResultDTO
footprint: $footprint,
);
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php
index f1eb28f7..2acf3e57 100644
--- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php
+++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php
@@ -28,21 +28,21 @@ use Brick\Math\BigDecimal;
/**
* This DTO represents a price for a single unit in a certain discount range
*/
-class PriceDTO
+readonly class PriceDTO
{
- private readonly BigDecimal $price_as_big_decimal;
+ private BigDecimal $price_as_big_decimal;
public function __construct(
/** @var float The minimum amount that needs to get ordered for this price to be valid */
- public readonly float $minimum_discount_amount,
+ public float $minimum_discount_amount,
/** @var string The price as string (with .) */
- public readonly string $price,
+ public string $price,
/** @var string The currency of the used ISO code of this price detail */
- public readonly ?string $currency_iso_code,
+ public ?string $currency_iso_code,
/** @var bool If the price includes tax */
- public readonly ?bool $includes_tax = true,
+ public ?bool $includes_tax = true,
/** @var float the price related quantity */
- public readonly ?float $price_related_quantity = 1.0,
+ public ?float $price_related_quantity = 1.0,
)
{
$this->price_as_big_decimal = BigDecimal::of($this->price);
diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php
index bcd8be43..9ac142ff 100644
--- a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php
+++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php
@@ -27,15 +27,15 @@ namespace App\Services\InfoProviderSystem\DTOs;
* This DTO represents a purchase information for a part (supplier name, order number and prices).
* @see \App\Tests\Services\InfoProviderSystem\DTOs\PurchaseInfoDTOTest
*/
-class PurchaseInfoDTO
+readonly class PurchaseInfoDTO
{
public function __construct(
- public readonly string $distributor_name,
- public readonly string $order_number,
+ public string $distributor_name,
+ public string $order_number,
/** @var PriceDTO[] */
- public readonly array $prices,
+ public array $prices,
/** @var string|null An url to the product page of the vendor */
- public readonly ?string $product_url = null,
+ public ?string $product_url = null,
)
{
//Ensure that the prices are PriceDTO instances
@@ -45,4 +45,4 @@ class PurchaseInfoDTO
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php
index 28943702..a70b2486 100644
--- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php
+++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php
@@ -59,8 +59,8 @@ class SearchResultDTO
public readonly ?string $provider_url = null,
/** @var string|null A footprint representation of the providers page */
public readonly ?string $footprint = null,
- ) {
-
+ )
+ {
if ($preview_image_url !== null) {
//Utilize the escaping mechanism of FileDTO to ensure that the preview image URL is correctly encoded
//See issue #521: https://github.com/Part-DB/Part-DB-server/issues/521
@@ -71,4 +71,47 @@ class SearchResultDTO
$this->preview_image_url = null;
}
}
-}
\ No newline at end of file
+
+ /**
+ * This method creates a normalized array representation of the DTO.
+ * @return array
+ */
+ public function toNormalizedSearchResultArray(): array
+ {
+ return [
+ 'provider_key' => $this->provider_key,
+ 'provider_id' => $this->provider_id,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'category' => $this->category,
+ 'manufacturer' => $this->manufacturer,
+ 'mpn' => $this->mpn,
+ 'preview_image_url' => $this->preview_image_url,
+ 'manufacturing_status' => $this->manufacturing_status?->value,
+ 'provider_url' => $this->provider_url,
+ 'footprint' => $this->footprint,
+ ];
+ }
+
+ /**
+ * Creates a SearchResultDTO from a normalized array representation.
+ * @param array $data
+ * @return self
+ */
+ public static function fromNormalizedSearchResultArray(array $data): self
+ {
+ return new self(
+ provider_key: $data['provider_key'],
+ provider_id: $data['provider_id'],
+ name: $data['name'],
+ description: $data['description'],
+ category: $data['category'] ?? null,
+ manufacturer: $data['manufacturer'] ?? null,
+ mpn: $data['mpn'] ?? null,
+ preview_image_url: $data['preview_image_url'] ?? null,
+ manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null,
+ provider_url: $data['provider_url'] ?? null,
+ footprint: $data['footprint'] ?? null,
+ );
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php
index d09f1d05..a655a0df 100644
--- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php
+++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php
@@ -221,7 +221,7 @@ final class DTOtoEntityConverter
$attachment = $this->convertFile($image, $image_type);
$attachments_grouped[$attachment->getName()][] = $attachment;
- if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
+ if (count($attachments_grouped[$attachment->getName()]) > 1) {
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')');
}
@@ -236,7 +236,7 @@ final class DTOtoEntityConverter
$attachment = $this->convertFile($datasheet, $datasheet_type);
$attachments_grouped[$attachment->getName()][] = $attachment;
- if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
+ if (count($attachments_grouped[$attachment->getName()]) > 1) {
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')');
}
@@ -357,4 +357,4 @@ final class DTOtoEntityConverter
return $tmp;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php
new file mode 100644
index 00000000..549f117a
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php
@@ -0,0 +1,40 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\Providers;
+
+use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
+
+/**
+ * This interface marks a provider as a info provider which can provide information directly in batch operations
+ */
+interface BatchInfoProviderInterface extends InfoProviderInterface
+{
+ /**
+ * Search for multiple keywords in a single batch operation and return the results, ordered by the keywords.
+ * This allows for a more efficient search compared to running multiple single searches.
+ * @param string[] $keywords
+ * @return array An associative array where the key is the keyword and the value is the search results for that keyword
+ */
+ public function searchByKeywordsBatch(array $keywords): array;
+}
diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php
index 51f460e4..423b5244 100644
--- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php
@@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
+use App\Exceptions\OAuthReconnectRequiredException;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
@@ -117,12 +118,22 @@ class DigikeyProvider implements InfoProviderInterface
];
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
- $response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
- 'json' => $request,
- 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
- ]);
+ try {
+ $response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
+ 'json' => $request,
+ 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
+ ]);
+
+ $response_array = $response->toArray();
+ } catch (\InvalidArgumentException $exception) {
+ //Check if the exception was caused by an invalid or expired token
+ if (str_contains($exception->getMessage(), 'access_token')) {
+ throw OAuthReconnectRequiredException::forProvider($this->getProviderKey());
+ }
+
+ throw $exception;
+ }
- $response_array = $response->toArray();
$result = [];
@@ -150,9 +161,18 @@ class DigikeyProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO
{
- $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
- 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
- ]);
+ try {
+ $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
+ 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
+ ]);
+ } catch (\InvalidArgumentException $exception) {
+ //Check if the exception was caused by an invalid or expired token
+ if (str_contains($exception->getMessage(), 'access_token')) {
+ throw OAuthReconnectRequiredException::forProvider($this->getProviderKey());
+ }
+
+ throw $exception;
+ }
$response_array = $response->toArray();
$product = $response_array['Product'];
diff --git a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php
new file mode 100644
index 00000000..e0de9772
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php
@@ -0,0 +1,76 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\Providers;
+
+use App\Services\InfoProviderSystem\DTOs\FileDTO;
+use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
+use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
+use Symfony\Component\DependencyInjection\Attribute\When;
+
+/**
+ * This is a provider, which is used during tests. It always returns no results.
+ */
+#[When(env: 'test')]
+class EmptyProvider implements InfoProviderInterface
+{
+ public function getProviderInfo(): array
+ {
+ return [
+ 'name' => 'Empty Provider',
+ 'description' => 'This is a test provider',
+ //'url' => 'https://example.com',
+ 'disabled_help' => 'This provider is disabled for testing purposes'
+ ];
+ }
+
+ public function getProviderKey(): string
+ {
+ return 'empty';
+ }
+
+ public function isActive(): bool
+ {
+ return true;
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ return [
+
+ ];
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::FOOTPRINT,
+ ];
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ throw new \RuntimeException('No part details available');
+ }
+}
diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
index 4b5bc358..8dd3888d 100755
--- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Contracts\HttpClient\HttpClientInterface;
-class LCSCProvider implements InfoProviderInterface
+class LCSCProvider implements BatchInfoProviderInterface
{
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
@@ -69,9 +69,10 @@ class LCSCProvider implements InfoProviderInterface
/**
* @param string $id
+ * @param bool $lightweight If true, skip expensive operations like datasheet resolution
* @return PartDetailDTO
*/
- private function queryDetail(string $id): PartDetailDTO
+ private function queryDetail(string $id, bool $lightweight = false): PartDetailDTO
{
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
'headers' => [
@@ -89,7 +90,7 @@ class LCSCProvider implements InfoProviderInterface
throw new \RuntimeException('Could not find product code: ' . $id);
}
- return $this->getPartDetail($product);
+ return $this->getPartDetail($product, $lightweight);
}
/**
@@ -99,30 +100,42 @@ class LCSCProvider implements InfoProviderInterface
private function getRealDatasheetUrl(?string $url): string
{
if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) {
- if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
- $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
- }
- $response = $this->lcscClient->request('GET', $url, [
- 'headers' => [
- 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
- ],
- ]);
- if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
- //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
- //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
- $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}');
- $url = $jsonObj->previewPdfUrl;
- }
+ if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
+ $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
+ }
+ $response = $this->lcscClient->request('GET', $url, [
+ 'headers' => [
+ 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
+ ],
+ ]);
+ if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
+ //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
+ //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
+ $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}');
+ $url = $jsonObj->previewPdfUrl;
+ }
}
return $url;
}
/**
* @param string $term
+ * @param bool $lightweight If true, skip expensive operations like datasheet resolution
* @return PartDetailDTO[]
*/
- private function queryByTerm(string $term): array
+ private function queryByTerm(string $term, bool $lightweight = false): array
{
+ // Optimize: If term looks like an LCSC part number (starts with C followed by digits),
+ // use direct detail query instead of slower search
+ if (preg_match('/^C\d+$/i', trim($term))) {
+ try {
+ return [$this->queryDetail(trim($term), $lightweight)];
+ } catch (\Exception $e) {
+ // If direct lookup fails, fall back to search
+ // This handles cases where the C-code might not exist
+ }
+ }
+
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
@@ -145,11 +158,11 @@ class LCSCProvider implements InfoProviderInterface
// detailed product listing. It does so utilizing a product tip field.
// If product tip exists and there are no products in the product list try a detail query
if (count($products) === 0 && $tipProductCode !== null) {
- $result[] = $this->queryDetail($tipProductCode);
+ $result[] = $this->queryDetail($tipProductCode, $lightweight);
}
foreach ($products as $product) {
- $result[] = $this->getPartDetail($product);
+ $result[] = $this->getPartDetail($product, $lightweight);
}
return $result;
@@ -178,7 +191,7 @@ class LCSCProvider implements InfoProviderInterface
* @param array $product
* @return PartDetailDTO
*/
- private function getPartDetail(array $product): PartDetailDTO
+ private function getPartDetail(array $product, bool $lightweight = false): PartDetailDTO
{
// Get product images in advance
$product_images = $this->getProductImages($product['productImages'] ?? null);
@@ -214,10 +227,10 @@ class LCSCProvider implements InfoProviderInterface
manufacturing_status: null,
provider_url: $this->getProductShortURL($product['productCode']),
footprint: $this->sanitizeField($footprint),
- datasheets: $this->getProductDatasheets($product['pdfUrl'] ?? null),
- images: $product_images,
- parameters: $this->attributesToParameters($product['paramVOList'] ?? []),
- vendor_infos: $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
+ datasheets: $lightweight ? [] : $this->getProductDatasheets($product['pdfUrl'] ?? null),
+ images: $product_images, // Always include images - users need to see them
+ parameters: $lightweight ? [] : $this->attributesToParameters($product['paramVOList'] ?? []),
+ vendor_infos: $lightweight ? [] : $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
mass: $product['weight'] ?? null,
);
}
@@ -286,7 +299,7 @@ class LCSCProvider implements InfoProviderInterface
*/
private function getProductShortURL(string $product_code): string
{
- return 'https://www.lcsc.com/product-detail/' . $product_code .'.html';
+ return 'https://www.lcsc.com/product-detail/' . $product_code . '.html';
}
/**
@@ -327,7 +340,7 @@ class LCSCProvider implements InfoProviderInterface
//Skip this attribute if it's empty
if (in_array(trim((string) $attribute['paramValueEn']), ['', '-'], true)) {
- continue;
+ continue;
}
$result[] = ParameterDTO::parseValueIncludingUnit(name: $attribute['paramNameEn'], value: $attribute['paramValueEn'], group: null);
@@ -338,12 +351,86 @@ class LCSCProvider implements InfoProviderInterface
public function searchByKeyword(string $keyword): array
{
- return $this->queryByTerm($keyword);
+ return $this->queryByTerm($keyword, true); // Use lightweight mode for search
+ }
+
+ /**
+ * Batch search multiple keywords asynchronously (like JavaScript Promise.all)
+ * @param array $keywords Array of keywords to search
+ * @return array Results indexed by keyword
+ */
+ public function searchByKeywordsBatch(array $keywords): array
+ {
+ if (empty($keywords)) {
+ return [];
+ }
+
+ $responses = [];
+ $results = [];
+
+ // Start all requests immediately (like JavaScript promises without await)
+ foreach ($keywords as $keyword) {
+ if (preg_match('/^C\d+$/i', trim($keyword))) {
+ // Direct detail API call for C-codes
+ $responses[$keyword] = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
+ 'headers' => [
+ 'Cookie' => new Cookie('currencyCode', $this->settings->currency)
+ ],
+ 'query' => [
+ 'productCode' => trim($keyword),
+ ],
+ ]);
+ } else {
+ // Search API call for other terms
+ $responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
+ 'headers' => [
+ 'Cookie' => new Cookie('currencyCode', $this->settings->currency)
+ ],
+ 'json' => [
+ 'keyword' => $keyword,
+ ],
+ ]);
+ }
+ }
+
+ // Now collect all results (like .then() in JavaScript)
+ foreach ($responses as $keyword => $response) {
+ try {
+ $arr = $response->toArray(); // This waits for the response
+ $results[$keyword] = $this->processSearchResponse($arr, $keyword);
+ } catch (\Exception $e) {
+ $results[$keyword] = []; // Empty results on error
+ }
+ }
+
+ return $results;
+ }
+
+ private function processSearchResponse(array $arr, string $keyword): array
+ {
+ $result = [];
+
+ // Check if this looks like a detail response (direct C-code lookup)
+ if (isset($arr['result']['productCode'])) {
+ $product = $arr['result'];
+ $result[] = $this->getPartDetail($product, true); // lightweight mode
+ } else {
+ // This is a search response
+ $products = $arr['result']['productSearchResultVO']['productList'] ?? [];
+ $tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
+
+ // If no products but has tip, we'd need another API call - skip for batch mode
+ foreach ($products as $product) {
+ $result[] = $this->getPartDetail($product, true); // lightweight mode
+ }
+ }
+
+ return $result;
}
public function getDetails(string $id): PartDetailDTO
{
- $tmp = $this->queryByTerm($id);
+ $tmp = $this->queryByTerm($id, false);
if (count($tmp) === 0) {
throw new \RuntimeException('No part found with ID ' . $id);
}
diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php
index 6639e5c1..3171c994 100644
--- a/src/Services/InfoProviderSystem/Providers/MouserProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php
@@ -132,6 +132,15 @@ class MouserProvider implements InfoProviderInterface
],
]);
+ // Check for API errors before processing response
+ if ($response->getStatusCode() !== 200) {
+ throw new \RuntimeException(sprintf(
+ 'Mouser API returned HTTP %d: %s',
+ $response->getStatusCode(),
+ $response->getContent(false)
+ ));
+ }
+
return $this->responseToDTOArray($response);
}
@@ -169,6 +178,16 @@ class MouserProvider implements InfoProviderInterface
]
],
]);
+
+ // Check for API errors before processing response
+ if ($response->getStatusCode() !== 200) {
+ throw new \RuntimeException(sprintf(
+ 'Mouser API returned HTTP %d: %s',
+ $response->getStatusCode(),
+ $response->getContent(false)
+ ));
+ }
+
$tmp = $this->responseToDTOArray($response);
//Ensure that we have exactly one result
@@ -286,6 +305,17 @@ class MouserProvider implements InfoProviderInterface
return (float)$val;
}
+ private function mapCurrencyCode(string $currency): string
+ {
+ //Mouser uses "RMB" for Chinese Yuan, but the correct ISO code is "CNY"
+ if ($currency === "RMB") {
+ return "CNY";
+ }
+
+ //For all other currencies, we assume that the ISO code is correct
+ return $currency;
+ }
+
/**
* Converts the pricing (StandardPricing field) from the Mouser API to an array of PurchaseInfoDTOs
* @param array $price_breaks
@@ -302,7 +332,7 @@ class MouserProvider implements InfoProviderInterface
$prices[] = new PriceDTO(
minimum_discount_amount: $price_break['Quantity'],
price: (string)$number,
- currency_iso_code: $price_break['Currency']
+ currency_iso_code: $this->mapCurrencyCode($price_break['Currency'])
);
}
diff --git a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php
index 7ceb30dd..3df7d227 100644
--- a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php
+++ b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php
@@ -95,6 +95,11 @@ final class BarcodeContentGenerator
return $prefix.$id;
}
+ /**
+ * @param array $map
+ * @param object $target
+ * @return string
+ */
private function classToString(array $map, object $target): string
{
$class = $target::class;
diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php
index 616df229..945cff7b 100644
--- a/src/Services/Parts/PartsTableActionHandler.php
+++ b/src/Services/Parts/PartsTableActionHandler.php
@@ -30,13 +30,11 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Repository\PartRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
-use Symfony\Contracts\Translation\TranslatableInterface;
use function Symfony\Component\Translation\t;
@@ -100,7 +98,7 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
//When action starts with "export_" we have to redirect to the export controller
$matches = [];
- if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) {
+ if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) {
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
$level = match ($target_id) {
2 => 'extended',
@@ -119,6 +117,16 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
);
}
+ if ($action === 'bulk_info_provider_import') {
+ $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
+ return new RedirectResponse(
+ $this->urlGenerator->generate('bulk_info_provider_step1', [
+ 'ids' => $ids,
+ '_redirect' => $redirect_url
+ ])
+ );
+ }
+
//Iterate over the parts and apply the action to it:
foreach ($selected_parts as $part) {
diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php
index 269c7e4c..a541c29d 100644
--- a/src/Services/ProjectSystem/ProjectBuildHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildHelper.php
@@ -31,9 +31,9 @@ use App\Services\Parts\PartLotWithdrawAddHelper;
/**
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
*/
-class ProjectBuildHelper
+final readonly class ProjectBuildHelper
{
- public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper)
+ public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper)
{
}
@@ -63,20 +63,37 @@ class ProjectBuildHelper
*/
public function getMaximumBuildableCount(Project $project): int
{
+ $bom_entries = $project->getBomEntries();
+ if ($bom_entries->isEmpty()) {
+ return 0;
+ }
$maximum_buildable_count = PHP_INT_MAX;
- foreach ($project->getBomEntries() as $bom_entry) {
+ foreach ($bom_entries as $bom_entry) {
//Skip BOM entries without a part (as we can not determine that)
if (!$bom_entry->isPartBomEntry()) {
continue;
}
-
//The maximum buildable count for the whole project is the minimum of all BOM entries
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
}
-
return $maximum_buildable_count;
}
+ /**
+ * Returns the maximum buildable amount of the given project as string, based on the stock of the used parts in the BOM.
+ * If the maximum buildable count is infinite, the string '∞' is returned.
+ * @param Project $project
+ * @return string
+ */
+ public function getMaximumBuildableCountAsString(Project $project): string
+ {
+ $max_count = $this->getMaximumBuildableCount($project);
+ if ($max_count === PHP_INT_MAX) {
+ return '∞';
+ }
+ return (string) $max_count;
+ }
+
/**
* Checks if the given project can be built with the current stock.
* This means that the maximum buildable count is greater or equal than the requested $number_of_projects
diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php
index f7a9d1c4..036797f6 100644
--- a/src/Services/Trees/ToolsTreeBuilder.php
+++ b/src/Services/Trees/ToolsTreeBuilder.php
@@ -138,6 +138,11 @@ class ToolsTreeBuilder
$this->translator->trans('info_providers.search.title'),
$this->urlGenerator->generate('info_providers_search')
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
+
+ $nodes[] = (new TreeViewNode(
+ $this->translator->trans('info_providers.bulk_import.manage_jobs'),
+ $this->urlGenerator->generate('bulk_info_provider_manage')
+ ))->setIcon('fa-treeview fa-fw fa-solid fa-tasks');
}
return $nodes;
diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php
index 1695638a..42831d08 100644
--- a/src/Settings/AppSettings.php
+++ b/src/Settings/AppSettings.php
@@ -26,7 +26,7 @@ namespace App\Settings;
use App\Settings\BehaviorSettings\BehaviorSettings;
use App\Settings\InfoProviderSystem\InfoProviderSettings;
use App\Settings\MiscSettings\MiscSettings;
-use App\Settings\SystemSettings\AttachmentsSettings;
+use App\Settings\SystemSettings\SystemSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
@@ -49,4 +49,4 @@ class AppSettings
#[EmbeddedSettings()]
public ?MiscSettings $miscSettings = null;
-}
\ No newline at end of file
+}
diff --git a/src/Settings/BehaviorSettings/BehaviorSettings.php b/src/Settings/BehaviorSettings/BehaviorSettings.php
index 1251a097..3053073f 100644
--- a/src/Settings/BehaviorSettings/BehaviorSettings.php
+++ b/src/Settings/BehaviorSettings/BehaviorSettings.php
@@ -26,8 +26,9 @@ namespace App\Settings\BehaviorSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
+use Symfony\Component\Translation\TranslatableMessage as TM;
-#[Settings]
+#[Settings(label: new TM("settings.behavior"))]
class BehaviorSettings
{
use SettingsTrait;
@@ -40,4 +41,4 @@ class BehaviorSettings
#[EmbeddedSettings]
public ?PartInfoSettings $partInfo = null;
-}
\ No newline at end of file
+}
diff --git a/src/Settings/BehaviorSettings/SidebarSettings.php b/src/Settings/BehaviorSettings/SidebarSettings.php
index 1266fa47..a1ff6985 100644
--- a/src/Settings/BehaviorSettings/SidebarSettings.php
+++ b/src/Settings/BehaviorSettings/SidebarSettings.php
@@ -73,4 +73,11 @@ class SidebarSettings
*/
#[SettingsParameter(label: new TM("settings.behavior.sidebar.rootNodeRedirectsToNewEntity"))]
public bool $rootNodeRedirectsToNewEntity = false;
-}
\ No newline at end of file
+
+ /**
+ * @var bool Whether to include child nodes in the data structure nodes table, or only show the selected node's parts.
+ */
+ #[SettingsParameter(label: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children"),
+ description: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children.help"))]
+ public bool $dataStructureNodesTableIncludeChildren = true;
+}
diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php
index c686481a..d4679e23 100644
--- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php
+++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php
@@ -27,8 +27,9 @@ use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
+use Symfony\Component\Translation\TranslatableMessage as TM;
-#[Settings()]
+#[Settings(label: new TM("settings.ips"))]
class InfoProviderSettings
{
use SettingsTrait;
diff --git a/src/Settings/MiscSettings/MiscSettings.php b/src/Settings/MiscSettings/MiscSettings.php
index b8a3a73f..1c304d4a 100644
--- a/src/Settings/MiscSettings/MiscSettings.php
+++ b/src/Settings/MiscSettings/MiscSettings.php
@@ -25,8 +25,9 @@ namespace App\Settings\MiscSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
+use Symfony\Component\Translation\TranslatableMessage as TM;
-#[Settings]
+#[Settings(label: new TM("settings.misc"))]
class MiscSettings
{
#[EmbeddedSettings]
@@ -34,4 +35,4 @@ class MiscSettings
#[EmbeddedSettings]
public ?ExchangeRateSettings $exchangeRate = null;
-}
\ No newline at end of file
+}
diff --git a/src/Settings/SystemSettings/LocalizationSettings.php b/src/Settings/SystemSettings/LocalizationSettings.php
index 434a4e69..7c83d1ef 100644
--- a/src/Settings/SystemSettings/LocalizationSettings.php
+++ b/src/Settings/SystemSettings/LocalizationSettings.php
@@ -23,9 +23,12 @@ declare(strict_types=1);
namespace App\Settings\SystemSettings;
+use App\Form\Type\LanguageMenuEntriesType;
use App\Form\Type\LocaleSelectType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
+use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
+use Jbtronics\SettingsBundle\ParameterTypes\StringType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
@@ -60,4 +63,14 @@ class LocalizationSettings
envVar: "string:BASE_CURRENCY", envVarMode: EnvVarMode::OVERWRITE
)]
public string $baseCurrency = 'EUR';
-}
\ No newline at end of file
+
+ #[SettingsParameter(type: ArrayType::class,
+ label: new TM("settings.system.localization.language_menu_entries"),
+ description: new TM("settings.system.localization.language_menu_entries.description"),
+ options: ['type' => StringType::class],
+ formType: LanguageMenuEntriesType::class,
+ formOptions: ['multiple' => true, 'required' => false, 'ordered' => true],
+ )]
+ #[Assert\All([new Assert\Locale()])]
+ public array $languageMenuEntries = [];
+}
diff --git a/src/Settings/SystemSettings.php b/src/Settings/SystemSettings/SystemSettings.php
similarity index 82%
rename from src/Settings/SystemSettings.php
rename to src/Settings/SystemSettings/SystemSettings.php
index 83d00afc..71dd963d 100644
--- a/src/Settings/SystemSettings.php
+++ b/src/Settings/SystemSettings/SystemSettings.php
@@ -21,17 +21,13 @@
declare(strict_types=1);
-namespace App\Settings;
+namespace App\Settings\SystemSettings;
-use App\Settings\SystemSettings\AttachmentsSettings;
-use App\Settings\SystemSettings\CustomizationSettings;
-use App\Settings\SystemSettings\HistorySettings;
-use App\Settings\SystemSettings\LocalizationSettings;
-use App\Settings\SystemSettings\PrivacySettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
+use Symfony\Component\Translation\TranslatableMessage as TM;
-#[Settings]
+#[Settings(label: new TM("settings.system"))]
class SystemSettings
{
#[EmbeddedSettings()]
@@ -48,4 +44,4 @@ class SystemSettings
#[EmbeddedSettings()]
public ?HistorySettings $history = null;
-}
\ No newline at end of file
+}
diff --git a/src/Twig/FormatExtension.php b/src/Twig/FormatExtension.php
index 76628ccd..46313aaf 100644
--- a/src/Twig/FormatExtension.php
+++ b/src/Twig/FormatExtension.php
@@ -82,7 +82,7 @@ final class FormatExtension extends AbstractExtension
public function formatBytes(int $bytes, int $precision = 2): string
{
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];
- $factor = floor((strlen((string) $bytes) - 1) / 3);
+ $factor = (int) floor((strlen((string) $bytes) - 1) / 3);
//We use the real (10 based) SI prefix here
return sprintf("%.{$precision}f", $bytes / (1000 ** $factor)) . ' ' . @$size[$factor];
}
diff --git a/src/Twig/Sandbox/InheritanceSecurityPolicy.php b/src/Twig/Sandbox/InheritanceSecurityPolicy.php
index 93e874e9..06ab3a1f 100644
--- a/src/Twig/Sandbox/InheritanceSecurityPolicy.php
+++ b/src/Twig/Sandbox/InheritanceSecurityPolicy.php
@@ -34,9 +34,14 @@ use function is_array;
*/
final class InheritanceSecurityPolicy implements SecurityPolicyInterface
{
+ /**
+ * @var array
+ */
private array $allowedMethods;
- public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], private array $allowedProperties = [], private array $allowedFunctions = [])
+ public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [],
+ /** @var array */
+ private array $allowedProperties = [], private array $allowedFunctions = [])
{
$this->setAllowedMethods($allowedMethods);
}
diff --git a/templates/_turbo_control.html.twig b/templates/_turbo_control.html.twig
index 4c178038..90ae8d9a 100644
--- a/templates/_turbo_control.html.twig
+++ b/templates/_turbo_control.html.twig
@@ -22,9 +22,14 @@
- {% for locale in locale_menu %}
+ {% set locales = settings_instance('localization').languageMenuEntries %}
+ {% if locales is empty %}
+ {% set locales = locale_menu %}
+ {% endif %}
+
+ {% for locale in locales %}
{{ locale|language_name }} ({{ locale|upper }})
{% endfor %}
-
\ No newline at end of file
+
diff --git a/templates/base.html.twig b/templates/base.html.twig
index ee79549b..58cccec5 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -66,12 +66,6 @@
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{{ encore_entry_script_tags('webauthn_tfa') }}
-
- {# load translation files for ckeditor #}
- {% set two_chars_locale = app.request.locale|default("en")|slice(0,2) %}
- {% if two_chars_locale != "en" %}
-
- {% endif %}
{% endblock %}
diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig
index 009f815e..d7873498 100644
--- a/templates/components/datatables.macro.html.twig
+++ b/templates/components/datatables.macro.html.twig
@@ -30,8 +30,6 @@
- {# #}
-
@@ -41,7 +39,7 @@
diff --git a/templates/form/permission_layout.html.twig b/templates/form/permission_layout.html.twig
index 166147b4..747208dd 100644
--- a/templates/form/permission_layout.html.twig
+++ b/templates/form/permission_layout.html.twig
@@ -70,18 +70,20 @@
{% endif %}
{% if show_presets %}
+ {# This hidden field is there to ensure that none of the presets is submitted, if a user presses enter #}
+
-
-
-
+
+
+
-
-
+
+
@@ -110,4 +112,4 @@
{% endfor %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/templates/helper.twig b/templates/helper.twig
index bd1d2aa7..66268a96 100644
--- a/templates/helper.twig
+++ b/templates/helper.twig
@@ -214,11 +214,11 @@
{% endmacro %}
{% macro parameters_table(parameters) %}
-
+
{% trans %}specifications.property{% endtrans %}
-
{% trans %}specifications.symbol{% endtrans %}
+
{% trans %}specifications.symbol{% endtrans %}
{% trans %}specifications.value{% endtrans %}
@@ -240,4 +240,4 @@
{% else %}
{{ datetime|format_datetime }}
{% endif %}
-{% endmacro %}
\ No newline at end of file
+{% endmacro %}
diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig
new file mode 100644
index 00000000..9bbed906
--- /dev/null
+++ b/templates/info_providers/bulk_import/manage.html.twig
@@ -0,0 +1,124 @@
+{% extends "main_card.html.twig" %}
+
+{% block title %}
+ {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}
+{% endblock %}
+
+{% block card_title %}
+ {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}
+{% endblock %}
+
+{% block card_content %}
+
+
+
+
+
+ {% trans %}info_providers.bulk_import.manage_jobs_description{% endtrans %}
+
+
+
+ {% if jobs is not empty %}
+
+
+
+
+
{% trans %}info_providers.bulk_import.job_name{% endtrans %}
+
{% trans %}info_providers.bulk_import.parts_count{% endtrans %}
+
{% trans %}info_providers.bulk_import.results_count{% endtrans %}
+
{% trans %}info_providers.bulk_import.progress{% endtrans %}
+
{% trans %}info_providers.bulk_import.status{% endtrans %}
+
{% trans %}info_providers.bulk_import.created_by{% endtrans %}
+
{% trans %}info_providers.bulk_import.created_at{% endtrans %}
+
{% trans %}info_providers.bulk_import.completed_at{% endtrans %}
+
{% trans %}info_providers.bulk_import.action.label{% endtrans %}
+
+
+
+ {% for job in jobs %}
+
+
+ {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
+ {% if job.isInProgress %}
+ Active
+ {% endif %}
+
+
{{ job.partCount }}
+
{{ job.resultCount }}
+
+
+
+
+
+
+ {{ job.progressPercentage }}%
+
+
+ {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
+
+
+
+ {% if job.isPending %}
+ {% trans %}info_providers.bulk_import.status.pending{% endtrans %}
+ {% elseif job.isInProgress %}
+ {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}
+ {% elseif job.isCompleted %}
+ {% trans %}info_providers.bulk_import.status.completed{% endtrans %}
+ {% elseif job.isStopped %}
+ {% trans %}info_providers.bulk_import.status.stopped{% endtrans %}
+ {% elseif job.isFailed %}
+ {% trans %}info_providers.bulk_import.status.failed{% endtrans %}
+ {% endif %}
+
{% trans %}info_providers.table.provider.label{% endtrans %}
+
{% trans %}info_providers.bulk_import.source_field{% endtrans %}
+
{% trans %}info_providers.bulk_import.action.label{% endtrans %}
+
+
+
+ {% for result in part_result.searchResults %}
+ {# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #}
+ {% set dto = result.searchResult %}
+ {% set localPart = result.localPart %}
+
+
+
+
+
+ {% if dto.provider_url is not null %}
+ {{ dto.name }}
+ {% else %}
+ {{ dto.name }}
+ {% endif %}
+ {% if dto.mpn is not null %}
+ {{ dto.mpn }}
+ {% endif %}
+