Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
600686c32b | ||
|
|
2e3ff05d83 | ||
|
|
e9dcdbc30d | ||
|
|
6cbb482c0f | ||
|
|
68aafc4d2e | ||
|
|
70354c8599 | ||
|
|
43601e060c | ||
|
|
56f82a7587 | ||
|
|
4c30cab7c1 | ||
|
|
1c8ca6c0a2 | ||
|
|
5dbe4ba00b | ||
|
|
377feaf566 | ||
|
|
05839a549c | ||
|
|
7a1a458abe | ||
|
|
c71e4cd063 | ||
|
|
d8e093e0c5 | ||
|
|
351e084ab1 | ||
|
|
bba6fff4a5 | ||
|
|
a8e92b5f46 | ||
|
|
8315e33258 | ||
|
|
3881c26ee0 | ||
|
|
028c64f6ec | ||
|
|
4088b141a6 | ||
|
|
445881bae9 | ||
|
|
b3f7e445fe | ||
|
|
1f2a7b86e5 | ||
|
|
ae787530ff | ||
|
|
6e4ae15438 | ||
|
|
e06d9da186 | ||
|
|
b035014867 | ||
|
|
746aa53bc3 | ||
|
|
7b61a00f21 | ||
|
|
aa24888ee5 | ||
|
|
c735bfdb1d | ||
|
|
41dbc27e27 | ||
|
|
4d98605e93 | ||
|
|
07166037b9 | ||
|
|
e1418dfdc1 | ||
|
|
ab92620f56 | ||
|
|
0a4b873b77 | ||
|
|
23bafa4471 | ||
|
|
436d3df83f | ||
|
|
37393dd6c9 | ||
|
|
8c15af3105 | ||
|
|
0ac1d19415 | ||
|
|
63a33d1057 | ||
|
|
a9d0caad5f | ||
|
|
6ed4ad4c8c | ||
|
|
71946afd75 | ||
|
|
919bf49ec1 | ||
|
|
001f2e97ea | ||
|
|
d2d5490aab | ||
|
|
c788fa99e3 | ||
|
|
34d284b1c4 | ||
|
|
67c736f979 | ||
|
|
6b1e7b3544 | ||
|
|
da30a6657e | ||
|
|
2e0b5edd95 | ||
|
|
1d6f0b403a | ||
|
|
df65f39d5e | ||
|
|
1bfea3c48a | ||
|
|
07db1554c7 | ||
|
|
ed1e51f694 | ||
|
|
5b71d68179 | ||
|
|
b94e28a961 | ||
|
|
1d52b7c464 | ||
|
|
0d49632b92 | ||
|
|
702e5c8732 | ||
|
|
d2b605edc0 | ||
|
|
4c28871283 | ||
|
|
1d38c50abc | ||
|
|
710569daaf | ||
|
|
92cd645945 | ||
|
|
16126c4000 | ||
|
|
eda6deff47 | ||
|
|
27a18bdc1e | ||
|
|
98b62cc81e | ||
|
|
2c195d9767 | ||
|
|
bb49c67108 | ||
|
|
f0dc80aac9 | ||
|
|
8998b006e0 | ||
|
|
b4b758c356 | ||
|
|
a399b629d1 | ||
|
|
41a7238ab7 | ||
|
|
0e99faee0a | ||
|
|
13e75808f8 | ||
|
|
1a0fab0615 | ||
|
|
fcdeb0479a | ||
|
|
79ac318d0f | ||
|
|
6765c110c6 | ||
|
|
f6f83cc111 | ||
|
|
c6d5fb3f57 | ||
|
|
4b8ef4b0fa | ||
|
|
46d8c86e0c | ||
|
|
c7102bcd8c | ||
|
|
d6ac16ede0 | ||
|
|
23cad8261b | ||
|
|
65d840c444 | ||
|
|
c52126ccf8 | ||
|
|
8eec606589 | ||
|
|
cdc58507db | ||
|
|
03f7ad66d2 | ||
|
|
3b01af1247 | ||
|
|
8d2ff6f5d7 | ||
|
|
6ff7f64384 | ||
|
|
c2cbbee0df | ||
|
|
e81c8470be | ||
|
|
ecd2abe00e | ||
|
|
0d1ae030be | ||
|
|
1f669a9c53 | ||
|
|
8ff2fc5a82 | ||
|
|
c7ec8adc31 | ||
|
|
cee6d355e8 | ||
|
|
4b00697f02 | ||
|
|
617ae03b48 | ||
|
|
71629a696c | ||
|
|
14cc0b9e9a | ||
|
|
c5a1df37b9 | ||
|
|
46d1a0cb1b | ||
|
|
a18ec373d2 | ||
|
|
ced16620ec | ||
|
|
890621b651 | ||
|
|
5a5691a8c4 | ||
|
|
fb92db8c05 | ||
|
|
2b28aa8ba9 | ||
|
|
90f83273da | ||
|
|
76f3c379b5 | ||
|
|
1d33d95c57 | ||
|
|
72e3766be5 | ||
|
|
7c1ab6460d | ||
|
|
d0f2422e0d | ||
|
|
4277f42285 | ||
|
|
0e9558e331 | ||
|
|
4e9e82d9f1 | ||
|
|
411ac500ba | ||
|
|
b1443a817b | ||
|
|
3e8ca06177 | ||
|
|
c1b7272ab1 | ||
|
|
b093866d15 | ||
|
|
065ef9f8ae | ||
|
|
9b17efc12c | ||
|
|
fe7910a2f2 | ||
|
|
eb4258053e | ||
|
|
117ff4484d | ||
|
|
ba7d139f8a | ||
|
|
d657b2ff04 | ||
|
|
0637c05053 | ||
|
|
88fbc46325 | ||
|
|
379155e839 | ||
|
|
0717239296 | ||
|
|
d3e3c4e3f8 | ||
|
|
c9a1febc56 | ||
|
|
7f099972e1 | ||
|
|
52444e05e4 | ||
|
|
4fcd55748f | ||
|
|
d57107ed3e | ||
|
|
0c7aa5e92a | ||
|
|
17f123ba8a | ||
|
|
1156bb52af | ||
|
|
71be75b3e7 | ||
|
|
5a4f151ca3 | ||
|
|
9729a43f2b | ||
|
|
4da403569c | ||
|
|
74be016b68 | ||
|
|
3896d3d9ab | ||
|
|
ed396765c8 | ||
|
|
cc9d50a8fe | ||
|
|
9b4d5e9c27 | ||
|
|
ccb837e4b4 | ||
|
|
2bc39e7791 | ||
|
|
fa7f3a1da1 | ||
|
|
c91d37d2a4 | ||
|
|
5ab7ac4d4b | ||
|
|
4c8940f9c3 | ||
|
|
aa29f10d51 | ||
|
|
78885ec3c5 | ||
|
|
1fb137e89f | ||
|
|
facfb37383 | ||
|
|
c5751b2aa6 | ||
|
|
aa4299041b | ||
|
|
c27f2246a3 | ||
|
|
a6be786d5d | ||
|
|
578a030175 | ||
|
|
f858e68f12 | ||
|
|
bdd88700d4 | ||
|
|
87cf75f67d | ||
|
|
c3cc7cb0d6 | ||
|
|
e1600cdec9 | ||
|
|
431cf23600 | ||
|
|
cc70e77dee | ||
|
|
b19cc13897 | ||
|
|
50f478f7ef |
@@ -20,25 +20,6 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Pass all environment variables to PHP-FPM
|
||||
# Path where PHP-FPM pool configs live
|
||||
PHP_FPM_ENV_CONF="/etc/php/PHP_VERSION/fpm/pool.d/99-env.conf"
|
||||
|
||||
# start fresh
|
||||
echo "; auto-generated env config" > "$PHP_FPM_ENV_CONF"
|
||||
echo "[www]" >> "$PHP_FPM_ENV_CONF"
|
||||
echo "clear_env = no" >> "$PHP_FPM_ENV_CONF"
|
||||
|
||||
# add all container envs
|
||||
printenv | while IFS='=' read -r name value; do
|
||||
case "$name" in
|
||||
HOSTNAME|PWD|SHLVL|PATH|_*) continue ;;
|
||||
esac
|
||||
# write literal value in quotes
|
||||
echo "env[$name] = \"$value\"" >> "$PHP_FPM_ENV_CONF"
|
||||
done
|
||||
|
||||
|
||||
# recursive chowns can take a while, so we'll just do it if the owner is wrong
|
||||
|
||||
# Chown uploads/ folder if it does not belong to www-data
|
||||
@@ -59,7 +40,7 @@ if [ -d /var/www/html/var/db ]; then
|
||||
fi
|
||||
|
||||
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
|
||||
service phpPHP_VERSION-fpm start
|
||||
php-fpmPHP_VERSION -F &
|
||||
|
||||
|
||||
# Run migrations if automigration is enabled via env variable DB_AUTOMIGRATE
|
||||
|
||||
2
.github/workflows/assets_artifact_build.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
@@ -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
@@ -48,3 +48,6 @@ yarn-error.log
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
91
Makefile
Normal 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!"
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||
[](https://codecov.io/gh/Part-DB/Part-DB-server)
|
||||

|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
} );
|
||||
});
|
||||
|
||||
84
assets/ckeditor/plugins/PartDBLabel/lang/en.js
Normal 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',
|
||||
} );
|
||||
359
assets/controllers/bulk_import_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
92
assets/controllers/bulk_job_manage_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,16 @@ export default class MarkdownController extends Controller {
|
||||
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
|
||||
|
||||
for(let a of this.element.querySelectorAll('a')) {
|
||||
//Mark all links as external
|
||||
a.classList.add('link-external');
|
||||
//Open links in new tag
|
||||
a.setAttribute('target', '_blank');
|
||||
//Dont track
|
||||
a.setAttribute('rel', 'noopener');
|
||||
// test if link is absolute
|
||||
var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
|
||||
if (r.test(a.getAttribute('href'))) {
|
||||
//Mark all links as external
|
||||
a.classList.add('link-external');
|
||||
//Open links in new tag
|
||||
a.setAttribute('target', '_blank');
|
||||
//Dont track
|
||||
a.setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
//Apply bootstrap styles to tables
|
||||
@@ -108,4 +112,4 @@ export default class MarkdownController extends Controller {
|
||||
gfm: true,
|
||||
});
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +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: dropdownParent,
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
return '<span>' + escape(data.label) + '</span>';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -45,8 +45,10 @@ export default class extends DatatablesController {
|
||||
//Hide/Unhide panel with the selection tools
|
||||
if (count > 0) {
|
||||
selectPanel.classList.remove('d-none');
|
||||
selectPanel.classList.add('sticky-select-bar');
|
||||
} else {
|
||||
selectPanel.classList.add('d-none');
|
||||
selectPanel.classList.remove('sticky-select-bar');
|
||||
}
|
||||
|
||||
//Update selection count text
|
||||
|
||||
@@ -10,12 +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: dropdownParent,
|
||||
preload: "focus",
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
@@ -71,4 +78,4 @@ export default class extends Controller {
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +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: dropdownParent,
|
||||
|
||||
render: {
|
||||
item: this.renderItem.bind(this),
|
||||
@@ -108,4 +113,4 @@ export default class extends Controller {
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +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: dropdownParent,
|
||||
plugins: ['remove_button'],
|
||||
});
|
||||
}
|
||||
@@ -39,4 +45,4 @@ export default class extends Controller {
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +55,7 @@ export default class extends Controller {
|
||||
valueField: 'text',
|
||||
searchField: 'text',
|
||||
orderField: 'text',
|
||||
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',
|
||||
|
||||
@@ -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,6 +57,7 @@ export default class extends Controller {
|
||||
maxItems: 1,
|
||||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||
splitOn: null,
|
||||
dropdownParent: dropdownParent,
|
||||
|
||||
searchField: [
|
||||
{field: "text", weight : 2},
|
||||
|
||||
@@ -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,6 +48,7 @@ export default class extends Controller {
|
||||
selectOnTab: true,
|
||||
createOnBlur: true,
|
||||
create: true,
|
||||
dropdownParent: dropdownParent,
|
||||
};
|
||||
|
||||
if(this.element.dataset.autocomplete) {
|
||||
@@ -73,4 +79,4 @@ export default class extends Controller {
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
assets/controllers/field_mapping_controller.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
.hoverpic {
|
||||
min-width: 10px;
|
||||
max-width: 30px;
|
||||
min-width: var(--table-image-preview-min-size, 20px);
|
||||
max-width: var(--table-image-preview-max-size, 35px);
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
.part-table-image {
|
||||
max-height: 40px;
|
||||
max-height: calc(1.2*var(--table-image-preview-max-size, 35px)); /** Aspect ratio of maximum 1.2 */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/****************************************
|
||||
* Action bar
|
||||
****************************************/
|
||||
|
||||
.sticky-select-bar {
|
||||
position: sticky;
|
||||
top: 120px;
|
||||
z-index: 1000; /* Ensure the bar is above other content */
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* Tables
|
||||
****************************************/
|
||||
@@ -84,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
|
||||
{
|
||||
@@ -109,4 +124,4 @@ Classes for Datatables export
|
||||
#export-messageTop,
|
||||
.export-helper{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-active-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-disabled-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-color: var(--bs-primary)
|
||||
--ck-color-button-on-color: var(--bs-primary);
|
||||
|
||||
}
|
||||
--ck-content-font-color: var(--ck-color-base-text);
|
||||
|
||||
}
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.0",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||
"doctrine/orm": "^3.2.0",
|
||||
"dompdf/dompdf": "^v3.0.0",
|
||||
"florianv/swap": "^4.0",
|
||||
"florianv/swap-bundle": "dev-master",
|
||||
"dompdf/dompdf": "^3.1.2",
|
||||
"gregwar/captcha-bundle": "^2.1.0",
|
||||
"hshn/base64-encoded-file": "^5.0",
|
||||
"jbtronics/2fa-webauthn": "^3.0.0",
|
||||
@@ -38,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",
|
||||
@@ -46,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",
|
||||
@@ -158,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": "*"
|
||||
|
||||
2153
composer.lock
generated
33
config/packages/doctrine.php
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -69,6 +69,7 @@ when@docker:
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
include_stacktraces: true
|
||||
channels: ["!deprecation"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "php://stderr"
|
||||
|
||||
@@ -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).
|
||||
@@ -69,9 +63,3 @@ nelmio_security:
|
||||
- 'data:'
|
||||
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
|
||||
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
|
||||
|
||||
when@dev:
|
||||
# disables the Content-Security-Policy header
|
||||
nelmio_security:
|
||||
csp:
|
||||
enabled: false
|
||||
@@ -5,4 +5,11 @@ jbtronics_settings:
|
||||
default_cacheable: true
|
||||
|
||||
orm_storage:
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
|
||||
|
||||
# Disable caching for development environment
|
||||
when@dev:
|
||||
jbtronics_settings:
|
||||
cache:
|
||||
default_cacheable: false
|
||||
|
||||
@@ -5,6 +5,12 @@ florianv_swap:
|
||||
|
||||
providers:
|
||||
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
|
||||
fixer: # Fixer.io (needs an API key)
|
||||
access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%"
|
||||
#exchange_rates_api: ~
|
||||
central_bank_of_czech_republic: ~
|
||||
central_bank_of_republic_turkey: ~
|
||||
national_bank_of_romania: ~
|
||||
|
||||
fixer: # Fixer.io (needs an API key)
|
||||
access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
|
||||
|
||||
frankfurter: ~
|
||||
fawazahmed_currency_api: ~
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||
label: "perm.revert_elements"
|
||||
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
import:
|
||||
label: "perm.import"
|
||||
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
|
||||
api:
|
||||
label: "perm.api"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -28,9 +28,14 @@ It is recommended to install Part-DB on a 64-bit system, as the 32-bit version o
|
||||
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
|
||||
|
||||
```bash
|
||||
sudo apt install git curl zip ca-certificates software-properties-common apt-transport-https lsb-release nano wget
|
||||
sudo apt update && apt upgrade
|
||||
sudo apt install git curl zip ca-certificates software-properties-common \
|
||||
apt-transport-https lsb-release nano wget sqlite3
|
||||
```
|
||||
|
||||
Please run `sqlite3 --version` to assert that the SQLite version is 3.35 or higher.
|
||||
Otherwise some database migrations will not succeed.
|
||||
|
||||
### Install PHP and apache2
|
||||
|
||||
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -34,3 +34,12 @@ select the BOM file you want to import and some options for the import process:
|
||||
has a different format and does not work with this type.
|
||||
You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save
|
||||
the file to your desired location.
|
||||
* **KiCAD Schematic BOM (CSV file)**: A CSV file of the Bill of Material (BOM) generated
|
||||
by [KiCAD Eeschema](https://www.kicad.org/).
|
||||
You can generate this BOM file by going to "Tools" -> "Generate Bill of Materials" in Eeschema and save the file to your
|
||||
desired location. In the next step you can customize the mapping of the fields in Part-DB, if you have any special fields
|
||||
in your BOM to locate your fields correctly.
|
||||
* **Generic CSV file**: A generic CSV file. You can use this option if you use some different ECAD software or wanna create
|
||||
your own CSV file. You will need to specify at least the designators, quantity and value fields in the CSV. In the next
|
||||
step you can customize the mapping of the fields in Part-DB, if you have any special fields in your BOM to locate your
|
||||
parts correctly.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
91
makefile
Normal 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!"
|
||||
70
migrations/Version20250802205143.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 482 B |
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 352 B |
|
Before Width: | Height: | Size: 364 B |
|
Before Width: | Height: | Size: 489 B |
|
Before Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 477 B |
|
Before Width: | Height: | Size: 346 B |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 779 B |
|
Before Width: | Height: | Size: 1004 B |
|
Before Width: | Height: | Size: 645 B |
|
Before Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 362 B |
|
Before Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 134 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1000 B |
|
Before Width: | Height: | Size: 96 B |
|
Before Width: | Height: | Size: 23 KiB |
@@ -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 |
@@ -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.0 KiB |
@@ -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 |
@@ -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 |
@@ -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.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 606 B |
@@ -24,6 +24,7 @@ namespace App\Controller;
|
||||
|
||||
use App\DataTables\AttachmentDataTable;
|
||||
use App\DataTables\Filters\AttachmentFilter;
|
||||
use App\DataTables\PartsDataTable;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Form\Filters\AttachmentFilterType;
|
||||
use App\Services\Attachments\AttachmentManager;
|
||||
@@ -112,7 +113,7 @@ class AttachmentFileController extends AbstractController
|
||||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
|
||||
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize])
|
||||
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
||||
588
src/Controller/BulkInfoProviderImportController.php
Normal 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()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,13 @@ 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;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
@@ -113,7 +115,7 @@ class InfoProviderController extends AbstractController
|
||||
|
||||
#[Route('/search', name: 'info_providers_search')]
|
||||
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
@@ -144,6 +146,23 @@ class InfoProviderController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
//If the providers form is still empty, use our default value from the settings
|
||||
if (count($form->get('providers')->getData() ?? []) === 0) {
|
||||
$default_providers = $infoProviderSettings->defaultSearchProviders;
|
||||
$provider_objects = [];
|
||||
foreach ($default_providers as $provider_key) {
|
||||
try {
|
||||
$tmp = $this->providerRegistry->getProviderByKey($provider_key);
|
||||
if ($tmp->isActive()) {
|
||||
$provider_objects[] = $tmp;
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
//If the provider is not found, just ignore it
|
||||
}
|
||||
}
|
||||
$form->get('providers')->setData($provider_objects);
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$keyword = $form->get('keyword')->getData();
|
||||
$providers = $form->get('providers')->getData();
|
||||
@@ -157,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
|
||||
|
||||
@@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[Route(path: '/label')]
|
||||
class LabelController extends AbstractController
|
||||
{
|
||||
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
|
||||
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
|
||||
private readonly ValidatorInterface $validator
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -85,6 +88,7 @@ class LabelController extends AbstractController
|
||||
|
||||
$form = $this->createForm(LabelDialogType::class, null, [
|
||||
'disable_options' => $disable_options,
|
||||
'profile' => $profile
|
||||
]);
|
||||
|
||||
//Try to parse given target_type and target_id
|
||||
@@ -120,13 +124,50 @@ class LabelController extends AbstractController
|
||||
goto render;
|
||||
}
|
||||
|
||||
$profile = new LabelProfile();
|
||||
$profile->setName($form->get('save_profile_name')->getData());
|
||||
$profile->setOptions($form_options);
|
||||
$this->em->persist($profile);
|
||||
$new_profile = new LabelProfile();
|
||||
$new_profile->setName($form->get('save_profile_name')->getData());
|
||||
$new_profile->setOptions($form_options);
|
||||
|
||||
//Validate the profile name
|
||||
$errors = $this->validator->validate($new_profile);
|
||||
if (count($errors) > 0) {
|
||||
foreach ($errors as $error) {
|
||||
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
|
||||
}
|
||||
goto render;
|
||||
}
|
||||
|
||||
$this->em->persist($new_profile);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'label_generator.profile_saved');
|
||||
|
||||
return $this->redirectToRoute('label_dialog_profile', [
|
||||
'profile' => $new_profile->getID(),
|
||||
'target_id' => (string) $form->get('target_id')->getData()
|
||||
]);
|
||||
}
|
||||
|
||||
//Check if the current profile should be updated
|
||||
if ($form->has('update_profile')
|
||||
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
|
||||
&& $profile instanceof LabelProfile
|
||||
&& $this->isGranted('edit', $profile)) {
|
||||
//Update the profile options
|
||||
$profile->setOptions($form_options);
|
||||
|
||||
//Validate the profile name
|
||||
$errors = $this->validator->validate($profile);
|
||||
if (count($errors) > 0) {
|
||||
foreach ($errors as $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
goto render;
|
||||
}
|
||||
|
||||
$this->em->persist($profile);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'label_generator.profile_updated');
|
||||
|
||||
return $this->redirectToRoute('label_dialog_profile', [
|
||||
'profile' => $profile->getID(),
|
||||
'target_id' => (string) $form->get('target_id')->getData()
|
||||
|
||||
@@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
|
||||
use App\Services\Parts\PartLotWithdrawAddHelper;
|
||||
use App\Services\Parts\PricedetailHelper;
|
||||
use App\Services\ProjectSystem\ProjectBuildPartHelper;
|
||||
use App\Settings\BehaviorSettings\PartInfoSettings;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
@@ -63,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 AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EventCommentHelper $commentHelper,
|
||||
private readonly PartInfoSettings $partInfoSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,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;
|
||||
@@ -119,8 +130,8 @@ class PartController extends AbstractController
|
||||
'pricedetail_helper' => $this->pricedetailHelper,
|
||||
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
|
||||
'timeTravel' => $timeTravel_timestamp,
|
||||
'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
|
||||
'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
|
||||
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
|
||||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
]
|
||||
);
|
||||
@@ -131,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'])]
|
||||
@@ -139,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));
|
||||
|
||||
@@ -158,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
|
||||
@@ -257,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');
|
||||
|
||||
@@ -273,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
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -311,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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -352,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()]);
|
||||
}
|
||||
|
||||
@@ -370,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')
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -386,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!");
|
||||
}
|
||||
@@ -410,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!");
|
||||
}
|
||||
|
||||
@@ -459,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
|
||||
|
||||
@@ -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,14 +165,21 @@ 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), ['pageLength' => $this->tableSettings->fullDefaultPageSize])
|
||||
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
|
||||
['filter' => $filter], $additional_table_vars),
|
||||
['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
@@ -184,7 +202,7 @@ class PartListsController extends AbstractController
|
||||
|
||||
return $this->render($template, array_merge([
|
||||
'datatable' => $table,
|
||||
'filterForm' => $filterForm->createView(),
|
||||
'filterForm' => $filterForm?->createView(),
|
||||
], $additonal_template_vars));
|
||||
}
|
||||
|
||||
@@ -196,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'));
|
||||
}, [
|
||||
@@ -214,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'));
|
||||
}, [
|
||||
@@ -232,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'));
|
||||
}, [
|
||||
@@ -250,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'));
|
||||
}, [
|
||||
@@ -268,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'));
|
||||
}, [
|
||||
|
||||
@@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Csv\SyntaxError;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
@@ -102,9 +103,14 @@ class ProjectController extends AbstractController
|
||||
$this->addFlash('success', 'project.build.flash.success');
|
||||
|
||||
return $this->redirect(
|
||||
$request->get('_redirect',
|
||||
$this->generateUrl('project_info', ['id' => $project->getID()]
|
||||
)));
|
||||
$request->get(
|
||||
'_redirect',
|
||||
$this->generateUrl(
|
||||
'project_info',
|
||||
['id' => $project->getID()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash('error', 'project.build.flash.invalid_input');
|
||||
@@ -120,9 +126,13 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
|
||||
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
|
||||
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
|
||||
{
|
||||
public function importBOM(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
Project $project,
|
||||
BOMImporter $BOMImporter,
|
||||
ValidatorInterface $validator
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
|
||||
$builder = $this->createFormBuilder();
|
||||
@@ -138,6 +148,8 @@ class ProjectController extends AbstractController
|
||||
'required' => true,
|
||||
'choices' => [
|
||||
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
|
||||
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
|
||||
'project.bom_import.type.generic_csv' => 'generic_csv',
|
||||
]
|
||||
]);
|
||||
$builder->add('clear_existing_bom', CheckboxType::class, [
|
||||
@@ -161,25 +173,40 @@ class ProjectController extends AbstractController
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
$import_type = $form->get('type')->getData();
|
||||
|
||||
try {
|
||||
// For schematic imports, redirect to field mapping step
|
||||
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
|
||||
// Store file content and options in session for field mapping step
|
||||
$file_content = $form->get('file')->getData()->getContent();
|
||||
$clear_existing = $form->get('clear_existing_bom')->getData();
|
||||
|
||||
$request->getSession()->set('bom_import_data', $file_content);
|
||||
$request->getSession()->set('bom_import_clear', $clear_existing);
|
||||
|
||||
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// For PCB imports, proceed directly
|
||||
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
|
||||
'type' => $form->get('type')->getData(),
|
||||
'type' => $import_type,
|
||||
]);
|
||||
|
||||
//Validate the project entries
|
||||
// Validate the project entries
|
||||
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||
|
||||
//If no validation errors occured, save the changes and redirect to edit page
|
||||
if (count ($errors) === 0) {
|
||||
// If no validation errors occurred, save the changes and redirect to edit page
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||
$entityManager->flush();
|
||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
//When we get here, there were validation errors
|
||||
// When we get here, there were validation errors
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
|
||||
|
||||
} catch (\UnexpectedValueException|SyntaxError $e) {
|
||||
} catch (\UnexpectedValueException | SyntaxError $e) {
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
@@ -191,11 +218,267 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
|
||||
public function importBOMMapFields(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
Project $project,
|
||||
BOMImporter $BOMImporter,
|
||||
ValidatorInterface $validator,
|
||||
LoggerInterface $logger
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
|
||||
// Get stored data from session
|
||||
$file_content = $request->getSession()->get('bom_import_data');
|
||||
$clear_existing = $request->getSession()->get('bom_import_clear', false);
|
||||
|
||||
|
||||
if (!$file_content) {
|
||||
$this->addFlash('error', 'project.bom_import.flash.session_expired');
|
||||
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// Detect fields and get suggestions
|
||||
$detected_fields = $BOMImporter->detectFields($file_content);
|
||||
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
|
||||
|
||||
// Create mapping of original field names to sanitized field names for template
|
||||
$field_name_mapping = [];
|
||||
foreach ($detected_fields as $field) {
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$field_name_mapping[$field] = $sanitized_field;
|
||||
}
|
||||
|
||||
// Create form for field mapping
|
||||
$builder = $this->createFormBuilder();
|
||||
|
||||
// Add delimiter selection
|
||||
$builder->add('delimiter', ChoiceType::class, [
|
||||
'label' => 'project.bom_import.delimiter',
|
||||
'required' => true,
|
||||
'data' => ',',
|
||||
'choices' => [
|
||||
'project.bom_import.delimiter.comma' => ',',
|
||||
'project.bom_import.delimiter.semicolon' => ';',
|
||||
'project.bom_import.delimiter.tab' => "\t",
|
||||
]
|
||||
]);
|
||||
|
||||
// Get dynamic field mapping targets from BOMImporter
|
||||
$available_targets = $BOMImporter->getAvailableFieldTargets();
|
||||
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
|
||||
|
||||
foreach ($available_targets as $target_key => $target_info) {
|
||||
$target_fields[$target_info['label']] = $target_key;
|
||||
}
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
// Sanitize field name for form use - replace invalid characters with underscores
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
|
||||
'label' => $field,
|
||||
'required' => false,
|
||||
'choices' => $target_fields,
|
||||
'data' => $suggested_mapping[$field] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'project.bom_import.preview',
|
||||
]);
|
||||
|
||||
$form = $builder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Build field mapping array with priority support
|
||||
$field_mapping = [];
|
||||
$field_priorities = [];
|
||||
$delimiter = $form->get('delimiter')->getData();
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$target = $form->get('mapping_' . $sanitized_field)->getData();
|
||||
if (!empty($target)) {
|
||||
$field_mapping[$field] = $target;
|
||||
|
||||
// Get priority from request (default to 10)
|
||||
$priority = $request->request->get('priority_' . $sanitized_field, 10);
|
||||
$field_priorities[$field] = (int) $priority;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field mapping
|
||||
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
|
||||
|
||||
if (!$validation['is_valid']) {
|
||||
foreach ($validation['errors'] as $error) {
|
||||
$this->addFlash('error', $error);
|
||||
}
|
||||
foreach ($validation['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form->createView(),
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
// Show warnings but continue
|
||||
foreach ($validation['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-detect fields with chosen delimiter
|
||||
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
|
||||
|
||||
// Clear existing BOM entries if requested
|
||||
if ($clear_existing) {
|
||||
$existing_count = $project->getBomEntries()->count();
|
||||
$logger->info('Clearing existing BOM entries', [
|
||||
'existing_count' => $existing_count,
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
$project->getBomEntries()->clear();
|
||||
$entityManager->flush();
|
||||
$logger->info('Existing BOM entries cleared');
|
||||
} else {
|
||||
$existing_count = $project->getBomEntries()->count();
|
||||
$logger->info('Keeping existing BOM entries', [
|
||||
'existing_count' => $existing_count,
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate data before importing
|
||||
$validation_result = $BOMImporter->validateBOMData($file_content, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => $delimiter,
|
||||
]);
|
||||
|
||||
// Log validation results
|
||||
$logger->info('BOM import validation completed', [
|
||||
'total_entries' => $validation_result['total_entries'],
|
||||
'valid_entries' => $validation_result['valid_entries'],
|
||||
'invalid_entries' => $validation_result['invalid_entries'],
|
||||
'error_count' => count($validation_result['errors']),
|
||||
'warning_count' => count($validation_result['warnings']),
|
||||
]);
|
||||
|
||||
// Show validation warnings to user
|
||||
foreach ($validation_result['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
// If there are validation errors, show them and stop
|
||||
if (!empty($validation_result['errors'])) {
|
||||
foreach ($validation_result['errors'] as $error) {
|
||||
$this->addFlash('error', $error);
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form->createView(),
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
'validation_result' => $validation_result,
|
||||
]);
|
||||
}
|
||||
|
||||
// Import with field mapping and priorities (validation already passed)
|
||||
$entries = $BOMImporter->stringToBOMEntries($file_content, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => $delimiter,
|
||||
]);
|
||||
|
||||
// Log entry details for debugging
|
||||
$logger->info('BOM entries created', [
|
||||
'total_entries' => count($entries),
|
||||
]);
|
||||
|
||||
foreach ($entries as $index => $entry) {
|
||||
$logger->debug("BOM entry {$index}", [
|
||||
'name' => $entry->getName(),
|
||||
'mountnames' => $entry->getMountnames(),
|
||||
'quantity' => $entry->getQuantity(),
|
||||
'comment' => $entry->getComment(),
|
||||
'part_id' => $entry->getPart()?->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Assign entries to project
|
||||
$logger->info('Adding BOM entries to project', [
|
||||
'entries_count' => count($entries),
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
|
||||
foreach ($entries as $index => $entry) {
|
||||
$logger->debug("Adding BOM entry {$index} to project", [
|
||||
'name' => $entry->getName(),
|
||||
'part_id' => $entry->getPart()?->getID(),
|
||||
'quantity' => $entry->getQuantity(),
|
||||
]);
|
||||
$project->addBomEntry($entry);
|
||||
}
|
||||
|
||||
// Validate the project entries (includes collection constraints)
|
||||
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||
|
||||
// If no validation errors occurred, save and redirect
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||
$entityManager->flush();
|
||||
|
||||
// Clear session data
|
||||
$request->getSession()->remove('bom_import_data');
|
||||
$request->getSession()->remove('bom_import_clear');
|
||||
|
||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// When we get here, there were validation errors
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
|
||||
|
||||
//Print validation errors to log for debugging
|
||||
foreach ($errors as $error) {
|
||||
$logger->error('BOM entry validation error', [
|
||||
'message' => $error->getMessage(),
|
||||
'invalid_value' => $error->getInvalidValue(),
|
||||
]);
|
||||
//And show as flash message
|
||||
$this->addFlash('error', $error->getMessage(),);
|
||||
}
|
||||
|
||||
} catch (\UnexpectedValueException | SyntaxError $e) {
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form,
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
|
||||
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
|
||||
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
|
||||
{
|
||||
if($project instanceof Project) {
|
||||
if ($project instanceof Project) {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
} else {
|
||||
$this->denyAccessUnlessGranted('@projects.edit');
|
||||
@@ -242,7 +525,7 @@ class ProjectController extends AbstractController
|
||||
|
||||
$data = $form->getData();
|
||||
$bom_entries = $data['bom_entries'];
|
||||
foreach ($bom_entries as $bom_entry){
|
||||
foreach ($bom_entries as $bom_entry) {
|
||||
$target_project->addBOMEntry($bom_entry);
|
||||
}
|
||||
|
||||
|
||||
64
src/DataFixtures/CurrencyFixtures.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class CurrencyFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$currency1 = new Currency();
|
||||
$currency1->setName('US-Dollar');
|
||||
$currency1->setIsoCode('USD');
|
||||
$manager->persist($currency1);
|
||||
|
||||
$currency2 = new Currency();
|
||||
$currency2->setName('Swiss Franc');
|
||||
$currency2->setIsoCode('CHF');
|
||||
$currency2->setExchangeRate(BigDecimal::of('0.91'));
|
||||
$manager->persist($currency2);
|
||||
|
||||
$currency3 = new Currency();
|
||||
$currency3->setName('Great British Pound');
|
||||
$currency3->setIsoCode('GBP');
|
||||
$currency3->setExchangeRate(BigDecimal::of('0.78'));
|
||||
$manager->persist($currency3);
|
||||
|
||||
$currency7 = new Currency();
|
||||
$currency7->setName('Test Currency with long name');
|
||||
$currency7->setIsoCode('CNY');
|
||||
$manager->persist($currency7);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
|
||||
//Ensure that currency 7 gets ID 7
|
||||
$manager->getRepository(Currency::class)->changeID($currency7, 7);
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
|
||||
{
|
||||
use FilterTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected string $identifier;
|
||||
protected ?string $identifier;
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 . '%');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 . ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||