Merge tag 'v2.2.1' into Buerklin-provider

This commit is contained in:
Marc Kreidler 2025-12-06 15:10:53 +01:00
commit 7de735eb1e
191 changed files with 27939 additions and 2310 deletions

View file

@ -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'

View file

@ -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'

3
.gitignore vendored
View file

@ -48,3 +48,6 @@ yarn-error.log
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.claude/
CLAUDE.md

91
Makefile Normal file
View file

@ -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!"

View file

@ -1 +1 @@
2.1.2
2.2.1

View file

@ -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],

View file

@ -17,15 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// 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',
} );
});

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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',
} );

View file

@ -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 = `
<i class="fas fa-check-circle"></i>
Job completed! All parts have been processed.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
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 = `
<div class="alert ${alertClass} alert-dismissible fade show position-fixed"
style="top: 20px; right: 20px; z-index: 9999; max-width: 400px;"
id="${alertId}">
<i class="fas ${iconClass} me-2"></i>
${message}
<button type="button" class="btn-close" onclick="this.parentElement.remove()" aria-label="Close"></button>
</div>
`
// 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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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 '<span>' + escape(data.label) + '</span>';

View file

@ -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)

View file

@ -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) => {

View file

@ -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),

View file

@ -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'],
});
}

View file

@ -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',

View file

@ -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},

View file

@ -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) {

View file

@ -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 = `
<td>${fieldWidget ? fieldWidget.outerHTML : ''}</td>
<td>${providerWidget ? providerWidget.outerHTML : ''}</td>
<td>${priorityWidget ? priorityWidget.outerHTML : ''}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" data-action="click->field-mapping#removeMapping">
<i class="fas fa-trash"></i>
</button>
</td>
`
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
}
})
}
}
}

View file

@ -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
{

View file

@ -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": "*"

1618
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* 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);
}
};

View file

@ -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).

View file

@ -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

View file

@ -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;<b>HTML</b>;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
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

1 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 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
2 BC547 MLCC; 0603; 0.22uF NPN transistor Multilayer ceramic capacitor Transistors -> NPN Electrical Components->Passive Components->Capacitors_SMD very important notes High quality MLCC TO -> TO-92 0603 NPN,Transistor Capacitor,SMD,MLCC,0603 5 500 Room 1 -> Shelf 1 -> Box 2 Room 1->Shelf 1->Box 2 10 0.1 CL10B224KO8NNNC CL10B224KO8NNNC Manufacturer active Samsung You need to fill this line, to use spn and price LCSC BC547C C160828 2,3 0.0023 0 0 1 pcs C 0.22uF 1 0 0 0 Device:C Capacitor_SMD:C_0603_1608Metric
3 BC557 MLCC; 0402; 10pF PNP transistor Small MLCC for high frequency <b>HTML</b> Electrical Components->Passive Components->Capacitors_SMD TO -> TO-92 0402 PNP,Transistor Capacitor,SMD,MLCC,0402 10 500 Room 2-> Box 3 Room 1->Shelf 1->Box 3 0.05 Internal1234 FCC0402N100J500AT FCC0402N100J500AT active Fenghua LCSC C5137557 0.0015 0 1 0 1 pcs active C 10pF 1 0 0 0 Device:C Capacitor_SMD:C_0402_1005Metric
4 Copper Wire Diode; 1N4148W Fast switching diode Wire 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 Meter pcs D 1N4148W 1 0 0 0 Device:D Diode_SMD:D_SOD-123
5 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
6 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
7 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

View file

@ -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.

View file

@ -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:

View file

@ -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.

View file

@ -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!"

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250802205143 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add bulk info provider import jobs and job parts tables';
}
public function mySQLUp(Schema $schema): void
{
$this->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');
}
}

View file

@ -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",

View file

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,004 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,000 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View file

@ -1,131 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="384"
height="448"
viewBox="0 0 384 448"
id="svg7"
sodipodi:docname="file_all.svg"
inkscape:version="0.92.1 r15371">
<metadata
id="metadata13">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs11" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview9"
showgrid="false"
inkscape:zoom="0.52678571"
inkscape:cx="192"
inkscape:cy="192.54785"
inkscape:window-x="1272"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg7" />
<g
id="icomoon-ignore" />
<path
d="M367 95c9.25 9.25 17 27.75 17 41v288c0 13.25-10.75 24-24 24h-336c-13.25 0-24-10.75-24-24v-400c0-13.25 10.75-24 24-24h224c13.25 0 31.75 7.75 41 17zM256 34v94h94c-1.5-4.25-3.75-8.5-5.5-10.25l-78.25-78.25c-1.75-1.75-6-4-10.25-5.5zM352 416v-256h-104c-13.25 0-24-10.75-24-24v-104h-192v384h320z"
id="path5"
style="fill:#1a1a1a" />
<flowRoot
xml:space="preserve"
id="flowRoot3687"
style="fill:#ffffff;fill-opacity:1;stroke:none;font-family:sans-serif;font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;letter-spacing:0px;word-spacing:0px;"><flowRegion
id="flowRegion3689"
style="fill:#ffffff;"><rect
id="rect3691"
width="251.68207"
height="110.74011"
x="69.128677"
y="214.43904"
style="fill:#ffffff;" /></flowRegion><flowPara
id="flowPara3693" /></flowRoot> <g
aria-label="ALL "
transform="matrix(1.7053159,0,0,1.4411413,-124.25849,-88.403923)"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#1a1a1a;fill-opacity:1;stroke:none"
id="flowRoot3699">
<path
d="m 114.24512,247.89827 -6.32813,16.17188 h 6.9375 v 4.64062 H 98.260742 v -4.64062 h 4.031248 l 25.92188,-65.90625 h 5.57812 l 25.92188,65.90625 h 4.03125 v 4.64062 h -20.90625 v -4.64062 h 6.32812 l -6.375,-16.17188 z m 1.82812,-4.64062 h 24.89063 l -12.46875,-31.64063 z"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:96px;font-family:'Lucida Fax';-inkscape-font-specification:'Lucida Fax';text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path40" />
<path
d="m 218.72949,268.71077 h -48.5625 v -4.64062 h 6.9375 v -60.14063 h -6.9375 v -4.59375 h 23.10938 v 4.59375 h -6.32813 v 59.57813 h 26.01563 v -8.67188 h 5.76562 z"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:96px;font-family:'Lucida Fax';-inkscape-font-specification:'Lucida Fax';text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path42" />
<path
d="m 273.66699,268.71077 h -48.5625 v -4.64062 h 6.9375 v -60.14063 h -6.9375 v -4.59375 h 23.10938 v 4.59375 h -6.32813 v 59.57813 h 26.01563 v -8.67188 h 5.76562 z"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:96px;font-family:'Lucida Fax';-inkscape-font-specification:'Lucida Fax';text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path44" />
</g>
<g
aria-label="DATASHEET"
transform="matrix(1.3097344,0,0,1.4436797,-64.263952,-115.73324)"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#1a1a1a;fill-opacity:1;stroke:none"
id="flowRoot3709">
<path
d="m 85.748047,302.43555 v 22.67578 h 4.765625 q 6.035156,0 8.828125,-2.73438 2.812503,-2.73437 2.812503,-8.63281 0,-5.85937 -2.812503,-8.57422 -2.792969,-2.73437 -8.828125,-2.73437 z m -3.945313,-3.24219 h 8.105469 q 8.476563,0 12.441407,3.53516 3.96484,3.51562 3.96484,11.01562 0,7.53906 -3.98437,11.07422 -3.984377,3.53516 -12.421877,3.53516 h -8.105469 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path21" />
<path
d="m 121.62695,303.08008 -5.35156,14.51172 h 10.72266 z m -2.22656,-3.88672 h 4.47266 l 11.11328,29.16016 h -4.10156 l -2.65625,-7.48047 h -13.14454 l -2.65625,7.48047 h -4.16015 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path23" />
<path
d="m 132.05664,299.19336 h 24.66797 v 3.32031 h -10.35156 v 25.83985 h -3.96485 v -25.83985 h -10.35156 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path25" />
<path
d="m 167.17383,303.08008 -5.35156,14.51172 h 10.72265 z m -2.22656,-3.88672 h 4.47265 l 11.11328,29.16016 h -4.10156 l -2.65625,-7.48047 h -13.14453 l -2.65625,7.48047 h -4.16016 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path27" />
<path
d="m 202.25195,300.15039 v 3.84766 q -2.24609,-1.07422 -4.23828,-1.60157 -1.99219,-0.52734 -3.84765,-0.52734 -3.22266,0 -4.98047,1.25 -1.73828,1.25 -1.73828,3.55469 0,1.93359 1.15234,2.92969 1.17187,0.97656 4.41406,1.58203 l 2.38281,0.48828 q 4.41407,0.83984 6.50391,2.96875 2.10938,2.10937 2.10938,5.66406 0,4.23828 -2.85157,6.42578 -2.83203,2.1875 -8.32031,2.1875 -2.07031,0 -4.41406,-0.46875 -2.32422,-0.46875 -4.82422,-1.38672 v -4.0625 q 2.40234,1.34766 4.70703,2.03125 2.30469,0.6836 4.53125,0.6836 3.37891,0 5.21484,-1.32813 1.83594,-1.32812 1.83594,-3.78906 0,-2.14844 -1.32812,-3.35938 -1.3086,-1.21093 -4.31641,-1.8164 l -2.40234,-0.46875 q -4.41407,-0.87891 -6.38672,-2.75391 -1.97266,-1.875 -1.97266,-5.21484 0,-3.86719 2.71485,-6.09375 2.73437,-2.22656 7.51953,-2.22656 2.05078,0 4.17968,0.37109 2.12891,0.37109 4.35547,1.11328 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path29" />
<path
d="m 210.16211,299.19336 h 3.94531 v 11.95312 h 14.33594 v -11.95312 h 3.94531 v 29.16016 h -3.94531 V 314.4668 h -14.33594 v 13.88672 h -3.94531 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path31" />
<path
d="m 240.24023,299.19336 h 18.4375 v 3.32031 h -14.49218 v 8.63281 h 13.88672 v 3.32032 h -13.88672 v 10.5664 h 14.84375 v 3.32032 h -18.78907 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path33" />
<path
d="m 265.55273,299.19336 h 18.4375 v 3.32031 h -14.49218 v 8.63281 h 13.88672 v 3.32032 h -13.88672 v 10.5664 h 14.84375 v 3.32032 h -18.78907 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path35" />
<path
d="m 286.82227,299.19336 h 24.66796 v 3.32031 h -10.35156 v 25.83985 h -3.96484 v -25.83985 h -10.35156 z"
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
id="path37" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="384"
height="448"
viewBox="0 0 384 448"
id="svg7"
sodipodi:docname="file_dc.svg"
inkscape:version="0.92.1 r15371">
<metadata
id="metadata13">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs11" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview9"
showgrid="false"
inkscape:zoom="1.0535715"
inkscape:cx="192"
inkscape:cy="219.39394"
inkscape:window-x="1272"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg7" />
<g
id="icomoon-ignore" />
<path
d="M367 95c9.25 9.25 17 27.75 17 41v288c0 13.25-10.75 24-24 24h-336c-13.25 0-24-10.75-24-24v-400c0-13.25 10.75-24 24-24h224c13.25 0 31.75 7.75 41 17zM256 34v94h94c-1.5-4.25-3.75-8.5-5.5-10.25l-78.25-78.25c-1.75-1.75-6-4-10.25-5.5zM352 416v-256h-104c-13.25 0-24-10.75-24-24v-104h-192v384h320z"
id="path5"
style="fill:#1a1a1a" />
<rect
id="rect3685"
width="289.93774"
height="149.66695"
x="48.32296"
y="188.2641"
style="fill:#1a1a1a" />
<flowRoot
xml:space="preserve"
id="flowRoot3687"
style="fill:#ffffff;fill-opacity:1;stroke:none;font-family:sans-serif;font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;letter-spacing:0px;word-spacing:0px;"><flowRegion
id="flowRegion3689"
style="fill:#ffffff;"><rect
id="rect3691"
width="251.68207"
height="110.74011"
x="69.128677"
y="214.43904"
style="fill:#ffffff;" /></flowRegion><flowPara
id="flowPara3693" /></flowRoot> <text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:191.63136292px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.79078484"
x="33.330128"
y="354.68042"
id="text3697"
transform="scale(1.0793658,0.92646993)"><tspan
sodipodi:role="line"
id="tspan3695"
x="33.330128"
y="354.68042"
style="fill:#ffffff;stroke-width:4.79078484">DC</tspan></text>
</svg>

Before

Width:  |  Height:  |  Size: 3 KiB

View file

@ -1,5 +0,0 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 24 28">
<title>google</title>
<path d="M12 12.281h11.328c0.109 0.609 0.187 1.203 0.187 2 0 6.844-4.594 11.719-11.516 11.719-6.641 0-12-5.359-12-12s5.359-12 12-12c3.234 0 5.953 1.188 8.047 3.141l-3.266 3.141c-0.891-0.859-2.453-1.859-4.781-1.859-4.094 0-7.438 3.391-7.438 7.578s3.344 7.578 7.438 7.578c4.75 0 6.531-3.406 6.813-5.172h-6.813v-4.125z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 485 B

View file

@ -1,5 +0,0 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 24 28">
<title>cog</title>
<path d="M16 14c0-2.203-1.797-4-4-4s-4 1.797-4 4 1.797 4 4 4 4-1.797 4-4zM24 12.297v3.469c0 0.234-0.187 0.516-0.438 0.562l-2.891 0.438c-0.172 0.5-0.359 0.969-0.609 1.422 0.531 0.766 1.094 1.453 1.672 2.156 0.094 0.109 0.156 0.25 0.156 0.391s-0.047 0.25-0.141 0.359c-0.375 0.5-2.484 2.797-3.016 2.797-0.141 0-0.281-0.063-0.406-0.141l-2.156-1.687c-0.453 0.234-0.938 0.438-1.422 0.594-0.109 0.953-0.203 1.969-0.453 2.906-0.063 0.25-0.281 0.438-0.562 0.438h-3.469c-0.281 0-0.531-0.203-0.562-0.469l-0.438-2.875c-0.484-0.156-0.953-0.344-1.406-0.578l-2.203 1.672c-0.109 0.094-0.25 0.141-0.391 0.141s-0.281-0.063-0.391-0.172c-0.828-0.75-1.922-1.719-2.578-2.625-0.078-0.109-0.109-0.234-0.109-0.359 0-0.141 0.047-0.25 0.125-0.359 0.531-0.719 1.109-1.406 1.641-2.141-0.266-0.5-0.484-1.016-0.641-1.547l-2.859-0.422c-0.266-0.047-0.453-0.297-0.453-0.562v-3.469c0-0.234 0.187-0.516 0.422-0.562l2.906-0.438c0.156-0.5 0.359-0.969 0.609-1.437-0.531-0.75-1.094-1.453-1.672-2.156-0.094-0.109-0.156-0.234-0.156-0.375s0.063-0.25 0.141-0.359c0.375-0.516 2.484-2.797 3.016-2.797 0.141 0 0.281 0.063 0.406 0.156l2.156 1.672c0.453-0.234 0.938-0.438 1.422-0.594 0.109-0.953 0.203-1.969 0.453-2.906 0.063-0.25 0.281-0.438 0.562-0.438h3.469c0.281 0 0.531 0.203 0.562 0.469l0.438 2.875c0.484 0.156 0.953 0.344 1.406 0.578l2.219-1.672c0.094-0.094 0.234-0.141 0.375-0.141s0.281 0.063 0.391 0.156c0.828 0.766 1.922 1.734 2.578 2.656 0.078 0.094 0.109 0.219 0.109 0.344 0 0.141-0.047 0.25-0.125 0.359-0.531 0.719-1.109 1.406-1.641 2.141 0.266 0.5 0.484 1.016 0.641 1.531l2.859 0.438c0.266 0.047 0.453 0.297 0.453 0.562z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,98 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="384"
height="448"
viewBox="0 0 384 448"
id="svg7"
sodipodi:docname="file_reichelt.svg"
inkscape:version="0.92.1 r15371">
<metadata
id="metadata13">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs11" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview9"
showgrid="false"
inkscape:zoom="0.74498751"
inkscape:cx="192"
inkscape:cy="218.60367"
inkscape:window-x="1272"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg7" />
<g
id="icomoon-ignore" />
<path
d="M367 95c9.25 9.25 17 27.75 17 41v288c0 13.25-10.75 24-24 24h-336c-13.25 0-24-10.75-24-24v-400c0-13.25 10.75-24 24-24h224c13.25 0 31.75 7.75 41 17zM256 34v94h94c-1.5-4.25-3.75-8.5-5.5-10.25l-78.25-78.25c-1.75-1.75-6-4-10.25-5.5zM352 416v-256h-104c-13.25 0-24-10.75-24-24v-104h-192v384h320z"
id="path5"
style="fill:#1a1a1a" />
<flowRoot
xml:space="preserve"
id="flowRoot3687"
style="fill:#ffffff;fill-opacity:1;stroke:none;font-family:sans-serif;font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;letter-spacing:0px;word-spacing:0px;"><flowRegion
id="flowRegion3689"
style="fill:#ffffff;"><rect
id="rect3691"
width="251.68207"
height="110.74011"
x="69.128677"
y="214.43904"
style="fill:#ffffff;" /></flowRegion><flowPara
id="flowPara3693" /></flowRoot> <rect
style="fill:#666666;stroke-width:1.27060354"
id="rect3719"
width="150"
height="150"
x="98.65937"
y="204.70981" />
<rect
style="fill:#1a1a1a;stroke-width:1.30443311"
id="rect3717"
width="150"
height="150"
x="130.20366"
y="175.17915" />
<rect
style="fill:#ffffff;stroke-width:1.07838833"
id="rect3721"
width="39.59798"
height="138.9285"
x="153.02271"
y="198.33139" />
<circle
style="fill:#ffffff;stroke-width:1.69401228"
id="path3723"
cx="227.01927"
cy="214.12033"
r="30.69738" />
</svg>

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

View file

@ -0,0 +1,588 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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()
]
);
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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'));
}, [

View file

@ -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');

View file

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

View file

@ -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;
}
/**

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\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() . ')');
}
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\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);
}
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\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);
}
}
}

View file

@ -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();

View file

@ -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) {

View file

@ -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');

View file

@ -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

View file

@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 . '%');
}
}

View file

@ -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;
}

View file

@ -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 . ')';
}
}
}

View file

@ -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)]

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\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';
}

View file

@ -0,0 +1,32 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\InfoProviderSystem;
enum BulkImportPartStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case SKIPPED = 'skipped';
case FAILED = 'failed';
}

View file

@ -0,0 +1,449 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<int, BulkInfoProviderImportJobPart> */
#[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;
}
}

View file

@ -0,0 +1,182 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\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;
}
}

View file

@ -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,
};
}

View file

@ -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<int, BulkInfoProviderImportJobPart>
*/
#[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<int, BulkInfoProviderImportJobPart>
*/
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;
}
}

View file

@ -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 = '';

View file

@ -15,7 +15,7 @@ trait ProjectTrait
/**
* @var Collection<ProjectBOMEntry> $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;
/**

View file

@ -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 = '';

View file

@ -0,0 +1,59 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\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);
}
}
}

View file

@ -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) {

View file

@ -0,0 +1,48 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\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;
}
}

View file

@ -59,6 +59,8 @@ class ImportType extends AbstractType
'XML' => 'xml',
'CSV' => 'csv',
'YAML' => 'yaml',
'XLSX' => 'xlsx',
'XLS' => 'xls',
],
'label' => 'export.format',
'disabled' => $disabled,

View file

@ -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,
]);

View file

@ -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',

Some files were not shown because too many files have changed in this diff Show more