diff --git a/composer.json b/composer.json
index e57ce6527..0d192ed2f 100644
--- a/composer.json
+++ b/composer.json
@@ -1,170 +1,171 @@
{
- "name": "part-db/part-db-server",
- "type": "project",
- "license": "AGPL-3.0-or-later",
- "require": {
- "php": "^8.1",
- "ext-ctype": "*",
- "ext-dom": "*",
- "ext-gd": "*",
- "ext-iconv": "*",
- "ext-intl": "*",
- "ext-json": "*",
- "ext-mbstring": "*",
- "amphp/http-client": "^5.1",
- "api-platform/core": "^3.1",
- "beberlei/doctrineextensions": "^1.2",
- "brick/math": "0.12.1 as 0.11.0",
- "composer/ca-bundle": "^1.5",
- "composer/package-versions-deprecated": "^1.11.99.5",
- "doctrine/data-fixtures": "^2.0.0",
- "doctrine/dbal": "^4.0.0",
- "doctrine/doctrine-bundle": "^2.0",
- "doctrine/doctrine-migrations-bundle": "^3.0",
- "doctrine/orm": "^3.2.0",
- "dompdf/dompdf": "^v3.0.0",
- "erusev/parsedown": "^1.7",
- "florianv/swap": "^4.0",
- "florianv/swap-bundle": "dev-master",
- "gregwar/captcha-bundle": "^2.1.0",
- "hshn/base64-encoded-file": "^5.0",
- "jbtronics/2fa-webauthn": "^v2.2.0",
- "jbtronics/dompdf-font-loader-bundle": "^1.0.0",
- "jfcherng/php-diff": "^6.14",
- "knpuniversity/oauth2-client-bundle": "^2.15",
- "league/csv": "^9.8.0",
- "league/html-to-markdown": "^5.0.1",
- "liip/imagine-bundle": "^2.2",
- "nbgrp/onelogin-saml-bundle": "^1.3",
- "nelexa/zip": "^4.0",
- "nelmio/cors-bundle": "^2.3",
- "nelmio/security-bundle": "^3.0",
- "nyholm/psr7": "^1.1",
- "omines/datatables-bundle": "^0.9.1",
- "paragonie/sodium_compat": "^1.21",
- "part-db/label-fonts": "^1.0",
- "rhukster/dom-sanitizer": "^1.0",
- "runtime/frankenphp-symfony": "^0.2.0",
- "s9e/text-formatter": "^2.1",
- "scheb/2fa-backup-code": "^6.8.0",
- "scheb/2fa-bundle": "^6.8.0",
- "scheb/2fa-google-authenticator": "^6.8.0",
- "scheb/2fa-trusted-device": "^6.8.0",
- "shivas/versioning-bundle": "^4.0",
- "spatie/db-dumper": "^3.3.1",
- "symfony/apache-pack": "^1.0",
- "symfony/asset": "6.4.*",
- "symfony/console": "6.4.*",
- "symfony/css-selector": "6.4.*",
- "symfony/dom-crawler": "6.4.*",
- "symfony/dotenv": "6.4.*",
- "symfony/expression-language": "6.4.*",
- "symfony/flex": "^v2.3.1",
- "symfony/form": "6.4.*",
- "symfony/framework-bundle": "6.4.*",
- "symfony/http-client": "6.4.*",
- "symfony/http-kernel": "6.4.*",
- "symfony/mailer": "6.4.*",
- "symfony/monolog-bundle": "^3.1",
- "symfony/polyfill-php82": "^1.28",
- "symfony/process": "6.4.*",
- "symfony/property-access": "6.4.*",
- "symfony/property-info": "6.4.*",
- "symfony/rate-limiter": "6.4.*",
- "symfony/runtime": "6.4.*",
- "symfony/security-bundle": "6.4.*",
- "symfony/serializer": "6.4.*",
- "symfony/string": "6.4.*",
- "symfony/translation": "6.4.*",
- "symfony/twig-bundle": "6.4.*",
- "symfony/ux-translator": "^2.10",
- "symfony/ux-turbo": "^2.0",
- "symfony/validator": "6.4.*",
- "symfony/web-link": "6.4.*",
- "symfony/webpack-encore-bundle": "^v2.0.1",
- "symfony/yaml": "6.4.*",
- "tecnickcom/tc-lib-barcode": "^2.1.4",
- "twig/cssinliner-extra": "^3.0",
- "twig/extra-bundle": "^3.8",
- "twig/html-extra": "^3.8",
- "twig/inky-extra": "^3.0",
- "twig/intl-extra": "^3.8",
- "twig/markdown-extra": "^3.8",
- "twig/string-extra": "^3.8",
- "web-auth/webauthn-symfony-bundle": "^4.0.0"
+ "name": "part-db/part-db-server",
+ "type": "project",
+ "license": "AGPL-3.0-or-later",
+ "require": {
+ "php": "^8.1",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-gd": "*",
+ "ext-iconv": "*",
+ "ext-intl": "*",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "amphp/http-client": "^5.1",
+ "api-platform/core": "^3.1",
+ "beberlei/doctrineextensions": "^1.2",
+ "brick/math": "0.12.1 as 0.11.0",
+ "composer/ca-bundle": "^1.5",
+ "composer/package-versions-deprecated": "^1.11.99.5",
+ "doctrine/data-fixtures": "^2.0.0",
+ "doctrine/dbal": "^4.0.0",
+ "doctrine/doctrine-bundle": "^2.0",
+ "doctrine/doctrine-migrations-bundle": "^3.0",
+ "doctrine/orm": "^3.2.0",
+ "dompdf/dompdf": "^v3.0.0",
+ "erusev/parsedown": "^1.7",
+ "florianv/swap": "^4.0",
+ "florianv/swap-bundle": "dev-master",
+ "gregwar/captcha-bundle": "^2.1.0",
+ "hshn/base64-encoded-file": "^5.0",
+ "jbtronics/2fa-webauthn": "^v2.2.0",
+ "jbtronics/dompdf-font-loader-bundle": "^1.0.0",
+ "jfcherng/php-diff": "^6.14",
+ "knpuniversity/oauth2-client-bundle": "^2.15",
+ "league/csv": "^9.8.0",
+ "league/html-to-markdown": "^5.0.1",
+ "liip/imagine-bundle": "^2.2",
+ "nbgrp/onelogin-saml-bundle": "^1.3",
+ "nelexa/zip": "^4.0",
+ "nelmio/cors-bundle": "^2.3",
+ "nelmio/security-bundle": "^3.0",
+ "nyholm/psr7": "^1.1",
+ "omines/datatables-bundle": "^0.9.1",
+ "paragonie/sodium_compat": "^1.21",
+ "part-db/label-fonts": "^1.0",
+ "phpoffice/phpspreadsheet": "*",
+ "rhukster/dom-sanitizer": "^1.0",
+ "runtime/frankenphp-symfony": "^0.2.0",
+ "s9e/text-formatter": "^2.1",
+ "scheb/2fa-backup-code": "^6.8.0",
+ "scheb/2fa-bundle": "^6.8.0",
+ "scheb/2fa-google-authenticator": "^6.8.0",
+ "scheb/2fa-trusted-device": "^6.8.0",
+ "shivas/versioning-bundle": "^4.0",
+ "spatie/db-dumper": "^3.3.1",
+ "symfony/apache-pack": "^1.0",
+ "symfony/asset": "6.4.*",
+ "symfony/console": "6.4.*",
+ "symfony/css-selector": "6.4.*",
+ "symfony/dom-crawler": "6.4.*",
+ "symfony/dotenv": "6.4.*",
+ "symfony/expression-language": "6.4.*",
+ "symfony/flex": "^v2.3.1",
+ "symfony/form": "6.4.*",
+ "symfony/framework-bundle": "6.4.*",
+ "symfony/http-client": "6.4.*",
+ "symfony/http-kernel": "6.4.*",
+ "symfony/mailer": "6.4.*",
+ "symfony/monolog-bundle": "^3.1",
+ "symfony/polyfill-php82": "^1.28",
+ "symfony/process": "6.4.*",
+ "symfony/property-access": "6.4.*",
+ "symfony/property-info": "6.4.*",
+ "symfony/rate-limiter": "6.4.*",
+ "symfony/runtime": "6.4.*",
+ "symfony/security-bundle": "6.4.*",
+ "symfony/serializer": "6.4.*",
+ "symfony/string": "6.4.*",
+ "symfony/translation": "6.4.*",
+ "symfony/twig-bundle": "6.4.*",
+ "symfony/ux-translator": "^2.10",
+ "symfony/ux-turbo": "^2.0",
+ "symfony/validator": "6.4.*",
+ "symfony/web-link": "6.4.*",
+ "symfony/webpack-encore-bundle": "^v2.0.1",
+ "symfony/yaml": "6.4.*",
+ "tecnickcom/tc-lib-barcode": "^2.1.4",
+ "twig/cssinliner-extra": "^3.0",
+ "twig/extra-bundle": "^3.8",
+ "twig/html-extra": "^3.8",
+ "twig/inky-extra": "^3.0",
+ "twig/intl-extra": "^3.8",
+ "twig/markdown-extra": "^3.8",
+ "twig/string-extra": "^3.8",
+ "web-auth/webauthn-symfony-bundle": "^4.0.0"
+ },
+ "require-dev": {
+ "dama/doctrine-test-bundle": "^v8.0.0",
+ "doctrine/doctrine-fixtures-bundle": "^4.0.0",
+ "ekino/phpstan-banned-code": "^v3.0.0",
+ "jbtronics/translation-editor-bundle": "^1.0",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^2.0.4",
+ "phpstan/phpstan-doctrine": "^2.0.1",
+ "phpstan/phpstan-strict-rules": "^2.0.1",
+ "phpstan/phpstan-symfony": "^2.0.0",
+ "phpunit/phpunit": "^9.5",
+ "rector/rector": "^2.0.4",
+ "roave/security-advisories": "dev-latest",
+ "symfony/browser-kit": "6.4.*",
+ "symfony/debug-bundle": "6.4.*",
+ "symfony/maker-bundle": "^1.13",
+ "symfony/phpunit-bridge": "6.4.*",
+ "symfony/stopwatch": "6.4.*",
+ "symfony/web-profiler-bundle": "6.4.*",
+ "symplify/easy-coding-standard": "^12.0"
+ },
+ "suggest": {
+ "ext-bcmath": "Used to improve price calculation performance",
+ "ext-gmp": "Used to improve price calculation performanice"
+ },
+ "config": {
+ "preferred-install": {
+ "*": "dist"
},
- "require-dev": {
- "dama/doctrine-test-bundle": "^v8.0.0",
- "doctrine/doctrine-fixtures-bundle": "^4.0.0",
- "ekino/phpstan-banned-code": "^v3.0.0",
- "jbtronics/translation-editor-bundle": "^1.0",
- "phpstan/extension-installer": "^1.0",
- "phpstan/phpstan": "^2.0.4",
- "phpstan/phpstan-doctrine": "^2.0.1",
- "phpstan/phpstan-strict-rules": "^2.0.1",
- "phpstan/phpstan-symfony": "^2.0.0",
- "phpunit/phpunit": "^9.5",
- "rector/rector": "^2.0.4",
- "roave/security-advisories": "dev-latest",
- "symfony/browser-kit": "6.4.*",
- "symfony/debug-bundle": "6.4.*",
- "symfony/maker-bundle": "^1.13",
- "symfony/phpunit-bridge": "6.4.*",
- "symfony/stopwatch": "6.4.*",
- "symfony/web-profiler-bundle": "6.4.*",
- "symplify/easy-coding-standard": "^12.0"
+ "platform": {
+ "php": "8.1.0"
},
- "suggest": {
- "ext-bcmath": "Used to improve price calculation performance",
- "ext-gmp": "Used to improve price calculation performanice"
- },
- "config": {
- "preferred-install": {
- "*": "dist"
- },
- "platform": {
- "php": "8.1.0"
- },
- "sort-packages": true,
- "allow-plugins": {
- "composer/package-versions-deprecated": true,
- "symfony/flex": true,
- "phpstan/extension-installer": true,
- "symfony/runtime": true,
- "php-http/discovery": true
- }
- },
- "autoload": {
- "psr-4": {
- "App\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "App\\Tests\\": "tests/"
- }
- },
- "scripts": {
- "auto-scripts": {
- "cache:clear": "symfony-cmd",
- "assets:install %PUBLIC_DIR%": "symfony-cmd"
- },
- "post-install-cmd": [
- "@auto-scripts"
- ],
- "post-update-cmd": [
- "@auto-scripts"
- ],
- "phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G"
- },
- "conflict": {
- "symfony/symfony": "*"
+ "sort-packages": true,
+ "allow-plugins": {
+ "composer/package-versions-deprecated": true,
+ "symfony/flex": true,
+ "phpstan/extension-installer": true,
+ "symfony/runtime": true,
+ "php-http/discovery": true
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "App\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "App\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "auto-scripts": {
+ "cache:clear": "symfony-cmd",
+ "assets:install %PUBLIC_DIR%": "symfony-cmd"
},
- "extra": {
- "symfony": {
- "allow-contrib": false,
- "require": "6.4.*",
- "docker": true
- }
+ "post-install-cmd": [
+ "@auto-scripts"
+ ],
+ "post-update-cmd": [
+ "@auto-scripts"
+ ],
+ "phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G"
+ },
+ "conflict": {
+ "symfony/symfony": "*"
+ },
+ "extra": {
+ "symfony": {
+ "allow-contrib": false,
+ "require": "6.4.*",
+ "docker": true
}
+ }
}
diff --git a/composer.lock b/composer.lock
index 7e8b34819..4409f9a67 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "27cd0d915eab5e7cb57215a4c0b529fb",
+ "content-hash": "74d80aa83c1a336f3a2b661e0c19c075",
"packages": [
{
"name": "amphp/amp",
@@ -1525,6 +1525,85 @@
],
"time": "2022-01-17T14:14:24+00:00"
},
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
{
"name": "daverandom/libdns",
"version": "v2.1.0",
@@ -5077,6 +5156,190 @@
},
"time": "2023-07-31T13:36:50+00:00"
},
+ {
+ "name": "maennchen/zipstream-php",
+ "version": "3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/maennchen/ZipStream-PHP.git",
+ "reference": "6187e9cc4493da94b9b63eb2315821552015fca9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9",
+ "reference": "6187e9cc4493da94b9b63eb2315821552015fca9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "ext-zlib": "*",
+ "php-64bit": "^8.1"
+ },
+ "require-dev": {
+ "ext-zip": "*",
+ "friendsofphp/php-cs-fixer": "^3.16",
+ "guzzlehttp/guzzle": "^7.5",
+ "mikey179/vfsstream": "^1.6",
+ "php-coveralls/php-coveralls": "^2.5",
+ "phpunit/phpunit": "^10.0",
+ "vimeo/psalm": "^5.0"
+ },
+ "suggest": {
+ "guzzlehttp/psr7": "^2.4",
+ "psr/http-message": "^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ZipStream\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paul Duncan",
+ "email": "pabs@pablotron.org"
+ },
+ {
+ "name": "Jonatan Männchen",
+ "email": "jonatan@maennchen.ch"
+ },
+ {
+ "name": "Jesse Donat",
+ "email": "donatj@gmail.com"
+ },
+ {
+ "name": "András Kolesár",
+ "email": "kolesar@kolesar.hu"
+ }
+ ],
+ "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
+ "keywords": [
+ "stream",
+ "zip"
+ ],
+ "support": {
+ "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
+ "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/maennchen",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-10T12:33:01+00:00"
+ },
+ {
+ "name": "markbaker/complex",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPComplex.git",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Complex\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@lange.demon.co.uk"
+ }
+ ],
+ "description": "PHP Class for working with complex numbers",
+ "homepage": "https://github.com/MarkBaker/PHPComplex",
+ "keywords": [
+ "complex",
+ "mathematics"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPComplex/issues",
+ "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
+ },
+ "time": "2022-12-06T16:21:08+00:00"
+ },
+ {
+ "name": "markbaker/matrix",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPMatrix.git",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpdocumentor/phpdocumentor": "2.*",
+ "phploc/phploc": "^4.0",
+ "phpmd/phpmd": "2.*",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "sebastian/phpcpd": "^4.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Matrix\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@demon-angel.eu"
+ }
+ ],
+ "description": "PHP Class for working with matrices",
+ "homepage": "https://github.com/MarkBaker/PHPMatrix",
+ "keywords": [
+ "mathematics",
+ "matrix",
+ "vector"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
+ "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
+ },
+ "time": "2022-12-02T22:17:43+00:00"
+ },
{
"name": "masterminds/html5",
"version": "2.9.0",
@@ -6446,6 +6709,112 @@
},
"time": "2024-11-09T15:12:26+00:00"
},
+ {
+ "name": "phpoffice/phpspreadsheet",
+ "version": "4.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
+ "reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2ea9786632e6fac1aee601b6e426bcc723d8ce13",
+ "reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1||^2||^3",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-fileinfo": "*",
+ "ext-gd": "*",
+ "ext-iconv": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-simplexml": "*",
+ "ext-xml": "*",
+ "ext-xmlreader": "*",
+ "ext-xmlwriter": "*",
+ "ext-zip": "*",
+ "ext-zlib": "*",
+ "maennchen/zipstream-php": "^2.1 || ^3.0",
+ "markbaker/complex": "^3.0",
+ "markbaker/matrix": "^3.0",
+ "php": "^8.1",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.0",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
+ "dompdf/dompdf": "^2.0 || ^3.0",
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "mitoteam/jpgraph": "^10.3",
+ "mpdf/mpdf": "^8.1.1",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpstan/phpstan": "^1.1 || ^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+ "phpunit/phpunit": "^10.5",
+ "squizlabs/php_codesniffer": "^3.7",
+ "tecnickcom/tcpdf": "^6.5"
+ },
+ "suggest": {
+ "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
+ "ext-intl": "PHP Internationalization Functions",
+ "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
+ "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
+ "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Maarten Balliauw",
+ "homepage": "https://blog.maartenballiauw.be"
+ },
+ {
+ "name": "Mark Baker",
+ "homepage": "https://markbakeruk.net"
+ },
+ {
+ "name": "Franck Lefevre",
+ "homepage": "https://rootslabs.net"
+ },
+ {
+ "name": "Erik Tilt"
+ },
+ {
+ "name": "Adrien Crivelli"
+ }
+ ],
+ "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
+ "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
+ "keywords": [
+ "OpenXML",
+ "excel",
+ "gnumeric",
+ "ods",
+ "php",
+ "spreadsheet",
+ "xls",
+ "xlsx"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/4.5.0"
+ },
+ "time": "2025-07-24T05:15:59+00:00"
+ },
{
"name": "phpstan/phpdoc-parser",
"version": "2.1.0",
@@ -19005,9 +19374,9 @@
"ext-json": "*",
"ext-mbstring": "*"
},
- "platform-dev": [],
+ "platform-dev": {},
"platform-overrides": {
"php": "8.1.0"
},
- "plugin-api-version": "2.3.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/docs/assets/usage/import_export/part_import_example.csv b/docs/assets/usage/import_export/part_import_example.csv
index 087014260..14d4500f9 100644
--- a/docs/assets/usage/import_export/part_import_example.csv
+++ b/docs/assets/usage/import_export/part_import_example.csv
@@ -1,4 +1,7 @@
-name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status
-BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;;
-BC557;PNP transistor;HTML;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
-Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
\ No newline at end of file
+name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint
+"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric
+"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric
+"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123
+BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
+BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
+Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical
diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php
index 3e87812c7..0bd3cea1e 100644
--- a/src/Form/AdminPages/ImportType.php
+++ b/src/Form/AdminPages/ImportType.php
@@ -59,6 +59,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'XML' => 'xml',
'CSV' => 'csv',
'YAML' => 'yaml',
+ 'XLSX' => 'xlsx',
+ 'XLS' => 'xls',
],
'label' => 'export.format',
'disabled' => $disabled,
diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php
index 4ce779e88..01b53e25e 100644
--- a/src/Services/EntityMergers/Mergers/PartMerger.php
+++ b/src/Services/EntityMergers/Mergers/PartMerger.php
@@ -100,7 +100,8 @@ public function merge(object $target, object $other, array $context = []): Part
return $target;
}
- private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
+ private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool
+ {
//We compare the translation keys, as it contains info about the type and other type info
return $t->getOther() === $o->getOther()
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
@@ -141,40 +142,39 @@ private function mergeCollectionFields(Part $target, Part $other, array $context
$owner->addAssociatedPartsAsOwner($clone);
}
+ // Merge orderdetails, considering same supplier+part number as duplicates
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
- //First check that the orderdetails infos are equal
- $tmp = $t->getSupplier() === $o->getSupplier()
- && $t->getSupplierPartNr() === $o->getSupplierPartNr()
- && $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
-
- if (!$tmp) {
- return false;
- }
-
- //Check if the pricedetails are equal
- $t_pricedetails = $t->getPricedetails();
- $o_pricedetails = $o->getPricedetails();
- //Ensure that both pricedetails have the same length
- if (count($t_pricedetails) !== count($o_pricedetails)) {
- return false;
- }
-
- //Check if all pricedetails are equal
- for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
- $t_price = $t_pricedetails->get($n);
- $o_price = $o_pricedetails->get($n);
-
- if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
- || $t_price->getCurrency() !== $o_price->getCurrency()
- || $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
- || $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
- ) {
- return false;
+ // If supplier and part number match, merge the orderdetails
+ if ($t->getSupplier() === $o->getSupplier() && $t->getSupplierPartNr() === $o->getSupplierPartNr()) {
+ // Update URL if target doesn't have one
+ if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) {
+ $t->setSupplierProductUrl($o->getSupplierProductUrl(false));
}
+ // Merge price details: add new ones, update empty ones, keep existing non-empty ones
+ foreach ($o->getPricedetails() as $otherPrice) {
+ $found = false;
+ foreach ($t->getPricedetails() as $targetPrice) {
+ if ($targetPrice->getMinDiscountQuantity() === $otherPrice->getMinDiscountQuantity()
+ && $targetPrice->getCurrency() === $otherPrice->getCurrency()) {
+ // Only update price if the existing one is zero/empty (most logical)
+ if ($targetPrice->getPrice()->isZero()) {
+ $targetPrice->setPrice($otherPrice->getPrice());
+ $targetPrice->setPriceRelatedQuantity($otherPrice->getPriceRelatedQuantity());
+ }
+ $found = true;
+ break;
+ }
+ }
+ // Add completely new price tiers
+ if (!$found) {
+ $clonedPrice = clone $otherPrice;
+ $clonedPrice->setOrderdetail($t);
+ $t->addPricedetail($clonedPrice);
+ }
+ }
+ return true; // Consider them equal so the other one gets skipped
}
-
- //If all pricedetails are equal, the orderdetails are equal
- return true;
+ return false; // Different supplier/part number, add as new
});
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
foreach ($target->getOrderdetails() as $orderdetail) {
diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php
index c37db50c0..e654fdd19 100644
--- a/src/Services/ImportExportSystem/EntityExporter.php
+++ b/src/Services/ImportExportSystem/EntityExporter.php
@@ -38,6 +38,9 @@
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface;
use function Symfony\Component\String\u;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use PhpOffice\PhpSpreadsheet\Writer\Xls;
/**
* Use this class to export an entity to multiple file formats.
@@ -52,7 +55,7 @@ public function __construct(protected SerializerInterface $serializer)
protected function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('format', 'csv');
- $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
+ $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setDefault('csv_delimiter', ';');
$resolver->setAllowedTypes('csv_delimiter', 'string');
@@ -88,28 +91,35 @@ public function exportEntities(AbstractNamedDBElement|array $entities, array $op
$options = $resolver->resolve($options);
+ //Handle Excel formats by converting from CSV
+ if (in_array($options['format'], ['xlsx', 'xls'], true)) {
+ return $this->exportToExcel($entities, $options);
+ }
+
//If include children is set, then we need to add the include_children group
$groups = [$options['level']];
if ($options['include_children']) {
$groups[] = 'include_children';
}
- return $this->serializer->serialize($entities, $options['format'],
+ return $this->serializer->serialize(
+ $entities,
+ $options['format'],
[
'groups' => $groups,
'as_collection' => true,
'csv_delimiter' => $options['csv_delimiter'],
'xml_root_node_name' => 'PartDBExport',
'partdb_export' => true,
- //Skip the item normalizer, so that we dont get IRIs in the output
+ //Skip the item normalizer, so that we dont get IRIs in the output
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
- //Handle circular references
+ //Handle circular references
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
]
);
}
- private function handleCircularReference(object $object, string $format, array $context): string
+ private function handleCircularReference(object $object): string
{
if ($object instanceof AbstractStructuralDBElement) {
return $object->getFullPath("->");
@@ -119,7 +129,70 @@ private function handleCircularReference(object $object, string $format, array $
return $object->__toString();
}
- throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
+ throw new CircularReferenceException('Circular reference detected for object of type ' . get_class($object));
+ }
+
+ /**
+ * Exports entities to Excel format (xlsx or xls).
+ *
+ * @param AbstractNamedDBElement[] $entities The entities to export
+ * @param array $options The export options
+ *
+ * @return string The Excel file content as binary string
+ */
+ protected function exportToExcel(array $entities, array $options): string
+ {
+ //First get CSV data using existing serializer
+ $groups = [$options['level']];
+ if ($options['include_children']) {
+ $groups[] = 'include_children';
+ }
+
+ $csvData = $this->serializer->serialize(
+ $entities,
+ 'csv',
+ [
+ 'groups' => $groups,
+ 'as_collection' => true,
+ 'csv_delimiter' => $options['csv_delimiter'],
+ 'partdb_export' => true,
+ SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
+ AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
+ ]
+ );
+
+ //Convert CSV to Excel
+ $spreadsheet = new Spreadsheet();
+ $worksheet = $spreadsheet->getActiveSheet();
+
+ $rows = explode("\n", $csvData);
+ $rowIndex = 1;
+
+ foreach ($rows as $row) {
+ if (trim($row) === '') {
+ continue;
+ }
+
+ $columns = str_getcsv($row, $options['csv_delimiter'], '"', '\\');
+ $colIndex = 1;
+
+ foreach ($columns as $column) {
+ $cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
+ $worksheet->setCellValue($cellCoordinate, $column);
+ $colIndex++;
+ }
+ $rowIndex++;
+ }
+
+ //Save to memory stream
+ $writer = $options['format'] === 'xlsx' ? new Xlsx($spreadsheet) : new Xls($spreadsheet);
+
+ ob_start();
+ $writer->save('php://output');
+ $content = ob_get_contents();
+ ob_end_clean();
+
+ return $content;
}
/**
@@ -156,19 +229,15 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities,
//Determine the content type for the response
- //Plain text should work for all types
- $content_type = 'text/plain';
-
//Try to use better content types based on the format
$format = $options['format'];
- switch ($format) {
- case 'xml':
- $content_type = 'application/xml';
- break;
- case 'json':
- $content_type = 'application/json';
- break;
- }
+ $content_type = match ($format) {
+ 'xml' => 'application/xml',
+ 'json' => 'application/json',
+ 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'xls' => 'application/vnd.ms-excel',
+ default => 'text/plain',
+ };
$response->headers->set('Content-Type', $content_type);
//If view option is not specified, then download the file.
@@ -186,7 +255,7 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities,
$level = $options['level'];
- $filename = 'export_'.$entity_name.'_'.$level.'.'.$format;
+ $filename = "export_{$entity_name}_{$level}.{$format}";
//Sanitize the filename
$filename = FilenameSanatizer::sanitizeFilename($filename);
diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php
index cecab12dc..97d501eb7 100644
--- a/src/Services/ImportExportSystem/EntityImporter.php
+++ b/src/Services/ImportExportSystem/EntityImporter.php
@@ -38,6 +38,9 @@
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use Psr\Log\LoggerInterface;
/**
* @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
@@ -50,7 +53,7 @@ class EntityImporter
*/
private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
- public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator)
+ public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger)
{
}
@@ -101,7 +104,7 @@ public function massCreation(string $lines, string $class_name, ?AbstractStructu
foreach ($names as $name) {
//Count indentation level (whitespace characters at the beginning of the line)
- $identSize = strlen($name)-strlen(ltrim($name));
+ $identSize = strlen($name) - strlen(ltrim($name));
//If the line is intended more than the last line, we have a new parent element
if ($identSize > end($indentations)) {
@@ -188,16 +191,20 @@ public function importString(string $data, array $options = [], array &$errors =
}
//The [] behind class_name denotes that we expect an array.
- $entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'],
+ $entities = $this->serializer->deserialize(
+ $data,
+ $options['class'] . '[]',
+ $options['format'],
[
'groups' => $groups,
'csv_delimiter' => $options['csv_delimiter'],
'create_unknown_datastructures' => $options['create_unknown_datastructures'],
'path_delimiter' => $options['path_delimiter'],
'partdb_import' => true,
- //Disable API Platform normalizer, as we don't want to use it here
+ //Disable API Platform normalizer, as we don't want to use it here
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
- ]);
+ ]
+ );
//Ensure we have an array of entity elements.
if (!is_array($entities)) {
@@ -272,7 +279,7 @@ protected function configureOptions(OptionsResolver $resolver): OptionsResolver
'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element
]);
- $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
+ $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setAllowedTypes('csv_delimiter', 'string');
$resolver->setAllowedTypes('preserve_children', 'bool');
$resolver->setAllowedTypes('class', 'string');
@@ -328,6 +335,33 @@ public function importFileAndPersistToDB(File $file, array $options = [], array
*/
public function importFile(File $file, array $options = [], array &$errors = []): array
{
+ $resolver = new OptionsResolver();
+ $this->configureOptions($resolver);
+ $options = $resolver->resolve($options);
+
+ if (in_array($options['format'], ['xlsx', 'xls'], true)) {
+ $this->logger->info('Converting Excel file to CSV', [
+ 'filename' => $file->getFilename(),
+ 'format' => $options['format'],
+ 'delimiter' => $options['csv_delimiter']
+ ]);
+
+ $csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']);
+ $options['format'] = 'csv';
+
+ $this->logger->debug('Excel to CSV conversion completed', [
+ 'csv_length' => strlen($csvData),
+ 'csv_lines' => substr_count($csvData, "\n") + 1
+ ]);
+
+ // Log the converted CSV for debugging (first 1000 characters)
+ $this->logger->debug('Converted CSV preview', [
+ 'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '')
+ ]);
+
+ return $this->importString($csvData, $options, $errors);
+ }
+
return $this->importString($file->getContent(), $options, $errors);
}
@@ -347,10 +381,103 @@ public function determineFormat(string $extension): ?string
'xml' => 'xml',
'csv', 'tsv' => 'csv',
'yaml', 'yml' => 'yaml',
+ 'xlsx' => 'xlsx',
+ 'xls' => 'xls',
default => null,
};
}
+ /**
+ * Converts Excel file to CSV format using PhpSpreadsheet.
+ *
+ * @param File $file The Excel file to convert
+ * @param string $delimiter The CSV delimiter to use
+ *
+ * @return string The CSV data as string
+ */
+ protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
+ {
+ try {
+ $this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]);
+ $spreadsheet = IOFactory::load($file->getPathname());
+ $worksheet = $spreadsheet->getActiveSheet();
+
+ $csvData = [];
+ $highestRow = $worksheet->getHighestRow();
+ $highestColumn = $worksheet->getHighestColumn();
+
+ $this->logger->debug('Excel file dimensions', [
+ 'rows' => $highestRow,
+ 'columns_detected' => $highestColumn,
+ 'worksheet_title' => $worksheet->getTitle()
+ ]);
+
+ $highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
+
+ for ($row = 1; $row <= $highestRow; $row++) {
+ $rowData = [];
+
+ // Read all columns using numeric index
+ for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
+ $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
+ try {
+ $cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
+ $rowData[] = $cellValue ?? '';
+
+ } catch (\Exception $e) {
+ $this->logger->warning('Error reading cell value', [
+ 'cell' => "{$col}{$row}",
+ 'error' => $e->getMessage()
+ ]);
+ $rowData[] = '';
+ }
+ }
+
+ $csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) {
+ $value = (string) $value;
+ if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) {
+ return '"' . str_replace('"', '""', $value) . '"';
+ }
+ return $value;
+ }, $rowData));
+
+ $csvData[] = $csvRow;
+
+ // Log first few rows for debugging
+ if ($row <= 3) {
+ $this->logger->debug("Row {$row} converted", [
+ 'original_data' => $rowData,
+ 'csv_row' => $csvRow,
+ 'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(),
+ 'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue()
+ ]);
+ }
+ }
+
+ $result = implode("\n", $csvData);
+
+ $this->logger->info('Excel to CSV conversion successful', [
+ 'total_rows' => count($csvData),
+ 'total_characters' => strlen($result)
+ ]);
+
+ $this->logger->debug('Full CSV data', [
+ 'csv_data' => $result
+ ]);
+
+ return $result;
+
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to convert Excel to CSV', [
+ 'file' => $file->getFilename(),
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+ throw $e;
+ }
+ }
+
+
/**
* This functions corrects the parent setting based on the children value of the parent.
*
diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php
index 616df2293..bb8ab45fb 100644
--- a/src/Services/Parts/PartsTableActionHandler.php
+++ b/src/Services/Parts/PartsTableActionHandler.php
@@ -30,13 +30,11 @@
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Repository\PartRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
-use Symfony\Contracts\Translation\TranslatableInterface;
use function Symfony\Component\Translation\t;
@@ -100,7 +98,7 @@ public function handleAction(string $action, array $selected_parts, ?int $target
//When action starts with "export_" we have to redirect to the export controller
$matches = [];
- if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) {
+ if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) {
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
$level = match ($target_id) {
2 => 'extended',
diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig
index 5ce0f23f6..5e1747e35 100644
--- a/templates/components/datatables.macro.html.twig
+++ b/templates/components/datatables.macro.html.twig
@@ -72,6 +72,7 @@
+
diff --git a/tests/Services/ImportExportSystem/EntityExporterTest.php b/tests/Services/ImportExportSystem/EntityExporterTest.php
index 004971aba..e9b924b1e 100644
--- a/tests/Services/ImportExportSystem/EntityExporterTest.php
+++ b/tests/Services/ImportExportSystem/EntityExporterTest.php
@@ -26,6 +26,7 @@
use App\Services\ImportExportSystem\EntityExporter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
+use PhpOffice\PhpSpreadsheet\IOFactory;
class EntityExporterTest extends WebTestCase
{
@@ -76,7 +77,40 @@ public function testExportEntityFromRequest(): void
$this->assertSame('application/json', $response->headers->get('Content-Type'));
$this->assertNotEmpty($response->headers->get('Content-Disposition'));
+ }
+
+ public function testExportToExcel(): void
+ {
+ $entities = $this->getEntities();
+
+ $xlsxData = $this->service->exportEntities($entities, ['format' => 'xlsx', 'level' => 'simple']);
+ $this->assertNotEmpty($xlsxData);
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'test_export') . '.xlsx';
+ file_put_contents($tempFile, $xlsxData);
+
+ $spreadsheet = IOFactory::load($tempFile);
+ $worksheet = $spreadsheet->getActiveSheet();
+
+ $this->assertSame('name', $worksheet->getCell('A1')->getValue());
+ $this->assertSame('full_name', $worksheet->getCell('B1')->getValue());
+
+ $this->assertSame('Enitity 1', $worksheet->getCell('A2')->getValue());
+ $this->assertSame('Enitity 1', $worksheet->getCell('B2')->getValue());
+
+ unlink($tempFile);
+ }
+ public function testExportExcelFromRequest(): void
+ {
+ $entities = $this->getEntities();
+
+ $request = new Request();
+ $request->request->set('format', 'xlsx');
+ $request->request->set('level', 'simple');
+ $response = $this->service->exportEntityFromRequest($entities, $request);
+ $this->assertSame('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
+ $this->assertStringContainsString('export_Category_simple.xlsx', $response->headers->get('Content-Disposition'));
}
}
diff --git a/tests/Services/ImportExportSystem/EntityImporterTest.php b/tests/Services/ImportExportSystem/EntityImporterTest.php
index 31859b6af..db0b496b8 100644
--- a/tests/Services/ImportExportSystem/EntityImporterTest.php
+++ b/tests/Services/ImportExportSystem/EntityImporterTest.php
@@ -34,6 +34,9 @@
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
+use Symfony\Component\HttpFoundation\File\File;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
/**
* @group DB
@@ -182,6 +185,10 @@ public function formatDataProvider(): \Iterator
yield ['json', 'json'];
yield ['yaml', 'yml'];
yield ['yaml', 'YAML'];
+ yield ['xlsx', 'xlsx'];
+ yield ['xlsx', 'XLSX'];
+ yield ['xls', 'xls'];
+ yield ['xls', 'XLS'];
}
/**
@@ -319,4 +326,41 @@ public function testImportStringParts(): void
$this->assertSame($category, $results[0]->getCategory());
$this->assertSame('test,test2', $results[0]->getTags());
}
+
+ public function testImportExcelFileProjects(): void
+ {
+ $spreadsheet = new Spreadsheet();
+ $worksheet = $spreadsheet->getActiveSheet();
+
+ $worksheet->setCellValue('A1', 'name');
+ $worksheet->setCellValue('B1', 'comment');
+ $worksheet->setCellValue('A2', 'Test Excel 1');
+ $worksheet->setCellValue('B2', 'Test Excel 1 notes');
+ $worksheet->setCellValue('A3', 'Test Excel 2');
+ $worksheet->setCellValue('B3', 'Test Excel 2 notes');
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'test_excel') . '.xlsx';
+ $writer = new Xlsx($spreadsheet);
+ $writer->save($tempFile);
+
+ $file = new File($tempFile);
+
+ $errors = [];
+ $results = $this->service->importFile($file, [
+ 'class' => Project::class,
+ 'format' => 'xlsx',
+ 'csv_delimiter' => ';',
+ ], $errors);
+
+ $this->assertCount(2, $results);
+ $this->assertEmpty($errors);
+ $this->assertContainsOnlyInstancesOf(Project::class, $results);
+
+ $this->assertSame('Test Excel 1', $results[0]->getName());
+ $this->assertSame('Test Excel 1 notes', $results[0]->getComment());
+ $this->assertSame('Test Excel 2', $results[1]->getName());
+ $this->assertSame('Test Excel 2 notes', $results[1]->getComment());
+
+ unlink($tempFile);
+ }
}
diff --git a/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php b/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php
index 07bb42708..eb80828b1 100644
--- a/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php
+++ b/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php
@@ -59,26 +59,29 @@ class TimestampableElementProviderTest extends WebTestCase
protected function setUp(): void
{
self::bootKernel();
- \Locale::setDefault('en');
+ \Locale::setDefault('en_US');
$this->service = self::getContainer()->get(TimestampableElementProvider::class);
- $this->target = new class() implements TimeStampableInterface {
+ $this->target = new class () implements TimeStampableInterface {
public function getLastModified(): ?DateTime
{
- return new \DateTime('2000-01-01');
+ return new DateTime('2000-01-01');
}
public function getAddedDate(): ?DateTime
{
- return new \DateTime('2000-01-01');
+ return new DateTime('2000-01-01');
}
};
}
public function dataProvider(): \Iterator
{
- \Locale::setDefault('en');
- yield ['1/1/00, 12:00 AM', '[[LAST_MODIFIED]]'];
- yield ['1/1/00, 12:00 AM', '[[CREATION_DATE]]'];
+ \Locale::setDefault('en_US');
+ // Use IntlDateFormatter like the actual service does
+ $formatter = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
+ $expectedFormat = $formatter->format(new DateTime('2000-01-01'));
+ yield [$expectedFormat, '[[LAST_MODIFIED]]'];
+ yield [$expectedFormat, '[[CREATION_DATE]]'];
}
/**
diff --git a/tests/Services/Parts/PartsTableActionHandlerTest.php b/tests/Services/Parts/PartsTableActionHandlerTest.php
new file mode 100644
index 000000000..c5105cd7b
--- /dev/null
+++ b/tests/Services/Parts/PartsTableActionHandlerTest.php
@@ -0,0 +1,62 @@
+.
+ */
+namespace App\Tests\Services\Parts;
+
+use App\Entity\Parts\Part;
+use App\Services\Parts\PartsTableActionHandler;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+class PartsTableActionHandlerTest extends WebTestCase
+{
+ private PartsTableActionHandler $service;
+
+ protected function setUp(): void
+ {
+ self::bootKernel();
+ $this->service = self::getContainer()->get(PartsTableActionHandler::class);
+ }
+
+ public function testExportActionsRedirectToExportController(): void
+ {
+ // Mock a Part entity with required properties
+ $part = $this->createMock(Part::class);
+ $part->method('getId')->willReturn(1);
+ $part->method('getName')->willReturn('Test Part');
+
+ $selected_parts = [$part];
+
+ // Test each export format, focusing on our new xlsx format
+ $formats = ['json', 'csv', 'xml', 'yaml', 'xlsx'];
+
+ foreach ($formats as $format) {
+ $action = "export_{$format}";
+ $result = $this->service->handleAction($action, $selected_parts, 1, '/test');
+
+ $this->assertInstanceOf(RedirectResponse::class, $result);
+ $this->assertStringContainsString('parts/export', $result->getTargetUrl());
+ $this->assertStringContainsString("format={$format}", $result->getTargetUrl());
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index e974d34a9..b95c61c8c 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -10903,6 +10903,12 @@ Element 3
Export to XML
+
+
+ part_list.action.export_xlsx
+ Export to Excel
+
+
parts.import.title