diff --git a/.docker/frankenphp/docker-entrypoint.sh b/.docker/frankenphp/docker-entrypoint.sh index 1655af5a..56b3bc31 100644 --- a/.docker/frankenphp/docker-entrypoint.sh +++ b/.docker/frankenphp/docker-entrypoint.sh @@ -26,6 +26,28 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then composer install --prefer-dist --no-progress --no-interaction fi + # Install additional composer packages if COMPOSER_EXTRA_PACKAGES is set + if [ -n "$COMPOSER_EXTRA_PACKAGES" ]; then + echo "Installing additional composer packages: $COMPOSER_EXTRA_PACKAGES" + # Note: COMPOSER_EXTRA_PACKAGES is intentionally not quoted to allow word splitting + # This enables passing multiple package names separated by spaces + # shellcheck disable=SC2086 + composer require $COMPOSER_EXTRA_PACKAGES --no-install --no-interaction --no-progress + if [ $? -eq 0 ]; then + echo "Running composer install to install packages without dev dependencies..." + composer install --no-dev --no-interaction --no-progress --optimize-autoloader + if [ $? -eq 0 ]; then + echo "Successfully installed additional composer packages" + else + echo "Failed to install composer dependencies" + exit 1 + fi + else + echo "Failed to add additional composer packages to composer.json" + exit 1 + fi + fi + if grep -q ^DATABASE_URL= .env; then echo "Waiting for database to be ready..." ATTEMPTS_LEFT_TO_REACH_DATABASE=60 diff --git a/.docker/partdb-entrypoint.sh b/.docker/partdb-entrypoint.sh index ffd2b24a..61e8d1e6 100644 --- a/.docker/partdb-entrypoint.sh +++ b/.docker/partdb-entrypoint.sh @@ -39,8 +39,30 @@ if [ -d /var/www/html/var/db ]; then fi fi +# Install additional composer packages if COMPOSER_EXTRA_PACKAGES is set +if [ -n "$COMPOSER_EXTRA_PACKAGES" ]; then + echo "Installing additional composer packages: $COMPOSER_EXTRA_PACKAGES" + # Note: COMPOSER_EXTRA_PACKAGES is intentionally not quoted to allow word splitting + # This enables passing multiple package names separated by spaces + # shellcheck disable=SC2086 + sudo -E -u www-data composer require $COMPOSER_EXTRA_PACKAGES --no-install --no-interaction --no-progress + if [ $? -eq 0 ]; then + echo "Running composer install to install packages without dev dependencies..." + sudo -E -u www-data composer install --no-dev --no-interaction --no-progress --optimize-autoloader + if [ $? -eq 0 ]; then + echo "Successfully installed additional composer packages" + else + echo "Failed to install composer dependencies" + exit 1 + fi + else + echo "Failed to add additional composer packages to composer.json" + exit 1 + fi +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 @@ -90,4 +112,4 @@ if [ "${1#-}" != "$1" ]; then fi # Pass to the original entrypoint -exec "$@" \ No newline at end of file +exec "$@" diff --git a/.docker/symfony.conf b/.docker/symfony.conf index b5229bf6..aa88eef2 100644 --- a/.docker/symfony.conf +++ b/.docker/symfony.conf @@ -24,34 +24,10 @@ ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined - # Pass the configuration from the docker env to the PHP environment (here you should list all .env options) - PassEnv APP_ENV APP_DEBUG APP_SECRET REDIRECT_TO_HTTPS DISABLE_YEAR2038_BUG_CHECK - PassEnv TRUSTED_PROXIES TRUSTED_HOSTS LOCK_DSN - PassEnv DATABASE_URL ENFORCE_CHANGE_COMMENTS_FOR DATABASE_MYSQL_USE_SSL_CA DATABASE_MYSQL_SSL_VERIFY_CERT - PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR MAX_ATTACHMENT_FILE_SIZE DEFAULT_URI CHECK_FOR_UPDATES ATTACHMENT_DOWNLOAD_BY_DEFAULT - PassEnv MAILER_DSN ALLOW_EMAIL_PW_RESET EMAIL_SENDER_EMAIL EMAIL_SENDER_NAME - PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA HISTORY_SAVE_NEW_DATA - PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP - PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER - # In old version the SAML sp private key env, was wrongly named SAMLP_SP_PRIVATE_KEY, keep it for backward compatibility - PassEnv SAML_ENABLED SAML_BEHIND_PROXY SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY - PassEnv TABLE_DEFAULT_PAGE_SIZE TABLE_PARTS_DEFAULT_COLUMNS - - PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY - PassEnv PROVIDER_ELEMENT14_KEY PROVIDER_ELEMENT14_STORE_ID - PassEnv PROVIDER_TME_KEY PROVIDER_TME_SECRET PROVIDER_TME_CURRENCY PROVIDER_TME_LANGUAGE PROVIDER_TME_COUNTRY PROVIDER_TME_GET_GROSS_PRICES - PassEnv PROVIDER_OCTOPART_CLIENT_ID PROVIDER_OCTOPART_SECRET PROVIDER_OCTOPART_CURRENCY PROVIDER_OCTOPART_COUNTRY PROVIDER_OCTOPART_SEARCH_LIMIT PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS - PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE - PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY - PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA - PassEnv PROVIDER_REICHELT_ENABLED PROVIDER_REICHELT_CURRENCY PROVIDER_REICHELT_COUNTRY PROVIDER_REICHELT_LANGUAGE PROVIDER_REICHELT_INCLUDE_VAT - PassEnv PROVIDER_POLLIN_ENABLED - PassEnv EDA_KICAD_CATEGORY_DEPTH - # For most configuration files from conf-available/, which are # enabled or disabled at a global level, it is possible to # include a line for only one particular virtual host. For example the # following line enables the CGI configuration for this host only # after it has been globally disabled with "a2disconf". #Include conf-available/serve-cgi-bin.conf - \ No newline at end of file + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..66990769 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env index 1806e9c6..9a6ce846 100644 --- a/.env +++ b/.env @@ -31,37 +31,9 @@ DATABASE_EMULATE_NATURAL_SORT=0 # General settings ################################################################################### -# The language to use serverwide as default (en, de, ru, etc.) -DEFAULT_LANG="en" -# The default timezone to use serverwide (e.g. Europe/Berlin) -DEFAULT_TIMEZONE="Europe/Berlin" -# The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country -BASE_CURRENCY="EUR" -# The name of this installation. This will be shown as title in the browser and in the header of the website -INSTANCE_NAME="Part-DB" -# Allow users to download attachments to the server by providing an URL -# This could be a potential security issue, as the user can retrieve any file the server has access to (via internet) -ALLOW_ATTACHMENT_DOWNLOADS=0 -# Set this to 1, if the "download external files" checkbox should be checked by default for new attachments -ATTACHMENT_DOWNLOAD_BY_DEFAULT=0 -# Use gravatars for user avatars, when user has no own avatar defined -USE_GRAVATAR=0 -# The maximum allowed size for attachment files in bytes (you can use M for megabytes and G for gigabytes) -# Please note that the php.ini setting upload_max_filesize also limits the maximum size of uploaded files -MAX_ATTACHMENT_FILE_SIZE="100M" - -# The public reachable URL of this Part-DB installation. This is used for generating links in SAML and email templates -# This must end with a slash! +# The public reachable URL of this Part-DB installation. This is used for generating links in SAML and email templates or when no request context is available. DEFAULT_URI="https://partdb.changeme.invalid/" -# With this option you can configure, where users are enforced to give a change reason, which will be logged -# This is a comma separated list of values, see documentation for available values -# Leave this empty, to make all change reasons optional -ENFORCE_CHANGE_COMMENTS_FOR="" - -# Disable that if you do not want that Part-DB connects to GitHub to check for available updates, or if your server can not connect to the internet -CHECK_FOR_UPDATES=1 - ################################################################################### # Email settings ################################################################################### @@ -78,21 +50,6 @@ EMAIL_SENDER_NAME="Part-DB Mailer" # Set this to 1 to allow reset of a password per email ALLOW_EMAIL_PW_RESET=0 -###################################################################################### -# History/Eventlog settings -###################################################################################### -# If you want to use full timetrave functionality all values below have to be set to 1 - -# Save which fields were changed in a ElementEdited log entry -HISTORY_SAVE_CHANGED_FIELDS=1 -# Save the old data in the ElementEdited log entry (warning this could increase the database size in short time) -HISTORY_SAVE_CHANGED_DATA=1 -# Save the data of an element that gets removed into log entry. This allows to undelete an element -HISTORY_SAVE_REMOVED_DATA=1 -# Save the new data of an element that gets changed or added. This allows an easy comparison of the old and new data on the detail page -# This option only becomes active when HISTORY_SAVE_CHANGED_DATA is set to 1 -HISTORY_SAVE_NEW_DATA=1 - ################################################################################### # Error pages settings ################################################################################### @@ -102,149 +59,6 @@ ERROR_PAGE_ADMIN_EMAIL='' # If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them... ERROR_PAGE_SHOW_HELP=1 -################################################################################## -# Part table settings -################################################################################## - -# The default page size for the part table (set to -1 to show all parts on one page) -TABLE_DEFAULT_PAGE_SIZE=50 -# Configure which columns will be visible by default in the parts table (and in which order). -# This is a comma separated list of column names. See documentation for available values. -TABLE_PARTS_DEFAULT_COLUMNS=name,description,category,footprint,manufacturer,storage_location,amount - -################################################################################## -# Info provider settings -################################################################################## - -# Digikey Provider: -# You can get your client id and secret from https://developer.digikey.com/ -PROVIDER_DIGIKEY_CLIENT_ID= -PROVIDER_DIGIKEY_SECRET= -# The currency to get prices in -PROVIDER_DIGIKEY_CURRENCY=EUR -# The language to get results in (en, de, fr, it, es, zh, ja, ko) -PROVIDER_DIGIKEY_LANGUAGE=en -# The country to get results for -PROVIDER_DIGIKEY_COUNTRY=DE - -# Farnell Provider: -# You can get your API key from https://partner.element14.com/ -PROVIDER_ELEMENT14_KEY= -# Configure the store domain you want to use. This decides the language and currency of results. You can get a list of available stores from https://partner.element14.com/docs/Product_Search_API_REST__Description -PROVIDER_ELEMENT14_STORE_ID=de.farnell.com - -# TME Provider: -# You can get your API key from https://developers.tme.eu/en/ -PROVIDER_TME_KEY= -PROVIDER_TME_SECRET= -# The currency to get prices in -PROVIDER_TME_CURRENCY=EUR -# The language to get results in (en, de, pl) -PROVIDER_TME_LANGUAGE=en -# The country to get results for -PROVIDER_TME_COUNTRY=DE -# [DEPRECATED] Set this to 1 to get gross prices (including VAT) instead of net prices -# With private API keys, this option cannot be used anymore is ignored by Part-DB. The VAT inclusion depends on your TME account settings. -PROVIDER_TME_GET_GROSS_PRICES=1 - -# Octopart / Nexar Provider: -# You can get your API key from https://nexar.com/api -PROVIDER_OCTOPART_CLIENT_ID= -PROVIDER_OCTOPART_SECRET= -# The currency and country to get prices for (you have to set both to get meaningful results) -# 3 letter ISO currency code (e.g. EUR, USD, GBP) -PROVIDER_OCTOPART_CURRENCY=EUR -# 2 letter ISO country code (e.g. DE, US, GB) -PROVIDER_OCTOPART_COUNTRY=DE -# The number of results to get from Octopart while searching (please note that this counts towards your API limits) -PROVIDER_OCTOPART_SEARCH_LIMIT=10 -# Set to false to include non authorized offers in the results -PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS=1 - -# Mouser Provider API V2: -# You can get your API key from https://www.mouser.it/api-hub/ -PROVIDER_MOUSER_KEY= -# Filter search results by RoHS compliance and stock availability: -# Available options: None | Rohs | InStock | RohsAndInStock -PROVIDER_MOUSER_SEARCH_OPTION='None' -# The number of results to get from Mouser while searching (please note that this value is max 50) -PROVIDER_MOUSER_SEARCH_LIMIT=50 -# It is recommended to leave this set to 'true'. The option is not really good doumented by Mouser: -# Used when searching for keywords in the language specified when you signed up for Search API. -PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE='true' - -# LCSC Provider: -# LCSC does not provide an offical API, so this used the API LCSC uses to render their webshop. -# LCSC did not intended the use of this API and it could break any time, so use it at your own risk. - -# We dont require an API key for LCSC, just set this to 1 to enable LCSC support -PROVIDER_LCSC_ENABLED=0 -# The currency to get prices in (e.g. EUR, USD, etc.) -PROVIDER_LCSC_CURRENCY=EUR - -# Oemsecrets Provider API 3.0.1: -# You can get your API key from https://www.oemsecrets.com/api -PROVIDER_OEMSECRETS_KEY= -# The country you want the output for -PROVIDER_OEMSECRETS_COUNTRY_CODE=DE -# Available country code are: -# AD, AE, AQ, AR, AT, AU, BE, BO, BR, BV, BY, CA, CH, CL, CN, CO, CZ, DE, DK, EC, EE, EH, -# ES, FI, FK, FO, FR, GB, GE, GF, GG, GI, GL, GR, GS, GY, HK, HM, HR, HU, IE, IM, IN, IS, -# IT, JM, JP, KP, KR, KZ, LI, LK, LT, LU, MC, MD, ME, MK, MT, NL, NO, NZ, PE, PH, PL, PT, -# PY, RO, RS, RU, SB, SD, SE, SG, SI, SJ, SK, SM, SO, SR, SY, SZ, TC, TF, TG, TH, TJ, TK, -# TM, TN, TO, TR, TT, TV, TW, TZ, UA, UG, UM, US, UY, UZ, VA, VE, VG, VI, VN, VU, WF, YE, -# ZA, ZM, ZW -# -# The currency you want the prices to be displayed in -PROVIDER_OEMSECRETS_CURRENCY=EUR -# Available currency are:AUD, CAD, CHF, CNY, DKK, EUR, GBP, HKD, ILS, INR, JPY, KRW, NOK, -# NZD, RUB, SEK, SGD, TWD, USD -# -# If PROVIDER_OEMSECRETS_ZERO_PRICE is set to 0, distributors with zero prices -# will be discarded from the creation of a new part (set to 1 otherwise) -PROVIDER_OEMSECRETS_ZERO_PRICE=0 -# -# When PROVIDER_OEMSECRETS_SET_PARAM is set to 1 the parameters for the part are generated -# from the description transforming unstructured descriptions into structured parameters; -# each parameter in description should have the form: "...;name1:value1;name2:value2" -PROVIDER_OEMSECRETS_SET_PARAM=1 -# -# This environment variable determines the sorting criteria for product results. -# The sorting process first arranges items based on the provided keyword. -# Then, if set to 'C', it further sorts by completeness (prioritizing items with the most -# detailed information). If set to 'M', it further sorts by manufacturer name. -#If unset or set to any other value, no sorting is performed. -PROVIDER_OEMSECRETS_SORT_CRITERIA=C - - -# Reichelt provider: -# Reichelt.com offers no official API, so this info provider webscrapes the website to extract info -# It could break at any time, use it at your own risk -# We dont require an API key for Reichelt, just set this to 1 to enable Reichelt support -PROVIDER_REICHELT_ENABLED=0 -# The country to get prices for -PROVIDER_REICHELT_COUNTRY=DE -# The language to get results in (en, de, fr, nl, pl, it, es) -PROVIDER_REICHELT_LANGUAGE=en -# Include VAT in prices (set to 1 to include VAT, 0 to exclude VAT) -PROVIDER_REICHELT_INCLUDE_VAT=1 -# The currency to get prices in (only for countries with countries other than EUR) -PROVIDER_REICHELT_CURRENCY=EUR - -# Pollin provider: -# Pollin.de offers no official API, so this info provider webscrapes the website to extract info -# It could break at any time, use it at your own risk -# We dont require an API key for Pollin, just set this to 1 to enable Pollin support -PROVIDER_POLLIN_ENABLED=0 - -################################################################################## -# EDA integration related settings -################################################################################## - -# This value determines the depth of the category tree, that is visible inside KiCad -# 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. -# Set to -1, to show all parts of Part-DB inside a single category in KiCad -EDA_KICAD_CATEGORY_DEPTH=0 ################################################################################### # SAML Single sign on-settings @@ -298,16 +112,6 @@ NO_URL_REWRITE_AVAILABLE=0 # Set to 1, if Part-DB should redirect all HTTP requests to HTTPS. You dont need to configure this, if your webserver already does this. REDIRECT_TO_HTTPS=0 -# If you want to use fixer.io for currency conversion, you have to set this to your API key -FIXER_API_KEY=CHANGEME - -# Override value if you want to show to show a given text on homepage. -# When this is empty the content of config/banner.md is used as banner -BANNER="" - -APP_ENV=prod -APP_SECRET=a03498528f5a5fc089273ec9ae5b2849 - # Set this to zero, if you want to disable the year 2038 bug check on 32-bit systems (it will cause errors with current 32-bit PHP versions) DISABLE_YEAR2038_BUG_CHECK=0 @@ -325,3 +129,9 @@ LOCK_DSN=flock ###> nelmio/cors-bundle ### CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' ###< nelmio/cors-bundle ### + +###> symfony/framework-bundle ### +APP_ENV=prod +APP_SECRET=a03498528f5a5fc089273ec9ae5b2849 +APP_SHARE_DIR=var/share +###< symfony/framework-bundle ### diff --git a/.env.dev b/.env.dev index e69de29b..53b05877 100644 --- a/.env.dev +++ b/.env.dev @@ -0,0 +1,4 @@ + +###> symfony/framework-bundle ### +APP_SECRET=318b5d659e07a0b3f96d9b3a83b254ca +###< symfony/framework-bundle ### diff --git a/.env.test b/.env.test index 3dbece81..9117ff16 100644 --- a/.env.test +++ b/.env.test @@ -10,4 +10,6 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/app_test.db" #DATABASE_URL=mysql://root:@127.0.0.1:3306/part-db # Disable update checks, as tests would fail, when github is not reachable -CHECK_FOR_UPDATES=0 \ No newline at end of file +CHECK_FOR_UPDATES=0 + +INSTANCE_NAME="Part-DB" \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..637a66b5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,186 @@ +# Copilot Instructions for Part-DB + +Part-DB is an Open-Source inventory management system for electronic components built with Symfony 7.4 and modern web technologies. + +## Technology Stack + +- **Backend**: PHP 8.2+, Symfony 7.4, Doctrine ORM +- **Frontend**: Bootstrap 5, Hotwire Stimulus/Turbo, TypeScript, Webpack Encore +- **Database**: MySQL 5.7+/MariaDB 10.4+/PostgreSQL 10+/SQLite +- **Testing**: PHPUnit with DAMA Doctrine Test Bundle +- **Code Quality**: Easy Coding Standard (ECS), PHPStan (level 5) + +## Project Structure + +- `src/`: PHP application code organized by purpose (Controller, Entity, Service, Form, etc.) +- `assets/`: Frontend TypeScript/JavaScript and CSS files +- `templates/`: Twig templates for views +- `tests/`: PHPUnit tests mirroring the `src/` structure +- `config/`: Symfony configuration files +- `public/`: Web-accessible files +- `translations/`: Translation files for multi-language support + +## Coding Standards + +### PHP Code + +- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) and [Symfony coding standards](https://symfony.com/doc/current/contributing/code/standards.html) +- Use type hints for all parameters and return types +- Always declare strict types: `declare(strict_types=1);` at the top of PHP files +- Use PHPDoc blocks for complex logic or when type information is needed + +### TypeScript/JavaScript + +- Use TypeScript for new frontend code +- Follow existing Stimulus controller patterns in `assets/controllers/` +- Use Bootstrap 5 components and utilities +- Leverage Hotwire Turbo for dynamic page updates + +### Naming Conventions + +- Entities: Use descriptive names that reflect database models (e.g., `Part`, `StorageLocation`) +- Controllers: Suffix with `Controller` (e.g., `PartController`) +- Services: Descriptive names reflecting their purpose (e.g., `PartService`, `LabelGenerator`) +- Tests: Match the class being tested with `Test` suffix (e.g., `PartTest`, `PartControllerTest`) + +## Development Workflow + +### Dependencies + +- Install PHP dependencies: `composer install` +- Install JS dependencies: `yarn install` +- Build frontend assets: `yarn build` (production) or `yarn watch` (development) + +### Database + +- Create database: `php bin/console doctrine:database:create --env=dev` +- Run migrations: `php bin/console doctrine:migrations:migrate --env=dev` +- Load fixtures: `php bin/console partdb:fixtures:load -n --env=dev` + +Or use Makefile shortcuts: +- `make dev-setup`: Complete development environment setup +- `make dev-reset`: Reset development environment (cache clear + migrate) + +### Testing + +- Set up test environment: `make test-setup` +- Run all tests: `php bin/phpunit` +- Run specific test: `php bin/phpunit tests/Path/To/SpecificTest.php` +- Run tests with coverage: `php bin/phpunit --coverage-html var/coverage` +- Test environment uses SQLite by default for speed + +### Static Analysis + +- Run PHPStan: `composer phpstan` or `COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5` +- PHPStan configuration is in `phpstan.dist.neon` + +### Running the Application + +- Development server: `symfony serve` (requires Symfony CLI) +- Or configure Apache/nginx to serve from `public/` directory +- Set `APP_ENV=dev` in `.env.local` for development mode + +## Best Practices + +### Security + +- Always sanitize user input +- Use Symfony's security component for authentication/authorization +- Check permissions using the permission system before allowing actions +- Never expose sensitive data in logs or error messages +- Use parameterized queries (Doctrine handles this automatically) + +### Performance + +- Use Doctrine query builder for complex queries instead of DQL when possible +- Lazy load relationships to avoid N+1 queries +- Cache results when appropriate using Symfony's cache component +- Use pagination for large result sets (DataTables integration available) + +### Database + +- Always create migrations for schema changes: `php bin/console make:migration` +- Review migration files before running them +- Use Doctrine annotations or attributes for entity mapping +- Follow existing entity patterns for relationships and lifecycle callbacks + +### Frontend + +- Use Stimulus controllers for interactive components +- Leverage Turbo for dynamic page updates without full page reloads +- Use Bootstrap 5 classes for styling +- Keep JavaScript modular and organized in controllers +- Use the translation system for user-facing strings + +### Translations + +- Use translation keys, not hardcoded strings: `{{ 'part.info.title'|trans }}` +- Add new translation keys to `translations/` files +- Primary language is English (en) +- Translations are managed via Crowdin, but can be edited locally if needed + +### Testing + +- Write unit tests for services and helpers +- Write functional tests for controllers +- Use fixtures for test data +- Tests should be isolated and not depend on execution order +- Mock external dependencies when appropriate +- Follow existing test patterns in the repository + +## Common Patterns + +### Creating an Entity + +1. Create entity class in `src/Entity/` with Doctrine attributes +2. Generate migration: `php bin/console make:migration` +3. Review and run migration: `php bin/console doctrine:migrations:migrate` +4. Create repository if needed in `src/Repository/` +5. Add fixtures in `src/DataFixtures/` for testing + +### Adding a Form + +1. Create form type in `src/Form/` +2. Extend `AbstractType` and implement `buildForm()` and `configureOptions()` +3. Use in controller and render in Twig template +4. Follow existing form patterns for consistency + +### Creating a Controller Action + +1. Add method to appropriate controller in `src/Controller/` +2. Use route attributes for routing +3. Check permissions using security voters +4. Return Response or render Twig template +5. Add corresponding template in `templates/` + +### Adding a Service + +1. Create service class in `src/Services/` +2. Use dependency injection via constructor +3. Tag service in `config/services.yaml` if needed +4. Services are autowired by default + +## Important Notes + +- Part-DB uses fine-grained permissions - always check user permissions before actions +- Multi-language support is critical - use translation keys everywhere +- The application supports multiple database backends - write portable code +- Responsive design is important - test on mobile/tablet viewports +- Event system is used for logging changes - emit events when appropriate +- API Platform is integrated for REST API endpoints + +## Multi-tenancy Considerations + +- Part-DB is designed as a single-tenant application with multiple users +- User groups have different permission levels +- Always scope queries to respect user permissions +- Use the security context to get current user information + +## Resources + +- [Documentation](https://docs.part-db.de/) +- [Contributing Guide](CONTRIBUTING.md) +- [Symfony Documentation](https://symfony.com/doc/current/index.html) +- [Doctrine Documentation](https://www.doctrine-project.org/projects/doctrine-orm/en/current/) +- [Bootstrap 5 Documentation](https://getbootstrap.com/docs/5.1/) +- [Hotwire Documentation](https://hotwired.dev/) diff --git a/.github/workflows/assets_artifact_build.yml b/.github/workflows/assets_artifact_build.yml index 0bbfe432..3409b7fd 100644 --- a/.github/workflows/assets_artifact_build.yml +++ b/.github/workflows/assets_artifact_build.yml @@ -1,5 +1,8 @@ name: Build assets artifact +permissions: + contents: read + on: push: branches: @@ -19,7 +22,7 @@ jobs: APP_ENV: prod steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -34,12 +37,12 @@ jobs: run: | echo "::set-output name=dir::$(composer config cache-files-dir)" - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-composer- + ${{ runner.os }}-composer- - name: Install dependencies run: composer install --prefer-dist --no-progress --no-dev -a @@ -48,7 +51,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v4 + - uses: actions/cache@v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -57,9 +60,9 @@ jobs: ${{ runner.os }}-yarn- - name: Setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '18' + node-version: '20' - name: Install yarn dependencies run: yarn install @@ -77,13 +80,13 @@ jobs: run: zip -r /tmp/partdb_assets.zip public/build/ vendor/ - name: Upload assets artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Only dependencies and built assets path: /tmp/partdb_assets.zip - name: Upload full artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Full Part-DB including dependencies and built assets path: /tmp/partdb_with_assets.zip diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 64287d83..ce3243ca 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -1,5 +1,8 @@ name: Docker Image Build +permissions: + contents: read + on: #schedule: # - cron: '0 10 * * *' # everyday at 10am @@ -17,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Docker meta id: docker_meta @@ -73,4 +76,4 @@ jobs: tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker_frankenphp.yml b/.github/workflows/docker_frankenphp.yml index d8cd0695..1180f0c5 100644 --- a/.github/workflows/docker_frankenphp.yml +++ b/.github/workflows/docker_frankenphp.yml @@ -1,5 +1,8 @@ name: Docker Image Build (FrankenPHP) +permissions: + contents: read + on: #schedule: # - cron: '0 10 * * *' # everyday at 10am @@ -17,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Docker meta id: docker_meta @@ -74,4 +77,4 @@ jobs: tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 20150b28..f47ce87b 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -1,5 +1,8 @@ name: Static analysis +permissions: + contents: read + on: push: branches: @@ -16,7 +19,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -30,20 +33,20 @@ jobs: id: composer-cache run: | echo "::set-output name=dir::$(composer config cache-files-dir)" - - - uses: actions/cache@v4 + + - uses: actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-composer- + ${{ runner.os }}-composer- - name: Install dependencies run: composer install --prefer-dist --no-progress - name: Lint config files run: ./bin/console lint:yaml config --parse-tags - + - name: Lint twig templates run: ./bin/console lint:twig templates --env=prod @@ -53,13 +56,13 @@ jobs: - name: Check dependencies for security uses: symfonycorp/security-checker-action@v5 - + - name: Check doctrine mapping run: ./bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction # Use the -d option to raise the max nesting level - name: Generate dev container run: php -d xdebug.max_nesting_level=1000 ./bin/console cache:clear --env dev - + - name: Run PHPstan run: composer phpstan diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e6ea54c..3df1955a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,8 @@ name: PHPUnit Tests +permissions: + contents: read + on: push: branches: @@ -9,7 +12,7 @@ on: branches: - '*' - "!l10n_*" - + jobs: phpunit: name: PHPUnit and coverage Test (PHP ${{ matrix.php-versions }}, ${{ matrix.db-type }}) @@ -18,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: [ '8.1', '8.2', '8.3', '8.4' ] + php-versions: ['8.2', '8.3', '8.4', '8.5' ] db-type: [ 'mysql', 'sqlite', 'postgres' ] env: @@ -43,7 +46,7 @@ jobs: if: matrix.db-type == 'postgres' - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -52,7 +55,7 @@ jobs: coverage: pcov ini-values: xdebug.max_nesting_level=1000 extensions: mbstring, intl, gd, xsl, gmp, bcmath, :php-psr - + - name: Start MySQL run: sudo systemctl start mysql.service if: matrix.db-type == 'mysql' @@ -71,73 +74,73 @@ jobs: # mysql version: 5.7 # mysql database: 'part-db' # mysql root password: '1234' - + ## Setup caches - + - name: Get Composer Cache Directory id: composer-cache run: | echo "::set-output name=dir::$(composer config cache-files-dir)" - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-composer- - + ${{ runner.os }}-composer- + - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v4 + - uses: actions/cache@v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - + - name: Install composer dependencies run: composer install --prefer-dist --no-progress - + - name: Setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '18' - + node-version: '20' + - name: Install yarn dependencies run: yarn install - + - name: Build frontend run: yarn build - + - name: Create DB run: php bin/console --env test doctrine:database:create --if-not-exists -n if: matrix.db-type == 'mysql' || matrix.db-type == 'postgres' - + - name: Do migrations run: php bin/console --env test doctrine:migrations:migrate -n # Use our own custom fixtures loading command to circumvent some problems with reset the autoincrement values - name: Load fixtures run: php bin/console --env test partdb:fixtures:load -n - + - name: Run PHPunit and generate coverage run: ./bin/phpunit --coverage-clover=coverage.xml - + - name: Upload coverage uses: codecov/codecov-action@v5 with: env_vars: PHP_VERSION,DB_TYPE token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true - + - name: Test app:clean-attachments run: php bin/console partdb:attachments:clean-unused -n - + - name: Test app:convert-bbcode run: php bin/console app:convert-bbcode -n - + - name: Test app:show-logs run: php bin/console app:show-logs -n diff --git a/.gitignore b/.gitignore index b726f64c..dd5c43db 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.env.local /.env.local.php /.env.*.local +/.env.local.bak /config/secrets/prod/prod.decrypt.private.php /public/bundles/ /var/ @@ -41,9 +42,12 @@ yarn-error.log ###> phpunit/phpunit ### /phpunit.xml -.phpunit.result.cache +/.phpunit.cache/ ###< phpunit/phpunit ### ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### + +.claude/ +CLAUDE.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d31c904e..5994a115 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # How to contribute -Thank you for consider to contribute to Part-DB! -Please read the text below, so your contributed content can be contributed easily to Part-DB. +Thank you for considering contributing to Part-DB! +Please read the text below, so your contributed content can be incorporated into Part-DB easily. You can contribute to Part-DB in various ways: * Report bugs and request new features via [issues](https://github.com/Part-DB/Part-DB-server/issues) @@ -18,38 +18,38 @@ Part-DB uses translation keys (e.g. part.info.title) that are sorted by their us was translated in other languages (this is possible via the "Other languages" dropdown in the translation editor). ## Project structure -Part-DB uses symfony's recommended [project structure](https://symfony.com/doc/current/best_practices.html). +Part-DB uses Symfony's recommended [project structure](https://symfony.com/doc/current/best_practices.html). Interesting folders are: -* `public`: Everything in this directory will be publicy accessible via web. Use this folder to serve static images. -* `assets`: The frontend assets are saved here. You can find the javascript and CSS code here. -* `src`: Part-DB's PHP code is saved here. Note that the sub directories are structured by the classes purposes (so use `Controller` Controllers, `Entities` for Database models, etc.) -* `translations`: The translations used in Part-DB are saved here +* `public`: Everything in this directory will be publicly accessible via web. Use this folder to serve static images. +* `assets`: The frontend assets are saved here. You can find the JavaScript and CSS code here. +* `src`: Part-DB's PHP code is saved here. Note that the subdirectories are structured by the classes' purposes (so use `Controller` for Controllers, `Entity` for Database models, etc.) +* `translations`: The translations used in Part-DB are saved here. * `templates`: The templates (HTML) that are used by Twig to render the different pages. Email templates are also saved here. -* `tests/`: Tests that can be run by PHPunit. +* `tests/`: Tests that can be run by PHPUnit. ## Development environment -For setting up an development you will need to install PHP, composer, a database server (MySQL or MariaDB) and yarn (which needs an nodejs environment). -* Copy `.env` to `.env.local` and change `APP_ENV` to `APP_ENV=dev`. That way you will get development tools (symfony profiler) and other features that +For setting up a development environment, you will need to install PHP, Composer, a database server (MySQL or MariaDB) and yarn (which needs a Node.js environment). +* Copy `.env` to `.env.local` and change `APP_ENV` to `APP_ENV=dev`. That way you will get development tools (Symfony profiler) and other features that will simplify development. -* Run `composer install` (without -o) to install PHP dependencies and `yarn install` to install frontend dependencies -* Run `yarn watch`. The program will run in the background and compile the frontend files whenever you change something in the CSS or TypeScript files -* For running Part-DB it is recommended to use [Symfony CLI](https://symfony.com/download). -That way you can run a correct configured webserver with `symfony serve` +* Run `composer install` (without -o) to install PHP dependencies and `yarn install` to install frontend dependencies. +* Run `yarn watch`. The program will run in the background and compile the frontend files whenever you change something in the CSS or TypeScript files. +* For running Part-DB, it is recommended to use [Symfony CLI](https://symfony.com/download). +That way you can run a correctly configured webserver with `symfony serve`. ## Coding style -Code should follow the [PSR12-Standard](https://www.php-fig.org/psr/psr-12/) and symfony's [coding standards](https://symfony.com/doc/current/contributing/code/standards.html). +Code should follow the [PSR-12 Standard](https://www.php-fig.org/psr/psr-12/) and Symfony's [coding standards](https://symfony.com/doc/current/contributing/code/standards.html). Part-DB uses [Easy Coding Standard](https://github.com/symplify/easy-coding-standard) to check and fix coding style violations: -* To check your code for valid code style run `vendor/bin/ecs check src/` -* To fix violations run `vendor/bin/ecs check src/` (please checks afterwards if the code is valid afterwards) +* To check your code for valid code style, run `vendor/bin/ecs check src/` +* To fix violations, run `vendor/bin/ecs check src/ --fix` (please check afterwards if the code is still valid) ## GitHub actions -Part-DB uses GitHub actions to run various tests and checks on the code: +Part-DB uses GitHub Actions to run various tests and checks on the code: * Yarn dependencies can compile -* PHPunit tests run successful -* Config files, translations and templates has valid syntax -* Doctrine schema valid -* No known vulnerable dependecies are used -* Static analysis successful (phpstan with `--level=2`) +* PHPUnit tests run successfully +* Config files, translations, and templates have valid syntax +* Doctrine schema is valid +* No known vulnerable dependencies are used +* Static analysis is successful (phpstan with `--level=2`) -Further the code coverage of the PHPunit tests is determined and uploaded to [CodeCov](https://codecov.io/gh/Part-DB/Part-DB-server). +Further, the code coverage of the PHPUnit tests is determined and uploaded to [CodeCov](https://codecov.io/gh/Part-DB/Part-DB-server). diff --git a/Dockerfile b/Dockerfile index 0f909f16..cb18c78f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG BASE_IMAGE=debian:bookworm-slim -ARG PHP_VERSION=8.3 +ARG PHP_VERSION=8.4 FROM ${BASE_IMAGE} AS base ARG PHP_VERSION @@ -48,7 +48,7 @@ RUN apt-get update && apt-get -y install \ # Install node and yarn RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ - curl -sL https://deb.nodesource.com/setup_20.x | bash - && \ + curl -sL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get update && apt-get install -y \ nodejs \ yarn \ @@ -119,12 +119,12 @@ realpath_cache_size=4096K realpath_cache_ttl=600 EOF -# Increase upload limit and enable preloading +# Increase upload limit and enable preloading (disabled for now, as it does not seem to work properly, and require prod env anyway) COPY </dev/null; \ + chmod 644 /etc/apt/keyrings/yarn.gpg; \ + \ + # Add Yarn repo with signed-by + echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian stable main" \ + | tee /etc/apt/sources.list.d/yarn.list; \ + \ + # Run NodeSource setup script (unchanged) + curl -sL https://deb.nodesource.com/setup_22.x | bash -; \ + \ + # Install Node.js + Yarn + apt-get update; \ + apt-get install -y --no-install-recommends \ + nodejs \ + yarn; \ + \ + # Cleanup + apt-get -y autoremove; \ + apt-get clean autoclean; \ + rm -rf /var/lib/apt/lists/* + # Install PHP RUN set -eux; \ diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..bc4d0bf3 --- /dev/null +++ b/Makefile @@ -0,0 +1,91 @@ +# PartDB Makefile for Test Environment Management + +.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ +test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ +section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset + +# Default target +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +# Dependencies +deps-install: ## Install PHP dependencies with unlimited memory + @echo "๐Ÿ“ฆ Installing PHP dependencies..." + COMPOSER_MEMORY_LIMIT=-1 composer install + yarn install + @echo "โœ… Dependencies installed" + +# Complete test environment setup +test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) + @echo "โœ… Test environment setup complete!" + +# Clean test environment +test-clean: ## Clean test cache and database files + @echo "๐Ÿงน Cleaning test environment..." + rm -rf var/cache/test + rm -f var/app_test.db + @echo "โœ… Test environment cleaned" + +# Create test database +test-db-create: ## Create test database (if not exists) + @echo "๐Ÿ—„๏ธ Creating test database..." + -php bin/console doctrine:database:create --if-not-exists --env test || echo "โš ๏ธ Database creation failed (expected for SQLite) - continuing..." + +# Run database migrations for test environment +test-db-migrate: ## Run database migrations for test environment + @echo "๐Ÿ”„ Running database migrations..." + COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test + +# Clear test cache +test-cache-clear: ## Clear test cache + @echo "๐Ÿ—‘๏ธ Clearing test cache..." + rm -rf var/cache/test + @echo "โœ… Test cache cleared" + +# Load test fixtures +test-fixtures: ## Load test fixtures + @echo "๐Ÿ“ฆ Loading test fixtures..." + php bin/console partdb:fixtures:load -n --env test + +# Run PHPUnit tests +test-run: ## Run PHPUnit tests + @echo "๐Ÿงช Running tests..." + php bin/phpunit + +# Quick test reset (clean + migrate + fixtures, skip DB creation) +test-reset: test-cache-clear test-db-migrate test-fixtures + @echo "โœ… Test environment reset complete!" + +test-typecheck: ## Run static analysis (PHPStan) + @echo "๐Ÿงช Running type checks..." + COMPOSER_MEMORY_LIMIT=-1 composer phpstan + +# Development helpers +dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) + @echo "โœ… Development environment setup complete!" + +dev-clean: ## Clean development cache and database files + @echo "๐Ÿงน Cleaning development environment..." + rm -rf var/cache/dev + rm -f var/app_dev.db + @echo "โœ… Development environment cleaned" + +dev-db-create: ## Create development database (if not exists) + @echo "๐Ÿ—„๏ธ Creating development database..." + -php bin/console doctrine:database:create --if-not-exists --env dev || echo "โš ๏ธ Database creation failed (expected for SQLite) - continuing..." + +dev-db-migrate: ## Run database migrations for development environment + @echo "๐Ÿ”„ Running database migrations..." + COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev + +dev-cache-clear: ## Clear development cache + @echo "๐Ÿ—‘๏ธ Clearing development cache..." + rm -rf var/cache/dev + @echo "โœ… Development cache cleared" + +dev-warmup: ## Warm up development cache + @echo "๐Ÿ”ฅ Warming up development cache..." + COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n + +dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) + @echo "โœ… Development environment reset complete!" \ No newline at end of file diff --git a/README.md b/README.md index 74ebfe7f..993a1a9c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg) [![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server) ![GitHub License](https://img.shields.io/github/license/Part-DB/Part-DB-symfony) -![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-green) +![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.2-green) ![Docker Pulls](https://img.shields.io/docker/pulls/jbtronics/part-db1) ![Docker Build Status](https://github.com/Part-DB/Part-DB-symfony/workflows/Docker%20Image%20Build/badge.svg) @@ -29,8 +29,8 @@ If you want to test Part-DB without installing it, you can use [this](https://de You can log in with username: *user* and password: *user*. -Every change to the master branch gets automatically deployed, so it represents the current development progress and is -may not completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading +Every change to the master branch gets automatically deployed, so it represents the current development progress and +may not be completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading the page for the first time. @@ -75,10 +75,10 @@ Part-DB is also used by small companies and universities for managing their inve * A **web server** (like Apache2 or nginx) that is capable of running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html), - this includes a minimum PHP version of **PHP 8.1** + this includes a minimum PHP version of **PHP 8.2** * A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite. * Shell access to your server is highly recommended! -* For building the client-side assets **yarn** and **nodejs** (>= 18.0) is needed. +* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed. ## Installation @@ -142,7 +142,7 @@ There you will find various methods to support development on a monthly or a one ## Built with -* [Symfony 5](https://symfony.com/): The main framework used for the serverside PHP +* [Symfony 6](https://symfony.com/): The main framework used for the serverside PHP * [Bootstrap 5](https://getbootstrap.com/) and [Bootswatch](https://bootswatch.com/): Used as website theme * [Fontawesome](https://fontawesome.com/): Used as icon set * [Hotwire Stimulus](https://stimulus.hotwired.dev/) and [Hotwire Turbo](https://turbo.hotwired.dev/): Frontend diff --git a/VERSION b/VERSION index 092afa15..197c4d5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.17.0 +2.4.0 diff --git a/assets/ckeditor/emojis.json b/assets/ckeditor/emojis.json new file mode 100644 index 00000000..a6bafe26 --- /dev/null +++ b/assets/ckeditor/emojis.json @@ -0,0 +1 @@ +[{"emoji":"๐Ÿ˜€","group":0,"order":1,"tags":["cheerful","cheery","face","grin","grinning","happy","laugh","nice","smile","smiling","teeth"],"version":1,"annotation":"grinning face","shortcodes":["grinning","grinning_face"]},{"emoji":"๐Ÿ˜ƒ","group":0,"order":2,"tags":["awesome","big","eyes","face","grin","grinning","happy","mouth","open","smile","smiling","teeth","yay"],"version":0.6,"annotation":"grinning face with big eyes","shortcodes":["grinning_face_with_big_eyes","smiley"]},{"emoji":"๐Ÿ˜„","group":0,"order":3,"tags":["eye","eyes","face","grin","grinning","happy","laugh","lol","mouth","open","smile","smiling"],"version":0.6,"annotation":"grinning face with smiling eyes","emoticon":":D","shortcodes":["grinning_face_with_closed_eyes","smile"]},{"emoji":"๐Ÿ˜","group":0,"order":4,"tags":["beaming","eye","eyes","face","grin","grinning","happy","nice","smile","smiling","teeth"],"version":0.6,"annotation":"beaming face with smiling eyes","shortcodes":["beaming_face","grin"]},{"emoji":"๐Ÿ˜†","group":0,"order":5,"tags":["closed","eyes","face","grinning","haha","hahaha","happy","laugh","lol","mouth","open","rofl","smile","smiling","squinting"],"version":0.6,"annotation":"grinning squinting face","emoticon":"XD","shortcodes":["laughing","lol","satisfied","squinting_face"]},{"emoji":"๐Ÿ˜…","group":0,"order":6,"tags":["cold","dejected","excited","face","grinning","mouth","nervous","open","smile","smiling","stress","stressed","sweat"],"version":0.6,"annotation":"grinning face with sweat","shortcodes":["grinning_face_with_sweat","sweat_smile"]},{"emoji":"๐Ÿคฃ","group":0,"order":7,"tags":["crying","face","floor","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","rofl","roflmao","rolling","tear"],"version":3,"annotation":"rolling on the floor laughing","emoticon":":'D","shortcodes":["rofl"]},{"emoji":"๐Ÿ˜‚","group":0,"order":8,"tags":["crying","face","feels","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","rofl","roflmao","tear"],"version":0.6,"annotation":"face with tears of joy","emoticon":":')","shortcodes":["joy","lmao","tears_of_joy"]},{"emoji":"๐Ÿ™‚","group":0,"order":9,"tags":["face","happy","slightly","smile","smiling"],"version":1,"annotation":"slightly smiling face","emoticon":":)","shortcodes":["slightly_smiling_face"]},{"emoji":"๐Ÿ™ƒ","group":0,"order":10,"tags":["face","hehe","smile","upside-down"],"version":1,"annotation":"upside-down face","shortcodes":["upside_down_face"]},{"emoji":"๐Ÿซ ","group":0,"order":11,"tags":["disappear","dissolve","embarrassed","face","haha","heat","hot","liquid","lol","melt","melting","sarcasm","sarcastic"],"version":14,"annotation":"melting face","shortcodes":["melt","melting_face"]},{"emoji":"๐Ÿ˜‰","group":0,"order":12,"tags":["face","flirt","heartbreaker","sexy","slide","tease","wink","winking","winks"],"version":0.6,"annotation":"winking face","emoticon":";)","shortcodes":["wink","winking_face"]},{"emoji":"๐Ÿ˜Š","group":0,"order":13,"tags":["blush","eye","eyes","face","glad","satisfied","smile","smiling"],"version":0.6,"annotation":"smiling face with smiling eyes","emoticon":":>","shortcodes":["blush","smiling_face_with_closed_eyes"]},{"emoji":"๐Ÿ˜‡","group":0,"order":14,"tags":["angel","angelic","angels","blessed","face","fairy","fairytale","fantasy","halo","happy","innocent","peaceful","smile","smiling","spirit","tale"],"version":1,"annotation":"smiling face with halo","emoticon":"O:)","shortcodes":["halo","innocent"]},{"emoji":"๐Ÿฅฐ","group":0,"order":15,"tags":["3","adore","crush","face","heart","hearts","ily","love","romance","smile","smiling","you"],"version":11,"annotation":"smiling face with hearts","shortcodes":["smiling_face_with_3_hearts"]},{"emoji":"๐Ÿ˜","group":0,"order":16,"tags":["143","bae","eye","face","feels","heart-eyes","hearts","ily","kisses","love","romance","romantic","smile","xoxo"],"version":0.6,"annotation":"smiling face with heart-eyes","shortcodes":["heart_eyes","smiling_face_with_heart_eyes"]},{"emoji":"๐Ÿคฉ","group":0,"order":17,"tags":["excited","eyes","face","grinning","smile","star","starry-eyed","wow"],"version":5,"annotation":"star-struck","shortcodes":["star_struck"]},{"emoji":"๐Ÿ˜˜","group":0,"order":18,"tags":["adorbs","bae","blowing","face","flirt","heart","ily","kiss","love","lover","miss","muah","romantic","smooch","xoxo","you"],"version":0.6,"annotation":"face blowing a kiss","emoticon":":X","shortcodes":["blowing_a_kiss","kissing_heart"]},{"emoji":"๐Ÿ˜—","group":0,"order":19,"tags":["143","date","dating","face","flirt","ily","kiss","love","smooch","smooches","xoxo","you"],"version":1,"annotation":"kissing face","shortcodes":["kissing","kissing_face"]},{"emoji":"โ˜บ๏ธ","group":0,"order":21,"tags":["face","happy","outlined","relaxed","smile","smiling"],"version":0.6,"annotation":"smiling face","shortcodes":["relaxed","smiling_face"]},{"emoji":"๐Ÿ˜š","group":0,"order":22,"tags":["143","bae","blush","closed","date","dating","eye","eyes","face","flirt","ily","kisses","kissing","smooches","xoxo"],"version":0.6,"annotation":"kissing face with closed eyes","emoticon":":*","shortcodes":["kissing_closed_eyes","kissing_face_with_closed_eyes"]},{"emoji":"๐Ÿ˜™","group":0,"order":23,"tags":["143","closed","date","dating","eye","eyes","face","flirt","ily","kiss","kisses","kissing","love","night","smile","smiling"],"version":1,"annotation":"kissing face with smiling eyes","shortcodes":["kissing_face_with_smiling_eyes","kissing_smiling_eyes"]},{"emoji":"๐Ÿฅฒ","group":0,"order":24,"tags":["face","glad","grateful","happy","joy","pain","proud","relieved","smile","smiley","smiling","tear","touched"],"version":13,"annotation":"smiling face with tear","shortcodes":["smiling_face_with_tear"]},{"emoji":"๐Ÿ˜‹","group":0,"order":25,"tags":["delicious","eat","face","food","full","hungry","savor","smile","smiling","tasty","um","yum","yummy"],"version":0.6,"annotation":"face savoring food","shortcodes":["savoring_food","yum"]},{"emoji":"๐Ÿ˜›","group":0,"order":26,"tags":["awesome","cool","face","nice","party","stuck-out","sweet","tongue"],"version":1,"annotation":"face with tongue","emoticon":":P","shortcodes":["face_with_tongue","stuck_out_tongue"]},{"emoji":"๐Ÿ˜œ","group":0,"order":27,"tags":["crazy","epic","eye","face","funny","joke","loopy","nutty","party","stuck-out","tongue","wacky","weirdo","wink","winking","yolo"],"version":0.6,"annotation":"winking face with tongue","emoticon":";P","shortcodes":["stuck_out_tongue_winking_eye"]},{"emoji":"๐Ÿคช","group":0,"order":28,"tags":["crazy","eye","eyes","face","goofy","large","small","zany"],"version":5,"annotation":"zany face","shortcodes":["zany","zany_face"]},{"emoji":"๐Ÿ˜","group":0,"order":29,"tags":["closed","eye","eyes","face","gross","horrible","omg","squinting","stuck-out","taste","tongue","whatever","yolo"],"version":0.6,"annotation":"squinting face with tongue","emoticon":"XP","shortcodes":["stuck_out_tongue_closed_eyes"]},{"emoji":"๐Ÿค‘","group":0,"order":30,"tags":["face","money","money-mouth","mouth","paid"],"version":1,"annotation":"money-mouth face","shortcodes":["money_mouth_face"]},{"emoji":"๐Ÿค—","group":0,"order":31,"tags":["face","hands","hug","hugging","open","smiling"],"version":1,"annotation":"smiling face with open hands","shortcodes":["hug","hugging","hugging_face"]},{"emoji":"๐Ÿคญ","group":0,"order":32,"tags":["face","giggle","giggling","hand","mouth","oops","realization","secret","shock","sudden","surprise","whoops"],"version":5,"annotation":"face with hand over mouth","shortcodes":["face_with_hand_over_mouth","hand_over_mouth"]},{"emoji":"๐Ÿซข","group":0,"order":33,"tags":["amazement","awe","disbelief","embarrass","eyes","face","gasp","hand","mouth","omg","open","over","quiet","scared","shock","surprise"],"version":14,"annotation":"face with open eyes and hand over mouth","shortcodes":["face_with_open_eyes_hand_over_mouth","gasp"]},{"emoji":"๐Ÿซฃ","group":0,"order":34,"tags":["captivated","embarrass","eye","face","hide","hiding","peek","peeking","peep","scared","shy","stare"],"version":14,"annotation":"face with peeking eye","shortcodes":["face_with_peeking_eye","peek"]},{"emoji":"๐Ÿคซ","group":0,"order":35,"tags":["face","quiet","shh","shush","shushing"],"version":5,"annotation":"shushing face","shortcodes":["shush","shushing_face"]},{"emoji":"๐Ÿค”","group":0,"order":36,"tags":["chin","consider","face","hmm","ponder","pondering","thinking","wondering"],"version":1,"annotation":"thinking face","emoticon":":L","shortcodes":["thinking","thinking_face","wtf"]},{"emoji":"๐Ÿซก","group":0,"order":37,"tags":["face","good","luck","maโ€™am","ok","respect","salute","saluting","sir","troops","yes"],"version":14,"annotation":"saluting face","shortcodes":["salute","saluting_face"]},{"emoji":"๐Ÿค","group":0,"order":38,"tags":["face","keep","mouth","quiet","secret","shut","zip","zipper","zipper-mouth"],"version":1,"annotation":"zipper-mouth face","emoticon":":Z","shortcodes":["zipper_mouth","zipper_mouth_face"]},{"emoji":"๐Ÿคจ","group":0,"order":39,"tags":["disapproval","disbelief","distrust","emoji","eyebrow","face","hmm","mild","raised","skeptic","skeptical","skepticism","surprise","what"],"version":5,"annotation":"face with raised eyebrow","shortcodes":["face_with_raised_eyebrow","raised_eyebrow"]},{"emoji":"๐Ÿ˜๏ธ","group":0,"order":40,"tags":["awkward","blank","deadpan","expressionless","face","fine","jealous","meh","neutral","oh","shade","straight","unamused","unhappy","unimpressed","whatever"],"version":0.7,"annotation":"neutral face","emoticon":":|","shortcodes":["neutral","neutral_face"]},{"emoji":"๐Ÿ˜‘","group":0,"order":41,"tags":["awkward","dead","expressionless","face","fine","inexpressive","jealous","meh","not","oh","omg","straight","uh","unhappy","unimpressed","whatever"],"version":1,"annotation":"expressionless face","shortcodes":["expressionless","expressionless_face"]},{"emoji":"๐Ÿ˜ถ","group":0,"order":42,"tags":["awkward","blank","expressionless","face","mouth","mouthless","mute","quiet","secret","silence","silent","speechless"],"version":1,"annotation":"face without mouth","emoticon":":#","shortcodes":["no_mouth"]},{"emoji":"๐Ÿซฅ","group":0,"order":43,"tags":["depressed","disappear","dotted","face","hidden","hide","introvert","invisible","line","meh","whatever","wtv"],"version":14,"annotation":"dotted line face","shortcodes":["dotted_line_face"]},{"emoji":"๐Ÿ˜ถโ€๐ŸŒซ๏ธ","group":0,"order":44,"tags":["absentminded","clouds","face","fog","head"],"version":13.1,"annotation":"face in clouds","shortcodes":["in_clouds"]},{"emoji":"๐Ÿ˜","group":0,"order":46,"tags":["boss","dapper","face","flirt","homie","kidding","leer","shade","slick","sly","smirk","smug","snicker","suave","suspicious","swag"],"version":0.6,"annotation":"smirking face","emoticon":":j","shortcodes":["smirk","smirking","smirking_face"]},{"emoji":"๐Ÿ˜’","group":0,"order":47,"tags":["...","bored","face","fine","jealous","jel","jelly","pissed","smh","ugh","uhh","unamused","unhappy","weird","whatever"],"version":0.6,"annotation":"unamused face","emoticon":":?","shortcodes":["unamused","unamused_face"]},{"emoji":"๐Ÿ™„","group":0,"order":48,"tags":["eyeroll","eyes","face","rolling","shade","ugh","whatever"],"version":1,"annotation":"face with rolling eyes","shortcodes":["rolling_eyes"]},{"emoji":"๐Ÿ˜ฌ","group":0,"order":49,"tags":["awk","awkward","dentist","face","grimace","grimacing","grinning","smile","smiling"],"version":1,"annotation":"grimacing face","emoticon":"8D","shortcodes":["grimacing","grimacing_face"]},{"emoji":"๐Ÿ˜ฎโ€๐Ÿ’จ","group":0,"order":50,"tags":["blow","blowing","exhale","exhaling","exhausted","face","gasp","groan","relief","sigh","smiley","smoke","whisper","whistle"],"version":13.1,"annotation":"face exhaling","shortcodes":["exhale","exhaling"]},{"emoji":"๐Ÿคฅ","group":0,"order":51,"tags":["face","liar","lie","lying","pinocchio"],"version":3,"annotation":"lying face","shortcodes":["lying","lying_face"]},{"emoji":"๐Ÿซจ","group":0,"order":52,"tags":["crazy","daze","earthquake","face","omg","panic","shaking","shock","surprise","vibrate","whoa","wow"],"version":15,"annotation":"shaking face","shortcodes":["shaking","shaking_face"]},{"emoji":"๐Ÿ™‚โ€โ†”๏ธ","group":0,"order":53,"tags":["head","horizontally","no","shake","shaking"],"version":15.1,"annotation":"head shaking horizontally","shortcodes":["head_shaking_horizontally"]},{"emoji":"๐Ÿ™‚โ€โ†•๏ธ","group":0,"order":55,"tags":["head","nod","shaking","vertically","yes"],"version":15.1,"annotation":"head shaking vertically","shortcodes":["head_shaking_vertically"]},{"emoji":"๐Ÿ˜Œ","group":0,"order":57,"tags":["calm","face","peace","relief","relieved","zen"],"version":0.6,"annotation":"relieved face","shortcodes":["relieved","relieved_face"]},{"emoji":"๐Ÿ˜”","group":0,"order":58,"tags":["awful","bored","dejected","died","disappointed","face","losing","lost","pensive","sad","sucks"],"version":0.6,"annotation":"pensive face","shortcodes":["pensive","pensive_face"]},{"emoji":"๐Ÿ˜ช","group":0,"order":59,"tags":["crying","face","good","night","sad","sleep","sleeping","sleepy","tired"],"version":0.6,"annotation":"sleepy face","shortcodes":["sleepy","sleepy_face"]},{"emoji":"๐Ÿคค","group":0,"order":60,"tags":["drooling","face"],"version":3,"annotation":"drooling face","shortcodes":["drooling","drooling_face"]},{"emoji":"๐Ÿ˜ด","group":0,"order":61,"tags":["bed","bedtime","face","good","goodnight","nap","night","sleep","sleeping","tired","whatever","yawn","zzz"],"version":1,"annotation":"sleeping face","shortcodes":["sleeping","sleeping_face"]},{"emoji":"๐Ÿซฉ","group":0,"order":62,"tags":["bags","bored","exhausted","eyes","face","fatigued","late","sleepy","tired","weary"],"version":16,"annotation":"face with bags under eyes","shortcodes":["face_with_eye_bags"]},{"emoji":"๐Ÿ˜ท","group":0,"order":63,"tags":["cold","dentist","dermatologist","doctor","dr","face","germs","mask","medical","medicine","sick"],"version":0.6,"annotation":"face with medical mask","shortcodes":["mask","medical_mask"]},{"emoji":"๐Ÿค’","group":0,"order":64,"tags":["face","ill","sick","thermometer"],"version":1,"annotation":"face with thermometer","shortcodes":["face_with_thermometer"]},{"emoji":"๐Ÿค•","group":0,"order":65,"tags":["bandage","face","head-bandage","hurt","injury","ouch"],"version":1,"annotation":"face with head-bandage","shortcodes":["face_with_head_bandage"]},{"emoji":"๐Ÿคข","group":0,"order":66,"tags":["face","gross","nasty","nauseated","sick","vomit"],"version":3,"annotation":"nauseated face","emoticon":"%(","shortcodes":["nauseated","nauseated_face"]},{"emoji":"๐Ÿคฎ","group":0,"order":67,"tags":["barf","ew","face","gross","puke","sick","spew","throw","up","vomit","vomiting"],"version":5,"annotation":"face vomiting","shortcodes":["face_vomiting","vomiting"]},{"emoji":"๐Ÿคง","group":0,"order":68,"tags":["face","fever","flu","gesundheit","sick","sneeze","sneezing"],"version":3,"annotation":"sneezing face","shortcodes":["sneezing","sneezing_face"]},{"emoji":"๐Ÿฅต","group":0,"order":69,"tags":["dying","face","feverish","heat","hot","panting","red-faced","stroke","sweating","tongue"],"version":11,"annotation":"hot face","shortcodes":["hot","hot_face"]},{"emoji":"๐Ÿฅถ","group":0,"order":70,"tags":["blue","blue-faced","cold","face","freezing","frostbite","icicles","subzero","teeth"],"version":11,"annotation":"cold face","shortcodes":["cold","cold_face"]},{"emoji":"๐Ÿฅด","group":0,"order":71,"tags":["dizzy","drunk","eyes","face","intoxicated","mouth","tipsy","uneven","wavy","woozy"],"version":11,"annotation":"woozy face","emoticon":":&","shortcodes":["woozy","woozy_face"]},{"emoji":"๐Ÿ˜ต","group":0,"order":72,"tags":["crossed-out","dead","dizzy","eyes","face","feels","knocked","out","sick","tired"],"version":0.6,"annotation":"face with crossed-out eyes","emoticon":"XO","shortcodes":["dizzy_face","knocked_out"]},{"emoji":"๐Ÿ˜ตโ€๐Ÿ’ซ","group":0,"order":73,"tags":["confused","dizzy","eyes","face","hypnotized","omg","smiley","spiral","trouble","whoa","woah","woozy"],"version":13.1,"annotation":"face with spiral eyes","shortcodes":["dizzy_eyes"]},{"emoji":"๐Ÿคฏ","group":0,"order":74,"tags":["blown","explode","exploding","head","mind","mindblown","no","shocked","way"],"version":5,"annotation":"exploding head","shortcodes":["exploding_head"]},{"emoji":"๐Ÿค ","group":0,"order":75,"tags":["cowboy","cowgirl","face","hat"],"version":3,"annotation":"cowboy hat face","shortcodes":["cowboy","cowboy_face"]},{"emoji":"๐Ÿฅณ","group":0,"order":76,"tags":["bday","birthday","celebrate","celebration","excited","face","happy","hat","hooray","horn","party","partying"],"version":11,"annotation":"partying face","shortcodes":["hooray","partying","partying_face"]},{"emoji":"๐Ÿฅธ","group":0,"order":77,"tags":["disguise","eyebrow","face","glasses","incognito","moustache","mustache","nose","person","spy","tache","tash"],"version":13,"annotation":"disguised face","shortcodes":["disguised","disguised_face"]},{"emoji":"๐Ÿ˜Ž","group":0,"order":78,"tags":["awesome","beach","bright","bro","chilling","cool","face","rad","relaxed","shades","slay","smile","style","sunglasses","swag","win"],"version":1,"annotation":"smiling face with sunglasses","emoticon":"8)","shortcodes":["smiling_face_with_sunglasses","sunglasses_cool","too_cool"]},{"emoji":"๐Ÿค“","group":0,"order":79,"tags":["brainy","clever","expert","face","geek","gifted","glasses","intelligent","nerd","smart"],"version":1,"annotation":"nerd face","emoticon":":B","shortcodes":["nerd","nerd_face"]},{"emoji":"๐Ÿง","group":0,"order":80,"tags":["classy","face","fancy","monocle","rich","stuffy","wealthy"],"version":5,"annotation":"face with monocle","shortcodes":["face_with_monocle"]},{"emoji":"๐Ÿ˜•","group":0,"order":81,"tags":["befuddled","confused","confusing","dunno","face","frown","hm","meh","not","sad","sorry","sure"],"version":1,"annotation":"confused face","emoticon":":/","shortcodes":["confused","confused_face"]},{"emoji":"๐Ÿซค","group":0,"order":82,"tags":["confused","confusion","diagonal","disappointed","doubt","doubtful","face","frustrated","frustration","meh","mouth","skeptical","unsure","whatever","wtv"],"version":14,"annotation":"face with diagonal mouth","shortcodes":["face_with_diagonal_mouth"]},{"emoji":"๐Ÿ˜Ÿ","group":0,"order":83,"tags":["anxious","butterflies","face","nerves","nervous","sad","stress","stressed","surprised","worried","worry"],"version":1,"annotation":"worried face","shortcodes":["worried","worried_face"]},{"emoji":"๐Ÿ™","group":0,"order":84,"tags":["face","frown","frowning","sad","slightly"],"version":1,"annotation":"slightly frowning face","shortcodes":["slightly_frowning_face"]},{"emoji":"โ˜น๏ธ","group":0,"order":86,"tags":["face","frown","frowning","sad"],"version":0.7,"annotation":"frowning face","emoticon":":(","shortcodes":["white_frowning_face"]},{"emoji":"๐Ÿ˜ฎ","group":0,"order":87,"tags":["believe","face","forgot","mouth","omg","open","shocked","surprised","sympathy","unbelievable","unreal","whoa","wow","you"],"version":1,"annotation":"face with open mouth","shortcodes":["face_with_open_mouth","open_mouth"]},{"emoji":"๐Ÿ˜ฏ","group":0,"order":88,"tags":["epic","face","hushed","omg","stunned","surprised","whoa","woah"],"version":1,"annotation":"hushed face","shortcodes":["hushed","hushed_face"]},{"emoji":"๐Ÿ˜ฒ","group":0,"order":89,"tags":["astonished","cost","face","no","omg","shocked","totally","way"],"version":0.6,"annotation":"astonished face","emoticon":":O","shortcodes":["astonished","astonished_face"]},{"emoji":"๐Ÿ˜ณ","group":0,"order":90,"tags":["amazed","awkward","crazy","dazed","dead","disbelief","embarrassed","face","flushed","geez","heat","hot","impressed","jeez","what","wow"],"version":0.6,"annotation":"flushed face","emoticon":":$","shortcodes":["flushed","flushed_face"]},{"emoji":"๐Ÿฅบ","group":0,"order":91,"tags":["begging","big","eyes","face","mercy","not","pleading","please","pretty","puppy","sad","why"],"version":11,"annotation":"pleading face","shortcodes":["pleading","pleading_face"]},{"emoji":"๐Ÿฅน","group":0,"order":92,"tags":["admiration","aww","back","cry","embarrassed","face","feelings","grateful","gratitude","holding","joy","please","proud","resist","sad","tears"],"version":14,"annotation":"face holding back tears","shortcodes":["face_holding_back_tears","watery_eyes"]},{"emoji":"๐Ÿ˜ฆ","group":0,"order":93,"tags":["caught","face","frown","frowning","guard","mouth","open","scared","scary","surprise","what","wow"],"version":1,"annotation":"frowning face with open mouth","shortcodes":["frowning","frowning_face"]},{"emoji":"๐Ÿ˜ง","group":0,"order":94,"tags":["anguished","face","forgot","scared","scary","stressed","surprise","unhappy","what","wow"],"version":1,"annotation":"anguished face","emoticon":":S","shortcodes":["anguished","anguished_face"]},{"emoji":"๐Ÿ˜จ","group":0,"order":95,"tags":["afraid","anxious","blame","face","fear","fearful","scared","worried"],"version":0.6,"annotation":"fearful face","shortcodes":["fearful","fearful_face"]},{"emoji":"๐Ÿ˜ฐ","group":0,"order":96,"tags":["anxious","blue","cold","eek","face","mouth","nervous","open","rushed","scared","sweat","yikes"],"version":0.6,"annotation":"anxious face with sweat","shortcodes":["anxious","anxious_face","cold_sweat"]},{"emoji":"๐Ÿ˜ฅ","group":0,"order":97,"tags":["anxious","call","close","complicated","disappointed","face","not","relieved","sad","sweat","time","whew"],"version":0.6,"annotation":"sad but relieved face","shortcodes":["disappointed_relieved","sad_relieved_face"]},{"emoji":"๐Ÿ˜ข","group":0,"order":98,"tags":["awful","cry","crying","face","feels","miss","sad","tear","triste","unhappy"],"version":0.6,"annotation":"crying face","emoticon":":'(","shortcodes":["cry","crying_face"]},{"emoji":"๐Ÿ˜ญ","group":0,"order":99,"tags":["bawling","cry","crying","face","loudly","sad","sob","tear","tears","unhappy"],"version":0.6,"annotation":"loudly crying face","emoticon":":'o","shortcodes":["loudly_crying_face","sob"]},{"emoji":"๐Ÿ˜ฑ","group":0,"order":100,"tags":["epic","face","fear","fearful","munch","scared","scream","screamer","screaming","shocked","surprised","woah"],"version":0.6,"annotation":"face screaming in fear","emoticon":"Dx","shortcodes":["scream","screaming_in_fear"]},{"emoji":"๐Ÿ˜–","group":0,"order":101,"tags":["annoyed","confounded","confused","cringe","distraught","face","feels","frustrated","mad","sad"],"version":0.6,"annotation":"confounded face","emoticon":"X(","shortcodes":["confounded","confounded_face"]},{"emoji":"๐Ÿ˜ฃ","group":0,"order":102,"tags":["concentrate","concentration","face","focus","headache","persevere","persevering"],"version":0.6,"annotation":"persevering face","shortcodes":["persevere","persevering_face"]},{"emoji":"๐Ÿ˜ž","group":0,"order":103,"tags":["awful","blame","dejected","disappointed","face","fail","losing","sad","unhappy"],"version":0.6,"annotation":"disappointed face","shortcodes":["disappointed","disappointed_face"]},{"emoji":"๐Ÿ˜“","group":0,"order":104,"tags":["close","cold","downcast","face","feels","headache","nervous","sad","scared","sweat","yikes"],"version":0.6,"annotation":"downcast face with sweat","emoticon":":<","shortcodes":["downcast_face","sweat"]},{"emoji":"๐Ÿ˜ฉ","group":0,"order":105,"tags":["crying","face","fail","feels","hungry","mad","nooo","sad","sleepy","tired","unhappy","weary"],"version":0.6,"annotation":"weary face","emoticon":"D:","shortcodes":["weary","weary_face"]},{"emoji":"๐Ÿ˜ซ","group":0,"order":106,"tags":["cost","face","feels","nap","sad","sneeze","tired"],"version":0.6,"annotation":"tired face","emoticon":":C","shortcodes":["tired","tired_face"]},{"emoji":"๐Ÿฅฑ","group":0,"order":107,"tags":["bedtime","bored","face","goodnight","nap","night","sleep","sleepy","tired","whatever","yawn","yawning","zzz"],"version":12,"annotation":"yawning face","shortcodes":["yawn","yawning","yawning_face"]},{"emoji":"๐Ÿ˜ค","group":0,"order":108,"tags":["anger","angry","face","feels","fume","fuming","furious","fury","mad","nose","steam","triumph","unhappy","won"],"version":0.6,"annotation":"face with steam from nose","shortcodes":["nose_steam","triumph"]},{"emoji":"๐Ÿ˜ก","group":0,"order":109,"tags":["anger","angry","enraged","face","feels","mad","maddening","pouting","rage","red","shade","unhappy","upset"],"version":0.6,"annotation":"enraged face","emoticon":">:/","shortcodes":["pout","pouting_face","rage"]},{"emoji":"๐Ÿ˜ ","group":0,"order":110,"tags":["anger","angry","blame","face","feels","frustrated","mad","maddening","rage","shade","unhappy","upset"],"version":0.6,"annotation":"angry face","shortcodes":["angry","angry_face"]},{"emoji":"๐Ÿคฌ","group":0,"order":111,"tags":["censor","cursing","cussing","face","mad","mouth","pissed","swearing","symbols"],"version":5,"annotation":"face with symbols on mouth","emoticon":":@","shortcodes":["censored","face_with_symbols_on_mouth"]},{"emoji":"๐Ÿ˜ˆ","group":0,"order":112,"tags":["demon","devil","evil","face","fairy","fairytale","fantasy","horns","purple","shade","smile","smiling","tale"],"version":1,"annotation":"smiling face with horns","emoticon":">:)","shortcodes":["smiling_imp"]},{"emoji":"๐Ÿ‘ฟ","group":0,"order":113,"tags":["angry","demon","devil","evil","face","fairy","fairytale","fantasy","horns","imp","mischievous","purple","shade","tale"],"version":0.6,"annotation":"angry face with horns","emoticon":">:(","shortcodes":["angry_imp","imp"]},{"emoji":"๐Ÿ’€","group":0,"order":114,"tags":["body","dead","death","face","fairy","fairytale","iโ€™m","lmao","monster","tale","yolo"],"version":0.6,"annotation":"skull","shortcodes":["skull"]},{"emoji":"โ˜ ๏ธ","group":0,"order":116,"tags":["bone","crossbones","dead","death","face","monster","skull"],"version":1,"annotation":"skull and crossbones","shortcodes":["skull_and_crossbones"]},{"emoji":"๐Ÿ’ฉ","group":0,"order":117,"tags":["bs","comic","doo","dung","face","fml","monster","pile","poo","poop","smelly","smh","stink","stinks","stinky","turd"],"version":0.6,"annotation":"pile of poo","shortcodes":["poop","shit"]},{"emoji":"๐Ÿคก","group":0,"order":118,"tags":["clown","face"],"version":3,"annotation":"clown face","shortcodes":["clown","clown_face"]},{"emoji":"๐Ÿ‘น","group":0,"order":119,"tags":["creature","devil","face","fairy","fairytale","fantasy","mask","monster","scary","tale"],"version":0.6,"annotation":"ogre","emoticon":">0)","shortcodes":["japanese_ogre","ogre"]},{"emoji":"๐Ÿ‘บ","group":0,"order":120,"tags":["angry","creature","face","fairy","fairytale","fantasy","mask","mean","monster","tale"],"version":0.6,"annotation":"goblin","shortcodes":["goblin","japanese_goblin"]},{"emoji":"๐Ÿ‘ป","group":0,"order":121,"tags":["boo","creature","excited","face","fairy","fairytale","fantasy","halloween","haunting","monster","scary","silly","tale"],"version":0.6,"annotation":"ghost","shortcodes":["ghost"]},{"emoji":"๐Ÿ‘ฝ๏ธ","group":0,"order":122,"tags":["creature","extraterrestrial","face","fairy","fairytale","fantasy","monster","space","tale","ufo"],"version":0.6,"annotation":"alien","shortcodes":["alien"]},{"emoji":"๐Ÿ‘พ","group":0,"order":123,"tags":["alien","creature","extraterrestrial","face","fairy","fairytale","fantasy","game","gamer","games","monster","pixelated","space","tale","ufo"],"version":0.6,"annotation":"alien monster","shortcodes":["alien_monster","space_invader"]},{"emoji":"๐Ÿค–","group":0,"order":124,"tags":["face","monster"],"version":1,"annotation":"robot","shortcodes":["robot","robot_face"]},{"emoji":"๐Ÿ˜บ","group":0,"order":125,"tags":["animal","cat","face","grinning","mouth","open","smile","smiling"],"version":0.6,"annotation":"grinning cat","shortcodes":["grinning_cat","smiley_cat"]},{"emoji":"๐Ÿ˜ธ","group":0,"order":126,"tags":["animal","cat","eye","eyes","face","grin","grinning","smile","smiling"],"version":0.6,"annotation":"grinning cat with smiling eyes","shortcodes":["grinning_cat_with_closed_eyes","smile_cat"]},{"emoji":"๐Ÿ˜น","group":0,"order":127,"tags":["animal","cat","face","joy","laugh","laughing","lol","tear","tears"],"version":0.6,"annotation":"cat with tears of joy","shortcodes":["joy_cat","tears_of_joy_cat"]},{"emoji":"๐Ÿ˜ป","group":0,"order":128,"tags":["animal","cat","eye","face","heart","heart-eyes","love","smile","smiling"],"version":0.6,"annotation":"smiling cat with heart-eyes","shortcodes":["heart_eyes_cat","smiling_cat_with_heart_eyes"]},{"emoji":"๐Ÿ˜ผ","group":0,"order":129,"tags":["animal","cat","face","ironic","smile","wry"],"version":0.6,"annotation":"cat with wry smile","shortcodes":["smirk_cat","wry_smile_cat"]},{"emoji":"๐Ÿ˜ฝ","group":0,"order":130,"tags":["animal","cat","closed","eye","eyes","face","kiss","kissing"],"version":0.6,"annotation":"kissing cat","emoticon":":3","shortcodes":["kissing_cat"]},{"emoji":"๐Ÿ™€","group":0,"order":131,"tags":["animal","cat","face","oh","surprised","weary"],"version":0.6,"annotation":"weary cat","shortcodes":["scream_cat","weary_cat"]},{"emoji":"๐Ÿ˜ฟ","group":0,"order":132,"tags":["animal","cat","cry","crying","face","sad","tear"],"version":0.6,"annotation":"crying cat","shortcodes":["crying_cat"]},{"emoji":"๐Ÿ˜พ","group":0,"order":133,"tags":["animal","cat","face","pouting"],"version":0.6,"annotation":"pouting cat","shortcodes":["pouting_cat"]},{"emoji":"๐Ÿ™ˆ","group":0,"order":134,"tags":["embarrassed","evil","face","forbidden","forgot","gesture","hide","monkey","no","omg","prohibited","scared","secret","smh","watch"],"version":0.6,"annotation":"see-no-evil monkey","shortcodes":["see_no_evil"]},{"emoji":"๐Ÿ™‰","group":0,"order":135,"tags":["animal","ears","evil","face","forbidden","gesture","hear","listen","monkey","no","not","prohibited","secret","shh","tmi"],"version":0.6,"annotation":"hear-no-evil monkey","shortcodes":["hear_no_evil"]},{"emoji":"๐Ÿ™Š","group":0,"order":136,"tags":["animal","evil","face","forbidden","gesture","monkey","no","not","oops","prohibited","quiet","secret","speak","stealth"],"version":0.6,"annotation":"speak-no-evil monkey","shortcodes":["speak_no_evil"]},{"emoji":"๐Ÿ’Œ","group":0,"order":137,"tags":["heart","letter","love","mail","romance","valentine"],"version":0.6,"annotation":"love letter","shortcodes":["love_letter"]},{"emoji":"๐Ÿ’˜","group":0,"order":138,"tags":["143","adorbs","arrow","cupid","date","emotion","heart","ily","love","romance","valentine"],"version":0.6,"annotation":"heart with arrow","shortcodes":["cupid","heart_with_arrow"]},{"emoji":"๐Ÿ’","group":0,"order":139,"tags":["143","anniversary","emotion","heart","ily","kisses","ribbon","valentine","xoxo"],"version":0.6,"annotation":"heart with ribbon","shortcodes":["gift_heart","heart_with_ribbon"]},{"emoji":"๐Ÿ’–","group":0,"order":140,"tags":["143","emotion","excited","good","heart","ily","kisses","morning","night","sparkle","sparkling","xoxo"],"version":0.6,"annotation":"sparkling heart","shortcodes":["sparkling_heart"]},{"emoji":"๐Ÿ’—","group":0,"order":141,"tags":["143","emotion","excited","growing","heart","heartpulse","ily","kisses","muah","nervous","pulse","xoxo"],"version":0.6,"annotation":"growing heart","shortcodes":["growing_heart","heartpulse"]},{"emoji":"๐Ÿ’“","group":0,"order":142,"tags":["143","beating","cardio","emotion","heart","heartbeat","ily","love","pulsating","pulse"],"version":0.6,"annotation":"beating heart","shortcodes":["beating_heart","heartbeat"]},{"emoji":"๐Ÿ’ž","group":0,"order":143,"tags":["143","adorbs","anniversary","emotion","heart","hearts","revolving"],"version":0.6,"annotation":"revolving hearts","shortcodes":["revolving_hearts"]},{"emoji":"๐Ÿ’•","group":0,"order":144,"tags":["143","anniversary","date","dating","emotion","heart","hearts","ily","kisses","love","loving","two","xoxo"],"version":0.6,"annotation":"two hearts","shortcodes":["two_hearts"]},{"emoji":"๐Ÿ’Ÿ","group":0,"order":145,"tags":["143","decoration","emotion","heart","hearth","purple","white"],"version":0.6,"annotation":"heart decoration","shortcodes":["heart_decoration"]},{"emoji":"โฃ๏ธ","group":0,"order":147,"tags":["exclamation","heart","heavy","mark","punctuation"],"version":1,"annotation":"heart exclamation","shortcodes":["heart_exclamation"]},{"emoji":"๐Ÿ’”","group":0,"order":148,"tags":["break","broken","crushed","emotion","heart","heartbroken","lonely","sad"],"version":0.6,"annotation":"broken heart","emoticon":"","shortcodes":["man_mage"],"skins":[{"tone":1,"emoji":"๐Ÿง™๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง™๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง™๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง™๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง™๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿง™โ€โ™€๏ธ","group":1,"order":1746,"tags":["fantasy","mage","magic","play","sorcerer","sorceress","sorcery","spell","summon","witch","wizard","woman"],"version":5,"annotation":"woman mage","shortcodes":["woman_mage"],"skins":[{"tone":1,"emoji":"๐Ÿง™๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง™๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง™๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง™๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง™๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿงš","group":1,"order":1758,"tags":["fairytale","fantasy","myth","person","pixie","tale","wings"],"version":5,"annotation":"fairy","shortcodes":["fairy"],"skins":[{"tone":1,"emoji":"๐Ÿงš๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿงš๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿงš๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿงš๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿงš๐Ÿฟ","version":5}]},{"emoji":"๐Ÿงšโ€โ™‚๏ธ","group":1,"order":1764,"tags":["fairy","fairytale","fantasy","man","myth","oberon","person","pixie","puck","tale","wings"],"version":5,"annotation":"man fairy","shortcodes":["man_fairy"],"skins":[{"tone":1,"emoji":"๐Ÿงš๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿงš๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿงš๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿงš๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿงš๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿงšโ€โ™€๏ธ","group":1,"order":1776,"tags":["fairy","fairytale","fantasy","myth","person","pixie","tale","titania","wings","woman"],"version":5,"annotation":"woman fairy","shortcodes":["woman_fairy"],"skins":[{"tone":1,"emoji":"๐Ÿงš๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿงš๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿงš๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿงš๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿงš๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿง›","group":1,"order":1788,"tags":["blood","dracula","fangs","halloween","scary","supernatural","teeth","undead"],"version":5,"annotation":"vampire","emoticon":":E","shortcodes":["vampire"],"skins":[{"tone":1,"emoji":"๐Ÿง›๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿง›๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿง›๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿง›๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿง›๐Ÿฟ","version":5}]},{"emoji":"๐Ÿง›โ€โ™‚๏ธ","group":1,"order":1794,"tags":["blood","fangs","halloween","man","scary","supernatural","teeth","undead","vampire"],"version":5,"annotation":"man vampire","shortcodes":["man_vampire"],"skins":[{"tone":1,"emoji":"๐Ÿง›๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง›๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง›๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง›๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง›๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿง›โ€โ™€๏ธ","group":1,"order":1806,"tags":["blood","fangs","halloween","scary","supernatural","teeth","undead","vampire","woman"],"version":5,"annotation":"woman vampire","shortcodes":["woman_vampire"],"skins":[{"tone":1,"emoji":"๐Ÿง›๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง›๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง›๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง›๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง›๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿงœ","group":1,"order":1818,"tags":["creature","fairytale","folklore","ocean","sea","siren","trident"],"version":5,"annotation":"merperson","shortcodes":["merperson"],"skins":[{"tone":1,"emoji":"๐Ÿงœ๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿงœ๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿงœ๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿงœ๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿงœ๐Ÿฟ","version":5}]},{"emoji":"๐Ÿงœโ€โ™‚๏ธ","group":1,"order":1824,"tags":["creature","fairytale","folklore","neptune","ocean","poseidon","sea","siren","trident","triton"],"version":5,"annotation":"merman","shortcodes":["merman"],"skins":[{"tone":1,"emoji":"๐Ÿงœ๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿงœ๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿงœ๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿงœ๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿงœ๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿงœโ€โ™€๏ธ","group":1,"order":1836,"tags":["creature","fairytale","folklore","merwoman","ocean","sea","siren","trident"],"version":5,"annotation":"mermaid","shortcodes":["mermaid"],"skins":[{"tone":1,"emoji":"๐Ÿงœ๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿงœ๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿงœ๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿงœ๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿงœ๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿง","group":1,"order":1848,"tags":["elves","enchantment","fantasy","folklore","magic","magical","myth"],"version":5,"annotation":"elf","shortcodes":["elf"],"skins":[{"tone":1,"emoji":"๐Ÿง๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿง๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿง๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿง๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿง๐Ÿฟ","version":5}]},{"emoji":"๐Ÿงโ€โ™‚๏ธ","group":1,"order":1854,"tags":["elf","elves","enchantment","fantasy","folklore","magic","magical","man","myth"],"version":5,"annotation":"man elf","shortcodes":["man_elf"],"skins":[{"tone":1,"emoji":"๐Ÿง๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿงโ€โ™€๏ธ","group":1,"order":1866,"tags":["elf","elves","enchantment","fantasy","folklore","magic","magical","myth","woman"],"version":5,"annotation":"woman elf","shortcodes":["woman_elf"],"skins":[{"tone":1,"emoji":"๐Ÿง๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿงž","group":1,"order":1878,"tags":["djinn","fantasy","jinn","lamp","myth","rub","wishes"],"version":5,"annotation":"genie","shortcodes":["genie"]},{"emoji":"๐Ÿงžโ€โ™‚๏ธ","group":1,"order":1879,"tags":["djinn","fantasy","genie","jinn","lamp","man","myth","rub","wishes"],"version":5,"annotation":"man genie","shortcodes":["man_genie"]},{"emoji":"๐Ÿงžโ€โ™€๏ธ","group":1,"order":1881,"tags":["djinn","fantasy","genie","jinn","lamp","myth","rub","wishes","woman"],"version":5,"annotation":"woman genie","shortcodes":["woman_genie"]},{"emoji":"๐ŸงŸ","group":1,"order":1883,"tags":["apocalypse","dead","halloween","horror","scary","undead","walking"],"version":5,"annotation":"zombie","emoticon":"8#","shortcodes":["zombie"]},{"emoji":"๐ŸงŸโ€โ™‚๏ธ","group":1,"order":1884,"tags":["apocalypse","dead","halloween","horror","man","scary","undead","walking","zombie"],"version":5,"annotation":"man zombie","shortcodes":["man_zombie"]},{"emoji":"๐ŸงŸโ€โ™€๏ธ","group":1,"order":1886,"tags":["apocalypse","dead","halloween","horror","scary","undead","walking","woman","zombie"],"version":5,"annotation":"woman zombie","shortcodes":["woman_zombie"]},{"emoji":"๐ŸงŒ","group":1,"order":1888,"tags":["fairy","fantasy","monster","tale","trolling"],"version":14,"annotation":"troll","shortcodes":["troll"]},{"emoji":"๐Ÿ’†","group":1,"order":1889,"tags":["face","getting","headache","massage","person","relax","relaxing","salon","soothe","spa","tension","therapy","treatment"],"version":0.6,"annotation":"person getting massage","shortcodes":["massage","person_getting_massage"],"skins":[{"tone":1,"emoji":"๐Ÿ’†๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ’†๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ’†๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ’†๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ’†๐Ÿฟ","version":1}]},{"emoji":"๐Ÿ’†โ€โ™‚๏ธ","group":1,"order":1895,"tags":["face","getting","headache","man","massage","relax","relaxing","salon","soothe","spa","tension","therapy","treatment"],"version":4,"annotation":"man getting massage","shortcodes":["man_getting_massage"],"skins":[{"tone":1,"emoji":"๐Ÿ’†๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ’†๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ’†๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ’†๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ’†๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿ’†โ€โ™€๏ธ","group":1,"order":1907,"tags":["face","getting","headache","massage","relax","relaxing","salon","soothe","spa","tension","therapy","treatment","woman"],"version":4,"annotation":"woman getting massage","shortcodes":["woman_getting_massage"],"skins":[{"tone":1,"emoji":"๐Ÿ’†๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ’†๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ’†๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ’†๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ’†๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿ’‡","group":1,"order":1919,"tags":["barber","beauty","chop","cosmetology","cut","groom","hair","haircut","parlor","person","shears","style"],"version":0.6,"annotation":"person getting haircut","shortcodes":["haircut","person_getting_haircut"],"skins":[{"tone":1,"emoji":"๐Ÿ’‡๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ’‡๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ’‡๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ’‡๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ’‡๐Ÿฟ","version":1}]},{"emoji":"๐Ÿ’‡โ€โ™‚๏ธ","group":1,"order":1925,"tags":["barber","beauty","chop","cosmetology","cut","groom","hair","haircut","man","parlor","person","shears","style"],"version":4,"annotation":"man getting haircut","shortcodes":["man_getting_haircut"],"skins":[{"tone":1,"emoji":"๐Ÿ’‡๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ’‡๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ’‡๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ’‡๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ’‡๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿ’‡โ€โ™€๏ธ","group":1,"order":1937,"tags":["barber","beauty","chop","cosmetology","cut","groom","hair","haircut","parlor","person","shears","style","woman"],"version":4,"annotation":"woman getting haircut","shortcodes":["woman_getting_haircut"],"skins":[{"tone":1,"emoji":"๐Ÿ’‡๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ’‡๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ’‡๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ’‡๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ’‡๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿšถ","group":1,"order":1949,"tags":["amble","gait","hike","man","pace","pedestrian","person","stride","stroll","walk","walking"],"version":0.6,"annotation":"person walking","shortcodes":["person_walking","walking"],"skins":[{"tone":1,"emoji":"๐Ÿšถ๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿšถ๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿšถ๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿšถ๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿšถ๐Ÿฟ","version":1}]},{"emoji":"๐Ÿšถโ€โ™‚๏ธ","group":1,"order":1955,"tags":["amble","gait","hike","man","pace","pedestrian","stride","stroll","walk","walking"],"version":4,"annotation":"man walking","shortcodes":["man_walking"],"skins":[{"tone":1,"emoji":"๐Ÿšถ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšถ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšถ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšถ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšถ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿšถโ€โ™€๏ธ","group":1,"order":1967,"tags":["amble","gait","hike","man","pace","pedestrian","stride","stroll","walk","walking","woman"],"version":4,"annotation":"woman walking","shortcodes":["woman_walking"],"skins":[{"tone":1,"emoji":"๐Ÿšถ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšถ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšถ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšถ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšถ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿšถโ€โžก๏ธ","group":1,"order":1979,"tags":["amble","gait","hike","man","pace","pedestrian","person","stride","stroll","walk","walking"],"version":15.1,"annotation":"person walking facing right","shortcodes":["person_walking_right"],"skins":[{"tone":1,"emoji":"๐Ÿšถ๐Ÿปโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿšถ๐Ÿผโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿšถ๐Ÿฝโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿšถ๐Ÿพโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿšถ๐Ÿฟโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿšถโ€โ™€๏ธโ€โžก๏ธ","group":1,"order":1991,"tags":["amble","gait","hike","man","pace","pedestrian","stride","stroll","walk","walking","woman"],"version":15.1,"annotation":"woman walking facing right","shortcodes":["woman_walking_right"],"skins":[{"tone":1,"emoji":"๐Ÿšถ๐Ÿปโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿšถ๐Ÿผโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿšถ๐Ÿฝโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿšถ๐Ÿพโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿšถ๐Ÿฟโ€โ™€๏ธโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿšถโ€โ™‚๏ธโ€โžก๏ธ","group":1,"order":2015,"tags":["amble","gait","hike","man","pace","pedestrian","stride","stroll","walk","walking"],"version":15.1,"annotation":"man walking facing right","shortcodes":["man_walking_right"],"skins":[{"tone":1,"emoji":"๐Ÿšถ๐Ÿปโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿšถ๐Ÿผโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿšถ๐Ÿฝโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿšถ๐Ÿพโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿšถ๐Ÿฟโ€โ™‚๏ธโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿง","group":1,"order":2039,"tags":["person","stand","standing"],"version":12,"annotation":"person standing","shortcodes":["person_standing","standing"],"skins":[{"tone":1,"emoji":"๐Ÿง๐Ÿป","version":12},{"tone":2,"emoji":"๐Ÿง๐Ÿผ","version":12},{"tone":3,"emoji":"๐Ÿง๐Ÿฝ","version":12},{"tone":4,"emoji":"๐Ÿง๐Ÿพ","version":12},{"tone":5,"emoji":"๐Ÿง๐Ÿฟ","version":12}]},{"emoji":"๐Ÿงโ€โ™‚๏ธ","group":1,"order":2045,"tags":["man","stand","standing"],"version":12,"annotation":"man standing","shortcodes":["man_standing"],"skins":[{"tone":1,"emoji":"๐Ÿง๐Ÿปโ€โ™‚๏ธ","version":12},{"tone":2,"emoji":"๐Ÿง๐Ÿผโ€โ™‚๏ธ","version":12},{"tone":3,"emoji":"๐Ÿง๐Ÿฝโ€โ™‚๏ธ","version":12},{"tone":4,"emoji":"๐Ÿง๐Ÿพโ€โ™‚๏ธ","version":12},{"tone":5,"emoji":"๐Ÿง๐Ÿฟโ€โ™‚๏ธ","version":12}]},{"emoji":"๐Ÿงโ€โ™€๏ธ","group":1,"order":2057,"tags":["stand","standing","woman"],"version":12,"annotation":"woman standing","shortcodes":["woman_standing"],"skins":[{"tone":1,"emoji":"๐Ÿง๐Ÿปโ€โ™€๏ธ","version":12},{"tone":2,"emoji":"๐Ÿง๐Ÿผโ€โ™€๏ธ","version":12},{"tone":3,"emoji":"๐Ÿง๐Ÿฝโ€โ™€๏ธ","version":12},{"tone":4,"emoji":"๐Ÿง๐Ÿพโ€โ™€๏ธ","version":12},{"tone":5,"emoji":"๐Ÿง๐Ÿฟโ€โ™€๏ธ","version":12}]},{"emoji":"๐ŸงŽ","group":1,"order":2069,"tags":["kneel","kneeling","knees","person"],"version":12,"annotation":"person kneeling","shortcodes":["kneeling","person_kneeling"],"skins":[{"tone":1,"emoji":"๐ŸงŽ๐Ÿป","version":12},{"tone":2,"emoji":"๐ŸงŽ๐Ÿผ","version":12},{"tone":3,"emoji":"๐ŸงŽ๐Ÿฝ","version":12},{"tone":4,"emoji":"๐ŸงŽ๐Ÿพ","version":12},{"tone":5,"emoji":"๐ŸงŽ๐Ÿฟ","version":12}]},{"emoji":"๐ŸงŽโ€โ™‚๏ธ","group":1,"order":2075,"tags":["kneel","kneeling","knees","man"],"version":12,"annotation":"man kneeling","shortcodes":["man_kneeling"],"skins":[{"tone":1,"emoji":"๐ŸงŽ๐Ÿปโ€โ™‚๏ธ","version":12},{"tone":2,"emoji":"๐ŸงŽ๐Ÿผโ€โ™‚๏ธ","version":12},{"tone":3,"emoji":"๐ŸงŽ๐Ÿฝโ€โ™‚๏ธ","version":12},{"tone":4,"emoji":"๐ŸงŽ๐Ÿพโ€โ™‚๏ธ","version":12},{"tone":5,"emoji":"๐ŸงŽ๐Ÿฟโ€โ™‚๏ธ","version":12}]},{"emoji":"๐ŸงŽโ€โ™€๏ธ","group":1,"order":2087,"tags":["kneel","kneeling","knees","woman"],"version":12,"annotation":"woman kneeling","shortcodes":["woman_kneeling"],"skins":[{"tone":1,"emoji":"๐ŸงŽ๐Ÿปโ€โ™€๏ธ","version":12},{"tone":2,"emoji":"๐ŸงŽ๐Ÿผโ€โ™€๏ธ","version":12},{"tone":3,"emoji":"๐ŸงŽ๐Ÿฝโ€โ™€๏ธ","version":12},{"tone":4,"emoji":"๐ŸงŽ๐Ÿพโ€โ™€๏ธ","version":12},{"tone":5,"emoji":"๐ŸงŽ๐Ÿฟโ€โ™€๏ธ","version":12}]},{"emoji":"๐ŸงŽโ€โžก๏ธ","group":1,"order":2099,"tags":["kneel","kneeling","knees","person"],"version":15.1,"annotation":"person kneeling facing right","shortcodes":["person_kneeling_right"],"skins":[{"tone":1,"emoji":"๐ŸงŽ๐Ÿปโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐ŸงŽ๐Ÿผโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐ŸงŽ๐Ÿฝโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐ŸงŽ๐Ÿพโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐ŸงŽ๐Ÿฟโ€โžก๏ธ","version":15.1}]},{"emoji":"๐ŸงŽโ€โ™€๏ธโ€โžก๏ธ","group":1,"order":2111,"tags":["kneel","kneeling","knees","woman"],"version":15.1,"annotation":"woman kneeling facing right","shortcodes":["woman_kneeling_right"],"skins":[{"tone":1,"emoji":"๐ŸงŽ๐Ÿปโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐ŸงŽ๐Ÿผโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐ŸงŽ๐Ÿฝโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐ŸงŽ๐Ÿพโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐ŸงŽ๐Ÿฟโ€โ™€๏ธโ€โžก๏ธ","version":15.1}]},{"emoji":"๐ŸงŽโ€โ™‚๏ธโ€โžก๏ธ","group":1,"order":2135,"tags":["kneel","kneeling","knees","man"],"version":15.1,"annotation":"man kneeling facing right","shortcodes":["man_kneeling_right"],"skins":[{"tone":1,"emoji":"๐ŸงŽ๐Ÿปโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐ŸงŽ๐Ÿผโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐ŸงŽ๐Ÿฝโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐ŸงŽ๐Ÿพโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐ŸงŽ๐Ÿฟโ€โ™‚๏ธโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿง‘โ€๐Ÿฆฏ","group":1,"order":2159,"tags":["accessibility","blind","cane","person","probing","white"],"version":12.1,"annotation":"person with white cane","shortcodes":["person_with_probing_cane","person_with_white_cane"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿฆฏ","version":12.1},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿฆฏ","version":12.1},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿฆฏ","version":12.1},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿฆฏ","version":12.1},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿฆฏ","version":12.1}]},{"emoji":"๐Ÿง‘โ€๐Ÿฆฏโ€โžก๏ธ","group":1,"order":2165,"tags":["accessibility","blind","cane","person","probing","white"],"version":15.1,"annotation":"person with white cane facing right","shortcodes":["person_with_white_cane_right"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ‘จโ€๐Ÿฆฏ","group":1,"order":2177,"tags":["accessibility","blind","cane","man","probing","white"],"version":12,"annotation":"man with white cane","shortcodes":["man_with_probing_cane","man_with_white_cane"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฏ","version":12},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿฆฏ","version":12},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿฆฏ","version":12},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿฆฏ","version":12},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿฆฏ","version":12}]},{"emoji":"๐Ÿ‘จโ€๐Ÿฆฏโ€โžก๏ธ","group":1,"order":2183,"tags":["accessibility","blind","cane","man","probing","white"],"version":15.1,"annotation":"man with white cane facing right","shortcodes":["man_with_white_cane_right"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿฆฏ","group":1,"order":2195,"tags":["accessibility","blind","cane","probing","white","woman"],"version":12,"annotation":"woman with white cane","shortcodes":["woman_with_probing_cane","woman_with_white_cane"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿฆฏ","version":12},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿฆฏ","version":12},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿฆฏ","version":12},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿฆฏ","version":12},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿฆฏ","version":12}]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿฆฏโ€โžก๏ธ","group":1,"order":2201,"tags":["accessibility","blind","cane","probing","white","woman"],"version":15.1,"annotation":"woman with white cane facing right","shortcodes":["woman_with_white_cane_right"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿฆฏโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿง‘โ€๐Ÿฆผ","group":1,"order":2213,"tags":["accessibility","motorized","person","wheelchair"],"version":12.1,"annotation":"person in motorized wheelchair","shortcodes":["person_in_motorized_wheelchair"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿฆผ","version":12.1},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿฆผ","version":12.1},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿฆผ","version":12.1},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿฆผ","version":12.1},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿฆผ","version":12.1}]},{"emoji":"๐Ÿง‘โ€๐Ÿฆผโ€โžก๏ธ","group":1,"order":2219,"tags":["accessibility","motorized","person","wheelchair"],"version":15.1,"annotation":"person in motorized wheelchair facing right","shortcodes":["person_in_motorized_wheelchair_right"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿฆผโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ‘จโ€๐Ÿฆผ","group":1,"order":2231,"tags":["accessibility","man","motorized","wheelchair"],"version":12,"annotation":"man in motorized wheelchair","shortcodes":["man_in_motorized_wheelchair"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿฆผ","version":12},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿฆผ","version":12},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿฆผ","version":12},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿฆผ","version":12},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿฆผ","version":12}]},{"emoji":"๐Ÿ‘จโ€๐Ÿฆผโ€โžก๏ธ","group":1,"order":2237,"tags":["accessibility","man","motorized","wheelchair"],"version":15.1,"annotation":"man in motorized wheelchair facing right","shortcodes":["man_in_motorized_wheelchair_right"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿฆผโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿฆผ","group":1,"order":2249,"tags":["accessibility","motorized","wheelchair","woman"],"version":12,"annotation":"woman in motorized wheelchair","shortcodes":["woman_in_motorized_wheelchair"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿฆผ","version":12},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿฆผ","version":12},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿฆผ","version":12},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿฆผ","version":12},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿฆผ","version":12}]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿฆผโ€โžก๏ธ","group":1,"order":2255,"tags":["accessibility","motorized","wheelchair","woman"],"version":15.1,"annotation":"woman in motorized wheelchair facing right","shortcodes":["woman_in_motorized_wheelchair_right"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿฆผโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿฆผโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿง‘โ€๐Ÿฆฝ","group":1,"order":2267,"tags":["accessibility","manual","person","wheelchair"],"version":12.1,"annotation":"person in manual wheelchair","shortcodes":["person_in_manual_wheelchair"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿฆฝ","version":12.1},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿฆฝ","version":12.1},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿฆฝ","version":12.1},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿฆฝ","version":12.1},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿฆฝ","version":12.1}]},{"emoji":"๐Ÿง‘โ€๐Ÿฆฝโ€โžก๏ธ","group":1,"order":2273,"tags":["accessibility","manual","person","wheelchair"],"version":15.1,"annotation":"person in manual wheelchair facing right","shortcodes":["person_in_manual_wheelchair_right"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ‘จโ€๐Ÿฆฝ","group":1,"order":2285,"tags":["accessibility","man","manual","wheelchair"],"version":12,"annotation":"man in manual wheelchair","shortcodes":["man_in_manual_wheelchair"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฝ","version":12},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿฆฝ","version":12},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿฆฝ","version":12},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿฆฝ","version":12},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿฆฝ","version":12}]},{"emoji":"๐Ÿ‘จโ€๐Ÿฆฝโ€โžก๏ธ","group":1,"order":2291,"tags":["accessibility","man","manual","wheelchair"],"version":15.1,"annotation":"man in manual wheelchair facing right","shortcodes":["man_in_manual_wheelchair_right"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿฆฝ","group":1,"order":2303,"tags":["accessibility","manual","wheelchair","woman"],"version":12,"annotation":"woman in manual wheelchair","shortcodes":["woman_in_manual_wheelchair"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿฆฝ","version":12},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿฆฝ","version":12},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿฆฝ","version":12},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿฆฝ","version":12},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿฆฝ","version":12}]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿฆฝโ€โžก๏ธ","group":1,"order":2309,"tags":["accessibility","manual","wheelchair","woman"],"version":15.1,"annotation":"woman in manual wheelchair facing right","shortcodes":["woman_in_manual_wheelchair_right"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿฆฝโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿƒ","group":1,"order":2321,"tags":["fast","hurry","marathon","move","person","quick","race","racing","run","rush","speed"],"version":0.6,"annotation":"person running","shortcodes":["person_running","running"],"skins":[{"tone":1,"emoji":"๐Ÿƒ๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿƒ๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿƒ๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿƒ๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿƒ๐Ÿฟ","version":1}]},{"emoji":"๐Ÿƒโ€โ™‚๏ธ","group":1,"order":2327,"tags":["fast","hurry","man","marathon","move","quick","race","racing","run","rush","speed"],"version":4,"annotation":"man running","shortcodes":["man_running"],"skins":[{"tone":1,"emoji":"๐Ÿƒ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿƒ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿƒ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿƒ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿƒ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿƒโ€โ™€๏ธ","group":1,"order":2339,"tags":["fast","hurry","marathon","move","quick","race","racing","run","rush","speed","woman"],"version":4,"annotation":"woman running","shortcodes":["woman_running"],"skins":[{"tone":1,"emoji":"๐Ÿƒ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿƒ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿƒ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿƒ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿƒ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿƒโ€โžก๏ธ","group":1,"order":2351,"tags":["fast","hurry","marathon","move","person","quick","race","racing","run","rush","speed"],"version":15.1,"annotation":"person running facing right","shortcodes":["person_running_right"],"skins":[{"tone":1,"emoji":"๐Ÿƒ๐Ÿปโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿƒ๐Ÿผโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿƒ๐Ÿฝโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿƒ๐Ÿพโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿƒ๐Ÿฟโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿƒโ€โ™€๏ธโ€โžก๏ธ","group":1,"order":2363,"tags":["fast","hurry","marathon","move","quick","race","racing","run","rush","speed","woman"],"version":15.1,"annotation":"woman running facing right","shortcodes":["woman_running_right"],"skins":[{"tone":1,"emoji":"๐Ÿƒ๐Ÿปโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿƒ๐Ÿผโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿƒ๐Ÿฝโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿƒ๐Ÿพโ€โ™€๏ธโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿƒ๐Ÿฟโ€โ™€๏ธโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿƒโ€โ™‚๏ธโ€โžก๏ธ","group":1,"order":2387,"tags":["fast","hurry","man","marathon","move","quick","race","racing","run","rush","speed"],"version":15.1,"annotation":"man running facing right","shortcodes":["man_running_right"],"skins":[{"tone":1,"emoji":"๐Ÿƒ๐Ÿปโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":2,"emoji":"๐Ÿƒ๐Ÿผโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":3,"emoji":"๐Ÿƒ๐Ÿฝโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":4,"emoji":"๐Ÿƒ๐Ÿพโ€โ™‚๏ธโ€โžก๏ธ","version":15.1},{"tone":5,"emoji":"๐Ÿƒ๐Ÿฟโ€โ™‚๏ธโ€โžก๏ธ","version":15.1}]},{"emoji":"๐Ÿ’ƒ","group":1,"order":2411,"tags":["dance","dancer","dancing","elegant","festive","flair","flamenco","groove","letโ€™s","salsa","tango","woman"],"version":0.6,"annotation":"woman dancing","shortcodes":["dancer","woman_dancing"],"skins":[{"tone":1,"emoji":"๐Ÿ’ƒ๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ’ƒ๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ’ƒ๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ’ƒ๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ’ƒ๐Ÿฟ","version":1}]},{"emoji":"๐Ÿ•บ","group":1,"order":2417,"tags":["dance","dancer","dancing","elegant","festive","flair","flamenco","groove","letโ€™s","man","salsa","tango"],"version":3,"annotation":"man dancing","shortcodes":["man_dancing"],"skins":[{"tone":1,"emoji":"๐Ÿ•บ๐Ÿป","version":3},{"tone":2,"emoji":"๐Ÿ•บ๐Ÿผ","version":3},{"tone":3,"emoji":"๐Ÿ•บ๐Ÿฝ","version":3},{"tone":4,"emoji":"๐Ÿ•บ๐Ÿพ","version":3},{"tone":5,"emoji":"๐Ÿ•บ๐Ÿฟ","version":3}]},{"emoji":"๐Ÿ•ด๏ธ","group":1,"order":2424,"tags":["business","levitating","person","suit"],"version":0.7,"annotation":"person in suit levitating","shortcodes":["levitate","levitating","person_in_suit_levitating"],"skins":[{"tone":1,"emoji":"๐Ÿ•ด๐Ÿป","version":4},{"tone":2,"emoji":"๐Ÿ•ด๐Ÿผ","version":4},{"tone":3,"emoji":"๐Ÿ•ด๐Ÿฝ","version":4},{"tone":4,"emoji":"๐Ÿ•ด๐Ÿพ","version":4},{"tone":5,"emoji":"๐Ÿ•ด๐Ÿฟ","version":4}]},{"emoji":"๐Ÿ‘ฏ","group":1,"order":2430,"tags":["bestie","bff","bunny","counterpart","dancer","double","ear","identical","pair","party","partying","people","soulmate","twin","twinsies"],"version":0.6,"annotation":"people with bunny ears","shortcodes":["dancers","people_with_bunny_ears_partying"]},{"emoji":"๐Ÿ‘ฏโ€โ™‚๏ธ","group":1,"order":2431,"tags":["bestie","bff","bunny","counterpart","dancer","double","ear","identical","men","pair","party","partying","people","soulmate","twin","twinsies"],"version":4,"annotation":"men with bunny ears","shortcodes":["men_with_bunny_ears_partying"]},{"emoji":"๐Ÿ‘ฏโ€โ™€๏ธ","group":1,"order":2433,"tags":["bestie","bff","bunny","counterpart","dancer","double","ear","identical","pair","party","partying","people","soulmate","twin","twinsies","women"],"version":4,"annotation":"women with bunny ears","shortcodes":["women_with_bunny_ears_partying"]},{"emoji":"๐Ÿง–","group":1,"order":2435,"tags":["day","luxurious","pamper","person","relax","room","sauna","spa","steam","steambath","unwind"],"version":5,"annotation":"person in steamy room","shortcodes":["person_in_steamy_room"],"skins":[{"tone":1,"emoji":"๐Ÿง–๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿง–๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿง–๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿง–๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿง–๐Ÿฟ","version":5}]},{"emoji":"๐Ÿง–โ€โ™‚๏ธ","group":1,"order":2441,"tags":["day","luxurious","man","pamper","relax","room","sauna","spa","steam","steambath","unwind"],"version":5,"annotation":"man in steamy room","shortcodes":["man_in_steamy_room"],"skins":[{"tone":1,"emoji":"๐Ÿง–๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง–๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง–๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง–๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง–๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿง–โ€โ™€๏ธ","group":1,"order":2453,"tags":["day","luxurious","pamper","relax","room","sauna","spa","steam","steambath","unwind","woman"],"version":5,"annotation":"woman in steamy room","shortcodes":["woman_in_steamy_room"],"skins":[{"tone":1,"emoji":"๐Ÿง–๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง–๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง–๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง–๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง–๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿง—","group":1,"order":2465,"tags":["climb","climber","climbing","mountain","person","rock","scale","up"],"version":5,"annotation":"person climbing","shortcodes":["climbing","person_climbing"],"skins":[{"tone":1,"emoji":"๐Ÿง—๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿง—๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿง—๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿง—๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿง—๐Ÿฟ","version":5}]},{"emoji":"๐Ÿง—โ€โ™‚๏ธ","group":1,"order":2471,"tags":["climb","climber","climbing","man","mountain","rock","scale","up"],"version":5,"annotation":"man climbing","shortcodes":["man_climbing"],"skins":[{"tone":1,"emoji":"๐Ÿง—๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง—๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง—๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง—๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง—๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿง—โ€โ™€๏ธ","group":1,"order":2483,"tags":["climb","climber","climbing","mountain","rock","scale","up","woman"],"version":5,"annotation":"woman climbing","shortcodes":["woman_climbing"],"skins":[{"tone":1,"emoji":"๐Ÿง—๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง—๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง—๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง—๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง—๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿคบ","group":1,"order":2495,"tags":["fencer","fencing","person","sword"],"version":3,"annotation":"person fencing","shortcodes":["fencer","fencing","person_fencing"]},{"emoji":"๐Ÿ‡","group":1,"order":2496,"tags":["horse","jockey","racehorse","racing","riding","sport"],"version":1,"annotation":"horse racing","shortcodes":["horse_racing"],"skins":[{"tone":1,"emoji":"๐Ÿ‡๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ‡๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ‡๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ‡๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ‡๐Ÿฟ","version":1}]},{"emoji":"โ›ท๏ธ","group":1,"order":2503,"tags":["ski","snow"],"version":0.7,"annotation":"skier","shortcodes":["person_skiing","skier","skiing"]},{"emoji":"๐Ÿ‚๏ธ","group":1,"order":2504,"tags":["ski","snow","snowboard","sport"],"version":0.6,"annotation":"snowboarder","shortcodes":["person_snowboarding","snowboarder","snowboarding"],"skins":[{"tone":1,"emoji":"๐Ÿ‚๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ‚๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ‚๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ‚๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ‚๐Ÿฟ","version":1}]},{"emoji":"๐ŸŒ๏ธ","group":1,"order":2511,"tags":["ball","birdie","caddy","driving","golf","golfing","green","person","pga","putt","range","tee"],"version":0.7,"annotation":"person golfing","shortcodes":["golfer","golfing","person_golfing"],"skins":[{"tone":1,"emoji":"๐ŸŒ๐Ÿป","version":4},{"tone":2,"emoji":"๐ŸŒ๐Ÿผ","version":4},{"tone":3,"emoji":"๐ŸŒ๐Ÿฝ","version":4},{"tone":4,"emoji":"๐ŸŒ๐Ÿพ","version":4},{"tone":5,"emoji":"๐ŸŒ๐Ÿฟ","version":4}]},{"emoji":"๐ŸŒ๏ธโ€โ™‚๏ธ","group":1,"order":2517,"tags":["ball","birdie","caddy","driving","golf","golfing","green","man","pga","putt","range","tee"],"version":4,"annotation":"man golfing","shortcodes":["man_golfing"],"skins":[{"tone":1,"emoji":"๐ŸŒ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐ŸŒ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐ŸŒ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐ŸŒ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐ŸŒ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐ŸŒ๏ธโ€โ™€๏ธ","group":1,"order":2531,"tags":["ball","birdie","caddy","driving","golf","golfing","green","pga","putt","range","tee","woman"],"version":4,"annotation":"woman golfing","shortcodes":["woman_golfing"],"skins":[{"tone":1,"emoji":"๐ŸŒ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐ŸŒ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐ŸŒ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐ŸŒ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐ŸŒ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿ„๏ธ","group":1,"order":2545,"tags":["beach","ocean","person","sport","surf","surfer","surfing","swell","waves"],"version":0.6,"annotation":"person surfing","shortcodes":["person_surfing","surfer","surfing"],"skins":[{"tone":1,"emoji":"๐Ÿ„๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ„๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ„๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ„๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ„๐Ÿฟ","version":1}]},{"emoji":"๐Ÿ„โ€โ™‚๏ธ","group":1,"order":2551,"tags":["beach","man","ocean","sport","surf","surfer","surfing","swell","waves"],"version":4,"annotation":"man surfing","shortcodes":["man_surfing"],"skins":[{"tone":1,"emoji":"๐Ÿ„๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ„๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ„๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ„๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ„๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿ„โ€โ™€๏ธ","group":1,"order":2563,"tags":["beach","ocean","person","sport","surf","surfer","surfing","swell","waves"],"version":4,"annotation":"woman surfing","shortcodes":["woman_surfing"],"skins":[{"tone":1,"emoji":"๐Ÿ„๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ„๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ„๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ„๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ„๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿšฃ","group":1,"order":2575,"tags":["boat","canoe","cruise","fishing","lake","oar","paddle","person","raft","river","row","rowboat","rowing"],"version":1,"annotation":"person rowing boat","shortcodes":["person_rowing_boat","rowboat"],"skins":[{"tone":1,"emoji":"๐Ÿšฃ๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿšฃ๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿšฃ๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿšฃ๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿšฃ๐Ÿฟ","version":1}]},{"emoji":"๐Ÿšฃโ€โ™‚๏ธ","group":1,"order":2581,"tags":["boat","canoe","cruise","fishing","lake","man","oar","paddle","raft","river","row","rowboat","rowing"],"version":4,"annotation":"man rowing boat","shortcodes":["man_rowing_boat"],"skins":[{"tone":1,"emoji":"๐Ÿšฃ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšฃ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšฃ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšฃ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšฃ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿšฃโ€โ™€๏ธ","group":1,"order":2593,"tags":["boat","canoe","cruise","fishing","lake","oar","paddle","raft","river","row","rowboat","rowing","woman"],"version":4,"annotation":"woman rowing boat","shortcodes":["woman_rowing_boat"],"skins":[{"tone":1,"emoji":"๐Ÿšฃ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšฃ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšฃ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšฃ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšฃ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐ŸŠ๏ธ","group":1,"order":2605,"tags":["freestyle","person","sport","swim","swimmer","swimming","triathlon"],"version":0.6,"annotation":"person swimming","shortcodes":["person_swimming","swimmer","swimming"],"skins":[{"tone":1,"emoji":"๐ŸŠ๐Ÿป","version":1},{"tone":2,"emoji":"๐ŸŠ๐Ÿผ","version":1},{"tone":3,"emoji":"๐ŸŠ๐Ÿฝ","version":1},{"tone":4,"emoji":"๐ŸŠ๐Ÿพ","version":1},{"tone":5,"emoji":"๐ŸŠ๐Ÿฟ","version":1}]},{"emoji":"๐ŸŠโ€โ™‚๏ธ","group":1,"order":2611,"tags":["freestyle","man","sport","swim","swimmer","swimming","triathlon"],"version":4,"annotation":"man swimming","shortcodes":["man_swimming"],"skins":[{"tone":1,"emoji":"๐ŸŠ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐ŸŠ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐ŸŠ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐ŸŠ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐ŸŠ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐ŸŠโ€โ™€๏ธ","group":1,"order":2623,"tags":["freestyle","man","sport","swim","swimmer","swimming","triathlon"],"version":4,"annotation":"woman swimming","shortcodes":["woman_swimming"],"skins":[{"tone":1,"emoji":"๐ŸŠ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐ŸŠ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐ŸŠ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐ŸŠ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐ŸŠ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"โ›น๏ธ","group":1,"order":2636,"tags":["athletic","ball","basketball","bouncing","championship","dribble","net","person","player","throw"],"version":0.7,"annotation":"person bouncing ball","shortcodes":["person_bouncing_ball"],"skins":[{"tone":1,"emoji":"โ›น๐Ÿป","version":2},{"tone":2,"emoji":"โ›น๐Ÿผ","version":2},{"tone":3,"emoji":"โ›น๐Ÿฝ","version":2},{"tone":4,"emoji":"โ›น๐Ÿพ","version":2},{"tone":5,"emoji":"โ›น๐Ÿฟ","version":2}]},{"emoji":"โ›น๏ธโ€โ™‚๏ธ","group":1,"order":2642,"tags":["athletic","ball","basketball","bouncing","championship","dribble","man","net","player","throw"],"version":4,"annotation":"man bouncing ball","shortcodes":["man_bouncing_ball"],"skins":[{"tone":1,"emoji":"โ›น๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"โ›น๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"โ›น๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"โ›น๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"โ›น๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"โ›น๏ธโ€โ™€๏ธ","group":1,"order":2656,"tags":["athletic","ball","basketball","bouncing","championship","dribble","net","player","throw","woman"],"version":4,"annotation":"woman bouncing ball","shortcodes":["woman_bouncing_ball"],"skins":[{"tone":1,"emoji":"โ›น๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"โ›น๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"โ›น๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"โ›น๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"โ›น๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿ‹๏ธ","group":1,"order":2671,"tags":["barbell","bodybuilder","deadlift","lifter","lifting","person","powerlifting","weight","weightlifter","weights","workout"],"version":0.7,"annotation":"person lifting weights","shortcodes":["person_lifting_weights","weight_lifter","weight_lifting"],"skins":[{"tone":1,"emoji":"๐Ÿ‹๐Ÿป","version":2},{"tone":2,"emoji":"๐Ÿ‹๐Ÿผ","version":2},{"tone":3,"emoji":"๐Ÿ‹๐Ÿฝ","version":2},{"tone":4,"emoji":"๐Ÿ‹๐Ÿพ","version":2},{"tone":5,"emoji":"๐Ÿ‹๐Ÿฟ","version":2}]},{"emoji":"๐Ÿ‹๏ธโ€โ™‚๏ธ","group":1,"order":2677,"tags":["barbell","bodybuilder","deadlift","lifter","lifting","man","powerlifting","weight","weightlifter","weights","workout"],"version":4,"annotation":"man lifting weights","shortcodes":["man_lifting_weights"],"skins":[{"tone":1,"emoji":"๐Ÿ‹๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ‹๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ‹๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ‹๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ‹๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿ‹๏ธโ€โ™€๏ธ","group":1,"order":2691,"tags":["barbell","bodybuilder","deadlift","lifter","lifting","powerlifting","weight","weightlifter","weights","woman","workout"],"version":4,"annotation":"woman lifting weights","shortcodes":["woman_lifting_weights"],"skins":[{"tone":1,"emoji":"๐Ÿ‹๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿ‹๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿ‹๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿ‹๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿ‹๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿšด","group":1,"order":2705,"tags":["bicycle","bicyclist","bike","biking","cycle","cyclist","person","riding","sport"],"version":1,"annotation":"person biking","shortcodes":["bicyclist","biking","person_biking"],"skins":[{"tone":1,"emoji":"๐Ÿšด๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿšด๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿšด๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿšด๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿšด๐Ÿฟ","version":1}]},{"emoji":"๐Ÿšดโ€โ™‚๏ธ","group":1,"order":2711,"tags":["bicycle","bicyclist","bike","biking","cycle","cyclist","man","riding","sport"],"version":4,"annotation":"man biking","shortcodes":["man_biking"],"skins":[{"tone":1,"emoji":"๐Ÿšด๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšด๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšด๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšด๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšด๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿšดโ€โ™€๏ธ","group":1,"order":2723,"tags":["bicycle","bicyclist","bike","biking","cycle","cyclist","riding","sport","woman"],"version":4,"annotation":"woman biking","shortcodes":["woman_biking"],"skins":[{"tone":1,"emoji":"๐Ÿšด๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšด๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšด๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšด๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšด๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿšต","group":1,"order":2735,"tags":["bicycle","bicyclist","bike","biking","cycle","cyclist","mountain","person","riding","sport"],"version":1,"annotation":"person mountain biking","shortcodes":["mountain_bicyclist","mountain_biking","person_mountain_biking"],"skins":[{"tone":1,"emoji":"๐Ÿšต๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿšต๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿšต๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿšต๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿšต๐Ÿฟ","version":1}]},{"emoji":"๐Ÿšตโ€โ™‚๏ธ","group":1,"order":2741,"tags":["bicycle","bicyclist","bike","biking","cycle","cyclist","man","mountain","riding","sport"],"version":4,"annotation":"man mountain biking","shortcodes":["man_mountain_biking"],"skins":[{"tone":1,"emoji":"๐Ÿšต๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšต๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšต๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšต๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšต๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿšตโ€โ™€๏ธ","group":1,"order":2753,"tags":["bicycle","bicyclist","bike","biking","cycle","cyclist","mountain","riding","sport","woman"],"version":4,"annotation":"woman mountain biking","shortcodes":["woman_mountain_biking"],"skins":[{"tone":1,"emoji":"๐Ÿšต๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿšต๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿšต๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿšต๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿšต๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿคธ","group":1,"order":2765,"tags":["active","cartwheel","cartwheeling","excited","flip","gymnastics","happy","person","somersault"],"version":3,"annotation":"person cartwheeling","shortcodes":["cartwheeling","person_cartwheel"],"skins":[{"tone":1,"emoji":"๐Ÿคธ๐Ÿป","version":3},{"tone":2,"emoji":"๐Ÿคธ๐Ÿผ","version":3},{"tone":3,"emoji":"๐Ÿคธ๐Ÿฝ","version":3},{"tone":4,"emoji":"๐Ÿคธ๐Ÿพ","version":3},{"tone":5,"emoji":"๐Ÿคธ๐Ÿฟ","version":3}]},{"emoji":"๐Ÿคธโ€โ™‚๏ธ","group":1,"order":2771,"tags":["active","cartwheel","cartwheeling","excited","flip","gymnastics","happy","man","somersault"],"version":4,"annotation":"man cartwheeling","shortcodes":["man_cartwheeling"],"skins":[{"tone":1,"emoji":"๐Ÿคธ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคธ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคธ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคธ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคธ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿคธโ€โ™€๏ธ","group":1,"order":2783,"tags":["active","cartwheel","cartwheeling","excited","flip","gymnastics","happy","somersault","woman"],"version":4,"annotation":"woman cartwheeling","shortcodes":["woman_cartwheeling"],"skins":[{"tone":1,"emoji":"๐Ÿคธ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคธ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคธ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคธ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคธ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿคผ","group":1,"order":2795,"tags":["combat","duel","grapple","people","ring","tournament","wrestle","wrestling"],"version":3,"annotation":"people wrestling","shortcodes":["people_wrestling","wrestlers","wrestling"]},{"emoji":"๐Ÿคผโ€โ™‚๏ธ","group":1,"order":2796,"tags":["combat","duel","grapple","men","ring","tournament","wrestle","wrestling"],"version":4,"annotation":"men wrestling","shortcodes":["men_wrestling"]},{"emoji":"๐Ÿคผโ€โ™€๏ธ","group":1,"order":2798,"tags":["combat","duel","grapple","ring","tournament","women","wrestle","wrestling"],"version":4,"annotation":"women wrestling","shortcodes":["women_wrestling"]},{"emoji":"๐Ÿคฝ","group":1,"order":2800,"tags":["person","playing","polo","sport","swimming","water","waterpolo"],"version":3,"annotation":"person playing water polo","shortcodes":["person_playing_water_polo","water_polo"],"skins":[{"tone":1,"emoji":"๐Ÿคฝ๐Ÿป","version":3},{"tone":2,"emoji":"๐Ÿคฝ๐Ÿผ","version":3},{"tone":3,"emoji":"๐Ÿคฝ๐Ÿฝ","version":3},{"tone":4,"emoji":"๐Ÿคฝ๐Ÿพ","version":3},{"tone":5,"emoji":"๐Ÿคฝ๐Ÿฟ","version":3}]},{"emoji":"๐Ÿคฝโ€โ™‚๏ธ","group":1,"order":2806,"tags":["man","playing","polo","sport","swimming","water","waterpolo"],"version":4,"annotation":"man playing water polo","shortcodes":["man_playing_water_polo"],"skins":[{"tone":1,"emoji":"๐Ÿคฝ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคฝ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคฝ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคฝ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคฝ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿคฝโ€โ™€๏ธ","group":1,"order":2818,"tags":["playing","polo","sport","swimming","water","waterpolo","woman"],"version":4,"annotation":"woman playing water polo","shortcodes":["woman_playing_water_polo"],"skins":[{"tone":1,"emoji":"๐Ÿคฝ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคฝ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคฝ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคฝ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคฝ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿคพ","group":1,"order":2830,"tags":["athletics","ball","catch","chuck","handball","hurl","lob","person","pitch","playing","sport","throw","toss"],"version":3,"annotation":"person playing handball","shortcodes":["handball","person_playing_handball"],"skins":[{"tone":1,"emoji":"๐Ÿคพ๐Ÿป","version":3},{"tone":2,"emoji":"๐Ÿคพ๐Ÿผ","version":3},{"tone":3,"emoji":"๐Ÿคพ๐Ÿฝ","version":3},{"tone":4,"emoji":"๐Ÿคพ๐Ÿพ","version":3},{"tone":5,"emoji":"๐Ÿคพ๐Ÿฟ","version":3}]},{"emoji":"๐Ÿคพโ€โ™‚๏ธ","group":1,"order":2836,"tags":["athletics","ball","catch","chuck","handball","hurl","lob","man","pitch","playing","sport","throw","toss"],"version":4,"annotation":"man playing handball","shortcodes":["man_playing_handball"],"skins":[{"tone":1,"emoji":"๐Ÿคพ๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคพ๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคพ๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคพ๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคพ๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿคพโ€โ™€๏ธ","group":1,"order":2848,"tags":["athletics","ball","catch","chuck","handball","hurl","lob","pitch","playing","sport","throw","toss","woman"],"version":4,"annotation":"woman playing handball","shortcodes":["woman_playing_handball"],"skins":[{"tone":1,"emoji":"๐Ÿคพ๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคพ๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคพ๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคพ๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคพ๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿคน","group":1,"order":2860,"tags":["act","balance","balancing","handle","juggle","juggling","manage","multitask","person","skill"],"version":3,"annotation":"person juggling","shortcodes":["juggler","juggling","person_juggling"],"skins":[{"tone":1,"emoji":"๐Ÿคน๐Ÿป","version":3},{"tone":2,"emoji":"๐Ÿคน๐Ÿผ","version":3},{"tone":3,"emoji":"๐Ÿคน๐Ÿฝ","version":3},{"tone":4,"emoji":"๐Ÿคน๐Ÿพ","version":3},{"tone":5,"emoji":"๐Ÿคน๐Ÿฟ","version":3}]},{"emoji":"๐Ÿคนโ€โ™‚๏ธ","group":1,"order":2866,"tags":["act","balance","balancing","handle","juggle","juggling","man","manage","multitask","skill"],"version":4,"annotation":"man juggling","shortcodes":["man_juggling"],"skins":[{"tone":1,"emoji":"๐Ÿคน๐Ÿปโ€โ™‚๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคน๐Ÿผโ€โ™‚๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคน๐Ÿฝโ€โ™‚๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคน๐Ÿพโ€โ™‚๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคน๐Ÿฟโ€โ™‚๏ธ","version":4}]},{"emoji":"๐Ÿคนโ€โ™€๏ธ","group":1,"order":2878,"tags":["act","balance","balancing","handle","juggle","juggling","manage","multitask","skill","woman"],"version":4,"annotation":"woman juggling","shortcodes":["woman_juggling"],"skins":[{"tone":1,"emoji":"๐Ÿคน๐Ÿปโ€โ™€๏ธ","version":4},{"tone":2,"emoji":"๐Ÿคน๐Ÿผโ€โ™€๏ธ","version":4},{"tone":3,"emoji":"๐Ÿคน๐Ÿฝโ€โ™€๏ธ","version":4},{"tone":4,"emoji":"๐Ÿคน๐Ÿพโ€โ™€๏ธ","version":4},{"tone":5,"emoji":"๐Ÿคน๐Ÿฟโ€โ™€๏ธ","version":4}]},{"emoji":"๐Ÿง˜","group":1,"order":2890,"tags":["cross","legged","legs","lotus","meditation","peace","person","position","relax","serenity","yoga","yogi","zen"],"version":5,"annotation":"person in lotus position","shortcodes":["person_in_lotus_position"],"skins":[{"tone":1,"emoji":"๐Ÿง˜๐Ÿป","version":5},{"tone":2,"emoji":"๐Ÿง˜๐Ÿผ","version":5},{"tone":3,"emoji":"๐Ÿง˜๐Ÿฝ","version":5},{"tone":4,"emoji":"๐Ÿง˜๐Ÿพ","version":5},{"tone":5,"emoji":"๐Ÿง˜๐Ÿฟ","version":5}]},{"emoji":"๐Ÿง˜โ€โ™‚๏ธ","group":1,"order":2896,"tags":["cross","legged","legs","lotus","man","meditation","peace","position","relax","serenity","yoga","yogi","zen"],"version":5,"annotation":"man in lotus position","shortcodes":["man_in_lotus_position"],"skins":[{"tone":1,"emoji":"๐Ÿง˜๐Ÿปโ€โ™‚๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง˜๐Ÿผโ€โ™‚๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง˜๐Ÿฝโ€โ™‚๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง˜๐Ÿพโ€โ™‚๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง˜๐Ÿฟโ€โ™‚๏ธ","version":5}]},{"emoji":"๐Ÿง˜โ€โ™€๏ธ","group":1,"order":2908,"tags":["cross","legged","legs","lotus","meditation","peace","position","relax","serenity","woman","yoga","yogi","zen"],"version":5,"annotation":"woman in lotus position","shortcodes":["woman_in_lotus_position"],"skins":[{"tone":1,"emoji":"๐Ÿง˜๐Ÿปโ€โ™€๏ธ","version":5},{"tone":2,"emoji":"๐Ÿง˜๐Ÿผโ€โ™€๏ธ","version":5},{"tone":3,"emoji":"๐Ÿง˜๐Ÿฝโ€โ™€๏ธ","version":5},{"tone":4,"emoji":"๐Ÿง˜๐Ÿพโ€โ™€๏ธ","version":5},{"tone":5,"emoji":"๐Ÿง˜๐Ÿฟโ€โ™€๏ธ","version":5}]},{"emoji":"๐Ÿ›€","group":1,"order":2920,"tags":["bath","bathtub","person","taking","tub"],"version":0.6,"annotation":"person taking bath","shortcodes":["bath","person_taking_bath"],"skins":[{"tone":1,"emoji":"๐Ÿ›€๐Ÿป","version":1},{"tone":2,"emoji":"๐Ÿ›€๐Ÿผ","version":1},{"tone":3,"emoji":"๐Ÿ›€๐Ÿฝ","version":1},{"tone":4,"emoji":"๐Ÿ›€๐Ÿพ","version":1},{"tone":5,"emoji":"๐Ÿ›€๐Ÿฟ","version":1}]},{"emoji":"๐Ÿ›Œ","group":1,"order":2926,"tags":["bed","bedtime","good","goodnight","hotel","nap","night","person","sleep","tired","zzz"],"version":1,"annotation":"person in bed","shortcodes":["person_in_bed","sleeping_accommodation"],"skins":[{"tone":1,"emoji":"๐Ÿ›Œ๐Ÿป","version":4},{"tone":2,"emoji":"๐Ÿ›Œ๐Ÿผ","version":4},{"tone":3,"emoji":"๐Ÿ›Œ๐Ÿฝ","version":4},{"tone":4,"emoji":"๐Ÿ›Œ๐Ÿพ","version":4},{"tone":5,"emoji":"๐Ÿ›Œ๐Ÿฟ","version":4}]},{"emoji":"๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘","group":1,"order":2932,"tags":["bae","bestie","bff","couple","dating","flirt","friends","hand","hold","people","twins"],"version":12,"annotation":"people holding hands","shortcodes":["people_holding_hands"],"skins":[{"tone":1,"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿป","version":12},{"tone":[1,2],"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿผ","version":12.1},{"tone":[1,3],"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ","version":12.1},{"tone":[1,4],"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿพ","version":12.1},{"tone":[1,5],"emoji":"๐Ÿง‘๐Ÿปโ€๐Ÿคโ€๐Ÿง‘๐Ÿฟ","version":12.1},{"tone":[2,1],"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿคโ€๐Ÿง‘๐Ÿป","version":12},{"tone":2,"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿคโ€๐Ÿง‘๐Ÿผ","version":12},{"tone":[2,3],"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ","version":12.1},{"tone":[2,4],"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿคโ€๐Ÿง‘๐Ÿพ","version":12.1},{"tone":[2,5],"emoji":"๐Ÿง‘๐Ÿผโ€๐Ÿคโ€๐Ÿง‘๐Ÿฟ","version":12.1},{"tone":[3,1],"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿคโ€๐Ÿง‘๐Ÿป","version":12},{"tone":[3,2],"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿคโ€๐Ÿง‘๐Ÿผ","version":12},{"tone":3,"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ","version":12},{"tone":[3,4],"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿคโ€๐Ÿง‘๐Ÿพ","version":12.1},{"tone":[3,5],"emoji":"๐Ÿง‘๐Ÿฝโ€๐Ÿคโ€๐Ÿง‘๐Ÿฟ","version":12.1},{"tone":[4,1],"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿคโ€๐Ÿง‘๐Ÿป","version":12},{"tone":[4,2],"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿคโ€๐Ÿง‘๐Ÿผ","version":12},{"tone":[4,3],"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ","version":12},{"tone":4,"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿคโ€๐Ÿง‘๐Ÿพ","version":12},{"tone":[4,5],"emoji":"๐Ÿง‘๐Ÿพโ€๐Ÿคโ€๐Ÿง‘๐Ÿฟ","version":12.1},{"tone":[5,1],"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿคโ€๐Ÿง‘๐Ÿป","version":12},{"tone":[5,2],"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿคโ€๐Ÿง‘๐Ÿผ","version":12},{"tone":[5,3],"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿคโ€๐Ÿง‘๐Ÿฝ","version":12},{"tone":[5,4],"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿคโ€๐Ÿง‘๐Ÿพ","version":12},{"tone":5,"emoji":"๐Ÿง‘๐Ÿฟโ€๐Ÿคโ€๐Ÿง‘๐Ÿฟ","version":12}]},{"emoji":"๐Ÿ‘ญ","group":1,"order":2958,"tags":["bae","bestie","bff","couple","dating","flirt","friends","girls","hand","hold","sisters","twins","women"],"version":1,"annotation":"women holding hands","shortcodes":["two_women_holding_hands"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ญ๐Ÿป","version":12},{"tone":2,"emoji":"๐Ÿ‘ญ๐Ÿผ","version":12},{"tone":3,"emoji":"๐Ÿ‘ญ๐Ÿฝ","version":12},{"tone":4,"emoji":"๐Ÿ‘ญ๐Ÿพ","version":12},{"tone":5,"emoji":"๐Ÿ‘ญ๐Ÿฟ","version":12},{"tone":[1,2],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿผ","version":12.1},{"tone":[1,3],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฝ","version":12.1},{"tone":[1,4],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿพ","version":12.1},{"tone":[1,5],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฟ","version":12.1},{"tone":[2,1],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿป","version":12},{"tone":[2,3],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฝ","version":12.1},{"tone":[2,4],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿพ","version":12.1},{"tone":[2,5],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฟ","version":12.1},{"tone":[3,1],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿป","version":12},{"tone":[3,2],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿผ","version":12},{"tone":[3,4],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿพ","version":12.1},{"tone":[3,5],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฟ","version":12.1},{"tone":[4,1],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿป","version":12},{"tone":[4,2],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿผ","version":12},{"tone":[4,3],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฝ","version":12},{"tone":[4,5],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฟ","version":12.1},{"tone":[5,1],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿป","version":12},{"tone":[5,2],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿผ","version":12},{"tone":[5,3],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿฝ","version":12},{"tone":[5,4],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘ฉ๐Ÿพ","version":12}]},{"emoji":"๐Ÿ‘ซ","group":1,"order":2984,"tags":["bae","bestie","bff","couple","dating","flirt","friends","hand","hold","man","twins","woman"],"version":0.6,"annotation":"woman and man holding hands","shortcodes":["couple"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ซ๐Ÿป","version":12},{"tone":2,"emoji":"๐Ÿ‘ซ๐Ÿผ","version":12},{"tone":3,"emoji":"๐Ÿ‘ซ๐Ÿฝ","version":12},{"tone":4,"emoji":"๐Ÿ‘ซ๐Ÿพ","version":12},{"tone":5,"emoji":"๐Ÿ‘ซ๐Ÿฟ","version":12},{"tone":[1,2],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[1,3],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12},{"tone":[1,4],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12},{"tone":[1,5],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12},{"tone":[2,1],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[2,3],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12},{"tone":[2,4],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12},{"tone":[2,5],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12},{"tone":[3,1],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[3,2],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[3,4],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12},{"tone":[3,5],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12},{"tone":[4,1],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[4,2],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[4,3],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12},{"tone":[4,5],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12},{"tone":[5,1],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[5,2],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[5,3],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12},{"tone":[5,4],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12}]},{"emoji":"๐Ÿ‘ฌ","group":1,"order":3010,"tags":["bae","bestie","bff","boys","brothers","couple","dating","flirt","friends","hand","hold","men","twins"],"version":1,"annotation":"men holding hands","shortcodes":["two_men_holding_hands"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฌ๐Ÿป","version":12},{"tone":2,"emoji":"๐Ÿ‘ฌ๐Ÿผ","version":12},{"tone":3,"emoji":"๐Ÿ‘ฌ๐Ÿฝ","version":12},{"tone":4,"emoji":"๐Ÿ‘ฌ๐Ÿพ","version":12},{"tone":5,"emoji":"๐Ÿ‘ฌ๐Ÿฟ","version":12},{"tone":[1,2],"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12.1},{"tone":[1,3],"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12.1},{"tone":[1,4],"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12.1},{"tone":[1,5],"emoji":"๐Ÿ‘จ๐Ÿปโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12.1},{"tone":[2,1],"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[2,3],"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12.1},{"tone":[2,4],"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12.1},{"tone":[2,5],"emoji":"๐Ÿ‘จ๐Ÿผโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12.1},{"tone":[3,1],"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[3,2],"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[3,4],"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12.1},{"tone":[3,5],"emoji":"๐Ÿ‘จ๐Ÿฝโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12.1},{"tone":[4,1],"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[4,2],"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[4,3],"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12},{"tone":[4,5],"emoji":"๐Ÿ‘จ๐Ÿพโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฟ","version":12.1},{"tone":[5,1],"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿป","version":12},{"tone":[5,2],"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿผ","version":12},{"tone":[5,3],"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿฝ","version":12},{"tone":[5,4],"emoji":"๐Ÿ‘จ๐Ÿฟโ€๐Ÿคโ€๐Ÿ‘จ๐Ÿพ","version":12}]},{"emoji":"๐Ÿ’","group":1,"order":3036,"tags":["anniversary","babe","bae","couple","date","dating","heart","love","mwah","person","romance","together","xoxo"],"version":0.6,"annotation":"kiss","shortcodes":["couple_kiss","couplekiss"],"skins":[{"tone":1,"emoji":"๐Ÿ’๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ’๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ’๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ’๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ’๐Ÿฟ","version":13.1},{"tone":[1,2],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[2,3],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿง‘๐Ÿพ","version":13.1}]},{"emoji":"๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ","group":1,"order":3082,"tags":["anniversary","babe","bae","couple","date","dating","heart","kiss","love","man","mwah","person","romance","together","woman","xoxo"],"version":2,"annotation":"kiss: woman, man","shortcodes":["kiss_mw","kiss_wm"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[1,2],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[2,3],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1}]},{"emoji":"๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ","group":1,"order":3134,"tags":["anniversary","babe","bae","couple","date","dating","heart","kiss","love","man","mwah","person","romance","together","xoxo"],"version":2,"annotation":"kiss: man, man","shortcodes":["kiss_mm"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[1,2],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[2,3],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ๐Ÿฟ","version":13.1}]},{"emoji":"๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ","group":1,"order":3186,"tags":["anniversary","babe","bae","couple","date","dating","heart","kiss","love","mwah","person","romance","together","woman","xoxo"],"version":2,"annotation":"kiss: woman, woman","shortcodes":["kiss_ww"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[1,2],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[2,3],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1}]},{"emoji":"๐Ÿ’‘","group":1,"order":3238,"tags":["anniversary","babe","bae","couple","dating","heart","kiss","love","person","relationship","romance","together","you"],"version":0.6,"annotation":"couple with heart","shortcodes":["couple_with_heart"],"skins":[{"tone":1,"emoji":"๐Ÿ’‘๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ’‘๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ’‘๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ’‘๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ’‘๐Ÿฟ","version":13.1},{"tone":[1,2],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿง‘๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿง‘๐Ÿปโ€โค๏ธโ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[2,3],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿง‘๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿง‘๐Ÿผโ€โค๏ธโ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿง‘๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿง‘๐Ÿฝโ€โค๏ธโ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿง‘๐Ÿพโ€โค๏ธโ€๐Ÿง‘๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿง‘๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿง‘๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿง‘๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿง‘๐Ÿฟโ€โค๏ธโ€๐Ÿง‘๐Ÿพ","version":13.1}]},{"emoji":"๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ","group":1,"order":3284,"tags":["anniversary","babe","bae","couple","dating","heart","kiss","love","man","person","relationship","romance","together","woman","you"],"version":2,"annotation":"couple with heart: woman, man","shortcodes":["couple_with_heart_mw","couple_with_heart_wm"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[1,2],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[2,3],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1}]},{"emoji":"๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ","group":1,"order":3336,"tags":["anniversary","babe","bae","couple","dating","heart","kiss","love","man","person","relationship","romance","together","you"],"version":2,"annotation":"couple with heart: man, man","shortcodes":["couple_with_heart_mm"],"skins":[{"tone":1,"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[1,2],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿ‘จ๐Ÿปโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[2,3],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿ‘จ๐Ÿผโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿ‘จ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿ‘จ๐Ÿพโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ‘จ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘จ๐Ÿฟ","version":13.1}]},{"emoji":"๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ","group":1,"order":3388,"tags":["anniversary","babe","bae","couple","dating","heart","kiss","love","person","relationship","romance","together","woman","you"],"version":2,"annotation":"couple with heart: woman, woman","shortcodes":["couple_with_heart_ww"],"skins":[{"tone":1,"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[1,2],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[1,3],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[1,4],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[1,5],"emoji":"๐Ÿ‘ฉ๐Ÿปโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[2,1],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":2,"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[2,3],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[2,4],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[2,5],"emoji":"๐Ÿ‘ฉ๐Ÿผโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[3,1],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[3,2],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":3,"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[3,4],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[3,5],"emoji":"๐Ÿ‘ฉ๐Ÿฝโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[4,1],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[4,2],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[4,3],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":4,"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":[4,5],"emoji":"๐Ÿ‘ฉ๐Ÿพโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1},{"tone":[5,1],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿป","version":13.1},{"tone":[5,2],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿผ","version":13.1},{"tone":[5,3],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฝ","version":13.1},{"tone":[5,4],"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿพ","version":13.1},{"tone":5,"emoji":"๐Ÿ‘ฉ๐Ÿฟโ€โค๏ธโ€๐Ÿ‘ฉ๐Ÿฟ","version":13.1}]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ","group":1,"order":3440,"tags":["boy","child","family","man","woman"],"version":2,"annotation":"family: man, woman, boy","shortcodes":["family_mwb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง","group":1,"order":3441,"tags":["child","family","girl","man","woman"],"version":2,"annotation":"family: man, woman, girl","shortcodes":["family_mwg"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","group":1,"order":3442,"tags":["boy","child","family","girl","man","woman"],"version":2,"annotation":"family: man, woman, girl, boy","shortcodes":["family_mwgb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","group":1,"order":3443,"tags":["boy","child","family","man","woman"],"version":2,"annotation":"family: man, woman, boy, boy","shortcodes":["family_mwbb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง","group":1,"order":3444,"tags":["child","family","girl","man","woman"],"version":2,"annotation":"family: man, woman, girl, girl","shortcodes":["family_mwgg"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ","group":1,"order":3445,"tags":["boy","child","family","man"],"version":2,"annotation":"family: man, man, boy","shortcodes":["family_mmb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง","group":1,"order":3446,"tags":["child","family","girl","man"],"version":2,"annotation":"family: man, man, girl","shortcodes":["family_mmg"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","group":1,"order":3447,"tags":["boy","child","family","girl","man"],"version":2,"annotation":"family: man, man, girl, boy","shortcodes":["family_mmgb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","group":1,"order":3448,"tags":["boy","child","family","man"],"version":2,"annotation":"family: man, man, boy, boy","shortcodes":["family_mmbb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง","group":1,"order":3449,"tags":["child","family","girl","man"],"version":2,"annotation":"family: man, man, girl, girl","shortcodes":["family_mmgg"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ","group":1,"order":3450,"tags":["boy","child","family","woman"],"version":2,"annotation":"family: woman, woman, boy","shortcodes":["family_wwb"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง","group":1,"order":3451,"tags":["child","family","girl","woman"],"version":2,"annotation":"family: woman, woman, girl","shortcodes":["family_wwg"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","group":1,"order":3452,"tags":["boy","child","family","girl","woman"],"version":2,"annotation":"family: woman, woman, girl, boy","shortcodes":["family_wwgb"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","group":1,"order":3453,"tags":["boy","child","family","woman"],"version":2,"annotation":"family: woman, woman, boy, boy","shortcodes":["family_wwbb"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง","group":1,"order":3454,"tags":["child","family","girl","woman"],"version":2,"annotation":"family: woman, woman, girl, girl","shortcodes":["family_wwgg"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฆ","group":1,"order":3455,"tags":["boy","child","family","man"],"version":4,"annotation":"family: man, boy","shortcodes":["family_mb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","group":1,"order":3456,"tags":["boy","child","family","man"],"version":4,"annotation":"family: man, boy, boy","shortcodes":["family_mbb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘ง","group":1,"order":3457,"tags":["child","family","girl","man"],"version":4,"annotation":"family: man, girl","shortcodes":["family_mg"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","group":1,"order":3458,"tags":["boy","child","family","girl","man"],"version":4,"annotation":"family: man, girl, boy","shortcodes":["family_mgb"]},{"emoji":"๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง","group":1,"order":3459,"tags":["child","family","girl","man"],"version":4,"annotation":"family: man, girl, girl","shortcodes":["family_mgg"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฆ","group":1,"order":3460,"tags":["boy","child","family","woman"],"version":4,"annotation":"family: woman, boy","shortcodes":["family_wb"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","group":1,"order":3461,"tags":["boy","child","family","woman"],"version":4,"annotation":"family: woman, boy, boy","shortcodes":["family_wbb"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘ง","group":1,"order":3462,"tags":["child","family","girl","woman"],"version":4,"annotation":"family: woman, girl","shortcodes":["family_wg"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","group":1,"order":3463,"tags":["boy","child","family","girl","woman"],"version":4,"annotation":"family: woman, girl, boy","shortcodes":["family_wgb"]},{"emoji":"๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง","group":1,"order":3464,"tags":["child","family","girl","woman"],"version":4,"annotation":"family: woman, girl, girl","shortcodes":["family_wgg"]},{"emoji":"๐Ÿ—ฃ๏ธ","group":1,"order":3466,"tags":["face","head","silhouette","speak","speaking"],"version":0.7,"annotation":"speaking head","shortcodes":["speaking_head"]},{"emoji":"๐Ÿ‘ค","group":1,"order":3467,"tags":["bust","mysterious","shadow","silhouette"],"version":0.6,"annotation":"bust in silhouette","shortcodes":["bust_in_silhouette"]},{"emoji":"๐Ÿ‘ฅ","group":1,"order":3468,"tags":["bff","bust","busts","everyone","friend","friends","people","silhouette"],"version":1,"annotation":"busts in silhouette","shortcodes":["busts_in_silhouette"]},{"emoji":"๐Ÿซ‚","group":1,"order":3469,"tags":["comfort","embrace","farewell","friendship","goodbye","hello","hug","hugging","love","people","thanks"],"version":13,"annotation":"people hugging","shortcodes":["people_hugging"]},{"emoji":"๐Ÿ‘ช๏ธ","group":1,"order":3470,"tags":["child"],"version":0.6,"annotation":"family","shortcodes":["family"]},{"emoji":"๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’","group":1,"order":3471,"tags":["adult","child","family"],"version":15.1,"annotation":"family: adult, adult, child","shortcodes":["family_aac"]},{"emoji":"๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’","group":1,"order":3472,"tags":["adult","child","family"],"version":15.1,"annotation":"family: adult, adult, child, child","shortcodes":["family_aacc"]},{"emoji":"๐Ÿง‘โ€๐Ÿง’","group":1,"order":3473,"tags":["adult","child","family"],"version":15.1,"annotation":"family: adult, child","shortcodes":["family_ac"]},{"emoji":"๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’","group":1,"order":3474,"tags":["adult","child","family"],"version":15.1,"annotation":"family: adult, child, child","shortcodes":["family_acc"]},{"emoji":"๐Ÿ‘ฃ","group":1,"order":3475,"tags":["barefoot","clothing","footprint","omw","print","walk"],"version":0.6,"annotation":"footprints","shortcodes":["footprints"]},{"emoji":"๐Ÿซ†","group":1,"order":3476,"tags":["clue","crime","detective","forensics","identity","mystery","print","safety","trace"],"version":16,"annotation":"fingerprint","shortcodes":["fingerprint"]},{"emoji":"๐Ÿป","group":2,"order":3477,"tags":["1โ€“2","light","skin","tone","type"],"version":1,"annotation":"light skin tone","shortcodes":["tone1","tone_light"]},{"emoji":"๐Ÿผ","group":2,"order":3478,"tags":["3","medium-light","skin","tone","type"],"version":1,"annotation":"medium-light skin tone","shortcodes":["tone2","tone_medium_light"]},{"emoji":"๐Ÿฝ","group":2,"order":3479,"tags":["4","medium","skin","tone","type"],"version":1,"annotation":"medium skin tone","shortcodes":["tone3","tone_medium"]},{"emoji":"๐Ÿพ","group":2,"order":3480,"tags":["5","medium-dark","skin","tone","type"],"version":1,"annotation":"medium-dark skin tone","shortcodes":["tone4","tone_medium_dark"]},{"emoji":"๐Ÿฟ","group":2,"order":3481,"tags":["6","dark","skin","tone","type"],"version":1,"annotation":"dark skin tone","shortcodes":["tone5","tone_dark"]},{"emoji":"๐Ÿฆฐ","group":2,"order":3482,"tags":["ginger","hair","red","redhead"],"version":11,"annotation":"red hair","shortcodes":["red_hair"]},{"emoji":"๐Ÿฆฑ","group":2,"order":3483,"tags":["afro","curly","hair","ringlets"],"version":11,"annotation":"curly hair","shortcodes":["curly_hair"]},{"emoji":"๐Ÿฆณ","group":2,"order":3484,"tags":["gray","hair","old","white"],"version":11,"annotation":"white hair","shortcodes":["white_hair"]},{"emoji":"๐Ÿฆฒ","group":2,"order":3485,"tags":["chemotherapy","hair","hairless","no","shaven"],"version":11,"annotation":"bald","shortcodes":["no_hair"]},{"emoji":"๐Ÿต","group":3,"order":3486,"tags":["animal","banana","face","monkey"],"version":0.6,"annotation":"monkey face","shortcodes":["monkey_face"]},{"emoji":"๐Ÿ’","group":3,"order":3487,"tags":["animal","banana"],"version":0.6,"annotation":"monkey","shortcodes":["monkey"]},{"emoji":"๐Ÿฆ","group":3,"order":3488,"tags":["animal"],"version":3,"annotation":"gorilla","shortcodes":["gorilla"]},{"emoji":"๐Ÿฆง","group":3,"order":3489,"tags":["animal","ape","monkey"],"version":12,"annotation":"orangutan","shortcodes":["orangutan"]},{"emoji":"๐Ÿถ","group":3,"order":3490,"tags":["adorbs","animal","dog","face","pet","puppies","puppy"],"version":0.6,"annotation":"dog face","shortcodes":["dog_face"]},{"emoji":"๐Ÿ•๏ธ","group":3,"order":3491,"tags":["animal","animals","dogs","pet"],"version":0.7,"annotation":"dog","shortcodes":["dog"]},{"emoji":"๐Ÿฆฎ","group":3,"order":3492,"tags":["accessibility","animal","blind","dog","guide"],"version":12,"annotation":"guide dog","shortcodes":["guide_dog"]},{"emoji":"๐Ÿ•โ€๐Ÿฆบ","group":3,"order":3493,"tags":["accessibility","animal","assistance","dog","service"],"version":12,"annotation":"service dog","shortcodes":["service_dog"]},{"emoji":"๐Ÿฉ","group":3,"order":3494,"tags":["animal","dog","fluffy"],"version":0.6,"annotation":"poodle","shortcodes":["poodle"]},{"emoji":"๐Ÿบ","group":3,"order":3495,"tags":["animal","face"],"version":0.6,"annotation":"wolf","shortcodes":["wolf","wolf_face"]},{"emoji":"๐ŸฆŠ","group":3,"order":3496,"tags":["animal","face"],"version":3,"annotation":"fox","shortcodes":["fox","fox_face"]},{"emoji":"๐Ÿฆ","group":3,"order":3497,"tags":["animal","curious","sly"],"version":11,"annotation":"raccoon","shortcodes":["raccoon"]},{"emoji":"๐Ÿฑ","group":3,"order":3498,"tags":["animal","cat","face","kitten","kitty","pet"],"version":0.6,"annotation":"cat face","shortcodes":["cat_face"]},{"emoji":"๐Ÿˆ๏ธ","group":3,"order":3499,"tags":["animal","animals","cats","kitten","pet"],"version":0.7,"annotation":"cat","shortcodes":["cat"]},{"emoji":"๐Ÿˆโ€โฌ›","group":3,"order":3500,"tags":["animal","black","cat","feline","halloween","meow","unlucky"],"version":13,"annotation":"black cat","shortcodes":["black_cat"]},{"emoji":"๐Ÿฆ","group":3,"order":3501,"tags":["alpha","animal","face","leo","mane","order","rawr","roar","safari","strong","zodiac"],"version":1,"annotation":"lion","shortcodes":["lion","lion_face"]},{"emoji":"๐Ÿฏ","group":3,"order":3502,"tags":["animal","big","cat","face","predator","tiger"],"version":0.6,"annotation":"tiger face","shortcodes":["tiger_face"]},{"emoji":"๐Ÿ…","group":3,"order":3503,"tags":["animal","big","cat","predator","zoo"],"version":1,"annotation":"tiger","shortcodes":["tiger"]},{"emoji":"๐Ÿ†","group":3,"order":3504,"tags":["animal","big","cat","predator","zoo"],"version":1,"annotation":"leopard","shortcodes":["leopard"]},{"emoji":"๐Ÿด","group":3,"order":3505,"tags":["animal","dressage","equine","face","farm","horse","horses"],"version":0.6,"annotation":"horse face","shortcodes":["horse_face"]},{"emoji":"๐ŸซŽ","group":3,"order":3506,"tags":["alces","animal","antlers","elk","mammal"],"version":15,"annotation":"moose","shortcodes":["moose"]},{"emoji":"๐Ÿซ","group":3,"order":3507,"tags":["animal","ass","burro","hinny","mammal","mule","stubborn"],"version":15,"annotation":"donkey","shortcodes":["donkey"]},{"emoji":"๐ŸŽ","group":3,"order":3508,"tags":["animal","equestrian","farm","racehorse","racing"],"version":0.6,"annotation":"horse","shortcodes":["horse","racehorse"]},{"emoji":"๐Ÿฆ„","group":3,"order":3509,"tags":["face"],"version":1,"annotation":"unicorn","shortcodes":["unicorn","unicorn_face"]},{"emoji":"๐Ÿฆ“","group":3,"order":3510,"tags":["animal","stripe"],"version":5,"annotation":"zebra","shortcodes":["zebra"]},{"emoji":"๐ŸฆŒ","group":3,"order":3511,"tags":["animal"],"version":3,"annotation":"deer","shortcodes":["deer"]},{"emoji":"๐Ÿฆฌ","group":3,"order":3512,"tags":["animal","buffalo","herd","wisent"],"version":13,"annotation":"bison","shortcodes":["bison"]},{"emoji":"๐Ÿฎ","group":3,"order":3513,"tags":["animal","cow","face","farm","milk","moo"],"version":0.6,"annotation":"cow face","shortcodes":["cow_face"]},{"emoji":"๐Ÿ‚","group":3,"order":3514,"tags":["animal","animals","bull","farm","taurus","zodiac"],"version":1,"annotation":"ox","shortcodes":["ox"]},{"emoji":"๐Ÿƒ","group":3,"order":3515,"tags":["animal","buffalo","water","zoo"],"version":1,"annotation":"water buffalo","shortcodes":["water_buffalo"]},{"emoji":"๐Ÿ„","group":3,"order":3516,"tags":["animal","animals","farm","milk","moo"],"version":1,"annotation":"cow","shortcodes":["cow"]},{"emoji":"๐Ÿท","group":3,"order":3517,"tags":["animal","bacon","face","farm","pig","pork"],"version":0.6,"annotation":"pig face","shortcodes":["pig_face"]},{"emoji":"๐Ÿ–","group":3,"order":3518,"tags":["animal","bacon","farm","pork","sow"],"version":1,"annotation":"pig","shortcodes":["pig"]},{"emoji":"๐Ÿ—","group":3,"order":3519,"tags":["animal","pig"],"version":0.6,"annotation":"boar","shortcodes":["boar"]},{"emoji":"๐Ÿฝ","group":3,"order":3520,"tags":["animal","face","farm","nose","pig","smell","snout"],"version":0.6,"annotation":"pig nose","shortcodes":["pig_nose"]},{"emoji":"๐Ÿ","group":3,"order":3521,"tags":["animal","aries","horns","male","sheep","zodiac","zoo"],"version":1,"annotation":"ram","shortcodes":["ram"]},{"emoji":"๐Ÿ‘","group":3,"order":3522,"tags":["animal","baa","farm","female","fluffy","lamb","sheep","wool"],"version":0.6,"annotation":"ewe","shortcodes":["ewe","sheep"]},{"emoji":"๐Ÿ","group":3,"order":3523,"tags":["animal","capricorn","farm","milk","zodiac"],"version":1,"annotation":"goat","shortcodes":["goat"]},{"emoji":"๐Ÿช","group":3,"order":3524,"tags":["animal","desert","dromedary","hump","one"],"version":1,"annotation":"camel","shortcodes":["dromedary_camel"]},{"emoji":"๐Ÿซ","group":3,"order":3525,"tags":["animal","bactrian","camel","desert","hump","two","two-hump"],"version":0.6,"annotation":"two-hump camel","shortcodes":["camel"]},{"emoji":"๐Ÿฆ™","group":3,"order":3526,"tags":["alpaca","animal","guanaco","vicuรฑa","wool"],"version":11,"annotation":"llama","shortcodes":["llama"]},{"emoji":"๐Ÿฆ’","group":3,"order":3527,"tags":["animal","spots"],"version":5,"annotation":"giraffe","shortcodes":["giraffe"]},{"emoji":"๐Ÿ˜","group":3,"order":3528,"tags":["animal"],"version":0.6,"annotation":"elephant","shortcodes":["elephant"]},{"emoji":"๐Ÿฆฃ","group":3,"order":3529,"tags":["animal","extinction","large","tusk","wooly"],"version":13,"annotation":"mammoth","shortcodes":["mammoth"]},{"emoji":"๐Ÿฆ","group":3,"order":3530,"tags":["animal"],"version":3,"annotation":"rhinoceros","shortcodes":["rhino","rhinoceros"]},{"emoji":"๐Ÿฆ›","group":3,"order":3531,"tags":["animal","hippo"],"version":11,"annotation":"hippopotamus","shortcodes":["hippo"]},{"emoji":"๐Ÿญ","group":3,"order":3532,"tags":["animal","face","mouse"],"version":0.6,"annotation":"mouse face","shortcodes":["mouse_face"]},{"emoji":"๐Ÿ","group":3,"order":3533,"tags":["animal","animals"],"version":1,"annotation":"mouse","shortcodes":["mouse"]},{"emoji":"๐Ÿ€","group":3,"order":3534,"tags":["animal"],"version":1,"annotation":"rat","shortcodes":["rat"]},{"emoji":"๐Ÿน","group":3,"order":3535,"tags":["animal","face","pet"],"version":0.6,"annotation":"hamster","shortcodes":["hamster","hamster_face"]},{"emoji":"๐Ÿฐ","group":3,"order":3536,"tags":["animal","bunny","face","pet","rabbit"],"version":0.6,"annotation":"rabbit face","shortcodes":["rabbit_face"]},{"emoji":"๐Ÿ‡","group":3,"order":3537,"tags":["animal","bunny","pet"],"version":1,"annotation":"rabbit","shortcodes":["rabbit"]},{"emoji":"๐Ÿฟ๏ธ","group":3,"order":3539,"tags":["animal","squirrel"],"version":0.7,"annotation":"chipmunk","shortcodes":["chipmunk"]},{"emoji":"๐Ÿฆซ","group":3,"order":3540,"tags":["animal","dam","teeth"],"version":13,"annotation":"beaver","shortcodes":["beaver"]},{"emoji":"๐Ÿฆ”","group":3,"order":3541,"tags":["animal","spiny"],"version":5,"annotation":"hedgehog","shortcodes":["hedgehog"]},{"emoji":"๐Ÿฆ‡","group":3,"order":3542,"tags":["animal","vampire"],"version":3,"annotation":"bat","shortcodes":["bat"]},{"emoji":"๐Ÿป","group":3,"order":3543,"tags":["animal","face","grizzly","growl","honey"],"version":0.6,"annotation":"bear","shortcodes":["bear","bear_face"]},{"emoji":"๐Ÿปโ€โ„๏ธ","group":3,"order":3544,"tags":["animal","arctic","bear","polar","white"],"version":13,"annotation":"polar bear","shortcodes":["polar_bear","polar_bear_face"]},{"emoji":"๐Ÿจ","group":3,"order":3546,"tags":["animal","australia","bear","down","face","marsupial","under"],"version":0.6,"annotation":"koala","shortcodes":["koala","koala_face"]},{"emoji":"๐Ÿผ","group":3,"order":3547,"tags":["animal","bamboo","face"],"version":0.6,"annotation":"panda","shortcodes":["panda","panda_face"]},{"emoji":"๐Ÿฆฅ","group":3,"order":3548,"tags":["lazy","slow"],"version":12,"annotation":"sloth","shortcodes":["sloth"]},{"emoji":"๐Ÿฆฆ","group":3,"order":3549,"tags":["animal","fishing","playful"],"version":12,"annotation":"otter","shortcodes":["otter"]},{"emoji":"๐Ÿฆจ","group":3,"order":3550,"tags":["animal","stink"],"version":12,"annotation":"skunk","shortcodes":["skunk"]},{"emoji":"๐Ÿฆ˜","group":3,"order":3551,"tags":["animal","joey","jump","marsupial"],"version":11,"annotation":"kangaroo","shortcodes":["kangaroo"]},{"emoji":"๐Ÿฆก","group":3,"order":3552,"tags":["animal","honey","pester"],"version":11,"annotation":"badger","shortcodes":["badger"]},{"emoji":"๐Ÿพ","group":3,"order":3553,"tags":["feet","paw","paws","print","prints"],"version":0.6,"annotation":"paw prints","shortcodes":["paw_prints"]},{"emoji":"๐Ÿฆƒ","group":3,"order":3554,"tags":["bird","gobble","thanksgiving"],"version":1,"annotation":"turkey","shortcodes":["turkey"]},{"emoji":"๐Ÿ”","group":3,"order":3555,"tags":["animal","bird","ornithology"],"version":0.6,"annotation":"chicken","shortcodes":["chicken","chicken_face"]},{"emoji":"๐Ÿ“","group":3,"order":3556,"tags":["animal","bird","ornithology"],"version":1,"annotation":"rooster","shortcodes":["rooster"]},{"emoji":"๐Ÿฃ","group":3,"order":3557,"tags":["animal","baby","bird","chick","egg","hatching"],"version":0.6,"annotation":"hatching chick","shortcodes":["hatching_chick"]},{"emoji":"๐Ÿค","group":3,"order":3558,"tags":["animal","baby","bird","chick","ornithology"],"version":0.6,"annotation":"baby chick","shortcodes":["baby_chick"]},{"emoji":"๐Ÿฅ","group":3,"order":3559,"tags":["animal","baby","bird","chick","front-facing","newborn","ornithology"],"version":0.6,"annotation":"front-facing baby chick","shortcodes":["hatched_chick"]},{"emoji":"๐Ÿฆ๏ธ","group":3,"order":3560,"tags":["animal","ornithology"],"version":0.6,"annotation":"bird","shortcodes":["bird","bird_face"]},{"emoji":"๐Ÿง","group":3,"order":3561,"tags":["animal","antarctica","bird","ornithology"],"version":0.6,"annotation":"penguin","shortcodes":["penguin","penguin_face"]},{"emoji":"๐Ÿ•Š๏ธ","group":3,"order":3563,"tags":["bird","fly","ornithology","peace"],"version":0.7,"annotation":"dove","shortcodes":["dove"]},{"emoji":"๐Ÿฆ…","group":3,"order":3564,"tags":["animal","bird","ornithology"],"version":3,"annotation":"eagle","shortcodes":["eagle"]},{"emoji":"๐Ÿฆ†","group":3,"order":3565,"tags":["animal","bird","ornithology"],"version":3,"annotation":"duck","shortcodes":["duck"]},{"emoji":"๐Ÿฆข","group":3,"order":3566,"tags":["animal","bird","cygnet","duckling","ornithology","ugly"],"version":11,"annotation":"swan","shortcodes":["swan"]},{"emoji":"๐Ÿฆ‰","group":3,"order":3567,"tags":["animal","bird","ornithology","wise"],"version":3,"annotation":"owl","shortcodes":["owl"]},{"emoji":"๐Ÿฆค","group":3,"order":3568,"tags":["animal","bird","extinction","large","ornithology"],"version":13,"annotation":"dodo","shortcodes":["dodo"]},{"emoji":"๐Ÿชถ","group":3,"order":3569,"tags":["bird","flight","light","plumage"],"version":13,"annotation":"feather","shortcodes":["feather"]},{"emoji":"๐Ÿฆฉ","group":3,"order":3570,"tags":["animal","bird","flamboyant","ornithology","tropical"],"version":12,"annotation":"flamingo","shortcodes":["flamingo"]},{"emoji":"๐Ÿฆš","group":3,"order":3571,"tags":["animal","bird","colorful","ornithology","ostentatious","peahen","pretty","proud"],"version":11,"annotation":"peacock","shortcodes":["peacock"]},{"emoji":"๐Ÿฆœ","group":3,"order":3572,"tags":["animal","bird","ornithology","pirate","talk"],"version":11,"annotation":"parrot","shortcodes":["parrot"]},{"emoji":"๐Ÿชฝ","group":3,"order":3573,"tags":["angelic","ascend","aviation","bird","fly","flying","heavenly","mythology","soar"],"version":15,"annotation":"wing","shortcodes":["wing"]},{"emoji":"๐Ÿฆโ€โฌ›","group":3,"order":3574,"tags":["animal","beak","bird","black","caw","corvid","crow","ornithology","raven","rook"],"version":15,"annotation":"black bird","shortcodes":["black_bird"]},{"emoji":"๐Ÿชฟ","group":3,"order":3575,"tags":["animal","bird","duck","flock","fowl","gaggle","gander","geese","honk","ornithology","silly"],"version":15,"annotation":"goose","shortcodes":["goose"]},{"emoji":"๐Ÿฆโ€๐Ÿ”ฅ","group":3,"order":3576,"tags":["ascend","ascension","emerge","fantasy","firebird","glory","immortal","rebirth","reincarnation","reinvent","renewal","revival","revive","rise","transform"],"version":15.1,"annotation":"phoenix","shortcodes":["phoenix"]},{"emoji":"๐Ÿธ","group":3,"order":3577,"tags":["animal","face"],"version":0.6,"annotation":"frog","shortcodes":["frog","frog_face"]},{"emoji":"๐ŸŠ","group":3,"order":3578,"tags":["animal","zoo"],"version":1,"annotation":"crocodile","shortcodes":["crocodile"]},{"emoji":"๐Ÿข","group":3,"order":3579,"tags":["animal","terrapin","tortoise"],"version":0.6,"annotation":"turtle","shortcodes":["turtle"]},{"emoji":"๐ŸฆŽ","group":3,"order":3580,"tags":["animal","reptile"],"version":3,"annotation":"lizard","shortcodes":["lizard"]},{"emoji":"๐Ÿ","group":3,"order":3581,"tags":["animal","bearer","ophiuchus","serpent","zodiac"],"version":0.6,"annotation":"snake","shortcodes":["snake"]},{"emoji":"๐Ÿฒ","group":3,"order":3582,"tags":["animal","dragon","face","fairy","fairytale","tale"],"version":0.6,"annotation":"dragon face","shortcodes":["dragon_face"]},{"emoji":"๐Ÿ‰","group":3,"order":3583,"tags":["animal","fairy","fairytale","knights","tale"],"version":1,"annotation":"dragon","shortcodes":["dragon"]},{"emoji":"๐Ÿฆ•","group":3,"order":3584,"tags":["brachiosaurus","brontosaurus","dinosaur","diplodocus"],"version":5,"annotation":"sauropod","shortcodes":["sauropod"]},{"emoji":"๐Ÿฆ–","group":3,"order":3585,"tags":["dinosaur","rex","t","t-rex","tyrannosaurus"],"version":5,"annotation":"T-Rex","shortcodes":["t-rex","trex"]},{"emoji":"๐Ÿณ","group":3,"order":3586,"tags":["animal","beach","face","ocean","spouting","whale"],"version":0.6,"annotation":"spouting whale","shortcodes":["spouting_whale"]},{"emoji":"๐Ÿ‹","group":3,"order":3587,"tags":["animal","beach","ocean"],"version":1,"annotation":"whale","shortcodes":["whale"]},{"emoji":"๐Ÿฌ","group":3,"order":3588,"tags":["animal","beach","flipper","ocean"],"version":0.6,"annotation":"dolphin","shortcodes":["dolphin"]},{"emoji":"๐Ÿฆญ","group":3,"order":3589,"tags":["animal","lion","ocean","sea"],"version":13,"annotation":"seal","shortcodes":["seal"]},{"emoji":"๐ŸŸ๏ธ","group":3,"order":3590,"tags":["animal","dinner","fishes","fishing","pisces","zodiac"],"version":0.6,"annotation":"fish","shortcodes":["fish"]},{"emoji":"๐Ÿ ","group":3,"order":3591,"tags":["animal","fish","fishes","tropical"],"version":0.6,"annotation":"tropical fish","shortcodes":["tropical_fish"]},{"emoji":"๐Ÿก","group":3,"order":3592,"tags":["animal","fish"],"version":0.6,"annotation":"blowfish","shortcodes":["blowfish"]},{"emoji":"๐Ÿฆˆ","group":3,"order":3593,"tags":["animal","fish"],"version":3,"annotation":"shark","shortcodes":["shark"]},{"emoji":"๐Ÿ™","group":3,"order":3594,"tags":["animal","creature","ocean"],"version":0.6,"annotation":"octopus","shortcodes":["octopus"]},{"emoji":"๐Ÿš","group":3,"order":3595,"tags":["animal","beach","conch","sea","shell","spiral"],"version":0.6,"annotation":"spiral shell","shortcodes":["shell"]},{"emoji":"๐Ÿชธ","group":3,"order":3596,"tags":["change","climate","ocean","reef","sea"],"version":14,"annotation":"coral","shortcodes":["coral"]},{"emoji":"๐Ÿชผ","group":3,"order":3597,"tags":["animal","aquarium","burn","invertebrate","jelly","life","marine","ocean","ouch","plankton","sea","sting","stinger","tentacles"],"version":15,"annotation":"jellyfish","shortcodes":["jellyfish"]},{"emoji":"๐Ÿฆ€","group":3,"order":3598,"tags":["cancer","zodiac"],"version":1,"annotation":"crab","shortcodes":["crab"]},{"emoji":"๐Ÿฆž","group":3,"order":3599,"tags":["animal","bisque","claws","seafood"],"version":11,"annotation":"lobster","shortcodes":["lobster"]},{"emoji":"๐Ÿฆ","group":3,"order":3600,"tags":["food","shellfish","small"],"version":3,"annotation":"shrimp","shortcodes":["shrimp"]},{"emoji":"๐Ÿฆ‘","group":3,"order":3601,"tags":["animal","food","mollusk"],"version":3,"annotation":"squid","shortcodes":["squid"]},{"emoji":"๐Ÿฆช","group":3,"order":3602,"tags":["diving","pearl"],"version":12,"annotation":"oyster","shortcodes":["oyster"]},{"emoji":"๐ŸŒ","group":3,"order":3603,"tags":["animal","escargot","garden","nature","slug"],"version":0.6,"annotation":"snail","shortcodes":["snail"]},{"emoji":"๐Ÿฆ‹","group":3,"order":3604,"tags":["insect","pretty"],"version":3,"annotation":"butterfly","shortcodes":["butterfly"]},{"emoji":"๐Ÿ›","group":3,"order":3605,"tags":["animal","garden","insect"],"version":0.6,"annotation":"bug","shortcodes":["bug"]},{"emoji":"๐Ÿœ","group":3,"order":3606,"tags":["animal","garden","insect"],"version":0.6,"annotation":"ant","shortcodes":["ant"]},{"emoji":"๐Ÿ","group":3,"order":3607,"tags":["animal","bee","bumblebee","honey","insect","nature","spring"],"version":0.6,"annotation":"honeybee","shortcodes":["bee"]},{"emoji":"๐Ÿชฒ","group":3,"order":3608,"tags":["animal","bug","insect"],"version":13,"annotation":"beetle","shortcodes":["beetle"]},{"emoji":"๐Ÿž","group":3,"order":3609,"tags":["animal","beetle","garden","insect","lady","ladybird","ladybug","nature"],"version":0.6,"annotation":"lady beetle","shortcodes":["lady_beetle"]},{"emoji":"๐Ÿฆ—","group":3,"order":3610,"tags":["animal","bug","grasshopper","insect","orthoptera"],"version":5,"annotation":"cricket","shortcodes":["cricket"]},{"emoji":"๐Ÿชณ","group":3,"order":3611,"tags":["animal","insect","pest","roach"],"version":13,"annotation":"cockroach","shortcodes":["cockroach"]},{"emoji":"๐Ÿ•ท๏ธ","group":3,"order":3613,"tags":["animal","insect"],"version":0.7,"annotation":"spider","shortcodes":["spider"]},{"emoji":"๐Ÿ•ธ๏ธ","group":3,"order":3615,"tags":["spider","web"],"version":0.7,"annotation":"spider web","shortcodes":["spider_web"]},{"emoji":"๐Ÿฆ‚","group":3,"order":3616,"tags":["scorpio","scorpius","zodiac"],"version":1,"annotation":"scorpion","shortcodes":["scorpion"]},{"emoji":"๐ŸฆŸ","group":3,"order":3617,"tags":["bite","disease","fever","insect","malaria","pest","virus"],"version":11,"annotation":"mosquito","shortcodes":["mosquito"]},{"emoji":"๐Ÿชฐ","group":3,"order":3618,"tags":["animal","disease","insect","maggot","pest","rotting"],"version":13,"annotation":"fly","shortcodes":["fly"]},{"emoji":"๐Ÿชฑ","group":3,"order":3619,"tags":["animal","annelid","earthworm","parasite"],"version":13,"annotation":"worm","shortcodes":["worm"]},{"emoji":"๐Ÿฆ ","group":3,"order":3620,"tags":["amoeba","bacteria","science","virus"],"version":11,"annotation":"microbe","shortcodes":["microbe"]},{"emoji":"๐Ÿ’","group":3,"order":3621,"tags":["anniversary","birthday","date","flower","love","plant","romance"],"version":0.6,"annotation":"bouquet","shortcodes":["bouquet"]},{"emoji":"๐ŸŒธ","group":3,"order":3622,"tags":["blossom","cherry","flower","plant","spring","springtime"],"version":0.6,"annotation":"cherry blossom","shortcodes":["cherry_blossom"]},{"emoji":"๐Ÿ’ฎ","group":3,"order":3623,"tags":["flower","white"],"version":0.6,"annotation":"white flower","shortcodes":["white_flower"]},{"emoji":"๐Ÿชท","group":3,"order":3624,"tags":["beauty","buddhism","calm","flower","hinduism","peace","purity","serenity"],"version":14,"annotation":"lotus","shortcodes":["lotus"]},{"emoji":"๐Ÿต๏ธ","group":3,"order":3626,"tags":["plant"],"version":0.7,"annotation":"rosette","shortcodes":["rosette"]},{"emoji":"๐ŸŒน","group":3,"order":3627,"tags":["beauty","elegant","flower","love","plant","red","valentine"],"version":0.6,"annotation":"rose","shortcodes":["rose"]},{"emoji":"๐Ÿฅ€","group":3,"order":3628,"tags":["dying","flower","wilted"],"version":3,"annotation":"wilted flower","shortcodes":["wilted_flower"]},{"emoji":"๐ŸŒบ","group":3,"order":3629,"tags":["flower","plant"],"version":0.6,"annotation":"hibiscus","shortcodes":["hibiscus"]},{"emoji":"๐ŸŒป","group":3,"order":3630,"tags":["flower","outdoors","plant","sun"],"version":0.6,"annotation":"sunflower","shortcodes":["sunflower"]},{"emoji":"๐ŸŒผ","group":3,"order":3631,"tags":["buttercup","dandelion","flower","plant"],"version":0.6,"annotation":"blossom","shortcodes":["blossom"]},{"emoji":"๐ŸŒท","group":3,"order":3632,"tags":["blossom","flower","growth","plant"],"version":0.6,"annotation":"tulip","shortcodes":["tulip"]},{"emoji":"๐Ÿชป","group":3,"order":3633,"tags":["bloom","bluebonnet","flower","indigo","lavender","lilac","lupine","plant","purple","shrub","snapdragon","spring","violet"],"version":15,"annotation":"hyacinth","shortcodes":["hyacinth"]},{"emoji":"๐ŸŒฑ","group":3,"order":3634,"tags":["plant","sapling","sprout","young"],"version":0.6,"annotation":"seedling","shortcodes":["seedling"]},{"emoji":"๐Ÿชด","group":3,"order":3635,"tags":["decor","grow","house","nurturing","plant","pot","potted"],"version":13,"annotation":"potted plant","shortcodes":["potted_plant"]},{"emoji":"๐ŸŒฒ","group":3,"order":3636,"tags":["christmas","evergreen","forest","pine","tree"],"version":1,"annotation":"evergreen tree","shortcodes":["evergreen_tree"]},{"emoji":"๐ŸŒณ","group":3,"order":3637,"tags":["deciduous","forest","green","habitat","shedding","tree"],"version":1,"annotation":"deciduous tree","shortcodes":["deciduous_tree"]},{"emoji":"๐ŸŒด","group":3,"order":3638,"tags":["beach","palm","plant","tree","tropical"],"version":0.6,"annotation":"palm tree","shortcodes":["palm_tree"]},{"emoji":"๐ŸŒต","group":3,"order":3639,"tags":["desert","drought","nature","plant"],"version":0.6,"annotation":"cactus","shortcodes":["cactus"]},{"emoji":"๐ŸŒพ","group":3,"order":3640,"tags":["ear","grain","grains","plant","rice","sheaf"],"version":0.6,"annotation":"sheaf of rice","shortcodes":["ear_of_rice","sheaf_of_rice"]},{"emoji":"๐ŸŒฟ","group":3,"order":3641,"tags":["leaf","plant"],"version":0.6,"annotation":"herb","shortcodes":["herb"]},{"emoji":"โ˜˜๏ธ","group":3,"order":3643,"tags":["irish","plant"],"version":1,"annotation":"shamrock","shortcodes":["shamrock"]},{"emoji":"๐Ÿ€","group":3,"order":3644,"tags":["4","clover","four","four-leaf","irish","leaf","lucky","plant"],"version":0.6,"annotation":"four leaf clover","shortcodes":["four_leaf_clover"]},{"emoji":"๐Ÿ","group":3,"order":3645,"tags":["falling","leaf","maple"],"version":0.6,"annotation":"maple leaf","shortcodes":["maple_leaf"]},{"emoji":"๐Ÿ‚","group":3,"order":3646,"tags":["autumn","fall","fallen","falling","leaf"],"version":0.6,"annotation":"fallen leaf","shortcodes":["fallen_leaf"]},{"emoji":"๐Ÿƒ","group":3,"order":3647,"tags":["blow","flutter","fluttering","leaf","wind"],"version":0.6,"annotation":"leaf fluttering in wind","shortcodes":["leaves"]},{"emoji":"๐Ÿชน","group":3,"order":3648,"tags":["branch","empty","home","nest","nesting"],"version":14,"annotation":"empty nest","shortcodes":["empty_nest","nest"]},{"emoji":"๐Ÿชบ","group":3,"order":3649,"tags":["bird","branch","egg","eggs","nest","nesting"],"version":14,"annotation":"nest with eggs","shortcodes":["nest_with_eggs"]},{"emoji":"๐Ÿ„","group":3,"order":3650,"tags":["fungus","toadstool"],"version":0.6,"annotation":"mushroom","shortcodes":["mushroom"]},{"emoji":"๐Ÿชพ","group":3,"order":3651,"tags":["bare","barren","branches","dead","drought","leafless","tree","trunk","winter","wood"],"version":16,"annotation":"leafless tree","shortcodes":["leafless_tree"]},{"emoji":"๐Ÿ‡","group":4,"order":3652,"tags":["dionysus","fruit","grape"],"version":0.6,"annotation":"grapes","shortcodes":["grapes"]},{"emoji":"๐Ÿˆ","group":4,"order":3653,"tags":["cantaloupe","fruit"],"version":0.6,"annotation":"melon","shortcodes":["melon"]},{"emoji":"๐Ÿ‰","group":4,"order":3654,"tags":["fruit"],"version":0.6,"annotation":"watermelon","shortcodes":["watermelon"]},{"emoji":"๐ŸŠ","group":4,"order":3655,"tags":["c","citrus","fruit","nectarine","orange","vitamin"],"version":0.6,"annotation":"tangerine","shortcodes":["orange","tangerine"]},{"emoji":"๐Ÿ‹","group":4,"order":3656,"tags":["citrus","fruit","sour"],"version":1,"annotation":"lemon","shortcodes":["lemon"]},{"emoji":"๐Ÿ‹โ€๐ŸŸฉ","group":4,"order":3657,"tags":["acidity","citrus","cocktail","fruit","garnish","key","margarita","mojito","refreshing","salsa","sour","tangy","tequila","tropical","zest"],"version":15.1,"annotation":"lime","shortcodes":["lime"]},{"emoji":"๐ŸŒ","group":4,"order":3658,"tags":["fruit","potassium"],"version":0.6,"annotation":"banana","shortcodes":["banana"]},{"emoji":"๐Ÿ","group":4,"order":3659,"tags":["colada","fruit","pina","tropical"],"version":0.6,"annotation":"pineapple","shortcodes":["pineapple"]},{"emoji":"๐Ÿฅญ","group":4,"order":3660,"tags":["food","fruit","tropical"],"version":11,"annotation":"mango","shortcodes":["mango"]},{"emoji":"๐ŸŽ","group":4,"order":3661,"tags":["apple","diet","food","fruit","health","red","ripe"],"version":0.6,"annotation":"red apple","shortcodes":["apple","red_apple"]},{"emoji":"๐Ÿ","group":4,"order":3662,"tags":["apple","fruit","green"],"version":0.6,"annotation":"green apple","shortcodes":["green_apple"]},{"emoji":"๐Ÿ","group":4,"order":3663,"tags":["fruit"],"version":1,"annotation":"pear","shortcodes":["pear"]},{"emoji":"๐Ÿ‘","group":4,"order":3664,"tags":["fruit"],"version":0.6,"annotation":"peach","shortcodes":["peach"]},{"emoji":"๐Ÿ’","group":4,"order":3665,"tags":["berries","cherry","fruit","red"],"version":0.6,"annotation":"cherries","shortcodes":["cherries"]},{"emoji":"๐Ÿ“","group":4,"order":3666,"tags":["berry","fruit"],"version":0.6,"annotation":"strawberry","shortcodes":["strawberry"]},{"emoji":"๐Ÿซ","group":4,"order":3667,"tags":["berries","berry","bilberry","blue","blueberry","food","fruit"],"version":13,"annotation":"blueberries","shortcodes":["blueberries"]},{"emoji":"๐Ÿฅ","group":4,"order":3668,"tags":["food","fruit","kiwi"],"version":3,"annotation":"kiwi fruit","shortcodes":["kiwi"]},{"emoji":"๐Ÿ…","group":4,"order":3669,"tags":["food","fruit","vegetable"],"version":0.6,"annotation":"tomato","shortcodes":["tomato"]},{"emoji":"๐Ÿซ’","group":4,"order":3670,"tags":["food"],"version":13,"annotation":"olive","shortcodes":["olive"]},{"emoji":"๐Ÿฅฅ","group":4,"order":3671,"tags":["colada","palm","piรฑa"],"version":5,"annotation":"coconut","shortcodes":["coconut"]},{"emoji":"๐Ÿฅ‘","group":4,"order":3672,"tags":["food","fruit"],"version":3,"annotation":"avocado","shortcodes":["avocado"]},{"emoji":"๐Ÿ†","group":4,"order":3673,"tags":["aubergine","vegetable"],"version":0.6,"annotation":"eggplant","shortcodes":["eggplant"]},{"emoji":"๐Ÿฅ”","group":4,"order":3674,"tags":["food","vegetable"],"version":3,"annotation":"potato","shortcodes":["potato"]},{"emoji":"๐Ÿฅ•","group":4,"order":3675,"tags":["food","vegetable"],"version":3,"annotation":"carrot","shortcodes":["carrot"]},{"emoji":"๐ŸŒฝ","group":4,"order":3676,"tags":["corn","crops","ear","farm","maize","maze"],"version":0.6,"annotation":"ear of corn","shortcodes":["corn","ear_of_corn"]},{"emoji":"๐ŸŒถ๏ธ","group":4,"order":3678,"tags":["hot","pepper"],"version":0.7,"annotation":"hot pepper","shortcodes":["hot_pepper"]},{"emoji":"๐Ÿซ‘","group":4,"order":3679,"tags":["bell","capsicum","food","pepper","vegetable"],"version":13,"annotation":"bell pepper","shortcodes":["bell_pepper"]},{"emoji":"๐Ÿฅ’","group":4,"order":3680,"tags":["food","pickle","vegetable"],"version":3,"annotation":"cucumber","shortcodes":["cucumber"]},{"emoji":"๐Ÿฅฌ","group":4,"order":3681,"tags":["bok","burgers","cabbage","choy","green","kale","leafy","lettuce","salad"],"version":11,"annotation":"leafy green","shortcodes":["leafy_green"]},{"emoji":"๐Ÿฅฆ","group":4,"order":3682,"tags":["cabbage","wild"],"version":5,"annotation":"broccoli","shortcodes":["broccoli"]},{"emoji":"๐Ÿง„","group":4,"order":3683,"tags":["flavoring"],"version":12,"annotation":"garlic","shortcodes":["garlic"]},{"emoji":"๐Ÿง…","group":4,"order":3684,"tags":["flavoring"],"version":12,"annotation":"onion","shortcodes":["onion"]},{"emoji":"๐Ÿฅœ","group":4,"order":3685,"tags":["food","nut","peanut","vegetable"],"version":3,"annotation":"peanuts","shortcodes":["peanuts"]},{"emoji":"๐Ÿซ˜","group":4,"order":3686,"tags":["food","kidney","legume","small"],"version":14,"annotation":"beans","shortcodes":["beans"]},{"emoji":"๐ŸŒฐ","group":4,"order":3687,"tags":["almond","plant"],"version":0.6,"annotation":"chestnut","shortcodes":["chestnut"]},{"emoji":"๐Ÿซš","group":4,"order":3688,"tags":["beer","ginger","health","herb","natural","root","spice"],"version":15,"annotation":"ginger root","shortcodes":["ginger"]},{"emoji":"๐Ÿซ›","group":4,"order":3689,"tags":["beans","beanstalk","edamame","legume","pea","pod","soybean","vegetable","veggie"],"version":15,"annotation":"pea pod","shortcodes":["pea"]},{"emoji":"๐Ÿ„โ€๐ŸŸซ","group":4,"order":3690,"tags":["food","fungi","fungus","mushroom","nature","pizza","portobello","shiitake","shroom","spore","sprout","toppings","truffle","vegetable","vegetarian","veggie"],"version":15.1,"annotation":"brown mushroom","shortcodes":["brown_mushroom"]},{"emoji":"๐Ÿซœ","group":4,"order":3691,"tags":["beet","food","garden","radish","root","salad","turnip","vegetable","vegetarian"],"version":16,"annotation":"root vegetable","shortcodes":["root_vegetable"]},{"emoji":"๐Ÿž","group":4,"order":3692,"tags":["carbs","food","grain","loaf","restaurant","toast","wheat"],"version":0.6,"annotation":"bread","shortcodes":["bread"]},{"emoji":"๐Ÿฅ","group":4,"order":3693,"tags":["bread","breakfast","crescent","food","french","roll"],"version":3,"annotation":"croissant","shortcodes":["croissant"]},{"emoji":"๐Ÿฅ–","group":4,"order":3694,"tags":["baguette","bread","food","french"],"version":3,"annotation":"baguette bread","shortcodes":["baguette_bread"]},{"emoji":"๐Ÿซ“","group":4,"order":3695,"tags":["arepa","bread","food","gordita","lavash","naan","pita"],"version":13,"annotation":"flatbread","shortcodes":["flatbread"]},{"emoji":"๐Ÿฅจ","group":4,"order":3696,"tags":["convoluted","twisted"],"version":5,"annotation":"pretzel","shortcodes":["pretzel"]},{"emoji":"๐Ÿฅฏ","group":4,"order":3697,"tags":["bakery","bread","breakfast","schmear"],"version":11,"annotation":"bagel","shortcodes":["bagel"]},{"emoji":"๐Ÿฅž","group":4,"order":3698,"tags":["breakfast","crรชpe","food","hotcake","pancake"],"version":3,"annotation":"pancakes","shortcodes":["pancakes"]},{"emoji":"๐Ÿง‡","group":4,"order":3699,"tags":["breakfast","indecisive","iron"],"version":12,"annotation":"waffle","shortcodes":["waffle"]},{"emoji":"๐Ÿง€","group":4,"order":3700,"tags":["cheese","wedge"],"version":1,"annotation":"cheese wedge","shortcodes":["cheese"]},{"emoji":"๐Ÿ–","group":4,"order":3701,"tags":["bone","meat"],"version":0.6,"annotation":"meat on bone","shortcodes":["meat_on_bone"]},{"emoji":"๐Ÿ—","group":4,"order":3702,"tags":["bone","chicken","drumstick","hungry","leg","poultry","turkey"],"version":0.6,"annotation":"poultry leg","shortcodes":["poultry_leg"]},{"emoji":"๐Ÿฅฉ","group":4,"order":3703,"tags":["chop","cut","lambchop","meat","porkchop","red","steak"],"version":5,"annotation":"cut of meat","shortcodes":["cut_of_meat"]},{"emoji":"๐Ÿฅ“","group":4,"order":3704,"tags":["breakfast","food","meat"],"version":3,"annotation":"bacon","shortcodes":["bacon"]},{"emoji":"๐Ÿ”","group":4,"order":3705,"tags":["burger","eat","fast","food","hungry"],"version":0.6,"annotation":"hamburger","shortcodes":["hamburger"]},{"emoji":"๐ŸŸ","group":4,"order":3706,"tags":["fast","food","french","fries"],"version":0.6,"annotation":"french fries","shortcodes":["french_fries","fries"]},{"emoji":"๐Ÿ•","group":4,"order":3707,"tags":["cheese","food","hungry","pepperoni","slice"],"version":0.6,"annotation":"pizza","shortcodes":["pizza"]},{"emoji":"๐ŸŒญ","group":4,"order":3708,"tags":["dog","frankfurter","hot","hotdog","sausage"],"version":1,"annotation":"hot dog","shortcodes":["hotdog"]},{"emoji":"๐Ÿฅช","group":4,"order":3709,"tags":["bread"],"version":5,"annotation":"sandwich","shortcodes":["sandwich"]},{"emoji":"๐ŸŒฎ","group":4,"order":3710,"tags":["mexican"],"version":1,"annotation":"taco","shortcodes":["taco"]},{"emoji":"๐ŸŒฏ","group":4,"order":3711,"tags":["mexican","wrap"],"version":1,"annotation":"burrito","shortcodes":["burrito"]},{"emoji":"๐Ÿซ”","group":4,"order":3712,"tags":["food","mexican","pamonha","wrapped"],"version":13,"annotation":"tamale","shortcodes":["tamale"]},{"emoji":"๐Ÿฅ™","group":4,"order":3713,"tags":["falafel","flatbread","food","gyro","kebab","stuffed"],"version":3,"annotation":"stuffed flatbread","shortcodes":["stuffed_flatbread"]},{"emoji":"๐Ÿง†","group":4,"order":3714,"tags":["chickpea","meatball"],"version":12,"annotation":"falafel","shortcodes":["falafel"]},{"emoji":"๐Ÿฅš","group":4,"order":3715,"tags":["breakfast","food"],"version":3,"annotation":"egg","shortcodes":["egg"]},{"emoji":"๐Ÿณ","group":4,"order":3716,"tags":["breakfast","easy","egg","fry","frying","over","pan","restaurant","side","sunny","up"],"version":0.6,"annotation":"cooking","shortcodes":["cooking","fried_egg"]},{"emoji":"๐Ÿฅ˜","group":4,"order":3717,"tags":["casserole","food","paella","pan","shallow"],"version":3,"annotation":"shallow pan of food","shortcodes":["shallow_pan_of_food"]},{"emoji":"๐Ÿฒ","group":4,"order":3718,"tags":["food","pot","soup","stew"],"version":0.6,"annotation":"pot of food","shortcodes":["pot_of_food","stew"]},{"emoji":"๐Ÿซ•","group":4,"order":3719,"tags":["cheese","chocolate","food","melted","pot","ski"],"version":13,"annotation":"fondue","shortcodes":["fondue"]},{"emoji":"๐Ÿฅฃ","group":4,"order":3720,"tags":["bowl","breakfast","cereal","congee","oatmeal","porridge","spoon"],"version":5,"annotation":"bowl with spoon","shortcodes":["bowl_with_spoon"]},{"emoji":"๐Ÿฅ—","group":4,"order":3721,"tags":["food","green","salad"],"version":3,"annotation":"green salad","shortcodes":["green_salad","salad"]},{"emoji":"๐Ÿฟ","group":4,"order":3722,"tags":["corn","movie","pop"],"version":1,"annotation":"popcorn","shortcodes":["popcorn"]},{"emoji":"๐Ÿงˆ","group":4,"order":3723,"tags":["dairy"],"version":12,"annotation":"butter","shortcodes":["butter"]},{"emoji":"๐Ÿง‚","group":4,"order":3724,"tags":["condiment","flavor","mad","salty","shaker","taste","upset"],"version":11,"annotation":"salt","shortcodes":["salt"]},{"emoji":"๐Ÿฅซ","group":4,"order":3725,"tags":["can","canned","food"],"version":5,"annotation":"canned food","shortcodes":["canned_food"]},{"emoji":"๐Ÿฑ","group":4,"order":3726,"tags":["bento","box","food"],"version":0.6,"annotation":"bento box","shortcodes":["bento","bento_box"]},{"emoji":"๐Ÿ˜","group":4,"order":3727,"tags":["cracker","food","rice"],"version":0.6,"annotation":"rice cracker","shortcodes":["rice_cracker"]},{"emoji":"๐Ÿ™","group":4,"order":3728,"tags":["ball","food","japanese","rice"],"version":0.6,"annotation":"rice ball","shortcodes":["rice_ball"]},{"emoji":"๐Ÿš","group":4,"order":3729,"tags":["cooked","food","rice"],"version":0.6,"annotation":"cooked rice","shortcodes":["cooked_rice","rice"]},{"emoji":"๐Ÿ›","group":4,"order":3730,"tags":["curry","food","rice"],"version":0.6,"annotation":"curry rice","shortcodes":["curry","curry_rice"]},{"emoji":"๐Ÿœ","group":4,"order":3731,"tags":["bowl","chopsticks","food","noodle","pho","ramen","soup","steaming"],"version":0.6,"annotation":"steaming bowl","shortcodes":["ramen","steaming_bowl"]},{"emoji":"๐Ÿ","group":4,"order":3732,"tags":["food","meatballs","pasta","restaurant"],"version":0.6,"annotation":"spaghetti","shortcodes":["spaghetti"]},{"emoji":"๐Ÿ ","group":4,"order":3733,"tags":["food","potato","roasted","sweet"],"version":0.6,"annotation":"roasted sweet potato","shortcodes":["sweet_potato"]},{"emoji":"๐Ÿข","group":4,"order":3734,"tags":["food","kebab","restaurant","seafood","skewer","stick"],"version":0.6,"annotation":"oden","shortcodes":["oden"]},{"emoji":"๐Ÿฃ","group":4,"order":3735,"tags":["food"],"version":0.6,"annotation":"sushi","shortcodes":["sushi"]},{"emoji":"๐Ÿค","group":4,"order":3736,"tags":["fried","prawn","shrimp","tempura"],"version":0.6,"annotation":"fried shrimp","shortcodes":["fried_shrimp"]},{"emoji":"๐Ÿฅ","group":4,"order":3737,"tags":["cake","fish","food","pastry","restaurant","swirl"],"version":0.6,"annotation":"fish cake with swirl","shortcodes":["fish_cake"]},{"emoji":"๐Ÿฅฎ","group":4,"order":3738,"tags":["autumn","cake","festival","moon","yuรจbวng"],"version":11,"annotation":"moon cake","shortcodes":["moon_cake"]},{"emoji":"๐Ÿก","group":4,"order":3739,"tags":["dessert","japanese","skewer","stick","sweet"],"version":0.6,"annotation":"dango","shortcodes":["dango"]},{"emoji":"๐ŸฅŸ","group":4,"order":3740,"tags":["empanada","gyลza","jiaozi","pierogi","potsticker"],"version":5,"annotation":"dumpling","shortcodes":["dumpling"]},{"emoji":"๐Ÿฅ ","group":4,"order":3741,"tags":["cookie","fortune","prophecy"],"version":5,"annotation":"fortune cookie","shortcodes":["fortune_cookie"]},{"emoji":"๐Ÿฅก","group":4,"order":3742,"tags":["box","chopsticks","delivery","food","oyster","pail","takeout"],"version":5,"annotation":"takeout box","shortcodes":["takeout_box"]},{"emoji":"๐Ÿฆ","group":4,"order":3743,"tags":["cream","dessert","food","ice","icecream","restaurant","serve","soft","sweet"],"version":0.6,"annotation":"soft ice cream","shortcodes":["icecream","soft_serve"]},{"emoji":"๐Ÿง","group":4,"order":3744,"tags":["dessert","ice","restaurant","shaved","sweet"],"version":0.6,"annotation":"shaved ice","shortcodes":["shaved_ice"]},{"emoji":"๐Ÿจ","group":4,"order":3745,"tags":["cream","dessert","food","ice","restaurant","sweet"],"version":0.6,"annotation":"ice cream","shortcodes":["ice_cream"]},{"emoji":"๐Ÿฉ","group":4,"order":3746,"tags":["breakfast","dessert","donut","food","sweet"],"version":0.6,"annotation":"doughnut","shortcodes":["doughnut"]},{"emoji":"๐Ÿช","group":4,"order":3747,"tags":["chip","chocolate","dessert","sweet"],"version":0.6,"annotation":"cookie","shortcodes":["cookie"]},{"emoji":"๐ŸŽ‚","group":4,"order":3748,"tags":["bday","birthday","cake","celebration","dessert","happy","pastry","sweet"],"version":0.6,"annotation":"birthday cake","shortcodes":["birthday","birthday_cake"]},{"emoji":"๐Ÿฐ","group":4,"order":3749,"tags":["cake","dessert","pastry","slice","sweet"],"version":0.6,"annotation":"shortcake","shortcodes":["cake","shortcake"]},{"emoji":"๐Ÿง","group":4,"order":3750,"tags":["bakery","dessert","sprinkles","sugar","sweet","treat"],"version":11,"annotation":"cupcake","shortcodes":["cupcake"]},{"emoji":"๐Ÿฅง","group":4,"order":3751,"tags":["apple","filling","fruit","meat","pastry","pumpkin","slice"],"version":5,"annotation":"pie","shortcodes":["pie"]},{"emoji":"๐Ÿซ","group":4,"order":3752,"tags":["bar","candy","chocolate","dessert","halloween","sweet","tooth"],"version":0.6,"annotation":"chocolate bar","shortcodes":["chocolate_bar"]},{"emoji":"๐Ÿฌ","group":4,"order":3753,"tags":["cavities","dessert","halloween","restaurant","sweet","tooth","wrapper"],"version":0.6,"annotation":"candy","shortcodes":["candy"]},{"emoji":"๐Ÿญ","group":4,"order":3754,"tags":["candy","dessert","food","restaurant","sweet"],"version":0.6,"annotation":"lollipop","shortcodes":["lollipop"]},{"emoji":"๐Ÿฎ","group":4,"order":3755,"tags":["dessert","pudding","sweet"],"version":0.6,"annotation":"custard","shortcodes":["custard"]},{"emoji":"๐Ÿฏ","group":4,"order":3756,"tags":["barrel","bear","food","honey","honeypot","jar","pot","sweet"],"version":0.6,"annotation":"honey pot","shortcodes":["honey_pot"]},{"emoji":"๐Ÿผ","group":4,"order":3757,"tags":["babies","baby","birth","born","bottle","drink","infant","milk","newborn"],"version":1,"annotation":"baby bottle","shortcodes":["baby_bottle"]},{"emoji":"๐Ÿฅ›","group":4,"order":3758,"tags":["drink","glass","milk"],"version":3,"annotation":"glass of milk","shortcodes":["glass_of_milk","milk"]},{"emoji":"โ˜•๏ธ","group":4,"order":3759,"tags":["beverage","cafe","caffeine","chai","coffee","drink","hot","morning","steaming","tea"],"version":0.6,"annotation":"hot beverage","shortcodes":["coffee"]},{"emoji":"๐Ÿซ–","group":4,"order":3760,"tags":["brew","drink","food","pot","tea"],"version":13,"annotation":"teapot","shortcodes":["teapot"]},{"emoji":"๐Ÿต","group":4,"order":3761,"tags":["beverage","cup","drink","handle","oolong","tea","teacup"],"version":0.6,"annotation":"teacup without handle","shortcodes":["tea"]},{"emoji":"๐Ÿถ","group":4,"order":3762,"tags":["bar","beverage","bottle","cup","drink","restaurant"],"version":0.6,"annotation":"sake","shortcodes":["sake"]},{"emoji":"๐Ÿพ","group":4,"order":3763,"tags":["bar","bottle","cork","drink","popping"],"version":1,"annotation":"bottle with popping cork","shortcodes":["champagne"]},{"emoji":"๐Ÿท","group":4,"order":3764,"tags":["alcohol","bar","beverage","booze","club","drink","drinking","drinks","glass","restaurant","wine"],"version":0.6,"annotation":"wine glass","shortcodes":["wine_glass"]},{"emoji":"๐Ÿธ๏ธ","group":4,"order":3765,"tags":["alcohol","bar","booze","club","cocktail","drink","drinking","drinks","glass","mad","martini","men"],"version":0.6,"annotation":"cocktail glass","shortcodes":["cocktail"]},{"emoji":"๐Ÿน","group":4,"order":3766,"tags":["alcohol","bar","booze","club","cocktail","drink","drinking","drinks","drunk","mai","party","tai","tropical","tropics"],"version":0.6,"annotation":"tropical drink","shortcodes":["tropical_drink"]},{"emoji":"๐Ÿบ","group":4,"order":3767,"tags":["alcohol","ale","bar","beer","booze","drink","drinking","drinks","mug","octoberfest","oktoberfest","pint","stein","summer"],"version":0.6,"annotation":"beer mug","shortcodes":["beer"]},{"emoji":"๐Ÿป","group":4,"order":3768,"tags":["alcohol","bar","beer","booze","bottoms","cheers","clink","clinking","drinking","drinks","mugs"],"version":0.6,"annotation":"clinking beer mugs","shortcodes":["beers"]},{"emoji":"๐Ÿฅ‚","group":4,"order":3769,"tags":["celebrate","clink","clinking","drink","glass","glasses"],"version":3,"annotation":"clinking glasses","shortcodes":["clinking_glasses"]},{"emoji":"๐Ÿฅƒ","group":4,"order":3770,"tags":["glass","liquor","scotch","shot","tumbler","whiskey","whisky"],"version":3,"annotation":"tumbler glass","shortcodes":["tumbler_glass","whisky"]},{"emoji":"๐Ÿซ—","group":4,"order":3771,"tags":["accident","drink","empty","glass","liquid","oops","pour","pouring","spill","water"],"version":14,"annotation":"pouring liquid","shortcodes":["pour","pouring_liquid"]},{"emoji":"๐Ÿฅค","group":4,"order":3772,"tags":["cup","drink","juice","malt","soda","soft","straw","water"],"version":5,"annotation":"cup with straw","shortcodes":["cup_with_straw"]},{"emoji":"๐Ÿง‹","group":4,"order":3773,"tags":["boba","bubble","food","milk","pearl","tea"],"version":13,"annotation":"bubble tea","shortcodes":["boba_drink","bubble_tea"]},{"emoji":"๐Ÿงƒ","group":4,"order":3774,"tags":["beverage","box","juice","straw","sweet"],"version":12,"annotation":"beverage box","shortcodes":["beverage_box","juice_box"]},{"emoji":"๐Ÿง‰","group":4,"order":3775,"tags":["drink"],"version":12,"annotation":"mate","shortcodes":["mate"]},{"emoji":"๐ŸงŠ","group":4,"order":3776,"tags":["cold","cube","iceberg"],"version":12,"annotation":"ice","shortcodes":["ice","ice_cube"]},{"emoji":"๐Ÿฅข","group":4,"order":3777,"tags":["hashi","jeotgarak","kuaizi"],"version":5,"annotation":"chopsticks","shortcodes":["chopsticks"]},{"emoji":"๐Ÿฝ๏ธ","group":4,"order":3779,"tags":["cooking","dinner","eat","fork","knife","plate"],"version":0.7,"annotation":"fork and knife with plate","shortcodes":["fork_knife_plate"]},{"emoji":"๐Ÿด","group":4,"order":3780,"tags":["breakfast","breaky","cooking","cutlery","delicious","dinner","eat","feed","food","fork","hungry","knife","lunch","restaurant","yum","yummy"],"version":0.6,"annotation":"fork and knife","shortcodes":["fork_and_knife"]},{"emoji":"๐Ÿฅ„","group":4,"order":3781,"tags":["eat","tableware"],"version":3,"annotation":"spoon","shortcodes":["spoon"]},{"emoji":"๐Ÿ”ช","group":4,"order":3782,"tags":["chef","cooking","hocho","kitchen","knife","tool","weapon"],"version":0.6,"annotation":"kitchen knife","shortcodes":["knife"]},{"emoji":"๐Ÿซ™","group":4,"order":3783,"tags":["condiment","container","empty","nothing","sauce","store"],"version":14,"annotation":"jar","shortcodes":["jar"]},{"emoji":"๐Ÿบ","group":4,"order":3784,"tags":["aquarius","cooking","drink","jug","tool","weapon","zodiac"],"version":1,"annotation":"amphora","shortcodes":["amphora"]},{"emoji":"๐ŸŒ๏ธ","group":5,"order":3785,"tags":["africa","earth","europe","europe-africa","globe","showing","world"],"version":0.7,"annotation":"globe showing Europe-Africa","shortcodes":["earth_africa","earth_europe"]},{"emoji":"๐ŸŒŽ๏ธ","group":5,"order":3786,"tags":["americas","earth","globe","showing","world"],"version":0.7,"annotation":"globe showing Americas","shortcodes":["earth_americas"]},{"emoji":"๐ŸŒ๏ธ","group":5,"order":3787,"tags":["asia","asia-australia","australia","earth","globe","showing","world"],"version":0.6,"annotation":"globe showing Asia-Australia","shortcodes":["earth_asia"]},{"emoji":"๐ŸŒ","group":5,"order":3788,"tags":["earth","globe","internet","meridians","web","world","worldwide"],"version":1,"annotation":"globe with meridians","shortcodes":["globe_with_meridians"]},{"emoji":"๐Ÿ—บ๏ธ","group":5,"order":3790,"tags":["map","world"],"version":0.7,"annotation":"world map","shortcodes":["world_map"]},{"emoji":"๐Ÿ—พ","group":5,"order":3791,"tags":["japan","map"],"version":0.6,"annotation":"map of Japan","shortcodes":["japan_map"]},{"emoji":"๐Ÿงญ","group":5,"order":3792,"tags":["direction","magnetic","navigation","orienteering"],"version":11,"annotation":"compass","shortcodes":["compass"]},{"emoji":"๐Ÿ”๏ธ","group":5,"order":3794,"tags":["cold","mountain","snow","snow-capped"],"version":0.7,"annotation":"snow-capped mountain","shortcodes":["mountain_snow"]},{"emoji":"โ›ฐ๏ธ","group":5,"order":3796,"tags":["mountain"],"version":0.7,"annotation":"mountain","shortcodes":["mountain"]},{"emoji":"๐ŸŒ‹","group":5,"order":3797,"tags":["eruption","mountain","nature"],"version":0.6,"annotation":"volcano","shortcodes":["volcano"]},{"emoji":"๐Ÿ—ป","group":5,"order":3798,"tags":["fuji","mount","mountain","nature"],"version":0.6,"annotation":"mount fuji","shortcodes":["mount_fuji"]},{"emoji":"๐Ÿ•๏ธ","group":5,"order":3800,"tags":["camping"],"version":0.7,"annotation":"camping","shortcodes":["camping"]},{"emoji":"๐Ÿ–๏ธ","group":5,"order":3802,"tags":["beach","umbrella"],"version":0.7,"annotation":"beach with umbrella","shortcodes":["beach","beach_with_umbrella"]},{"emoji":"๐Ÿœ๏ธ","group":5,"order":3804,"tags":["desert"],"version":0.7,"annotation":"desert","shortcodes":["desert"]},{"emoji":"๐Ÿ๏ธ","group":5,"order":3806,"tags":["desert","island"],"version":0.7,"annotation":"desert island","shortcodes":["desert_island","island"]},{"emoji":"๐Ÿž๏ธ","group":5,"order":3808,"tags":["national","park"],"version":0.7,"annotation":"national park","shortcodes":["national_park"]},{"emoji":"๐ŸŸ๏ธ","group":5,"order":3810,"tags":["stadium"],"version":0.7,"annotation":"stadium","shortcodes":["stadium"]},{"emoji":"๐Ÿ›๏ธ","group":5,"order":3812,"tags":["building","classical"],"version":0.7,"annotation":"classical building","shortcodes":["classical_building"]},{"emoji":"๐Ÿ—๏ธ","group":5,"order":3814,"tags":["building","construction","crane"],"version":0.7,"annotation":"building construction","shortcodes":["building_construction","construction_site"]},{"emoji":"๐Ÿงฑ","group":5,"order":3815,"tags":["bricks","clay","mortar","wall"],"version":11,"annotation":"brick","shortcodes":["bricks"]},{"emoji":"๐Ÿชจ","group":5,"order":3816,"tags":["boulder","heavy","solid","stone","tough"],"version":13,"annotation":"rock","shortcodes":["rock"]},{"emoji":"๐Ÿชต","group":5,"order":3817,"tags":["log","lumber","timber"],"version":13,"annotation":"wood","shortcodes":["wood"]},{"emoji":"๐Ÿ›–","group":5,"order":3818,"tags":["home","house","roundhouse","shelter","yurt"],"version":13,"annotation":"hut","shortcodes":["hut"]},{"emoji":"๐Ÿ˜๏ธ","group":5,"order":3820,"tags":["house"],"version":0.7,"annotation":"houses","shortcodes":["homes","houses"]},{"emoji":"๐Ÿš๏ธ","group":5,"order":3822,"tags":["derelict","home","house"],"version":0.7,"annotation":"derelict house","shortcodes":["derelict_house","house_abandoned"]},{"emoji":"๐Ÿ ๏ธ","group":5,"order":3823,"tags":["building","country","heart","home","ranch","settle","simple","suburban","suburbia","where"],"version":0.6,"annotation":"house","shortcodes":["house"]},{"emoji":"๐Ÿก","group":5,"order":3824,"tags":["building","country","garden","heart","home","house","ranch","settle","simple","suburban","suburbia","where"],"version":0.6,"annotation":"house with garden","shortcodes":["house_with_garden"]},{"emoji":"๐Ÿข","group":5,"order":3825,"tags":["building","city","cubical","job","office"],"version":0.6,"annotation":"office building","shortcodes":["office"]},{"emoji":"๐Ÿฃ","group":5,"order":3826,"tags":["building","japanese","office","post"],"version":0.6,"annotation":"Japanese post office","shortcodes":["post_office"]},{"emoji":"๐Ÿค","group":5,"order":3827,"tags":["building","european","office","post"],"version":1,"annotation":"post office","shortcodes":["european_post_office"]},{"emoji":"๐Ÿฅ","group":5,"order":3828,"tags":["building","doctor","medicine"],"version":0.6,"annotation":"hospital","shortcodes":["hospital"]},{"emoji":"๐Ÿฆ","group":5,"order":3829,"tags":["building"],"version":0.6,"annotation":"bank","shortcodes":["bank"]},{"emoji":"๐Ÿจ","group":5,"order":3830,"tags":["building"],"version":0.6,"annotation":"hotel","shortcodes":["hotel"]},{"emoji":"๐Ÿฉ","group":5,"order":3831,"tags":["building","hotel","love"],"version":0.6,"annotation":"love hotel","shortcodes":["love_hotel"]},{"emoji":"๐Ÿช","group":5,"order":3832,"tags":["24","building","convenience","hours","store"],"version":0.6,"annotation":"convenience store","shortcodes":["convenience_store"]},{"emoji":"๐Ÿซ","group":5,"order":3833,"tags":["building"],"version":0.6,"annotation":"school","shortcodes":["school"]},{"emoji":"๐Ÿฌ","group":5,"order":3834,"tags":["building","department","store"],"version":0.6,"annotation":"department store","shortcodes":["department_store"]},{"emoji":"๐Ÿญ๏ธ","group":5,"order":3835,"tags":["building"],"version":0.6,"annotation":"factory","shortcodes":["factory"]},{"emoji":"๐Ÿฏ","group":5,"order":3836,"tags":["building","castle","japanese"],"version":0.6,"annotation":"Japanese castle","shortcodes":["japanese_castle"]},{"emoji":"๐Ÿฐ","group":5,"order":3837,"tags":["building","european"],"version":0.6,"annotation":"castle","shortcodes":["castle","european_castle"]},{"emoji":"๐Ÿ’’","group":5,"order":3838,"tags":["chapel","hitched","nuptials","romance"],"version":0.6,"annotation":"wedding","shortcodes":["wedding"]},{"emoji":"๐Ÿ—ผ","group":5,"order":3839,"tags":["tokyo","tower"],"version":0.6,"annotation":"Tokyo tower","shortcodes":["tokyo_tower"]},{"emoji":"๐Ÿ—ฝ","group":5,"order":3840,"tags":["liberty","new","ny","nyc","statue","york"],"version":0.6,"annotation":"Statue of Liberty","shortcodes":["statue_of_liberty"]},{"emoji":"โ›ช๏ธ","group":5,"order":3841,"tags":["bless","chapel","christian","cross","religion"],"version":0.6,"annotation":"church","shortcodes":["church"]},{"emoji":"๐Ÿ•Œ","group":5,"order":3842,"tags":["islam","masjid","muslim","religion"],"version":1,"annotation":"mosque","shortcodes":["mosque"]},{"emoji":"๐Ÿ›•","group":5,"order":3843,"tags":["hindu","temple"],"version":12,"annotation":"hindu temple","shortcodes":["hindu_temple"]},{"emoji":"๐Ÿ•","group":5,"order":3844,"tags":["jew","jewish","judaism","religion","temple"],"version":1,"annotation":"synagogue","shortcodes":["synagogue"]},{"emoji":"โ›ฉ๏ธ","group":5,"order":3846,"tags":["religion","shinto","shrine"],"version":0.7,"annotation":"shinto shrine","shortcodes":["shinto_shrine"]},{"emoji":"๐Ÿ•‹","group":5,"order":3847,"tags":["hajj","islam","muslim","religion","umrah"],"version":1,"annotation":"kaaba","shortcodes":["kaaba"]},{"emoji":"โ›ฒ๏ธ","group":5,"order":3848,"tags":["fountain"],"version":0.6,"annotation":"fountain","shortcodes":["fountain"]},{"emoji":"โ›บ๏ธ","group":5,"order":3849,"tags":["camping"],"version":0.6,"annotation":"tent","shortcodes":["tent"]},{"emoji":"๐ŸŒ","group":5,"order":3850,"tags":["fog"],"version":0.6,"annotation":"foggy","shortcodes":["foggy"]},{"emoji":"๐ŸŒƒ","group":5,"order":3851,"tags":["night","star","stars"],"version":0.6,"annotation":"night with stars","shortcodes":["night_with_stars"]},{"emoji":"๐Ÿ™๏ธ","group":5,"order":3853,"tags":["city"],"version":0.7,"annotation":"cityscape","shortcodes":["cityscape"]},{"emoji":"๐ŸŒ„","group":5,"order":3854,"tags":["morning","mountains","over","sun","sunrise"],"version":0.6,"annotation":"sunrise over mountains","shortcodes":["sunrise_over_mountains"]},{"emoji":"๐ŸŒ…","group":5,"order":3855,"tags":["morning","nature","sun"],"version":0.6,"annotation":"sunrise","shortcodes":["sunrise"]},{"emoji":"๐ŸŒ†","group":5,"order":3856,"tags":["at","building","city","cityscape","dusk","evening","landscape","sun","sunset"],"version":0.6,"annotation":"cityscape at dusk","shortcodes":["city_dusk"]},{"emoji":"๐ŸŒ‡","group":5,"order":3857,"tags":["building","dusk","sun"],"version":0.6,"annotation":"sunset","shortcodes":["city_sunrise","city_sunset"]},{"emoji":"๐ŸŒ‰","group":5,"order":3858,"tags":["at","bridge","night"],"version":0.6,"annotation":"bridge at night","shortcodes":["bridge_at_night"]},{"emoji":"โ™จ๏ธ","group":5,"order":3860,"tags":["hot","hotsprings","springs","steaming"],"version":0.6,"annotation":"hot springs","shortcodes":["hotsprings"]},{"emoji":"๐ŸŽ ","group":5,"order":3861,"tags":["carousel","entertainment","horse"],"version":0.6,"annotation":"carousel horse","shortcodes":["carousel_horse"]},{"emoji":"๐Ÿ›","group":5,"order":3862,"tags":["amusement","park","play","playground","playing","slide","sliding","theme"],"version":14,"annotation":"playground slide","shortcodes":["playground_slide","slide"]},{"emoji":"๐ŸŽก","group":5,"order":3863,"tags":["amusement","ferris","park","theme","wheel"],"version":0.6,"annotation":"ferris wheel","shortcodes":["ferris_wheel"]},{"emoji":"๐ŸŽข","group":5,"order":3864,"tags":["amusement","coaster","park","roller","theme"],"version":0.6,"annotation":"roller coaster","shortcodes":["roller_coaster"]},{"emoji":"๐Ÿ’ˆ","group":5,"order":3865,"tags":["barber","cut","fresh","haircut","pole","shave"],"version":0.6,"annotation":"barber pole","shortcodes":["barber","barber_pole"]},{"emoji":"๐ŸŽช","group":5,"order":3866,"tags":["circus","tent"],"version":0.6,"annotation":"circus tent","shortcodes":["circus_tent"]},{"emoji":"๐Ÿš‚","group":5,"order":3867,"tags":["caboose","engine","railway","steam","train","trains","travel"],"version":1,"annotation":"locomotive","shortcodes":["steam_locomotive"]},{"emoji":"๐Ÿšƒ","group":5,"order":3868,"tags":["car","electric","railway","train","tram","travel","trolleybus"],"version":0.6,"annotation":"railway car","shortcodes":["railway_car"]},{"emoji":"๐Ÿš„","group":5,"order":3869,"tags":["high-speed","railway","shinkansen","speed","train"],"version":0.6,"annotation":"high-speed train","shortcodes":["bullettrain_side"]},{"emoji":"๐Ÿš…","group":5,"order":3870,"tags":["bullet","high-speed","nose","railway","shinkansen","speed","train","travel"],"version":0.6,"annotation":"bullet train","shortcodes":["bullettrain_front"]},{"emoji":"๐Ÿš†","group":5,"order":3871,"tags":["arrived","choo","railway"],"version":1,"annotation":"train","shortcodes":["train"]},{"emoji":"๐Ÿš‡๏ธ","group":5,"order":3872,"tags":["subway","travel"],"version":0.6,"annotation":"metro","shortcodes":["metro"]},{"emoji":"๐Ÿšˆ","group":5,"order":3873,"tags":["arrived","light","monorail","rail","railway"],"version":1,"annotation":"light rail","shortcodes":["light_rail"]},{"emoji":"๐Ÿš‰","group":5,"order":3874,"tags":["railway","train"],"version":0.6,"annotation":"station","shortcodes":["station"]},{"emoji":"๐ŸšŠ","group":5,"order":3875,"tags":["trolleybus"],"version":1,"annotation":"tram","shortcodes":["tram"]},{"emoji":"๐Ÿš","group":5,"order":3876,"tags":["vehicle"],"version":1,"annotation":"monorail","shortcodes":["monorail"]},{"emoji":"๐Ÿšž","group":5,"order":3877,"tags":["car","mountain","railway","trip"],"version":1,"annotation":"mountain railway","shortcodes":["mountain_railway"]},{"emoji":"๐Ÿš‹","group":5,"order":3878,"tags":["bus","car","tram","trolley","trolleybus"],"version":1,"annotation":"tram car","shortcodes":["tram_car"]},{"emoji":"๐ŸšŒ","group":5,"order":3879,"tags":["school","vehicle"],"version":0.6,"annotation":"bus","shortcodes":["bus"]},{"emoji":"๐Ÿš๏ธ","group":5,"order":3880,"tags":["bus","cars","oncoming"],"version":0.7,"annotation":"oncoming bus","shortcodes":["oncoming_bus"]},{"emoji":"๐ŸšŽ","group":5,"order":3881,"tags":["bus","tram","trolley"],"version":1,"annotation":"trolleybus","shortcodes":["trolleybus"]},{"emoji":"๐Ÿš","group":5,"order":3882,"tags":["bus","drive","van","vehicle"],"version":1,"annotation":"minibus","shortcodes":["minibus"]},{"emoji":"๐Ÿš‘๏ธ","group":5,"order":3883,"tags":["emergency","vehicle"],"version":0.6,"annotation":"ambulance","shortcodes":["ambulance"]},{"emoji":"๐Ÿš’","group":5,"order":3884,"tags":["engine","fire","truck"],"version":0.6,"annotation":"fire engine","shortcodes":["fire_engine"]},{"emoji":"๐Ÿš“","group":5,"order":3885,"tags":["5โ€“0","car","cops","patrol","police"],"version":0.6,"annotation":"police car","shortcodes":["police_car"]},{"emoji":"๐Ÿš”๏ธ","group":5,"order":3886,"tags":["car","oncoming","police"],"version":0.7,"annotation":"oncoming police car","shortcodes":["oncoming_police_car"]},{"emoji":"๐Ÿš•","group":5,"order":3887,"tags":["cab","cabbie","car","drive","vehicle","yellow"],"version":0.6,"annotation":"taxi","shortcodes":["taxi"]},{"emoji":"๐Ÿš–","group":5,"order":3888,"tags":["cab","cabbie","cars","drove","hail","oncoming","taxi","yellow"],"version":1,"annotation":"oncoming taxi","shortcodes":["oncoming_taxi"]},{"emoji":"๐Ÿš—","group":5,"order":3889,"tags":["car","driving","vehicle"],"version":0.6,"annotation":"automobile","shortcodes":["car","red_car"]},{"emoji":"๐Ÿš˜๏ธ","group":5,"order":3890,"tags":["automobile","car","cars","drove","oncoming","vehicle"],"version":0.7,"annotation":"oncoming automobile","shortcodes":["oncoming_automobile"]},{"emoji":"๐Ÿš™","group":5,"order":3891,"tags":["car","drive","recreational","sport","sportutility","utility","vehicle"],"version":0.6,"annotation":"sport utility vehicle","shortcodes":["blue_car","suv"]},{"emoji":"๐Ÿ›ป","group":5,"order":3892,"tags":["automobile","car","flatbed","pick-up","pickup","transportation","truck"],"version":13,"annotation":"pickup truck","shortcodes":["pickup_truck"]},{"emoji":"๐Ÿšš","group":5,"order":3893,"tags":["car","delivery","drive","truck","vehicle"],"version":0.6,"annotation":"delivery truck","shortcodes":["delivery_truck","truck"]},{"emoji":"๐Ÿš›","group":5,"order":3894,"tags":["articulated","car","drive","lorry","move","semi","truck","vehicle"],"version":1,"annotation":"articulated lorry","shortcodes":["articulated_lorry"]},{"emoji":"๐Ÿšœ","group":5,"order":3895,"tags":["vehicle"],"version":1,"annotation":"tractor","shortcodes":["tractor"]},{"emoji":"๐ŸŽ๏ธ","group":5,"order":3897,"tags":["car","racing","zoom"],"version":0.7,"annotation":"racing car","shortcodes":["racing_car"]},{"emoji":"๐Ÿ๏ธ","group":5,"order":3899,"tags":["racing"],"version":0.7,"annotation":"motorcycle","shortcodes":["motorcycle"]},{"emoji":"๐Ÿ›ต","group":5,"order":3900,"tags":["motor","scooter"],"version":3,"annotation":"motor scooter","shortcodes":["motor_scooter"]},{"emoji":"๐Ÿฆฝ","group":5,"order":3901,"tags":["accessibility","manual","wheelchair"],"version":12,"annotation":"manual wheelchair","shortcodes":["manual_wheelchair"]},{"emoji":"๐Ÿฆผ","group":5,"order":3902,"tags":["accessibility","motorized","wheelchair"],"version":12,"annotation":"motorized wheelchair","shortcodes":["motorized_wheelchair"]},{"emoji":"๐Ÿ›บ","group":5,"order":3903,"tags":["auto","rickshaw","tuk"],"version":12,"annotation":"auto rickshaw","shortcodes":["auto_rickshaw"]},{"emoji":"๐Ÿšฒ๏ธ","group":5,"order":3904,"tags":["bike","class","cycle","cycling","cyclist","gang","ride","spin","spinning"],"version":0.6,"annotation":"bicycle","shortcodes":["bicycle","bike"]},{"emoji":"๐Ÿ›ด","group":5,"order":3905,"tags":["kick","scooter"],"version":3,"annotation":"kick scooter","shortcodes":["scooter"]},{"emoji":"๐Ÿ›น","group":5,"order":3906,"tags":["board","skate","skater","wheels"],"version":11,"annotation":"skateboard","shortcodes":["skateboard"]},{"emoji":"๐Ÿ›ผ","group":5,"order":3907,"tags":["blades","roller","skate","skates","sport"],"version":13,"annotation":"roller skate","shortcodes":["roller_skate"]},{"emoji":"๐Ÿš","group":5,"order":3908,"tags":["bus","busstop","stop"],"version":0.6,"annotation":"bus stop","shortcodes":["busstop"]},{"emoji":"๐Ÿ›ฃ๏ธ","group":5,"order":3910,"tags":["highway","road"],"version":0.7,"annotation":"motorway","shortcodes":["motorway"]},{"emoji":"๐Ÿ›ค๏ธ","group":5,"order":3912,"tags":["railway","track","train"],"version":0.7,"annotation":"railway track","shortcodes":["railway_track"]},{"emoji":"๐Ÿ›ข๏ธ","group":5,"order":3914,"tags":["drum","oil"],"version":0.7,"annotation":"oil drum","shortcodes":["oil_drum"]},{"emoji":"โ›ฝ๏ธ","group":5,"order":3915,"tags":["diesel","fuel","fuelpump","gas","gasoline","pump","station"],"version":0.6,"annotation":"fuel pump","shortcodes":["fuelpump"]},{"emoji":"๐Ÿ›ž","group":5,"order":3916,"tags":["car","circle","tire","turn","vehicle"],"version":14,"annotation":"wheel","shortcodes":["wheel"]},{"emoji":"๐Ÿšจ","group":5,"order":3917,"tags":["alarm","alert","beacon","car","emergency","light","police","revolving","siren"],"version":0.6,"annotation":"police car light","shortcodes":["rotating_light"]},{"emoji":"๐Ÿšฅ","group":5,"order":3918,"tags":["horizontal","intersection","light","signal","stop","stoplight","traffic"],"version":0.6,"annotation":"horizontal traffic light","shortcodes":["traffic_light"]},{"emoji":"๐Ÿšฆ","group":5,"order":3919,"tags":["drove","intersection","light","signal","stop","stoplight","traffic","vertical"],"version":1,"annotation":"vertical traffic light","shortcodes":["vertical_traffic_light"]},{"emoji":"๐Ÿ›‘","group":5,"order":3920,"tags":["octagonal","sign","stop"],"version":3,"annotation":"stop sign","shortcodes":["octagonal_sign","stop_sign"]},{"emoji":"๐Ÿšง","group":5,"order":3921,"tags":["barrier"],"version":0.6,"annotation":"construction","shortcodes":["construction"]},{"emoji":"โš“๏ธ","group":5,"order":3922,"tags":["ship","tool"],"version":0.6,"annotation":"anchor","shortcodes":["anchor"]},{"emoji":"๐Ÿ›Ÿ","group":5,"order":3923,"tags":["buoy","float","life","lifesaver","preserver","rescue","ring","safety","save","saver","swim"],"version":14,"annotation":"ring buoy","shortcodes":["lifebuoy","ring_buoy"]},{"emoji":"โ›ต๏ธ","group":5,"order":3924,"tags":["boat","resort","sailing","sea","yacht"],"version":0.6,"annotation":"sailboat","shortcodes":["sailboat"]},{"emoji":"๐Ÿ›ถ","group":5,"order":3925,"tags":["boat"],"version":3,"annotation":"canoe","shortcodes":["canoe"]},{"emoji":"๐Ÿšค","group":5,"order":3926,"tags":["billionaire","boat","lake","luxury","millionaire","summer","travel"],"version":0.6,"annotation":"speedboat","shortcodes":["speedboat"]},{"emoji":"๐Ÿ›ณ๏ธ","group":5,"order":3928,"tags":["passenger","ship"],"version":0.7,"annotation":"passenger ship","shortcodes":["cruise_ship","passenger_ship"]},{"emoji":"โ›ด๏ธ","group":5,"order":3930,"tags":["boat","passenger"],"version":0.7,"annotation":"ferry","shortcodes":["ferry"]},{"emoji":"๐Ÿ›ฅ๏ธ","group":5,"order":3932,"tags":["boat","motor","motorboat"],"version":0.7,"annotation":"motor boat","shortcodes":["motorboat"]},{"emoji":"๐Ÿšข","group":5,"order":3933,"tags":["boat","passenger","travel"],"version":0.6,"annotation":"ship","shortcodes":["ship"]},{"emoji":"โœˆ๏ธ","group":5,"order":3935,"tags":["aeroplane","fly","flying","jet","plane","travel"],"version":0.6,"annotation":"airplane","shortcodes":["airplane"]},{"emoji":"๐Ÿ›ฉ๏ธ","group":5,"order":3937,"tags":["aeroplane","airplane","plane","small"],"version":0.7,"annotation":"small airplane","shortcodes":["small_airplane"]},{"emoji":"๐Ÿ›ซ","group":5,"order":3938,"tags":["aeroplane","airplane","check-in","departure","departures","plane"],"version":1,"annotation":"airplane departure","shortcodes":["airplane_departure"]},{"emoji":"๐Ÿ›ฌ","group":5,"order":3939,"tags":["aeroplane","airplane","arrival","arrivals","arriving","landing","plane"],"version":1,"annotation":"airplane arrival","shortcodes":["airplane_arriving"]},{"emoji":"๐Ÿช‚","group":5,"order":3940,"tags":["hang-glide","parasail","skydive"],"version":12,"annotation":"parachute","shortcodes":["parachute"]},{"emoji":"๐Ÿ’บ","group":5,"order":3941,"tags":["chair"],"version":0.6,"annotation":"seat","shortcodes":["seat"]},{"emoji":"๐Ÿš","group":5,"order":3942,"tags":["copter","roflcopter","travel","vehicle"],"version":1,"annotation":"helicopter","shortcodes":["helicopter"]},{"emoji":"๐ŸšŸ","group":5,"order":3943,"tags":["railway","suspension"],"version":1,"annotation":"suspension railway","shortcodes":["suspension_railway"]},{"emoji":"๐Ÿš ","group":5,"order":3944,"tags":["cable","cableway","gondola","lift","mountain","ski"],"version":1,"annotation":"mountain cableway","shortcodes":["mountain_cableway"]},{"emoji":"๐Ÿšก","group":5,"order":3945,"tags":["aerial","cable","car","gondola","ropeway","tramway"],"version":1,"annotation":"aerial tramway","shortcodes":["aerial_tramway"]},{"emoji":"๐Ÿ›ฐ๏ธ","group":5,"order":3947,"tags":["space"],"version":0.7,"annotation":"satellite","shortcodes":["satellite"]},{"emoji":"๐Ÿš€","group":5,"order":3948,"tags":["launch","rockets","space","travel"],"version":0.6,"annotation":"rocket","shortcodes":["rocket"]},{"emoji":"๐Ÿ›ธ","group":5,"order":3949,"tags":["aliens","extra","flying","saucer","terrestrial","ufo"],"version":5,"annotation":"flying saucer","shortcodes":["flying_saucer"]},{"emoji":"๐Ÿ›Ž๏ธ","group":5,"order":3951,"tags":["bell","bellhop","hotel"],"version":0.7,"annotation":"bellhop bell","shortcodes":["bellhop"]},{"emoji":"๐Ÿงณ","group":5,"order":3952,"tags":["bag","packing","roller","suitcase","travel"],"version":11,"annotation":"luggage","shortcodes":["luggage"]},{"emoji":"โŒ›๏ธ","group":5,"order":3953,"tags":["done","hourglass","sand","time","timer"],"version":0.6,"annotation":"hourglass done","shortcodes":["hourglass"]},{"emoji":"โณ๏ธ","group":5,"order":3954,"tags":["done","flowing","hourglass","hours","not","sand","timer","waiting","yolo"],"version":0.6,"annotation":"hourglass not done","shortcodes":["hourglass_flowing_sand"]},{"emoji":"โŒš๏ธ","group":5,"order":3955,"tags":["clock","time"],"version":0.6,"annotation":"watch","shortcodes":["watch"]},{"emoji":"โฐ๏ธ","group":5,"order":3956,"tags":["alarm","clock","hours","hrs","late","time","waiting"],"version":0.6,"annotation":"alarm clock","shortcodes":["alarm_clock"]},{"emoji":"โฑ๏ธ","group":5,"order":3958,"tags":["clock","time"],"version":1,"annotation":"stopwatch","shortcodes":["stopwatch"]},{"emoji":"โฒ๏ธ","group":5,"order":3960,"tags":["clock","timer"],"version":1,"annotation":"timer clock","shortcodes":["timer_clock"]},{"emoji":"๐Ÿ•ฐ๏ธ","group":5,"order":3962,"tags":["clock","mantelpiece","time"],"version":0.7,"annotation":"mantelpiece clock","shortcodes":["clock"]},{"emoji":"๐Ÿ•›๏ธ","group":5,"order":3963,"tags":["12","12:00","clock","oโ€™clock","time","twelve"],"version":0.6,"annotation":"twelve oโ€™clock","shortcodes":["clock12"]},{"emoji":"๐Ÿ•ง๏ธ","group":5,"order":3964,"tags":["12","12:30","30","clock","thirty","time","twelve"],"version":0.7,"annotation":"twelve-thirty","shortcodes":["clock1230"]},{"emoji":"๐Ÿ•๏ธ","group":5,"order":3965,"tags":["1","1:00","clock","one","oโ€™clock","time"],"version":0.6,"annotation":"one oโ€™clock","shortcodes":["clock1"]},{"emoji":"๐Ÿ•œ๏ธ","group":5,"order":3966,"tags":["1","1:30","30","clock","one","thirty","time"],"version":0.7,"annotation":"one-thirty","shortcodes":["clock130"]},{"emoji":"๐Ÿ•‘๏ธ","group":5,"order":3967,"tags":["2","2:00","clock","oโ€™clock","time","two"],"version":0.6,"annotation":"two oโ€™clock","shortcodes":["clock2"]},{"emoji":"๐Ÿ•๏ธ","group":5,"order":3968,"tags":["2","2:30","30","clock","thirty","time","two"],"version":0.7,"annotation":"two-thirty","shortcodes":["clock230"]},{"emoji":"๐Ÿ•’๏ธ","group":5,"order":3969,"tags":["3","3:00","clock","oโ€™clock","three","time"],"version":0.6,"annotation":"three oโ€™clock","shortcodes":["clock3"]},{"emoji":"๐Ÿ•ž๏ธ","group":5,"order":3970,"tags":["3","30","3:30","clock","thirty","three","time"],"version":0.7,"annotation":"three-thirty","shortcodes":["clock330"]},{"emoji":"๐Ÿ•“๏ธ","group":5,"order":3971,"tags":["4","4:00","clock","four","oโ€™clock","time"],"version":0.6,"annotation":"four oโ€™clock","shortcodes":["clock4"]},{"emoji":"๐Ÿ•Ÿ๏ธ","group":5,"order":3972,"tags":["30","4","4:30","clock","four","thirty","time"],"version":0.7,"annotation":"four-thirty","shortcodes":["clock430"]},{"emoji":"๐Ÿ•”๏ธ","group":5,"order":3973,"tags":["5","5:00","clock","five","oโ€™clock","time"],"version":0.6,"annotation":"five oโ€™clock","shortcodes":["clock5"]},{"emoji":"๐Ÿ• ๏ธ","group":5,"order":3974,"tags":["30","5","5:30","clock","five","thirty","time"],"version":0.7,"annotation":"five-thirty","shortcodes":["clock530"]},{"emoji":"๐Ÿ••๏ธ","group":5,"order":3975,"tags":["6","6:00","clock","oโ€™clock","six","time"],"version":0.6,"annotation":"six oโ€™clock","shortcodes":["clock6"]},{"emoji":"๐Ÿ•ก๏ธ","group":5,"order":3976,"tags":["30","6","6:30","clock","six","thirty"],"version":0.7,"annotation":"six-thirty","shortcodes":["clock630"]},{"emoji":"๐Ÿ•–๏ธ","group":5,"order":3977,"tags":["0","7","7:00","clock","oโ€™clock","seven"],"version":0.6,"annotation":"seven oโ€™clock","shortcodes":["clock7"]},{"emoji":"๐Ÿ•ข๏ธ","group":5,"order":3978,"tags":["30","7","7:30","clock","seven","thirty"],"version":0.7,"annotation":"seven-thirty","shortcodes":["clock730"]},{"emoji":"๐Ÿ•—๏ธ","group":5,"order":3979,"tags":["8","8:00","clock","eight","oโ€™clock","time"],"version":0.6,"annotation":"eight oโ€™clock","shortcodes":["clock8"]},{"emoji":"๐Ÿ•ฃ๏ธ","group":5,"order":3980,"tags":["30","8","8:30","clock","eight","thirty","time"],"version":0.7,"annotation":"eight-thirty","shortcodes":["clock830"]},{"emoji":"๐Ÿ•˜๏ธ","group":5,"order":3981,"tags":["9","9:00","clock","nine","oโ€™clock","time"],"version":0.6,"annotation":"nine oโ€™clock","shortcodes":["clock9"]},{"emoji":"๐Ÿ•ค๏ธ","group":5,"order":3982,"tags":["30","9","9:30","clock","nine","thirty","time"],"version":0.7,"annotation":"nine-thirty","shortcodes":["clock930"]},{"emoji":"๐Ÿ•™๏ธ","group":5,"order":3983,"tags":["0","10","10:00","clock","oโ€™clock","ten"],"version":0.6,"annotation":"ten oโ€™clock","shortcodes":["clock10"]},{"emoji":"๐Ÿ•ฅ๏ธ","group":5,"order":3984,"tags":["10","10:30","30","clock","ten","thirty","time"],"version":0.7,"annotation":"ten-thirty","shortcodes":["clock1030"]},{"emoji":"๐Ÿ•š๏ธ","group":5,"order":3985,"tags":["11","11:00","clock","eleven","oโ€™clock","time"],"version":0.6,"annotation":"eleven oโ€™clock","shortcodes":["clock11"]},{"emoji":"๐Ÿ•ฆ๏ธ","group":5,"order":3986,"tags":["11","11:30","30","clock","eleven","thirty","time"],"version":0.7,"annotation":"eleven-thirty","shortcodes":["clock1130"]},{"emoji":"๐ŸŒ‘","group":5,"order":3987,"tags":["dark","moon","new","space"],"version":0.6,"annotation":"new moon","shortcodes":["new_moon"]},{"emoji":"๐ŸŒ’","group":5,"order":3988,"tags":["crescent","dreams","moon","space","waxing"],"version":1,"annotation":"waxing crescent moon","shortcodes":["waxing_crescent_moon"]},{"emoji":"๐ŸŒ“","group":5,"order":3989,"tags":["first","moon","quarter","space"],"version":0.6,"annotation":"first quarter moon","shortcodes":["first_quarter_moon"]},{"emoji":"๐ŸŒ”","group":5,"order":3990,"tags":["gibbous","moon","space","waxing"],"version":0.6,"annotation":"waxing gibbous moon","shortcodes":["waxing_gibbous_moon"]},{"emoji":"๐ŸŒ•๏ธ","group":5,"order":3991,"tags":["full","moon","space"],"version":0.6,"annotation":"full moon","shortcodes":["full_moon"]},{"emoji":"๐ŸŒ–","group":5,"order":3992,"tags":["gibbous","moon","space","waning"],"version":1,"annotation":"waning gibbous moon","shortcodes":["waning_gibbous_moon"]},{"emoji":"๐ŸŒ—","group":5,"order":3993,"tags":["last","moon","quarter","space"],"version":1,"annotation":"last quarter moon","shortcodes":["last_quarter_moon"]},{"emoji":"๐ŸŒ˜","group":5,"order":3994,"tags":["crescent","moon","space","waning"],"version":1,"annotation":"waning crescent moon","shortcodes":["waning_crescent_moon"]},{"emoji":"๐ŸŒ™","group":5,"order":3995,"tags":["crescent","moon","ramadan","space"],"version":0.6,"annotation":"crescent moon","shortcodes":["crescent_moon"]},{"emoji":"๐ŸŒš","group":5,"order":3996,"tags":["face","moon","new","space"],"version":1,"annotation":"new moon face","shortcodes":["new_moon_with_face"]},{"emoji":"๐ŸŒ›","group":5,"order":3997,"tags":["face","first","moon","quarter","space"],"version":0.6,"annotation":"first quarter moon face","shortcodes":["first_quarter_moon_with_face"]},{"emoji":"๐ŸŒœ๏ธ","group":5,"order":3998,"tags":["dreams","face","last","moon","quarter"],"version":0.7,"annotation":"last quarter moon face","shortcodes":["last_quarter_moon_with_face"]},{"emoji":"๐ŸŒก๏ธ","group":5,"order":4000,"tags":["weather"],"version":0.7,"annotation":"thermometer","shortcodes":["thermometer"]},{"emoji":"โ˜€๏ธ","group":5,"order":4002,"tags":["bright","rays","space","sunny","weather"],"version":0.6,"annotation":"sun","shortcodes":["sun"]},{"emoji":"๐ŸŒ","group":5,"order":4003,"tags":["bright","face","full","moon"],"version":1,"annotation":"full moon face","shortcodes":["full_moon_with_face"]},{"emoji":"๐ŸŒž","group":5,"order":4004,"tags":["beach","bright","day","face","heat","shine","sun","sunny","sunshine","weather"],"version":1,"annotation":"sun with face","shortcodes":["sun_with_face"]},{"emoji":"๐Ÿช","group":5,"order":4005,"tags":["planet","ringed","saturn","saturnine"],"version":12,"annotation":"ringed planet","shortcodes":["ringed_planet","saturn"]},{"emoji":"โญ๏ธ","group":5,"order":4006,"tags":["astronomy","medium","stars","white"],"version":0.6,"annotation":"star","shortcodes":["star"]},{"emoji":"๐ŸŒŸ","group":5,"order":4007,"tags":["glittery","glow","glowing","night","shining","sparkle","star","win"],"version":0.6,"annotation":"glowing star","shortcodes":["glowing_star","star2"]},{"emoji":"๐ŸŒ ","group":5,"order":4008,"tags":["falling","night","shooting","space","star"],"version":0.6,"annotation":"shooting star","shortcodes":["shooting_star","stars"]},{"emoji":"๐ŸŒŒ","group":5,"order":4009,"tags":["milky","space","way"],"version":0.6,"annotation":"milky way","shortcodes":["milky_way"]},{"emoji":"โ˜๏ธ","group":5,"order":4011,"tags":["weather"],"version":0.6,"annotation":"cloud","shortcodes":["cloud"]},{"emoji":"โ›…๏ธ","group":5,"order":4012,"tags":["behind","cloud","cloudy","sun","weather"],"version":0.6,"annotation":"sun behind cloud","shortcodes":["partly_sunny","sun_behind_cloud"]},{"emoji":"โ›ˆ๏ธ","group":5,"order":4014,"tags":["cloud","lightning","rain","thunder","thunderstorm"],"version":0.7,"annotation":"cloud with lightning and rain","shortcodes":["stormy","thunder_cloud_and_rain"]},{"emoji":"๐ŸŒค๏ธ","group":5,"order":4016,"tags":["behind","cloud","sun","weather"],"version":0.7,"annotation":"sun behind small cloud","shortcodes":["sun_behind_small_cloud","sunny"]},{"emoji":"๐ŸŒฅ๏ธ","group":5,"order":4018,"tags":["behind","cloud","sun","weather"],"version":0.7,"annotation":"sun behind large cloud","shortcodes":["cloudy","sun_behind_large_cloud"]},{"emoji":"๐ŸŒฆ๏ธ","group":5,"order":4020,"tags":["behind","cloud","rain","sun","weather"],"version":0.7,"annotation":"sun behind rain cloud","shortcodes":["sun_and_rain","sun_behind_rain_cloud"]},{"emoji":"๐ŸŒง๏ธ","group":5,"order":4022,"tags":["cloud","rain","weather"],"version":0.7,"annotation":"cloud with rain","shortcodes":["cloud_with_rain","rainy"]},{"emoji":"๐ŸŒจ๏ธ","group":5,"order":4024,"tags":["cloud","cold","snow","weather"],"version":0.7,"annotation":"cloud with snow","shortcodes":["cloud_with_snow","snowy"]},{"emoji":"๐ŸŒฉ๏ธ","group":5,"order":4026,"tags":["cloud","lightning","weather"],"version":0.7,"annotation":"cloud with lightning","shortcodes":["cloud_with_lightning","lightning"]},{"emoji":"๐ŸŒช๏ธ","group":5,"order":4028,"tags":["cloud","weather","whirlwind"],"version":0.7,"annotation":"tornado","shortcodes":["tornado"]},{"emoji":"๐ŸŒซ๏ธ","group":5,"order":4030,"tags":["cloud","weather"],"version":0.7,"annotation":"fog","shortcodes":["fog"]},{"emoji":"๐ŸŒฌ๏ธ","group":5,"order":4032,"tags":["blow","cloud","face","wind"],"version":0.7,"annotation":"wind face","shortcodes":["wind_blowing_face"]},{"emoji":"๐ŸŒ€","group":5,"order":4033,"tags":["dizzy","hurricane","twister","typhoon","weather"],"version":0.6,"annotation":"cyclone","shortcodes":["cyclone"]},{"emoji":"๐ŸŒˆ","group":5,"order":4034,"tags":["gay","genderqueer","glbt","glbtq","lesbian","lgbt","lgbtq","lgbtqia","nature","pride","queer","rain","trans","transgender","weather"],"version":0.6,"annotation":"rainbow","shortcodes":["rainbow"]},{"emoji":"๐ŸŒ‚","group":5,"order":4035,"tags":["closed","clothing","rain","umbrella"],"version":0.6,"annotation":"closed umbrella","shortcodes":["closed_umbrella"]},{"emoji":"โ˜‚๏ธ","group":5,"order":4037,"tags":["clothing","rain"],"version":0.7,"annotation":"umbrella","shortcodes":["umbrella"]},{"emoji":"โ˜”๏ธ","group":5,"order":4038,"tags":["clothing","drop","drops","rain","umbrella","weather"],"version":0.6,"annotation":"umbrella with rain drops","shortcodes":["umbrella_with_rain"]},{"emoji":"โ›ฑ๏ธ","group":5,"order":4040,"tags":["ground","rain","sun","umbrella"],"version":0.7,"annotation":"umbrella on ground","shortcodes":["beach_umbrella","umbrella_on_ground"]},{"emoji":"โšก๏ธ","group":5,"order":4041,"tags":["danger","electric","electricity","high","lightning","nature","thunder","thunderbolt","voltage","zap"],"version":0.6,"annotation":"high voltage","shortcodes":["high_voltage","zap"]},{"emoji":"โ„๏ธ","group":5,"order":4043,"tags":["cold","snow","weather"],"version":0.6,"annotation":"snowflake","shortcodes":["snowflake"]},{"emoji":"โ˜ƒ๏ธ","group":5,"order":4045,"tags":["cold","man","snow"],"version":0.7,"annotation":"snowman","shortcodes":["snowman2"]},{"emoji":"โ›„๏ธ","group":5,"order":4046,"tags":["cold","man","snow","snowman"],"version":0.6,"annotation":"snowman without snow","shortcodes":["snowman"]},{"emoji":"โ˜„๏ธ","group":5,"order":4048,"tags":["space"],"version":1,"annotation":"comet","shortcodes":["comet"]},{"emoji":"๐Ÿ”ฅ","group":5,"order":4049,"tags":["af","burn","flame","hot","lit","litaf","tool"],"version":0.6,"annotation":"fire","shortcodes":["fire"]},{"emoji":"๐Ÿ’ง","group":5,"order":4050,"tags":["cold","comic","drop","nature","sad","sweat","tear","water","weather"],"version":0.6,"annotation":"droplet","shortcodes":["droplet"]},{"emoji":"๐ŸŒŠ","group":5,"order":4051,"tags":["nature","ocean","surf","surfer","surfing","water","wave"],"version":0.6,"annotation":"water wave","shortcodes":["ocean","water_wave"]},{"emoji":"๐ŸŽƒ","group":6,"order":4052,"tags":["celebration","halloween","jack","lantern","pumpkin"],"version":0.6,"annotation":"jack-o-lantern","shortcodes":["jack_o_lantern"]},{"emoji":"๐ŸŽ„","group":6,"order":4053,"tags":["celebration","christmas","tree"],"version":0.6,"annotation":"Christmas tree","shortcodes":["christmas_tree"]},{"emoji":"๐ŸŽ†","group":6,"order":4054,"tags":["boom","celebration","entertainment","yolo"],"version":0.6,"annotation":"fireworks","shortcodes":["fireworks"]},{"emoji":"๐ŸŽ‡","group":6,"order":4055,"tags":["boom","celebration","fireworks","sparkle"],"version":0.6,"annotation":"sparkler","shortcodes":["sparkler"]},{"emoji":"๐Ÿงจ","group":6,"order":4056,"tags":["dynamite","explosive","fire","fireworks","light","pop","popping","spark"],"version":11,"annotation":"firecracker","shortcodes":["firecracker"]},{"emoji":"โœจ๏ธ","group":6,"order":4057,"tags":["*","magic","sparkle","star"],"version":0.6,"annotation":"sparkles","shortcodes":["sparkles"]},{"emoji":"๐ŸŽˆ","group":6,"order":4058,"tags":["birthday","celebrate","celebration"],"version":0.6,"annotation":"balloon","shortcodes":["balloon"]},{"emoji":"๐ŸŽ‰","group":6,"order":4059,"tags":["awesome","birthday","celebrate","celebration","excited","hooray","party","popper","tada","woohoo"],"version":0.6,"annotation":"party popper","shortcodes":["party","party_popper","tada"]},{"emoji":"๐ŸŽŠ","group":6,"order":4060,"tags":["ball","celebrate","celebration","confetti","party","woohoo"],"version":0.6,"annotation":"confetti ball","shortcodes":["confetti_ball"]},{"emoji":"๐ŸŽ‹","group":6,"order":4061,"tags":["banner","celebration","japanese","tanabata","tree"],"version":0.6,"annotation":"tanabata tree","shortcodes":["tanabata_tree"]},{"emoji":"๐ŸŽ","group":6,"order":4062,"tags":["bamboo","celebration","decoration","japanese","pine","plant"],"version":0.6,"annotation":"pine decoration","shortcodes":["bamboo"]},{"emoji":"๐ŸŽŽ","group":6,"order":4063,"tags":["celebration","doll","dolls","festival","japanese"],"version":0.6,"annotation":"Japanese dolls","shortcodes":["dolls"]},{"emoji":"๐ŸŽ","group":6,"order":4064,"tags":["carp","celebration","streamer"],"version":0.6,"annotation":"carp streamer","shortcodes":["carp_streamer","flags"]},{"emoji":"๐ŸŽ","group":6,"order":4065,"tags":["bell","celebration","chime","wind"],"version":0.6,"annotation":"wind chime","shortcodes":["wind_chime"]},{"emoji":"๐ŸŽ‘","group":6,"order":4066,"tags":["celebration","ceremony","moon","viewing"],"version":0.6,"annotation":"moon viewing ceremony","shortcodes":["moon_ceremony","rice_scene"]},{"emoji":"๐Ÿงง","group":6,"order":4067,"tags":["envelope","gift","good","hรณngbฤo","lai","luck","money","red","see"],"version":11,"annotation":"red envelope","shortcodes":["red_envelope"]},{"emoji":"๐ŸŽ€","group":6,"order":4068,"tags":["celebration"],"version":0.6,"annotation":"ribbon","shortcodes":["ribbon"]},{"emoji":"๐ŸŽ","group":6,"order":4069,"tags":["birthday","bow","box","celebration","christmas","gift","present","surprise","wrapped"],"version":0.6,"annotation":"wrapped gift","shortcodes":["gift"]},{"emoji":"๐ŸŽ—๏ธ","group":6,"order":4071,"tags":["celebration","reminder","ribbon"],"version":0.7,"annotation":"reminder ribbon","shortcodes":["reminder_ribbon"]},{"emoji":"๐ŸŽŸ๏ธ","group":6,"order":4073,"tags":["admission","ticket","tickets"],"version":0.7,"annotation":"admission tickets","shortcodes":["admission_tickets","tickets"]},{"emoji":"๐ŸŽซ","group":6,"order":4074,"tags":["admission","stub"],"version":0.6,"annotation":"ticket","shortcodes":["ticket"]},{"emoji":"๐ŸŽ–๏ธ","group":6,"order":4076,"tags":["award","celebration","medal","military"],"version":0.7,"annotation":"military medal","shortcodes":["military_medal"]},{"emoji":"๐Ÿ†๏ธ","group":6,"order":4077,"tags":["champion","champs","prize","slay","sport","victory","win","winning"],"version":0.6,"annotation":"trophy","shortcodes":["trophy"]},{"emoji":"๐Ÿ…","group":6,"order":4078,"tags":["award","gold","medal","sports","winner"],"version":1,"annotation":"sports medal","shortcodes":["sports_medal"]},{"emoji":"๐Ÿฅ‡","group":6,"order":4079,"tags":["1st","first","gold","medal","place"],"version":3,"annotation":"1st place medal","shortcodes":["1st","first_place_medal"]},{"emoji":"๐Ÿฅˆ","group":6,"order":4080,"tags":["2nd","medal","place","second","silver"],"version":3,"annotation":"2nd place medal","shortcodes":["2nd","second_place_medal"]},{"emoji":"๐Ÿฅ‰","group":6,"order":4081,"tags":["3rd","bronze","medal","place","third"],"version":3,"annotation":"3rd place medal","shortcodes":["3rd","third_place_medal"]},{"emoji":"โšฝ๏ธ","group":6,"order":4082,"tags":["ball","football","futbol","soccer","sport"],"version":0.6,"annotation":"soccer ball","shortcodes":["soccer"]},{"emoji":"โšพ๏ธ","group":6,"order":4083,"tags":["ball","sport"],"version":0.6,"annotation":"baseball","shortcodes":["baseball"]},{"emoji":"๐ŸฅŽ","group":6,"order":4084,"tags":["ball","glove","sports","underarm"],"version":11,"annotation":"softball","shortcodes":["softball"]},{"emoji":"๐Ÿ€","group":6,"order":4085,"tags":["ball","hoop","sport"],"version":0.6,"annotation":"basketball","shortcodes":["basketball"]},{"emoji":"๐Ÿ","group":6,"order":4086,"tags":["ball","game"],"version":1,"annotation":"volleyball","shortcodes":["volleyball"]},{"emoji":"๐Ÿˆ","group":6,"order":4087,"tags":["american","ball","bowl","football","sport","super"],"version":0.6,"annotation":"american football","shortcodes":["football"]},{"emoji":"๐Ÿ‰","group":6,"order":4088,"tags":["ball","football","rugby","sport"],"version":1,"annotation":"rugby football","shortcodes":["rugby_football"]},{"emoji":"๐ŸŽพ","group":6,"order":4089,"tags":["ball","racquet","sport"],"version":0.6,"annotation":"tennis","shortcodes":["tennis"]},{"emoji":"๐Ÿฅ","group":6,"order":4090,"tags":["disc","flying","ultimate"],"version":11,"annotation":"flying disc","shortcodes":["flying_disc"]},{"emoji":"๐ŸŽณ","group":6,"order":4091,"tags":["ball","game","sport","strike"],"version":0.6,"annotation":"bowling","shortcodes":["bowling"]},{"emoji":"๐Ÿ","group":6,"order":4092,"tags":["ball","bat","cricket","game"],"version":1,"annotation":"cricket game","shortcodes":["cricket_game"]},{"emoji":"๐Ÿ‘","group":6,"order":4093,"tags":["ball","field","game","hockey","stick"],"version":1,"annotation":"field hockey","shortcodes":["field_hockey"]},{"emoji":"๐Ÿ’","group":6,"order":4094,"tags":["game","hockey","ice","puck","stick"],"version":1,"annotation":"ice hockey","shortcodes":["hockey"]},{"emoji":"๐Ÿฅ","group":6,"order":4095,"tags":["ball","goal","sports","stick"],"version":11,"annotation":"lacrosse","shortcodes":["lacrosse"]},{"emoji":"๐Ÿ“","group":6,"order":4096,"tags":["ball","bat","game","paddle","ping","pingpong","pong","table","tennis"],"version":1,"annotation":"ping pong","shortcodes":["ping_pong"]},{"emoji":"๐Ÿธ","group":6,"order":4097,"tags":["birdie","game","racquet","shuttlecock"],"version":1,"annotation":"badminton","shortcodes":["badminton"]},{"emoji":"๐ŸฅŠ","group":6,"order":4098,"tags":["boxing","glove"],"version":3,"annotation":"boxing glove","shortcodes":["boxing_glove"]},{"emoji":"๐Ÿฅ‹","group":6,"order":4099,"tags":["arts","judo","karate","martial","taekwondo","uniform"],"version":3,"annotation":"martial arts uniform","shortcodes":["martial_arts_uniform"]},{"emoji":"๐Ÿฅ…","group":6,"order":4100,"tags":["goal","net"],"version":3,"annotation":"goal net","shortcodes":["goal_net"]},{"emoji":"โ›ณ๏ธ","group":6,"order":4101,"tags":["flag","golf","hole","sport"],"version":0.6,"annotation":"flag in hole","shortcodes":["golf"]},{"emoji":"โ›ธ๏ธ","group":6,"order":4103,"tags":["ice","skate","skating"],"version":0.7,"annotation":"ice skate","shortcodes":["ice_skate"]},{"emoji":"๐ŸŽฃ","group":6,"order":4104,"tags":["entertainment","fish","fishing","pole","sport"],"version":0.6,"annotation":"fishing pole","shortcodes":["fishing_pole","fishing_pole_and_fish"]},{"emoji":"๐Ÿคฟ","group":6,"order":4105,"tags":["diving","mask","scuba","snorkeling"],"version":12,"annotation":"diving mask","shortcodes":["diving_mask"]},{"emoji":"๐ŸŽฝ","group":6,"order":4106,"tags":["athletics","running","sash","shirt"],"version":0.6,"annotation":"running shirt","shortcodes":["running_shirt","running_shirt_with_sash"]},{"emoji":"๐ŸŽฟ","group":6,"order":4107,"tags":["ski","snow","sport"],"version":0.6,"annotation":"skis","shortcodes":["ski"]},{"emoji":"๐Ÿ›ท","group":6,"order":4108,"tags":["luge","sledge","sleigh","snow","toboggan"],"version":5,"annotation":"sled","shortcodes":["sled"]},{"emoji":"๐ŸฅŒ","group":6,"order":4109,"tags":["curling","game","rock","stone"],"version":5,"annotation":"curling stone","shortcodes":["curling_stone"]},{"emoji":"๐ŸŽฏ","group":6,"order":4110,"tags":["bull","dart","direct","entertainment","game","hit","target"],"version":0.6,"annotation":"bullseye","shortcodes":["bullseye","dart","direct_hit"]},{"emoji":"๐Ÿช€","group":6,"order":4111,"tags":["fluctuate","toy"],"version":12,"annotation":"yo-yo","shortcodes":["yo_yo"]},{"emoji":"๐Ÿช","group":6,"order":4112,"tags":["fly","soar"],"version":12,"annotation":"kite","shortcodes":["kite"]},{"emoji":"๐Ÿ”ซ","group":6,"order":4113,"tags":["gun","handgun","pistol","revolver","tool","water","weapon"],"version":0.6,"annotation":"water pistol","shortcodes":["gun","pistol"]},{"emoji":"๐ŸŽฑ","group":6,"order":4114,"tags":["8","8ball","ball","billiard","eight","game","pool"],"version":0.6,"annotation":"pool 8 ball","shortcodes":["8ball","billiards"]},{"emoji":"๐Ÿ”ฎ","group":6,"order":4115,"tags":["ball","crystal","fairy","fairytale","fantasy","fortune","future","magic","tale","tool"],"version":0.6,"annotation":"crystal ball","shortcodes":["crystal_ball"]},{"emoji":"๐Ÿช„","group":6,"order":4116,"tags":["magic","magician","wand","witch","wizard"],"version":13,"annotation":"magic wand","shortcodes":["magic_wand"]},{"emoji":"๐ŸŽฎ๏ธ","group":6,"order":4117,"tags":["controller","entertainment","game","video"],"version":0.6,"annotation":"video game","shortcodes":["controller","video_game"]},{"emoji":"๐Ÿ•น๏ธ","group":6,"order":4119,"tags":["game","video","videogame"],"version":0.7,"annotation":"joystick","shortcodes":["joystick"]},{"emoji":"๐ŸŽฐ","group":6,"order":4120,"tags":["casino","gamble","gambling","game","machine","slot","slots"],"version":0.6,"annotation":"slot machine","shortcodes":["slot_machine"]},{"emoji":"๐ŸŽฒ","group":6,"order":4121,"tags":["dice","die","entertainment","game"],"version":0.6,"annotation":"game die","shortcodes":["game_die"]},{"emoji":"๐Ÿงฉ","group":6,"order":4122,"tags":["clue","interlocking","jigsaw","piece","puzzle"],"version":11,"annotation":"puzzle piece","shortcodes":["jigsaw","puzzle_piece"]},{"emoji":"๐Ÿงธ","group":6,"order":4123,"tags":["bear","plaything","plush","stuffed","teddy","toy"],"version":11,"annotation":"teddy bear","shortcodes":["teddy_bear"]},{"emoji":"๐Ÿช…","group":6,"order":4124,"tags":["candy","celebrate","celebration","cinco","de","festive","mayo","party","pinada","pinata"],"version":13,"annotation":"piรฑata","shortcodes":["pinata"]},{"emoji":"๐Ÿชฉ","group":6,"order":4125,"tags":["ball","dance","disco","glitter","mirror","party"],"version":14,"annotation":"mirror ball","shortcodes":["disco","disco_ball","mirror_ball"]},{"emoji":"๐Ÿช†","group":6,"order":4126,"tags":["babooshka","baboushka","babushka","doll","dolls","matryoshka","nesting","russia"],"version":13,"annotation":"nesting dolls","shortcodes":["nesting_dolls"]},{"emoji":"โ™ ๏ธ","group":6,"order":4128,"tags":["card","game","spade","suit"],"version":0.6,"annotation":"spade suit","shortcodes":["spades"]},{"emoji":"โ™ฅ๏ธ","group":6,"order":4130,"tags":["card","emotion","game","heart","hearts","suit"],"version":0.6,"annotation":"heart suit","shortcodes":["hearts"]},{"emoji":"โ™ฆ๏ธ","group":6,"order":4132,"tags":["card","diamond","game","suit"],"version":0.6,"annotation":"diamond suit","shortcodes":["diamonds"]},{"emoji":"โ™ฃ๏ธ","group":6,"order":4134,"tags":["card","club","clubs","game","suit"],"version":0.6,"annotation":"club suit","shortcodes":["clubs"]},{"emoji":"โ™Ÿ๏ธ","group":6,"order":4136,"tags":["chess","dupe","expendable","pawn"],"version":11,"annotation":"chess pawn","shortcodes":["chess_pawn"]},{"emoji":"๐Ÿƒ","group":6,"order":4137,"tags":["card","game","wildcard"],"version":0.6,"annotation":"joker","shortcodes":["black_joker"]},{"emoji":"๐Ÿ€„๏ธ","group":6,"order":4138,"tags":["dragon","game","mahjong","red"],"version":0.6,"annotation":"mahjong red dragon","shortcodes":["mahjong"]},{"emoji":"๐ŸŽด","group":6,"order":4139,"tags":["card","cards","flower","game","japanese","playing"],"version":0.6,"annotation":"flower playing cards","shortcodes":["flower_playing_cards"]},{"emoji":"๐ŸŽญ๏ธ","group":6,"order":4140,"tags":["actor","actress","art","arts","entertainment","mask","performing","theater","theatre","thespian"],"version":0.6,"annotation":"performing arts","shortcodes":["performing_arts"]},{"emoji":"๐Ÿ–ผ๏ธ","group":6,"order":4142,"tags":["art","frame","framed","museum","painting","picture"],"version":0.7,"annotation":"framed picture","shortcodes":["frame_with_picture","framed_picture"]},{"emoji":"๐ŸŽจ","group":6,"order":4143,"tags":["art","artist","artsy","arty","colorful","creative","entertainment","museum","painter","painting","palette"],"version":0.6,"annotation":"artist palette","shortcodes":["art","palette"]},{"emoji":"๐Ÿงต","group":6,"order":4144,"tags":["needle","sewing","spool","string"],"version":11,"annotation":"thread","shortcodes":["thread"]},{"emoji":"๐Ÿชก","group":6,"order":4145,"tags":["embroidery","needle","sew","sewing","stitches","sutures","tailoring","thread"],"version":13,"annotation":"sewing needle","shortcodes":["sewing_needle"]},{"emoji":"๐Ÿงถ","group":6,"order":4146,"tags":["ball","crochet","knit"],"version":11,"annotation":"yarn","shortcodes":["yarn"]},{"emoji":"๐Ÿชข","group":6,"order":4147,"tags":["cord","rope","tangled","tie","twine","twist"],"version":13,"annotation":"knot","shortcodes":["knot"]},{"emoji":"๐Ÿ‘“๏ธ","group":7,"order":4148,"tags":["clothing","eye","eyeglasses","eyewear"],"version":0.6,"annotation":"glasses","shortcodes":["eyeglasses","glasses"]},{"emoji":"๐Ÿ•ถ๏ธ","group":7,"order":4150,"tags":["dark","eye","eyewear","glasses"],"version":0.7,"annotation":"sunglasses","shortcodes":["sunglasses"]},{"emoji":"๐Ÿฅฝ","group":7,"order":4151,"tags":["dive","eye","protection","scuba","swimming","welding"],"version":11,"annotation":"goggles","shortcodes":["goggles"]},{"emoji":"๐Ÿฅผ","group":7,"order":4152,"tags":["clothes","coat","doctor","dr","experiment","jacket","lab","scientist","white"],"version":11,"annotation":"lab coat","shortcodes":["lab_coat"]},{"emoji":"๐Ÿฆบ","group":7,"order":4153,"tags":["emergency","safety","vest"],"version":12,"annotation":"safety vest","shortcodes":["safety_vest"]},{"emoji":"๐Ÿ‘”","group":7,"order":4154,"tags":["clothing","employed","serious","shirt","tie"],"version":0.6,"annotation":"necktie","shortcodes":["necktie"]},{"emoji":"๐Ÿ‘•","group":7,"order":4155,"tags":["blue","casual","clothes","clothing","collar","dressed","shirt","shopping","tshirt","weekend"],"version":0.6,"annotation":"t-shirt","shortcodes":["shirt"]},{"emoji":"๐Ÿ‘–","group":7,"order":4156,"tags":["blue","casual","clothes","clothing","denim","dressed","pants","shopping","trousers","weekend"],"version":0.6,"annotation":"jeans","shortcodes":["jeans"]},{"emoji":"๐Ÿงฃ","group":7,"order":4157,"tags":["bundle","cold","neck","up"],"version":5,"annotation":"scarf","shortcodes":["scarf"]},{"emoji":"๐Ÿงค","group":7,"order":4158,"tags":["hand"],"version":5,"annotation":"gloves","shortcodes":["gloves"]},{"emoji":"๐Ÿงฅ","group":7,"order":4159,"tags":["brr","bundle","cold","jacket","up"],"version":5,"annotation":"coat","shortcodes":["coat"]},{"emoji":"๐Ÿงฆ","group":7,"order":4160,"tags":["stocking"],"version":5,"annotation":"socks","shortcodes":["socks"]},{"emoji":"๐Ÿ‘—","group":7,"order":4161,"tags":["clothes","clothing","dressed","fancy","shopping"],"version":0.6,"annotation":"dress","shortcodes":["dress"]},{"emoji":"๐Ÿ‘˜","group":7,"order":4162,"tags":["clothing","comfortable"],"version":0.6,"annotation":"kimono","shortcodes":["kimono"]},{"emoji":"๐Ÿฅป","group":7,"order":4163,"tags":["clothing","dress"],"version":12,"annotation":"sari","shortcodes":["sari"]},{"emoji":"๐Ÿฉฑ","group":7,"order":4164,"tags":["bathing","one-piece","suit","swimsuit"],"version":12,"annotation":"one-piece swimsuit","shortcodes":["one_piece_swimsuit"]},{"emoji":"๐Ÿฉฒ","group":7,"order":4165,"tags":["bathing","one-piece","suit","swimsuit","underwear"],"version":12,"annotation":"briefs","shortcodes":["briefs"]},{"emoji":"๐Ÿฉณ","group":7,"order":4166,"tags":["bathing","pants","suit","swimsuit","underwear"],"version":12,"annotation":"shorts","shortcodes":["shorts"]},{"emoji":"๐Ÿ‘™","group":7,"order":4167,"tags":["bathing","beach","clothing","pool","suit","swim"],"version":0.6,"annotation":"bikini","shortcodes":["bikini"]},{"emoji":"๐Ÿ‘š","group":7,"order":4168,"tags":["blouse","clothes","clothing","collar","dress","dressed","lady","shirt","shopping","woman","womanโ€™s"],"version":0.6,"annotation":"womanโ€™s clothes","shortcodes":["womans_clothes"]},{"emoji":"๐Ÿชญ","group":7,"order":4169,"tags":["clack","clap","cool","cooling","dance","fan","flirt","flutter","folding","hand","hot","shy"],"version":15,"annotation":"folding hand fan","shortcodes":["folding_fan"]},{"emoji":"๐Ÿ‘›","group":7,"order":4170,"tags":["clothes","clothing","coin","dress","fancy","handbag","shopping"],"version":0.6,"annotation":"purse","shortcodes":["purse"]},{"emoji":"๐Ÿ‘œ","group":7,"order":4171,"tags":["bag","clothes","clothing","dress","lady","purse","shopping"],"version":0.6,"annotation":"handbag","shortcodes":["handbag"]},{"emoji":"๐Ÿ‘","group":7,"order":4172,"tags":["bag","clothes","clothing","clutch","dress","handbag","pouch","purse"],"version":0.6,"annotation":"clutch bag","shortcodes":["clutch_bag","pouch"]},{"emoji":"๐Ÿ›๏ธ","group":7,"order":4174,"tags":["bag","bags","hotel","shopping"],"version":0.7,"annotation":"shopping bags","shortcodes":["shopping_bags"]},{"emoji":"๐ŸŽ’","group":7,"order":4175,"tags":["backpacking","bag","bookbag","education","rucksack","satchel","school"],"version":0.6,"annotation":"backpack","shortcodes":["backpack","school_satchel"]},{"emoji":"๐Ÿฉด","group":7,"order":4176,"tags":["beach","flip","flop","sandal","sandals","shoe","thong","thongs","zลri"],"version":13,"annotation":"thong sandal","shortcodes":["thong_sandal"]},{"emoji":"๐Ÿ‘ž","group":7,"order":4177,"tags":["brown","clothes","clothing","feet","foot","kick","man","manโ€™s","shoe","shoes","shopping"],"version":0.6,"annotation":"manโ€™s shoe","shortcodes":["mans_shoe"]},{"emoji":"๐Ÿ‘Ÿ","group":7,"order":4178,"tags":["athletic","clothes","clothing","fast","kick","running","shoe","shoes","shopping","sneaker","tennis"],"version":0.6,"annotation":"running shoe","shortcodes":["athletic_shoe","sneaker"]},{"emoji":"๐Ÿฅพ","group":7,"order":4179,"tags":["backpacking","boot","brown","camping","hiking","outdoors","shoe"],"version":11,"annotation":"hiking boot","shortcodes":["hiking_boot"]},{"emoji":"๐Ÿฅฟ","group":7,"order":4180,"tags":["ballet","comfy","flat","flats","shoe","slip-on","slipper"],"version":11,"annotation":"flat shoe","shortcodes":["flat_shoe","womans_flat_shoe"]},{"emoji":"๐Ÿ‘ ","group":7,"order":4181,"tags":["clothes","clothing","dress","fashion","heel","heels","high-heeled","shoe","shoes","shopping","stiletto","woman"],"version":0.6,"annotation":"high-heeled shoe","shortcodes":["high_heel"]},{"emoji":"๐Ÿ‘ก","group":7,"order":4182,"tags":["clothing","sandal","shoe","woman","womanโ€™s"],"version":0.6,"annotation":"womanโ€™s sandal","shortcodes":["sandal"]},{"emoji":"๐Ÿฉฐ","group":7,"order":4183,"tags":["ballet","dance","shoes"],"version":12,"annotation":"ballet shoes","shortcodes":["ballet_shoes"]},{"emoji":"๐Ÿ‘ข","group":7,"order":4184,"tags":["boot","clothes","clothing","dress","shoe","shoes","shopping","woman","womanโ€™s"],"version":0.6,"annotation":"womanโ€™s boot","shortcodes":["boot"]},{"emoji":"๐Ÿชฎ","group":7,"order":4185,"tags":["afro","comb","groom","hair","pick"],"version":15,"annotation":"hair pick","shortcodes":["hair_pick"]},{"emoji":"๐Ÿ‘‘","group":7,"order":4186,"tags":["clothing","family","king","medieval","queen","royal","royalty","win"],"version":0.6,"annotation":"crown","shortcodes":["crown"]},{"emoji":"๐Ÿ‘’","group":7,"order":4187,"tags":["clothes","clothing","garden","hat","hats","party","woman","womanโ€™s"],"version":0.6,"annotation":"womanโ€™s hat","shortcodes":["womans_hat"]},{"emoji":"๐ŸŽฉ","group":7,"order":4188,"tags":["clothes","clothing","fancy","formal","hat","magic","top","tophat"],"version":0.6,"annotation":"top hat","shortcodes":["top_hat","tophat"]},{"emoji":"๐ŸŽ“๏ธ","group":7,"order":4189,"tags":["cap","celebration","clothing","education","graduation","hat","scholar"],"version":0.6,"annotation":"graduation cap","shortcodes":["graduation_cap","mortar_board"]},{"emoji":"๐Ÿงข","group":7,"order":4190,"tags":["baseball","bent","billed","cap","dad","hat"],"version":5,"annotation":"billed cap","shortcodes":["billed_cap"]},{"emoji":"๐Ÿช–","group":7,"order":4191,"tags":["army","helmet","military","soldier","war","warrior"],"version":13,"annotation":"military helmet","shortcodes":["military_helmet"]},{"emoji":"โ›‘๏ธ","group":7,"order":4193,"tags":["aid","cross","face","hat","helmet","rescue","workerโ€™s"],"version":0.7,"annotation":"rescue workerโ€™s helmet","shortcodes":["helmet_with_cross","rescue_worker_helmet"]},{"emoji":"๐Ÿ“ฟ","group":7,"order":4194,"tags":["beads","clothing","necklace","prayer","religion"],"version":1,"annotation":"prayer beads","shortcodes":["prayer_beads"]},{"emoji":"๐Ÿ’„","group":7,"order":4195,"tags":["cosmetics","date","makeup"],"version":0.6,"annotation":"lipstick","shortcodes":["lipstick"]},{"emoji":"๐Ÿ’","group":7,"order":4196,"tags":["diamond","engaged","engagement","married","romance","shiny","sparkling","wedding"],"version":0.6,"annotation":"ring","shortcodes":["ring"]},{"emoji":"๐Ÿ’Ž","group":7,"order":4197,"tags":["diamond","engagement","gem","jewel","money","romance","stone","wedding"],"version":0.6,"annotation":"gem stone","shortcodes":["gem"]},{"emoji":"๐Ÿ”‡","group":7,"order":4198,"tags":["mute","muted","quiet","silent","sound","speaker"],"version":1,"annotation":"muted speaker","shortcodes":["mute","no_sound"]},{"emoji":"๐Ÿ”ˆ๏ธ","group":7,"order":4199,"tags":["low","soft","sound","speaker","volume"],"version":0.7,"annotation":"speaker low volume","shortcodes":["low_volume","quiet_sound","speaker"]},{"emoji":"๐Ÿ”‰","group":7,"order":4200,"tags":["medium","sound","speaker","volume"],"version":1,"annotation":"speaker medium volume","shortcodes":["medium_volumne","sound"]},{"emoji":"๐Ÿ”Š","group":7,"order":4201,"tags":["high","loud","music","sound","speaker","volume"],"version":0.6,"annotation":"speaker high volume","shortcodes":["high_volume","loud_sound"]},{"emoji":"๐Ÿ“ข","group":7,"order":4202,"tags":["address","communication","loud","public","sound"],"version":0.6,"annotation":"loudspeaker","shortcodes":["loudspeaker"]},{"emoji":"๐Ÿ“ฃ","group":7,"order":4203,"tags":["cheering","sound"],"version":0.6,"annotation":"megaphone","shortcodes":["mega","megaphone"]},{"emoji":"๐Ÿ“ฏ","group":7,"order":4204,"tags":["horn","post","postal"],"version":1,"annotation":"postal horn","shortcodes":["postal_horn"]},{"emoji":"๐Ÿ””","group":7,"order":4205,"tags":["break","church","sound"],"version":0.6,"annotation":"bell","shortcodes":["bell"]},{"emoji":"๐Ÿ”•","group":7,"order":4206,"tags":["bell","forbidden","mute","no","not","prohibited","quiet","silent","slash","sound"],"version":1,"annotation":"bell with slash","shortcodes":["no_bell"]},{"emoji":"๐ŸŽผ","group":7,"order":4207,"tags":["music","musical","note","score"],"version":0.6,"annotation":"musical score","shortcodes":["musical_score"]},{"emoji":"๐ŸŽต","group":7,"order":4208,"tags":["music","musical","note","sound"],"version":0.6,"annotation":"musical note","shortcodes":["musical_note"]},{"emoji":"๐ŸŽถ","group":7,"order":4209,"tags":["music","musical","note","notes","sound"],"version":0.6,"annotation":"musical notes","shortcodes":["musical_notes","notes"]},{"emoji":"๐ŸŽ™๏ธ","group":7,"order":4211,"tags":["mic","microphone","music","studio"],"version":0.7,"annotation":"studio microphone","shortcodes":["studio_microphone"]},{"emoji":"๐ŸŽš๏ธ","group":7,"order":4213,"tags":["level","music","slider"],"version":0.7,"annotation":"level slider","shortcodes":["level_slider"]},{"emoji":"๐ŸŽ›๏ธ","group":7,"order":4215,"tags":["control","knobs","music"],"version":0.7,"annotation":"control knobs","shortcodes":["control_knobs"]},{"emoji":"๐ŸŽค","group":7,"order":4216,"tags":["karaoke","mic","music","sing","sound"],"version":0.6,"annotation":"microphone","shortcodes":["microphone"]},{"emoji":"๐ŸŽง๏ธ","group":7,"order":4217,"tags":["earbud","sound"],"version":0.6,"annotation":"headphone","shortcodes":["headphones"]},{"emoji":"๐Ÿ“ป๏ธ","group":7,"order":4218,"tags":["entertainment","tbt","video"],"version":0.6,"annotation":"radio","shortcodes":["radio"]},{"emoji":"๐ŸŽท","group":7,"order":4219,"tags":["instrument","music","sax"],"version":0.6,"annotation":"saxophone","shortcodes":["saxophone"]},{"emoji":"๐Ÿช—","group":7,"order":4220,"tags":["box","concertina","instrument","music","squeeze","squeezebox"],"version":13,"annotation":"accordion","shortcodes":["accordion"]},{"emoji":"๐ŸŽธ","group":7,"order":4221,"tags":["instrument","music","strat"],"version":0.6,"annotation":"guitar","shortcodes":["guitar"]},{"emoji":"๐ŸŽน","group":7,"order":4222,"tags":["instrument","keyboard","music","musical","piano"],"version":0.6,"annotation":"musical keyboard","shortcodes":["musical_keyboard"]},{"emoji":"๐ŸŽบ","group":7,"order":4223,"tags":["instrument","music"],"version":0.6,"annotation":"trumpet","shortcodes":["trumpet"]},{"emoji":"๐ŸŽป","group":7,"order":4224,"tags":["instrument","music"],"version":0.6,"annotation":"violin","shortcodes":["violin"]},{"emoji":"๐Ÿช•","group":7,"order":4225,"tags":["music","stringed"],"version":12,"annotation":"banjo","shortcodes":["banjo"]},{"emoji":"๐Ÿฅ","group":7,"order":4226,"tags":["drumsticks","music"],"version":3,"annotation":"drum","shortcodes":["drum"]},{"emoji":"๐Ÿช˜","group":7,"order":4227,"tags":["beat","conga","drum","instrument","long","rhythm"],"version":13,"annotation":"long drum","shortcodes":["long_drum"]},{"emoji":"๐Ÿช‡","group":7,"order":4228,"tags":["cha","dance","instrument","music","party","percussion","rattle","shake","shaker"],"version":15,"annotation":"maracas","shortcodes":["maracas"]},{"emoji":"๐Ÿชˆ","group":7,"order":4229,"tags":["band","fife","flautist","instrument","marching","music","orchestra","piccolo","pipe","recorder","woodwind"],"version":15,"annotation":"flute","shortcodes":["flute"]},{"emoji":"๐Ÿช‰","group":7,"order":4230,"tags":["cupid","instrument","love","music","orchestra"],"version":16,"annotation":"harp","shortcodes":["harp"]},{"emoji":"๐Ÿ“ฑ","group":7,"order":4231,"tags":["cell","communication","mobile","phone","telephone"],"version":0.6,"annotation":"mobile phone","shortcodes":["android","iphone","mobile_phone"]},{"emoji":"๐Ÿ“ฒ","group":7,"order":4232,"tags":["arrow","build","call","cell","communication","mobile","phone","receive","telephone"],"version":0.6,"annotation":"mobile phone with arrow","shortcodes":["calling","mobile_phone_arrow"]},{"emoji":"โ˜Ž๏ธ","group":7,"order":4234,"tags":["phone"],"version":0.6,"annotation":"telephone","shortcodes":["telephone"]},{"emoji":"๐Ÿ“ž","group":7,"order":4235,"tags":["communication","phone","receiver","telephone","voip"],"version":0.6,"annotation":"telephone receiver","shortcodes":["telephone_receiver"]},{"emoji":"๐Ÿ“Ÿ๏ธ","group":7,"order":4236,"tags":["communication"],"version":0.6,"annotation":"pager","shortcodes":["pager"]},{"emoji":"๐Ÿ“ ","group":7,"order":4237,"tags":["communication","fax","machine"],"version":0.6,"annotation":"fax machine","shortcodes":["fax","fax_machine"]},{"emoji":"๐Ÿ”‹","group":7,"order":4238,"tags":["battery"],"version":0.6,"annotation":"battery","shortcodes":["battery"]},{"emoji":"๐Ÿชซ","group":7,"order":4239,"tags":["battery","drained","electronic","energy","low","power"],"version":14,"annotation":"low battery","shortcodes":["low_battery"]},{"emoji":"๐Ÿ”Œ","group":7,"order":4240,"tags":["electric","electricity","plug"],"version":0.6,"annotation":"electric plug","shortcodes":["electric_plug"]},{"emoji":"๐Ÿ’ป๏ธ","group":7,"order":4241,"tags":["computer","office","pc","personal"],"version":0.6,"annotation":"laptop","shortcodes":["laptop"]},{"emoji":"๐Ÿ–ฅ๏ธ","group":7,"order":4243,"tags":["computer","desktop","monitor"],"version":0.7,"annotation":"desktop computer","shortcodes":["computer","desktop_computer"]},{"emoji":"๐Ÿ–จ๏ธ","group":7,"order":4245,"tags":["computer"],"version":0.7,"annotation":"printer","shortcodes":["printer"]},{"emoji":"โŒจ๏ธ","group":7,"order":4247,"tags":["computer"],"version":1,"annotation":"keyboard","shortcodes":["keyboard"]},{"emoji":"๐Ÿ–ฑ๏ธ","group":7,"order":4249,"tags":["computer","mouse"],"version":0.7,"annotation":"computer mouse","shortcodes":["computer_mouse"]},{"emoji":"๐Ÿ–ฒ๏ธ","group":7,"order":4251,"tags":["computer"],"version":0.7,"annotation":"trackball","shortcodes":["trackball"]},{"emoji":"๐Ÿ’ฝ","group":7,"order":4252,"tags":["computer","disk","minidisk","optical"],"version":0.6,"annotation":"computer disk","shortcodes":["computer_disk","minidisc"]},{"emoji":"๐Ÿ’พ","group":7,"order":4253,"tags":["computer","disk","floppy"],"version":0.6,"annotation":"floppy disk","shortcodes":["floppy_disk"]},{"emoji":"๐Ÿ’ฟ๏ธ","group":7,"order":4254,"tags":["blu-ray","cd","computer","disk","dvd","optical"],"version":0.6,"annotation":"optical disk","shortcodes":["cd","optical_disk"]},{"emoji":"๐Ÿ“€","group":7,"order":4255,"tags":["blu-ray","cd","computer","disk","optical"],"version":0.6,"annotation":"dvd","shortcodes":["dvd"]},{"emoji":"๐Ÿงฎ","group":7,"order":4256,"tags":["calculation","calculator"],"version":11,"annotation":"abacus","shortcodes":["abacus"]},{"emoji":"๐ŸŽฅ","group":7,"order":4257,"tags":["bollywood","camera","cinema","film","hollywood","movie","record"],"version":0.6,"annotation":"movie camera","shortcodes":["movie_camera"]},{"emoji":"๐ŸŽž๏ธ","group":7,"order":4259,"tags":["cinema","film","frames","movie"],"version":0.7,"annotation":"film frames","shortcodes":["film_frames"]},{"emoji":"๐Ÿ“ฝ๏ธ","group":7,"order":4261,"tags":["cinema","film","movie","projector","video"],"version":0.7,"annotation":"film projector","shortcodes":["film_projector"]},{"emoji":"๐ŸŽฌ๏ธ","group":7,"order":4262,"tags":["action","board","clapper","movie"],"version":0.6,"annotation":"clapper board","shortcodes":["clapper"]},{"emoji":"๐Ÿ“บ๏ธ","group":7,"order":4263,"tags":["tv","video"],"version":0.6,"annotation":"television","shortcodes":["tv"]},{"emoji":"๐Ÿ“ท๏ธ","group":7,"order":4264,"tags":["photo","selfie","snap","tbt","trip","video"],"version":0.6,"annotation":"camera","shortcodes":["camera"]},{"emoji":"๐Ÿ“ธ","group":7,"order":4265,"tags":["camera","flash","video"],"version":1,"annotation":"camera with flash","shortcodes":["camera_with_flash"]},{"emoji":"๐Ÿ“น๏ธ","group":7,"order":4266,"tags":["camcorder","camera","tbt","video"],"version":0.6,"annotation":"video camera","shortcodes":["video_camera"]},{"emoji":"๐Ÿ“ผ","group":7,"order":4267,"tags":["old","school","tape","vcr","vhs","video"],"version":0.6,"annotation":"videocassette","shortcodes":["vhs","videocassette"]},{"emoji":"๐Ÿ”๏ธ","group":7,"order":4268,"tags":["glass","lab","left","left-pointing","magnifying","science","search","tilted","tool"],"version":0.6,"annotation":"magnifying glass tilted left","shortcodes":["mag"]},{"emoji":"๐Ÿ”Ž","group":7,"order":4269,"tags":["contact","glass","lab","magnifying","right","right-pointing","science","search","tilted","tool"],"version":0.6,"annotation":"magnifying glass tilted right","shortcodes":["mag_right"]},{"emoji":"๐Ÿ•ฏ๏ธ","group":7,"order":4271,"tags":["light"],"version":0.7,"annotation":"candle","shortcodes":["candle"]},{"emoji":"๐Ÿ’ก","group":7,"order":4272,"tags":["bulb","comic","electric","idea","light"],"version":0.6,"annotation":"light bulb","shortcodes":["bulb","light_bulb"]},{"emoji":"๐Ÿ”ฆ","group":7,"order":4273,"tags":["electric","light","tool","torch"],"version":0.6,"annotation":"flashlight","shortcodes":["flashlight"]},{"emoji":"๐Ÿฎ","group":7,"order":4274,"tags":["bar","lantern","light","paper","red","restaurant"],"version":0.6,"annotation":"red paper lantern","shortcodes":["izakaya_lantern","red_paper_lantern"]},{"emoji":"๐Ÿช”","group":7,"order":4275,"tags":["diya","lamp","light","oil"],"version":12,"annotation":"diya lamp","shortcodes":["diya_lamp"]},{"emoji":"๐Ÿ“”","group":7,"order":4276,"tags":["book","cover","decorated","decorative","education","notebook","school","writing"],"version":0.6,"annotation":"notebook with decorative cover","shortcodes":["notebook_with_decorative_cover"]},{"emoji":"๐Ÿ“•","group":7,"order":4277,"tags":["book","closed","education"],"version":0.6,"annotation":"closed book","shortcodes":["closed_book"]},{"emoji":"๐Ÿ“–","group":7,"order":4278,"tags":["book","education","fantasy","knowledge","library","novels","open","reading"],"version":0.6,"annotation":"open book","shortcodes":["book","open_book"]},{"emoji":"๐Ÿ“—","group":7,"order":4279,"tags":["book","education","fantasy","green","library","reading"],"version":0.6,"annotation":"green book","shortcodes":["green_book"]},{"emoji":"๐Ÿ“˜","group":7,"order":4280,"tags":["blue","book","education","fantasy","library","reading"],"version":0.6,"annotation":"blue book","shortcodes":["blue_book"]},{"emoji":"๐Ÿ“™","group":7,"order":4281,"tags":["book","education","fantasy","library","orange","reading"],"version":0.6,"annotation":"orange book","shortcodes":["orange_book"]},{"emoji":"๐Ÿ“š๏ธ","group":7,"order":4282,"tags":["book","education","fantasy","knowledge","library","novels","reading","school","study"],"version":0.6,"annotation":"books","shortcodes":["books"]},{"emoji":"๐Ÿ““","group":7,"order":4283,"tags":["notebook"],"version":0.6,"annotation":"notebook","shortcodes":["notebook"]},{"emoji":"๐Ÿ“’","group":7,"order":4284,"tags":["notebook"],"version":0.6,"annotation":"ledger","shortcodes":["ledger"]},{"emoji":"๐Ÿ“ƒ","group":7,"order":4285,"tags":["curl","document","page","paper"],"version":0.6,"annotation":"page with curl","shortcodes":["page_with_curl"]},{"emoji":"๐Ÿ“œ","group":7,"order":4286,"tags":["paper"],"version":0.6,"annotation":"scroll","shortcodes":["scroll"]},{"emoji":"๐Ÿ“„","group":7,"order":4287,"tags":["document","facing","page","paper","up"],"version":0.6,"annotation":"page facing up","shortcodes":["page_facing_up"]},{"emoji":"๐Ÿ“ฐ","group":7,"order":4288,"tags":["communication","news","paper"],"version":0.6,"annotation":"newspaper","shortcodes":["newspaper"]},{"emoji":"๐Ÿ—ž๏ธ","group":7,"order":4290,"tags":["news","newspaper","paper","rolled","rolled-up"],"version":0.7,"annotation":"rolled-up newspaper","shortcodes":["rolled_up_newspaper"]},{"emoji":"๐Ÿ“‘","group":7,"order":4291,"tags":["bookmark","mark","marker","tabs"],"version":0.6,"annotation":"bookmark tabs","shortcodes":["bookmark_tabs"]},{"emoji":"๐Ÿ”–","group":7,"order":4292,"tags":["mark"],"version":0.6,"annotation":"bookmark","shortcodes":["bookmark"]},{"emoji":"๐Ÿท๏ธ","group":7,"order":4294,"tags":["tag"],"version":0.7,"annotation":"label","shortcodes":["label"]},{"emoji":"๐Ÿ’ฐ๏ธ","group":7,"order":4295,"tags":["bag","bank","bet","billion","cash","cost","dollar","gold","million","money","moneybag","paid","paying","pot","rich","win"],"version":0.6,"annotation":"money bag","shortcodes":["moneybag"]},{"emoji":"๐Ÿช™","group":7,"order":4296,"tags":["dollar","euro","gold","metal","money","rich","silver","treasure"],"version":13,"annotation":"coin","shortcodes":["coin"]},{"emoji":"๐Ÿ’ด","group":7,"order":4297,"tags":["bank","banknote","bill","currency","money","note","yen"],"version":0.6,"annotation":"yen banknote","shortcodes":["yen"]},{"emoji":"๐Ÿ’ต","group":7,"order":4298,"tags":["bank","banknote","bill","currency","dollar","money","note"],"version":0.6,"annotation":"dollar banknote","shortcodes":["dollar"]},{"emoji":"๐Ÿ’ถ","group":7,"order":4299,"tags":["100","bank","banknote","bill","currency","euro","money","note","rich"],"version":1,"annotation":"euro banknote","shortcodes":["euro"]},{"emoji":"๐Ÿ’ท","group":7,"order":4300,"tags":["bank","banknote","bill","billion","cash","currency","money","note","pound","pounds"],"version":1,"annotation":"pound banknote","shortcodes":["pound"]},{"emoji":"๐Ÿ’ธ","group":7,"order":4301,"tags":["bank","banknote","bill","billion","cash","dollar","fly","million","money","note","pay","wings"],"version":0.6,"annotation":"money with wings","shortcodes":["money_with_wings"]},{"emoji":"๐Ÿ’ณ๏ธ","group":7,"order":4302,"tags":["bank","card","cash","charge","credit","money","pay"],"version":0.6,"annotation":"credit card","shortcodes":["credit_card"]},{"emoji":"๐Ÿงพ","group":7,"order":4303,"tags":["accounting","bookkeeping","evidence","invoice","proof"],"version":11,"annotation":"receipt","shortcodes":["receipt"]},{"emoji":"๐Ÿ’น","group":7,"order":4304,"tags":["bank","chart","currency","graph","growth","increasing","market","money","rise","trend","upward","yen"],"version":0.6,"annotation":"chart increasing with yen","shortcodes":["chart"]},{"emoji":"โœ‰๏ธ","group":7,"order":4306,"tags":["e-mail","email","letter"],"version":0.6,"annotation":"envelope","shortcodes":["envelope"]},{"emoji":"๐Ÿ“ง","group":7,"order":4307,"tags":["email","letter","mail"],"version":0.6,"annotation":"e-mail","shortcodes":["e-mail","email"]},{"emoji":"๐Ÿ“จ","group":7,"order":4308,"tags":["delivering","e-mail","email","envelope","incoming","letter","mail","receive","sent"],"version":0.6,"annotation":"incoming envelope","shortcodes":["incoming_envelope"]},{"emoji":"๐Ÿ“ฉ","group":7,"order":4309,"tags":["arrow","communication","down","e-mail","email","envelope","letter","mail","outgoing","send","sent"],"version":0.6,"annotation":"envelope with arrow","shortcodes":["envelope_with_arrow"]},{"emoji":"๐Ÿ“ค๏ธ","group":7,"order":4310,"tags":["box","email","letter","mail","outbox","sent","tray"],"version":0.6,"annotation":"outbox tray","shortcodes":["outbox_tray"]},{"emoji":"๐Ÿ“ฅ๏ธ","group":7,"order":4311,"tags":["box","email","inbox","letter","mail","receive","tray","zero"],"version":0.6,"annotation":"inbox tray","shortcodes":["inbox_tray"]},{"emoji":"๐Ÿ“ฆ๏ธ","group":7,"order":4312,"tags":["box","communication","delivery","parcel","shipping"],"version":0.6,"annotation":"package","shortcodes":["package"]},{"emoji":"๐Ÿ“ซ๏ธ","group":7,"order":4313,"tags":["closed","communication","flag","mail","mailbox","postbox","raised"],"version":0.6,"annotation":"closed mailbox with raised flag","shortcodes":["mailbox"]},{"emoji":"๐Ÿ“ช๏ธ","group":7,"order":4314,"tags":["closed","flag","lowered","mail","mailbox","postbox"],"version":0.6,"annotation":"closed mailbox with lowered flag","shortcodes":["mailbox_closed"]},{"emoji":"๐Ÿ“ฌ๏ธ","group":7,"order":4315,"tags":["flag","mail","mailbox","open","postbox","raised"],"version":0.7,"annotation":"open mailbox with raised flag","shortcodes":["mailbox_with_mail"]},{"emoji":"๐Ÿ“ญ๏ธ","group":7,"order":4316,"tags":["flag","lowered","mail","mailbox","open","postbox"],"version":0.7,"annotation":"open mailbox with lowered flag","shortcodes":["mailbox_with_no_mail"]},{"emoji":"๐Ÿ“ฎ","group":7,"order":4317,"tags":["mail","mailbox"],"version":0.6,"annotation":"postbox","shortcodes":["postbox"]},{"emoji":"๐Ÿ—ณ๏ธ","group":7,"order":4319,"tags":["ballot","box"],"version":0.7,"annotation":"ballot box with ballot","shortcodes":["ballot_box"]},{"emoji":"โœ๏ธ","group":7,"order":4321,"tags":["pencil"],"version":0.6,"annotation":"pencil","shortcodes":["pencil"]},{"emoji":"โœ’๏ธ","group":7,"order":4323,"tags":["black","nib","pen"],"version":0.6,"annotation":"black nib","shortcodes":["black_nib"]},{"emoji":"๐Ÿ–‹๏ธ","group":7,"order":4325,"tags":["fountain","pen"],"version":0.7,"annotation":"fountain pen","shortcodes":["fountain_pen"]},{"emoji":"๐Ÿ–Š๏ธ","group":7,"order":4327,"tags":["ballpoint"],"version":0.7,"annotation":"pen","shortcodes":["pen"]},{"emoji":"๐Ÿ–Œ๏ธ","group":7,"order":4329,"tags":["painting"],"version":0.7,"annotation":"paintbrush","shortcodes":["paintbrush"]},{"emoji":"๐Ÿ–๏ธ","group":7,"order":4331,"tags":["crayon"],"version":0.7,"annotation":"crayon","shortcodes":["crayon"]},{"emoji":"๐Ÿ“","group":7,"order":4332,"tags":["communication","media","notes","pencil"],"version":0.6,"annotation":"memo","shortcodes":["memo"]},{"emoji":"๐Ÿ’ผ","group":7,"order":4333,"tags":["office"],"version":0.6,"annotation":"briefcase","shortcodes":["briefcase"]},{"emoji":"๐Ÿ“","group":7,"order":4334,"tags":["file","folder"],"version":0.6,"annotation":"file folder","shortcodes":["file_folder"]},{"emoji":"๐Ÿ“‚","group":7,"order":4335,"tags":["file","folder","open"],"version":0.6,"annotation":"open file folder","shortcodes":["open_file_folder"]},{"emoji":"๐Ÿ—‚๏ธ","group":7,"order":4337,"tags":["card","dividers","index"],"version":0.7,"annotation":"card index dividers","shortcodes":["card_index_dividers"]},{"emoji":"๐Ÿ“…","group":7,"order":4338,"tags":["date"],"version":0.6,"annotation":"calendar","shortcodes":["date"]},{"emoji":"๐Ÿ“†","group":7,"order":4339,"tags":["calendar","tear-off"],"version":0.6,"annotation":"tear-off calendar","shortcodes":["calendar"]},{"emoji":"๐Ÿ—’๏ธ","group":7,"order":4341,"tags":["note","notepad","pad","spiral"],"version":0.7,"annotation":"spiral notepad","shortcodes":["notepad_spiral"]},{"emoji":"๐Ÿ—“๏ธ","group":7,"order":4343,"tags":["calendar","pad","spiral"],"version":0.7,"annotation":"spiral calendar","shortcodes":["calendar_spiral"]},{"emoji":"๐Ÿ“‡","group":7,"order":4344,"tags":["card","index","old","rolodex","school"],"version":0.6,"annotation":"card index","shortcodes":["card_index"]},{"emoji":"๐Ÿ“ˆ","group":7,"order":4345,"tags":["chart","data","graph","growth","increasing","right","trend","up","upward"],"version":0.6,"annotation":"chart increasing","shortcodes":["chart_increasing","chart_with_upwards_trend"]},{"emoji":"๐Ÿ“‰","group":7,"order":4346,"tags":["chart","data","decreasing","down","downward","graph","negative","trend"],"version":0.6,"annotation":"chart decreasing","shortcodes":["chart_decreasing","chart_with_downwards_trend"]},{"emoji":"๐Ÿ“Š","group":7,"order":4347,"tags":["bar","chart","data","graph"],"version":0.6,"annotation":"bar chart","shortcodes":["bar_chart"]},{"emoji":"๐Ÿ“‹๏ธ","group":7,"order":4348,"tags":["do","list","notes"],"version":0.6,"annotation":"clipboard","shortcodes":["clipboard"]},{"emoji":"๐Ÿ“Œ","group":7,"order":4349,"tags":["collage","pin"],"version":0.6,"annotation":"pushpin","shortcodes":["pushpin"]},{"emoji":"๐Ÿ“","group":7,"order":4350,"tags":["location","map","pin","pushpin","round"],"version":0.6,"annotation":"round pushpin","shortcodes":["round_pushpin"]},{"emoji":"๐Ÿ“Ž","group":7,"order":4351,"tags":["paperclip"],"version":0.6,"annotation":"paperclip","shortcodes":["paperclip"]},{"emoji":"๐Ÿ–‡๏ธ","group":7,"order":4353,"tags":["link","linked","paperclip","paperclips"],"version":0.7,"annotation":"linked paperclips","shortcodes":["paperclips"]},{"emoji":"๐Ÿ“","group":7,"order":4354,"tags":["angle","edge","math","ruler","straight","straightedge"],"version":0.6,"annotation":"straight ruler","shortcodes":["straight_ruler"]},{"emoji":"๐Ÿ“","group":7,"order":4355,"tags":["angle","math","rule","ruler","set","slide","triangle","triangular"],"version":0.6,"annotation":"triangular ruler","shortcodes":["triangular_ruler"]},{"emoji":"โœ‚๏ธ","group":7,"order":4357,"tags":["cut","cutting","paper","tool"],"version":0.6,"annotation":"scissors","shortcodes":["scissors"]},{"emoji":"๐Ÿ—ƒ๏ธ","group":7,"order":4359,"tags":["box","card","file"],"version":0.7,"annotation":"card file box","shortcodes":["card_file_box"]},{"emoji":"๐Ÿ—„๏ธ","group":7,"order":4361,"tags":["cabinet","file","filing","paper"],"version":0.7,"annotation":"file cabinet","shortcodes":["file_cabinet"]},{"emoji":"๐Ÿ—‘๏ธ","group":7,"order":4363,"tags":["can","garbage","trash","waste"],"version":0.7,"annotation":"wastebasket","shortcodes":["trashcan","wastebasket"]},{"emoji":"๐Ÿ”’๏ธ","group":7,"order":4364,"tags":["closed","lock","private"],"version":0.6,"annotation":"locked","shortcodes":["lock","locked"]},{"emoji":"๐Ÿ”“๏ธ","group":7,"order":4365,"tags":["cracked","lock","open","unlock"],"version":0.6,"annotation":"unlocked","shortcodes":["unlock","unlocked"]},{"emoji":"๐Ÿ”","group":7,"order":4366,"tags":["ink","lock","locked","nib","pen","privacy"],"version":0.6,"annotation":"locked with pen","shortcodes":["lock_with_ink_pen","locked_with_pen"]},{"emoji":"๐Ÿ”","group":7,"order":4367,"tags":["bike","closed","key","lock","locked","secure"],"version":0.6,"annotation":"locked with key","shortcodes":["closed_lock_with_key","locked_with_key"]},{"emoji":"๐Ÿ”‘","group":7,"order":4368,"tags":["keys","lock","major","password","unlock"],"version":0.6,"annotation":"key","shortcodes":["key"]},{"emoji":"๐Ÿ—๏ธ","group":7,"order":4370,"tags":["clue","key","lock","old"],"version":0.7,"annotation":"old key","shortcodes":["old_key"]},{"emoji":"๐Ÿ”จ","group":7,"order":4371,"tags":["home","improvement","repairs","tool"],"version":0.6,"annotation":"hammer","shortcodes":["hammer"]},{"emoji":"๐Ÿช“","group":7,"order":4372,"tags":["ax","chop","hatchet","split","wood"],"version":12,"annotation":"axe","shortcodes":["axe"]},{"emoji":"โ›๏ธ","group":7,"order":4374,"tags":["hammer","mining","tool"],"version":0.7,"annotation":"pick","shortcodes":["pick"]},{"emoji":"โš’๏ธ","group":7,"order":4376,"tags":["hammer","pick","tool"],"version":1,"annotation":"hammer and pick","shortcodes":["hammer_and_pick"]},{"emoji":"๐Ÿ› ๏ธ","group":7,"order":4378,"tags":["hammer","spanner","tool","wrench"],"version":0.7,"annotation":"hammer and wrench","shortcodes":["hammer_and_wrench"]},{"emoji":"๐Ÿ—ก๏ธ","group":7,"order":4380,"tags":["knife","weapon"],"version":0.7,"annotation":"dagger","shortcodes":["dagger"]},{"emoji":"โš”๏ธ","group":7,"order":4382,"tags":["crossed","swords","weapon"],"version":1,"annotation":"crossed swords","shortcodes":["crossed_swords"]},{"emoji":"๐Ÿ’ฃ๏ธ","group":7,"order":4383,"tags":["boom","comic","dangerous","explosion","hot"],"version":0.6,"annotation":"bomb","shortcodes":["bomb"]},{"emoji":"๐Ÿชƒ","group":7,"order":4384,"tags":["rebound","repercussion","weapon"],"version":13,"annotation":"boomerang","shortcodes":["boomerang"]},{"emoji":"๐Ÿน","group":7,"order":4385,"tags":["archer","archery","arrow","bow","sagittarius","tool","weapon","zodiac"],"version":1,"annotation":"bow and arrow","shortcodes":["bow_and_arrow"]},{"emoji":"๐Ÿ›ก๏ธ","group":7,"order":4387,"tags":["weapon"],"version":0.7,"annotation":"shield","shortcodes":["shield"]},{"emoji":"๐Ÿชš","group":7,"order":4388,"tags":["carpenter","carpentry","cut","lumber","saw","tool","trim"],"version":13,"annotation":"carpentry saw","shortcodes":["carpentry_saw"]},{"emoji":"๐Ÿ”ง","group":7,"order":4389,"tags":["home","improvement","spanner","tool"],"version":0.6,"annotation":"wrench","shortcodes":["wrench"]},{"emoji":"๐Ÿช›","group":7,"order":4390,"tags":["flathead","handy","screw","tool"],"version":13,"annotation":"screwdriver","shortcodes":["screwdriver"]},{"emoji":"๐Ÿ”ฉ","group":7,"order":4391,"tags":["bolt","home","improvement","nut","tool"],"version":0.6,"annotation":"nut and bolt","shortcodes":["nut_and_bolt"]},{"emoji":"โš™๏ธ","group":7,"order":4393,"tags":["cog","cogwheel","tool"],"version":1,"annotation":"gear","shortcodes":["gear"]},{"emoji":"๐Ÿ—œ๏ธ","group":7,"order":4395,"tags":["compress","tool","vice"],"version":0.7,"annotation":"clamp","shortcodes":["clamp","compression"]},{"emoji":"โš–๏ธ","group":7,"order":4397,"tags":["balance","justice","libra","scale","scales","tool","weight","zodiac"],"version":1,"annotation":"balance scale","shortcodes":["scales"]},{"emoji":"๐Ÿฆฏ","group":7,"order":4398,"tags":["accessibility","blind","cane","probing","white"],"version":12,"annotation":"white cane","shortcodes":["probing_cane","white_cane"]},{"emoji":"๐Ÿ”—","group":7,"order":4399,"tags":["links"],"version":0.6,"annotation":"link","shortcodes":["link"]},{"emoji":"โ›“๏ธโ€๐Ÿ’ฅ","group":7,"order":4400,"tags":["break","breaking","broken","chain","cuffs","freedom"],"version":15.1,"annotation":"broken chain","shortcodes":["broken_chain"]},{"emoji":"โ›“๏ธ","group":7,"order":4403,"tags":["chain"],"version":0.7,"annotation":"chains","shortcodes":["chains"]},{"emoji":"๐Ÿช","group":7,"order":4404,"tags":["catch","crook","curve","ensnare","point","selling"],"version":13,"annotation":"hook","shortcodes":["hook"]},{"emoji":"๐Ÿงฐ","group":7,"order":4405,"tags":["box","chest","mechanic","red","tool"],"version":11,"annotation":"toolbox","shortcodes":["toolbox"]},{"emoji":"๐Ÿงฒ","group":7,"order":4406,"tags":["attraction","horseshoe","magnetic","negative","positive","shape","u"],"version":11,"annotation":"magnet","shortcodes":["magnet"]},{"emoji":"๐Ÿชœ","group":7,"order":4407,"tags":["climb","rung","step"],"version":13,"annotation":"ladder","shortcodes":["ladder"]},{"emoji":"๐Ÿช","group":7,"order":4408,"tags":["bury","dig","garden","hole","plant","scoop","snow","spade"],"version":16,"annotation":"shovel","shortcodes":["shovel"]},{"emoji":"โš—๏ธ","group":7,"order":4410,"tags":["chemistry","tool"],"version":1,"annotation":"alembic","shortcodes":["alembic"]},{"emoji":"๐Ÿงช","group":7,"order":4411,"tags":["chemist","chemistry","experiment","lab","science","test","tube"],"version":11,"annotation":"test tube","shortcodes":["test_tube"]},{"emoji":"๐Ÿงซ","group":7,"order":4412,"tags":["bacteria","biologist","biology","culture","dish","lab","petri"],"version":11,"annotation":"petri dish","shortcodes":["petri_dish"]},{"emoji":"๐Ÿงฌ","group":7,"order":4413,"tags":["biologist","evolution","gene","genetics","life"],"version":11,"annotation":"dna","shortcodes":["dna","double_helix"]},{"emoji":"๐Ÿ”ฌ","group":7,"order":4414,"tags":["experiment","lab","science","tool"],"version":1,"annotation":"microscope","shortcodes":["microscope"]},{"emoji":"๐Ÿ”ญ","group":7,"order":4415,"tags":["contact","extraterrestrial","science","tool"],"version":1,"annotation":"telescope","shortcodes":["telescope"]},{"emoji":"๐Ÿ“ก","group":7,"order":4416,"tags":["aliens","antenna","contact","dish","satellite","science"],"version":0.6,"annotation":"satellite antenna","shortcodes":["satellite_antenna"]},{"emoji":"๐Ÿ’‰","group":7,"order":4417,"tags":["doctor","flu","medicine","needle","shot","sick","tool","vaccination"],"version":0.6,"annotation":"syringe","shortcodes":["syringe"]},{"emoji":"๐Ÿฉธ","group":7,"order":4418,"tags":["bleed","blood","donation","drop","injury","medicine","menstruation"],"version":12,"annotation":"drop of blood","shortcodes":["drop_of_blood"]},{"emoji":"๐Ÿ’Š","group":7,"order":4419,"tags":["doctor","drugs","medicated","medicine","pills","sick","vitamin"],"version":0.6,"annotation":"pill","shortcodes":["pill"]},{"emoji":"๐Ÿฉน","group":7,"order":4420,"tags":["adhesive","bandage"],"version":12,"annotation":"adhesive bandage","shortcodes":["adhesive_bandage","bandaid"]},{"emoji":"๐Ÿฉผ","group":7,"order":4421,"tags":["aid","cane","disability","help","hurt","injured","mobility","stick"],"version":14,"annotation":"crutch","shortcodes":["crutch"]},{"emoji":"๐Ÿฉบ","group":7,"order":4422,"tags":["doctor","heart","medicine"],"version":12,"annotation":"stethoscope","shortcodes":["stethoscope"]},{"emoji":"๐Ÿฉป","group":7,"order":4423,"tags":["bones","doctor","medical","skeleton","skull","xray"],"version":14,"annotation":"x-ray","shortcodes":["x-ray","xray"]},{"emoji":"๐Ÿšช","group":7,"order":4424,"tags":["back","closet","front"],"version":0.6,"annotation":"door","shortcodes":["door"]},{"emoji":"๐Ÿ›—","group":7,"order":4425,"tags":["accessibility","hoist","lift"],"version":13,"annotation":"elevator","shortcodes":["elevator"]},{"emoji":"๐Ÿชž","group":7,"order":4426,"tags":["makeup","reflection","reflector","speculum"],"version":13,"annotation":"mirror","shortcodes":["mirror"]},{"emoji":"๐ŸชŸ","group":7,"order":4427,"tags":["air","frame","fresh","opening","transparent","view"],"version":13,"annotation":"window","shortcodes":["window"]},{"emoji":"๐Ÿ›๏ธ","group":7,"order":4429,"tags":["hotel","sleep"],"version":0.7,"annotation":"bed","shortcodes":["bed"]},{"emoji":"๐Ÿ›‹๏ธ","group":7,"order":4431,"tags":["couch","hotel","lamp"],"version":0.7,"annotation":"couch and lamp","shortcodes":["couch_and_lamp"]},{"emoji":"๐Ÿช‘","group":7,"order":4432,"tags":["seat","sit"],"version":12,"annotation":"chair","shortcodes":["chair"]},{"emoji":"๐Ÿšฝ","group":7,"order":4433,"tags":["bathroom"],"version":0.6,"annotation":"toilet","shortcodes":["toilet"]},{"emoji":"๐Ÿช ","group":7,"order":4434,"tags":["cup","force","plumber","poop","suction","toilet"],"version":13,"annotation":"plunger","shortcodes":["plunger"]},{"emoji":"๐Ÿšฟ","group":7,"order":4435,"tags":["water"],"version":1,"annotation":"shower","shortcodes":["shower"]},{"emoji":"๐Ÿ›","group":7,"order":4436,"tags":["bath"],"version":1,"annotation":"bathtub","shortcodes":["bathtub"]},{"emoji":"๐Ÿชค","group":7,"order":4437,"tags":["bait","cheese","lure","mouse","mousetrap","snare","trap"],"version":13,"annotation":"mouse trap","shortcodes":["mouse_trap"]},{"emoji":"๐Ÿช’","group":7,"order":4438,"tags":["sharp","shave"],"version":12,"annotation":"razor","shortcodes":["razor"]},{"emoji":"๐Ÿงด","group":7,"order":4439,"tags":["bottle","lotion","moisturizer","shampoo","sunscreen"],"version":11,"annotation":"lotion bottle","shortcodes":["lotion_bottle"]},{"emoji":"๐Ÿงท","group":7,"order":4440,"tags":["diaper","pin","punk","rock","safety"],"version":11,"annotation":"safety pin","shortcodes":["safety_pin"]},{"emoji":"๐Ÿงน","group":7,"order":4441,"tags":["cleaning","sweeping","witch"],"version":11,"annotation":"broom","shortcodes":["broom"]},{"emoji":"๐Ÿงบ","group":7,"order":4442,"tags":["farming","laundry","picnic"],"version":11,"annotation":"basket","shortcodes":["basket"]},{"emoji":"๐Ÿงป","group":7,"order":4443,"tags":["paper","roll","toilet","towels"],"version":11,"annotation":"roll of paper","shortcodes":["roll_of_paper","toilet_paper"]},{"emoji":"๐Ÿชฃ","group":7,"order":4444,"tags":["cask","pail","vat"],"version":13,"annotation":"bucket","shortcodes":["bucket"]},{"emoji":"๐Ÿงผ","group":7,"order":4445,"tags":["bar","bathing","clean","cleaning","lather","soapdish"],"version":11,"annotation":"soap","shortcodes":["soap"]},{"emoji":"๐Ÿซง","group":7,"order":4446,"tags":["bubble","burp","clean","floating","pearl","soap","underwater"],"version":14,"annotation":"bubbles","shortcodes":["bubbles"]},{"emoji":"๐Ÿชฅ","group":7,"order":4447,"tags":["bathroom","brush","clean","dental","hygiene","teeth","toiletry"],"version":13,"annotation":"toothbrush","shortcodes":["toothbrush"]},{"emoji":"๐Ÿงฝ","group":7,"order":4448,"tags":["absorbing","cleaning","porous","soak"],"version":11,"annotation":"sponge","shortcodes":["sponge"]},{"emoji":"๐Ÿงฏ","group":7,"order":4449,"tags":["extinguish","extinguisher","fire","quench"],"version":11,"annotation":"fire extinguisher","shortcodes":["fire_extinguisher"]},{"emoji":"๐Ÿ›’","group":7,"order":4450,"tags":["cart","shopping","trolley"],"version":3,"annotation":"shopping cart","shortcodes":["shopping_cart"]},{"emoji":"๐Ÿšฌ","group":7,"order":4451,"tags":["smoking"],"version":0.6,"annotation":"cigarette","shortcodes":["cigarette","smoking"]},{"emoji":"โšฐ๏ธ","group":7,"order":4453,"tags":["dead","death","vampire"],"version":1,"annotation":"coffin","shortcodes":["coffin"]},{"emoji":"๐Ÿชฆ","group":7,"order":4454,"tags":["cemetery","dead","grave","graveyard","memorial","rip","tomb","tombstone"],"version":13,"annotation":"headstone","shortcodes":["headstone"]},{"emoji":"โšฑ๏ธ","group":7,"order":4456,"tags":["ashes","death","funeral","urn"],"version":1,"annotation":"funeral urn","shortcodes":["funeral_urn"]},{"emoji":"๐Ÿงฟ","group":7,"order":4457,"tags":["amulet","bead","blue","charm","evil-eye","nazar","talisman"],"version":11,"annotation":"nazar amulet","shortcodes":["nazar_amulet"]},{"emoji":"๐Ÿชฌ","group":7,"order":4458,"tags":["amulet","fatima","fortune","guide","hand","mary","miriam","palm","protect","protection"],"version":14,"annotation":"hamsa","shortcodes":["hamsa"]},{"emoji":"๐Ÿ—ฟ","group":7,"order":4459,"tags":["face","moyai","statue","stoneface","travel"],"version":0.6,"annotation":"moai","shortcodes":["moai","moyai"]},{"emoji":"๐Ÿชง","group":7,"order":4460,"tags":["card","demonstration","notice","picket","plaque","protest","sign"],"version":13,"annotation":"placard","shortcodes":["placard"]},{"emoji":"๐Ÿชช","group":7,"order":4461,"tags":["card","credentials","document","id","identification","license","security"],"version":14,"annotation":"identification card","shortcodes":["id_card"]},{"emoji":"๐Ÿง","group":8,"order":4462,"tags":["atm","automated","bank","cash","money","sign","teller"],"version":0.6,"annotation":"ATM sign","shortcodes":["atm"]},{"emoji":"๐Ÿšฎ","group":8,"order":4463,"tags":["bin","litter","litterbin","sign"],"version":1,"annotation":"litter in bin sign","shortcodes":["litter_bin","put_litter_in_its_place"]},{"emoji":"๐Ÿšฐ","group":8,"order":4464,"tags":["drinking","potable","water"],"version":1,"annotation":"potable water","shortcodes":["potable_water"]},{"emoji":"โ™ฟ๏ธ","group":8,"order":4465,"tags":["access","handicap","symbol","wheelchair"],"version":0.6,"annotation":"wheelchair symbol","shortcodes":["handicapped","wheelchair"]},{"emoji":"๐Ÿšน๏ธ","group":8,"order":4466,"tags":["bathroom","lavatory","man","menโ€™s","restroom","room","toilet","wc"],"version":0.6,"annotation":"menโ€™s room","shortcodes":["mens"]},{"emoji":"๐Ÿšบ๏ธ","group":8,"order":4467,"tags":["bathroom","lavatory","restroom","room","toilet","wc","woman","womenโ€™s"],"version":0.6,"annotation":"womenโ€™s room","shortcodes":["womens"]},{"emoji":"๐Ÿšป","group":8,"order":4468,"tags":["bathroom","lavatory","toilet","wc"],"version":0.6,"annotation":"restroom","shortcodes":["bathroom","restroom"]},{"emoji":"๐Ÿšผ๏ธ","group":8,"order":4469,"tags":["baby","changing","symbol"],"version":0.6,"annotation":"baby symbol","shortcodes":["baby_symbol"]},{"emoji":"๐Ÿšพ","group":8,"order":4470,"tags":["bathroom","closet","lavatory","restroom","toilet","water","wc"],"version":0.6,"annotation":"water closet","shortcodes":["water_closet","wc"]},{"emoji":"๐Ÿ›‚","group":8,"order":4471,"tags":["control","passport"],"version":1,"annotation":"passport control","shortcodes":["passport_control"]},{"emoji":"๐Ÿ›ƒ","group":8,"order":4472,"tags":["packing"],"version":1,"annotation":"customs","shortcodes":["customs"]},{"emoji":"๐Ÿ›„","group":8,"order":4473,"tags":["arrived","baggage","bags","case","checked","claim","journey","packing","plane","ready","travel","trip"],"version":1,"annotation":"baggage claim","shortcodes":["baggage_claim"]},{"emoji":"๐Ÿ›…","group":8,"order":4474,"tags":["baggage","case","left","locker","luggage"],"version":1,"annotation":"left luggage","shortcodes":["left_luggage"]},{"emoji":"โš ๏ธ","group":8,"order":4476,"tags":["caution"],"version":0.6,"annotation":"warning","shortcodes":["warning"]},{"emoji":"๐Ÿšธ","group":8,"order":4477,"tags":["child","children","crossing","pedestrian","traffic"],"version":1,"annotation":"children crossing","shortcodes":["children_crossing"]},{"emoji":"โ›”๏ธ","group":8,"order":4478,"tags":["do","entry","fail","forbidden","no","not","pass","prohibited","traffic"],"version":0.6,"annotation":"no entry","shortcodes":["no_entry"]},{"emoji":"๐Ÿšซ","group":8,"order":4479,"tags":["entry","forbidden","no","not","smoke"],"version":0.6,"annotation":"prohibited","shortcodes":["no_entry_sign"]},{"emoji":"๐Ÿšณ","group":8,"order":4480,"tags":["bicycle","bicycles","bike","forbidden","no","not","prohibited"],"version":1,"annotation":"no bicycles","shortcodes":["no_bicycles"]},{"emoji":"๐Ÿšญ๏ธ","group":8,"order":4481,"tags":["forbidden","no","not","prohibited","smoke","smoking"],"version":0.6,"annotation":"no smoking","shortcodes":["no_smoking"]},{"emoji":"๐Ÿšฏ","group":8,"order":4482,"tags":["forbidden","litter","littering","no","not","prohibited"],"version":1,"annotation":"no littering","shortcodes":["do_not_litter","no_littering"]},{"emoji":"๐Ÿšฑ","group":8,"order":4483,"tags":["dry","non-drinking","non-potable","prohibited","water"],"version":1,"annotation":"non-potable water","shortcodes":["non-potable_water"]},{"emoji":"๐Ÿšท","group":8,"order":4484,"tags":["forbidden","no","not","pedestrian","pedestrians","prohibited"],"version":1,"annotation":"no pedestrians","shortcodes":["no_pedestrians"]},{"emoji":"๐Ÿ“ต","group":8,"order":4485,"tags":["cell","forbidden","mobile","no","not","phone","phones","prohibited","telephone"],"version":1,"annotation":"no mobile phones","shortcodes":["no_mobile_phones"]},{"emoji":"๐Ÿ”ž","group":8,"order":4486,"tags":["18","age","eighteen","forbidden","no","not","one","prohibited","restriction","underage"],"version":0.6,"annotation":"no one under eighteen","shortcodes":["no_one_under_18","underage"]},{"emoji":"โ˜ข๏ธ","group":8,"order":4488,"tags":["sign"],"version":1,"annotation":"radioactive","shortcodes":["radioactive"]},{"emoji":"โ˜ฃ๏ธ","group":8,"order":4490,"tags":["sign"],"version":1,"annotation":"biohazard","shortcodes":["biohazard"]},{"emoji":"โฌ†๏ธ","group":8,"order":4492,"tags":["arrow","cardinal","direction","north","up"],"version":0.6,"annotation":"up arrow","shortcodes":["arrow_up"]},{"emoji":"โ†—๏ธ","group":8,"order":4494,"tags":["arrow","direction","intercardinal","northeast","up-right"],"version":0.6,"annotation":"up-right arrow","shortcodes":["arrow_upper_right"]},{"emoji":"โžก๏ธ","group":8,"order":4496,"tags":["arrow","cardinal","direction","east","right"],"version":0.6,"annotation":"right arrow","shortcodes":["arrow_right"]},{"emoji":"โ†˜๏ธ","group":8,"order":4498,"tags":["arrow","direction","down-right","intercardinal","southeast"],"version":0.6,"annotation":"down-right arrow","shortcodes":["arrow_lower_right"]},{"emoji":"โฌ‡๏ธ","group":8,"order":4500,"tags":["arrow","cardinal","direction","down","south"],"version":0.6,"annotation":"down arrow","shortcodes":["arrow_down"]},{"emoji":"โ†™๏ธ","group":8,"order":4502,"tags":["arrow","direction","down-left","intercardinal","southwest"],"version":0.6,"annotation":"down-left arrow","shortcodes":["arrow_lower_left"]},{"emoji":"โฌ…๏ธ","group":8,"order":4504,"tags":["arrow","cardinal","direction","left","west"],"version":0.6,"annotation":"left arrow","shortcodes":["arrow_left"]},{"emoji":"โ†–๏ธ","group":8,"order":4506,"tags":["arrow","direction","intercardinal","northwest","up-left"],"version":0.6,"annotation":"up-left arrow","shortcodes":["arrow_upper_left"]},{"emoji":"โ†•๏ธ","group":8,"order":4508,"tags":["arrow","up-down"],"version":0.6,"annotation":"up-down arrow","shortcodes":["arrow_up_down"]},{"emoji":"โ†”๏ธ","group":8,"order":4510,"tags":["arrow","left-right"],"version":0.6,"annotation":"left-right arrow","shortcodes":["left_right_arrow"]},{"emoji":"โ†ฉ๏ธ","group":8,"order":4512,"tags":["arrow","curving","left","right"],"version":0.6,"annotation":"right arrow curving left","shortcodes":["arrow_left_hook","leftwards_arrow_with_hook"]},{"emoji":"โ†ช๏ธ","group":8,"order":4514,"tags":["arrow","curving","left","right"],"version":0.6,"annotation":"left arrow curving right","shortcodes":["arrow_right_hook","rightwards_arrow_with_hook"]},{"emoji":"โคด๏ธ","group":8,"order":4516,"tags":["arrow","curving","right","up"],"version":0.6,"annotation":"right arrow curving up","shortcodes":["arrow_heading_up"]},{"emoji":"โคต๏ธ","group":8,"order":4518,"tags":["arrow","curving","down","right"],"version":0.6,"annotation":"right arrow curving down","shortcodes":["arrow_heading_down"]},{"emoji":"๐Ÿ”ƒ","group":8,"order":4519,"tags":["arrow","arrows","clockwise","refresh","reload","vertical"],"version":0.6,"annotation":"clockwise vertical arrows","shortcodes":["arrows_clockwise","clockwise"]},{"emoji":"๐Ÿ”„","group":8,"order":4520,"tags":["again","anticlockwise","arrow","arrows","button","counterclockwise","deja","refresh","rewindershins","vu"],"version":1,"annotation":"counterclockwise arrows button","shortcodes":["arrows_counterclockwise","counterclockwise"]},{"emoji":"๐Ÿ”™","group":8,"order":4521,"tags":["arrow","back"],"version":0.6,"annotation":"BACK arrow","shortcodes":["back"]},{"emoji":"๐Ÿ”š","group":8,"order":4522,"tags":["arrow","end"],"version":0.6,"annotation":"END arrow","shortcodes":["end"]},{"emoji":"๐Ÿ”›","group":8,"order":4523,"tags":["arrow","mark","on!"],"version":0.6,"annotation":"ON! arrow","shortcodes":["on"]},{"emoji":"๐Ÿ”œ","group":8,"order":4524,"tags":["arrow","brb","omw","soon"],"version":0.6,"annotation":"SOON arrow","shortcodes":["soon"]},{"emoji":"๐Ÿ”","group":8,"order":4525,"tags":["arrow","homie","top","up"],"version":0.6,"annotation":"TOP arrow","shortcodes":["top"]},{"emoji":"๐Ÿ›","group":8,"order":4526,"tags":["place","pray","religion","worship"],"version":1,"annotation":"place of worship","shortcodes":["place_of_worship"]},{"emoji":"โš›๏ธ","group":8,"order":4528,"tags":["atheist","atom","symbol"],"version":1,"annotation":"atom symbol","shortcodes":["atom","atom_symbol"]},{"emoji":"๐Ÿ•‰๏ธ","group":8,"order":4530,"tags":["hindu","religion"],"version":0.7,"annotation":"om","shortcodes":["om"]},{"emoji":"โœก๏ธ","group":8,"order":4532,"tags":["david","jew","jewish","judaism","religion","star"],"version":0.7,"annotation":"star of David","shortcodes":["star_of_david"]},{"emoji":"โ˜ธ๏ธ","group":8,"order":4534,"tags":["buddhist","dharma","religion","wheel"],"version":0.7,"annotation":"wheel of dharma","shortcodes":["wheel_of_dharma"]},{"emoji":"โ˜ฏ๏ธ","group":8,"order":4536,"tags":["difficult","lives","religion","tao","taoist","total","yang","yin","yinyang"],"version":0.7,"annotation":"yin yang","shortcodes":["yin_yang"]},{"emoji":"โœ๏ธ","group":8,"order":4538,"tags":["christ","christian","cross","latin","religion"],"version":0.7,"annotation":"latin cross","shortcodes":["latin_cross"]},{"emoji":"โ˜ฆ๏ธ","group":8,"order":4540,"tags":["christian","cross","orthodox","religion"],"version":1,"annotation":"orthodox cross","shortcodes":["orthodox_cross"]},{"emoji":"โ˜ช๏ธ","group":8,"order":4542,"tags":["crescent","islam","muslim","ramadan","religion","star"],"version":0.7,"annotation":"star and crescent","shortcodes":["star_and_crescent"]},{"emoji":"โ˜ฎ๏ธ","group":8,"order":4544,"tags":["healing","peace","peaceful","symbol"],"version":1,"annotation":"peace symbol","shortcodes":["peace","peace_symbol"]},{"emoji":"๐Ÿ•Ž","group":8,"order":4545,"tags":["candelabrum","candlestick","hanukkah","jewish","judaism","religion"],"version":1,"annotation":"menorah","shortcodes":["menorah"]},{"emoji":"๐Ÿ”ฏ","group":8,"order":4546,"tags":["dotted","fortune","jewish","judaism","six-pointed","star"],"version":0.6,"annotation":"dotted six-pointed star","shortcodes":["six_pointed_star"]},{"emoji":"๐Ÿชฏ","group":8,"order":4547,"tags":["deg","fateh","khalsa","religion","sikh","sikhism","tegh"],"version":15,"annotation":"khanda","shortcodes":["khanda"]},{"emoji":"โ™ˆ๏ธ","group":8,"order":4548,"tags":["aries","horoscope","ram","zodiac"],"version":0.6,"annotation":"Aries","shortcodes":["aries"]},{"emoji":"โ™‰๏ธ","group":8,"order":4549,"tags":["bull","horoscope","ox","taurus","zodiac"],"version":0.6,"annotation":"Taurus","shortcodes":["taurus"]},{"emoji":"โ™Š๏ธ","group":8,"order":4550,"tags":["gemini","horoscope","twins","zodiac"],"version":0.6,"annotation":"Gemini","shortcodes":["gemini"]},{"emoji":"โ™‹๏ธ","group":8,"order":4551,"tags":["cancer","crab","horoscope","zodiac"],"version":0.6,"annotation":"Cancer","shortcodes":["cancer"]},{"emoji":"โ™Œ๏ธ","group":8,"order":4552,"tags":["horoscope","leo","lion","zodiac"],"version":0.6,"annotation":"Leo","shortcodes":["leo"]},{"emoji":"โ™๏ธ","group":8,"order":4553,"tags":["horoscope","virgo","zodiac"],"version":0.6,"annotation":"Virgo","shortcodes":["virgo"]},{"emoji":"โ™Ž๏ธ","group":8,"order":4554,"tags":["balance","horoscope","justice","libra","scales","zodiac"],"version":0.6,"annotation":"Libra","shortcodes":["libra"]},{"emoji":"โ™๏ธ","group":8,"order":4555,"tags":["horoscope","scorpio","scorpion","scorpius","zodiac"],"version":0.6,"annotation":"Scorpio","shortcodes":["scorpius"]},{"emoji":"โ™๏ธ","group":8,"order":4556,"tags":["archer","horoscope","sagittarius","zodiac"],"version":0.6,"annotation":"Sagittarius","shortcodes":["sagittarius"]},{"emoji":"โ™‘๏ธ","group":8,"order":4557,"tags":["capricorn","goat","horoscope","zodiac"],"version":0.6,"annotation":"Capricorn","shortcodes":["capricorn"]},{"emoji":"โ™’๏ธ","group":8,"order":4558,"tags":["aquarius","bearer","horoscope","water","zodiac"],"version":0.6,"annotation":"Aquarius","shortcodes":["aquarius"]},{"emoji":"โ™“๏ธ","group":8,"order":4559,"tags":["fish","horoscope","pisces","zodiac"],"version":0.6,"annotation":"Pisces","shortcodes":["pisces"]},{"emoji":"โ›Ž๏ธ","group":8,"order":4560,"tags":["bearer","ophiuchus","serpent","snake","zodiac"],"version":0.6,"annotation":"Ophiuchus","shortcodes":["ophiuchus"]},{"emoji":"๐Ÿ”€","group":8,"order":4561,"tags":["arrow","button","crossed","shuffle","tracks"],"version":1,"annotation":"shuffle tracks button","shortcodes":["shuffle","twisted_rightwards_arrows"]},{"emoji":"๐Ÿ”","group":8,"order":4562,"tags":["arrow","button","clockwise","repeat"],"version":1,"annotation":"repeat button","shortcodes":["repeat"]},{"emoji":"๐Ÿ”‚","group":8,"order":4563,"tags":["arrow","button","clockwise","once","repeat","single"],"version":1,"annotation":"repeat single button","shortcodes":["repeat_one"]},{"emoji":"โ–ถ๏ธ","group":8,"order":4565,"tags":["arrow","button","play","right","triangle"],"version":0.6,"annotation":"play button","shortcodes":["arrow_forward","play"]},{"emoji":"โฉ๏ธ","group":8,"order":4566,"tags":["arrow","button","double","fast","fast-forward","forward"],"version":0.6,"annotation":"fast-forward button","shortcodes":["fast_forward"]},{"emoji":"โญ๏ธ","group":8,"order":4568,"tags":["arrow","button","next","scene","track","triangle"],"version":0.7,"annotation":"next track button","shortcodes":["next_track"]},{"emoji":"โฏ๏ธ","group":8,"order":4570,"tags":["arrow","button","pause","play","right","triangle"],"version":1,"annotation":"play or pause button","shortcodes":["play_pause"]},{"emoji":"โ—€๏ธ","group":8,"order":4572,"tags":["arrow","button","left","reverse","triangle"],"version":0.6,"annotation":"reverse button","shortcodes":["arrow_backward","reverse"]},{"emoji":"โช๏ธ","group":8,"order":4573,"tags":["arrow","button","double","fast","reverse","rewind"],"version":0.6,"annotation":"fast reverse button","shortcodes":["fast_reverse","rewind"]},{"emoji":"โฎ๏ธ","group":8,"order":4575,"tags":["arrow","button","last","previous","scene","track","triangle"],"version":0.7,"annotation":"last track button","shortcodes":["previous_track"]},{"emoji":"๐Ÿ”ผ","group":8,"order":4576,"tags":["arrow","button","red","up","upwards"],"version":0.6,"annotation":"upwards button","shortcodes":["arrow_up_small","up"]},{"emoji":"โซ๏ธ","group":8,"order":4577,"tags":["arrow","button","double","fast","up"],"version":0.6,"annotation":"fast up button","shortcodes":["arrow_double_up","fast_up"]},{"emoji":"๐Ÿ”ฝ","group":8,"order":4578,"tags":["arrow","button","down","downwards","red"],"version":0.6,"annotation":"downwards button","shortcodes":["arrow_down_small","down"]},{"emoji":"โฌ๏ธ","group":8,"order":4579,"tags":["arrow","button","double","down","fast"],"version":0.6,"annotation":"fast down button","shortcodes":["arrow_double_down","fast_down"]},{"emoji":"โธ๏ธ","group":8,"order":4581,"tags":["bar","button","double","pause","vertical"],"version":0.7,"annotation":"pause button","shortcodes":["pause"]},{"emoji":"โน๏ธ","group":8,"order":4583,"tags":["button","square","stop"],"version":0.7,"annotation":"stop button","shortcodes":["stop"]},{"emoji":"โบ๏ธ","group":8,"order":4585,"tags":["button","circle","record"],"version":0.7,"annotation":"record button","shortcodes":["record"]},{"emoji":"โ๏ธ","group":8,"order":4587,"tags":["button","eject"],"version":1,"annotation":"eject button","shortcodes":["eject"]},{"emoji":"๐ŸŽฆ","group":8,"order":4588,"tags":["camera","film","movie"],"version":0.6,"annotation":"cinema","shortcodes":["cinema"]},{"emoji":"๐Ÿ”…","group":8,"order":4589,"tags":["brightness","button","dim","low"],"version":1,"annotation":"dim button","shortcodes":["dim_button","low_brightness"]},{"emoji":"๐Ÿ”†","group":8,"order":4590,"tags":["bright","brightness","button","light"],"version":1,"annotation":"bright button","shortcodes":["bright_button","high_brightness"]},{"emoji":"๐Ÿ“ถ","group":8,"order":4591,"tags":["antenna","bar","bars","cell","communication","mobile","phone","signal","telephone"],"version":0.6,"annotation":"antenna bars","shortcodes":["antenna_bars","signal_strength"]},{"emoji":"๐Ÿ›œ","group":8,"order":4592,"tags":["broadband","computer","connectivity","hotspot","internet","network","router","smartphone","wi-fi","wifi","wlan"],"version":15,"annotation":"wireless","shortcodes":["wireless"]},{"emoji":"๐Ÿ“ณ","group":8,"order":4593,"tags":["cell","communication","mobile","mode","phone","telephone","vibration"],"version":0.6,"annotation":"vibration mode","shortcodes":["vibration_mode"]},{"emoji":"๐Ÿ“ด","group":8,"order":4594,"tags":["cell","mobile","off","phone","telephone"],"version":0.6,"annotation":"mobile phone off","shortcodes":["mobile_phone_off"]},{"emoji":"โ™€๏ธ","group":8,"order":4596,"tags":["female","sign","woman"],"version":4,"annotation":"female sign","shortcodes":["female","female_sign"]},{"emoji":"โ™‚๏ธ","group":8,"order":4598,"tags":["male","man","sign"],"version":4,"annotation":"male sign","shortcodes":["male","male_sign"]},{"emoji":"โšง๏ธ","group":8,"order":4600,"tags":["symbol","transgender"],"version":13,"annotation":"transgender symbol","shortcodes":["transgender_symbol"]},{"emoji":"โœ–๏ธ","group":8,"order":4602,"tags":["cancel","multiplication","sign","x","ร—"],"version":0.6,"annotation":"multiply","shortcodes":["multiplication","multiply"]},{"emoji":"โž•๏ธ","group":8,"order":4603,"tags":["+"],"version":0.6,"annotation":"plus","shortcodes":["plus"]},{"emoji":"โž–๏ธ","group":8,"order":4604,"tags":["-","heavy","math","sign","โˆ’"],"version":0.6,"annotation":"minus","shortcodes":["minus"]},{"emoji":"โž—๏ธ","group":8,"order":4605,"tags":["division","heavy","math","sign","รท"],"version":0.6,"annotation":"divide","shortcodes":["divide","division"]},{"emoji":"๐ŸŸฐ","group":8,"order":4606,"tags":["answer","equal","equality","equals","heavy","math","sign"],"version":14,"annotation":"heavy equals sign","shortcodes":["heavy_equals_sign"]},{"emoji":"โ™พ๏ธ","group":8,"order":4608,"tags":["forever","unbounded","universal"],"version":11,"annotation":"infinity","shortcodes":["infinity"]},{"emoji":"โ€ผ๏ธ","group":8,"order":4610,"tags":["!","!!","bangbang","double","exclamation","mark","punctuation"],"version":0.6,"annotation":"double exclamation mark","shortcodes":["bangbang","double_exclamation"]},{"emoji":"โ‰๏ธ","group":8,"order":4612,"tags":["!","!?","?","exclamation","interrobang","mark","punctuation","question"],"version":0.6,"annotation":"exclamation question mark","shortcodes":["exclamation_question","interrobang"]},{"emoji":"โ“๏ธ","group":8,"order":4613,"tags":["?","mark","punctuation","question","red"],"version":0.6,"annotation":"red question mark","shortcodes":["question"]},{"emoji":"โ”๏ธ","group":8,"order":4614,"tags":["?","mark","outlined","punctuation","question","white"],"version":0.6,"annotation":"white question mark","shortcodes":["white_question"]},{"emoji":"โ•๏ธ","group":8,"order":4615,"tags":["!","exclamation","mark","outlined","punctuation","white"],"version":0.6,"annotation":"white exclamation mark","shortcodes":["white_exclamation"]},{"emoji":"โ—๏ธ","group":8,"order":4616,"tags":["!","exclamation","mark","punctuation","red"],"version":0.6,"annotation":"red exclamation mark","shortcodes":["exclamation"]},{"emoji":"ใ€ฐ๏ธ","group":8,"order":4618,"tags":["dash","punctuation","wavy"],"version":0.6,"annotation":"wavy dash","shortcodes":["wavy_dash"]},{"emoji":"๐Ÿ’ฑ","group":8,"order":4619,"tags":["bank","currency","exchange","money"],"version":0.6,"annotation":"currency exchange","shortcodes":["currency_exchange"]},{"emoji":"๐Ÿ’ฒ","group":8,"order":4620,"tags":["billion","cash","charge","currency","dollar","heavy","million","money","pay","sign"],"version":0.6,"annotation":"heavy dollar sign","shortcodes":["heavy_dollar_sign"]},{"emoji":"โš•๏ธ","group":8,"order":4622,"tags":["aesculapius","medical","medicine","staff","symbol"],"version":4,"annotation":"medical symbol","shortcodes":["medical","medical_symbol"]},{"emoji":"โ™ป๏ธ","group":8,"order":4624,"tags":["recycle","recycling","symbol"],"version":0.6,"annotation":"recycling symbol","shortcodes":["recycle","recycling_symbol"]},{"emoji":"โšœ๏ธ","group":8,"order":4626,"tags":["knights"],"version":1,"annotation":"fleur-de-lis","shortcodes":["fleur-de-lis"]},{"emoji":"๐Ÿ”ฑ","group":8,"order":4627,"tags":["anchor","emblem","poseidon","ship","tool","trident"],"version":0.6,"annotation":"trident emblem","shortcodes":["trident"]},{"emoji":"๐Ÿ“›","group":8,"order":4628,"tags":["badge","name"],"version":0.6,"annotation":"name badge","shortcodes":["name_badge"]},{"emoji":"๐Ÿ”ฐ","group":8,"order":4629,"tags":["beginner","chevron","green","japanese","leaf","symbol","tool","yellow"],"version":0.6,"annotation":"Japanese symbol for beginner","shortcodes":["beginner"]},{"emoji":"โญ•๏ธ","group":8,"order":4630,"tags":["circle","heavy","hollow","large","o","red"],"version":0.6,"annotation":"hollow red circle","shortcodes":["hollow_red_circle","red_o"]},{"emoji":"โœ…๏ธ","group":8,"order":4631,"tags":["button","check","checked","checkmark","complete","completed","done","fixed","mark","tick","โœ“"],"version":0.6,"annotation":"check mark button","shortcodes":["check_mark_button","white_check_mark"]},{"emoji":"โ˜‘๏ธ","group":8,"order":4633,"tags":["ballot","box","check","checked","done","off","tick","โœ“"],"version":0.6,"annotation":"check box with check","shortcodes":["ballot_box_with_check"]},{"emoji":"โœ”๏ธ","group":8,"order":4635,"tags":["check","checked","checkmark","done","heavy","mark","tick","โœ“"],"version":0.6,"annotation":"check mark","shortcodes":["check_mark","heavy_check_mark"]},{"emoji":"โŒ๏ธ","group":8,"order":4636,"tags":["cancel","cross","mark","multiplication","multiply","x","ร—"],"version":0.6,"annotation":"cross mark","shortcodes":["cross_mark","x"]},{"emoji":"โŽ๏ธ","group":8,"order":4637,"tags":["button","cross","mark","multiplication","multiply","square","x","ร—"],"version":0.6,"annotation":"cross mark button","shortcodes":["cross_mark_button","negative_squared_cross_mark"]},{"emoji":"โžฐ๏ธ","group":8,"order":4638,"tags":["curl","curly","loop"],"version":0.6,"annotation":"curly loop","shortcodes":["curly_loop"]},{"emoji":"โžฟ๏ธ","group":8,"order":4639,"tags":["curl","curly","double","loop"],"version":1,"annotation":"double curly loop","shortcodes":["double_curly_loop","loop"]},{"emoji":"ใ€ฝ๏ธ","group":8,"order":4641,"tags":["alternation","mark","part"],"version":0.6,"annotation":"part alternation mark","shortcodes":["part_alternation_mark"]},{"emoji":"โœณ๏ธ","group":8,"order":4643,"tags":["*","asterisk","eight-spoked"],"version":0.6,"annotation":"eight-spoked asterisk","shortcodes":["eight_spoked_asterisk"]},{"emoji":"โœด๏ธ","group":8,"order":4645,"tags":["*","eight-pointed","star"],"version":0.6,"annotation":"eight-pointed star","shortcodes":["eight_pointed_black_star"]},{"emoji":"โ‡๏ธ","group":8,"order":4647,"tags":["*"],"version":0.6,"annotation":"sparkle","shortcodes":["sparkle"]},{"emoji":"ยฉ๏ธ","group":8,"order":4649,"tags":["c"],"version":0.6,"annotation":"copyright","shortcodes":["copyright"]},{"emoji":"ยฎ๏ธ","group":8,"order":4651,"tags":["r"],"version":0.6,"annotation":"registered","shortcodes":["registered"]},{"emoji":"โ„ข๏ธ","group":8,"order":4653,"tags":["mark","tm","trade","trademark"],"version":0.6,"annotation":"trade mark","shortcodes":["tm","trade_mark"]},{"emoji":"๐ŸซŸ","group":8,"order":4654,"tags":["drip","holi","ink","liquid","mess","paint","spill","stain"],"version":16,"annotation":"splatter","shortcodes":["splatter"]},{"emoji":"#๏ธโƒฃ","group":8,"order":4655,"tags":["keycap"],"version":0.6,"annotation":"keycap: #","shortcodes":["hash","number_sign"]},{"emoji":"*๏ธโƒฃ","group":8,"order":4657,"tags":["keycap"],"version":2,"annotation":"keycap: *","shortcodes":["asterisk"]},{"emoji":"0๏ธโƒฃ","group":8,"order":4659,"tags":["keycap"],"version":0.6,"annotation":"keycap: 0","shortcodes":["zero"]},{"emoji":"1๏ธโƒฃ","group":8,"order":4661,"tags":["keycap"],"version":0.6,"annotation":"keycap: 1","shortcodes":["one"]},{"emoji":"2๏ธโƒฃ","group":8,"order":4663,"tags":["keycap"],"version":0.6,"annotation":"keycap: 2","shortcodes":["two"]},{"emoji":"3๏ธโƒฃ","group":8,"order":4665,"tags":["keycap"],"version":0.6,"annotation":"keycap: 3","shortcodes":["three"]},{"emoji":"4๏ธโƒฃ","group":8,"order":4667,"tags":["keycap"],"version":0.6,"annotation":"keycap: 4","shortcodes":["four"]},{"emoji":"5๏ธโƒฃ","group":8,"order":4669,"tags":["keycap"],"version":0.6,"annotation":"keycap: 5","shortcodes":["five"]},{"emoji":"6๏ธโƒฃ","group":8,"order":4671,"tags":["keycap"],"version":0.6,"annotation":"keycap: 6","shortcodes":["six"]},{"emoji":"7๏ธโƒฃ","group":8,"order":4673,"tags":["keycap"],"version":0.6,"annotation":"keycap: 7","shortcodes":["seven"]},{"emoji":"8๏ธโƒฃ","group":8,"order":4675,"tags":["keycap"],"version":0.6,"annotation":"keycap: 8","shortcodes":["eight"]},{"emoji":"9๏ธโƒฃ","group":8,"order":4677,"tags":["keycap"],"version":0.6,"annotation":"keycap: 9","shortcodes":["nine"]},{"emoji":"๐Ÿ”Ÿ","group":8,"order":4679,"tags":["keycap"],"version":0.6,"annotation":"keycap: 10","shortcodes":["ten"]},{"emoji":"๐Ÿ” ","group":8,"order":4680,"tags":["abcd","input","latin","letters","uppercase"],"version":0.6,"annotation":"input latin uppercase","shortcodes":["capital_abcd"]},{"emoji":"๐Ÿ”ก","group":8,"order":4681,"tags":["abcd","input","latin","letters","lowercase"],"version":0.6,"annotation":"input latin lowercase","shortcodes":["abcd"]},{"emoji":"๐Ÿ”ข","group":8,"order":4682,"tags":["1234","input","numbers"],"version":0.6,"annotation":"input numbers","shortcodes":["1234"]},{"emoji":"๐Ÿ”ฃ","group":8,"order":4683,"tags":["%","&","input","symbols","โ™ช","ใ€’"],"version":0.6,"annotation":"input symbols","shortcodes":["symbols"]},{"emoji":"๐Ÿ”ค","group":8,"order":4684,"tags":["abc","alphabet","input","latin","letters"],"version":0.6,"annotation":"input latin letters","shortcodes":["abc"]},{"emoji":"๐Ÿ…ฐ๏ธ","group":8,"order":4686,"tags":["blood","button","type"],"version":0.6,"annotation":"A button (blood type)","shortcodes":["a","a_blood"]},{"emoji":"๐Ÿ†Ž","group":8,"order":4687,"tags":["ab","blood","button","type"],"version":0.6,"annotation":"AB button (blood type)","shortcodes":["ab","ab_blood"]},{"emoji":"๐Ÿ…ฑ๏ธ","group":8,"order":4689,"tags":["b","blood","button","type"],"version":0.6,"annotation":"B button (blood type)","shortcodes":["b","b_blood"]},{"emoji":"๐Ÿ†‘","group":8,"order":4690,"tags":["button","cl"],"version":0.6,"annotation":"CL button","shortcodes":["cl"]},{"emoji":"๐Ÿ†’","group":8,"order":4691,"tags":["button","cool"],"version":0.6,"annotation":"COOL button","shortcodes":["cool"]},{"emoji":"๐Ÿ†“","group":8,"order":4692,"tags":["button","free"],"version":0.6,"annotation":"FREE button","shortcodes":["free"]},{"emoji":"โ„น๏ธ","group":8,"order":4694,"tags":["i"],"version":0.6,"annotation":"information","shortcodes":["info","information_source"]},{"emoji":"๐Ÿ†”","group":8,"order":4695,"tags":["button","id","identity"],"version":0.6,"annotation":"ID button","shortcodes":["id"]},{"emoji":"โ“‚๏ธ","group":8,"order":4697,"tags":["circle","circled","m"],"version":0.6,"annotation":"circled M","shortcodes":["m"]},{"emoji":"๐Ÿ†•","group":8,"order":4698,"tags":["button","new"],"version":0.6,"annotation":"NEW button","shortcodes":["new"]},{"emoji":"๐Ÿ†–","group":8,"order":4699,"tags":["button","ng"],"version":0.6,"annotation":"NG button","shortcodes":["ng"]},{"emoji":"๐Ÿ…พ๏ธ","group":8,"order":4701,"tags":["blood","button","o","type"],"version":0.6,"annotation":"O button (blood type)","shortcodes":["o","o_blood"]},{"emoji":"๐Ÿ†—","group":8,"order":4702,"tags":["button","ok","okay"],"version":0.6,"annotation":"OK button","shortcodes":["ok"]},{"emoji":"๐Ÿ…ฟ๏ธ","group":8,"order":4704,"tags":["button","p","parking"],"version":0.6,"annotation":"P button","shortcodes":["parking"]},{"emoji":"๐Ÿ†˜","group":8,"order":4705,"tags":["button","help","sos"],"version":0.6,"annotation":"SOS button","shortcodes":["sos"]},{"emoji":"๐Ÿ†™","group":8,"order":4706,"tags":["button","mark","up","up!"],"version":0.6,"annotation":"UP! button","shortcodes":["up2"]},{"emoji":"๐Ÿ†š","group":8,"order":4707,"tags":["button","versus","vs"],"version":0.6,"annotation":"VS button","shortcodes":["vs"]},{"emoji":"๐Ÿˆ","group":8,"order":4708,"tags":["button","here","japanese","katakana"],"version":0.6,"annotation":"Japanese โ€œhereโ€ button","shortcodes":["ja_here","koko"]},{"emoji":"๐Ÿˆ‚๏ธ","group":8,"order":4710,"tags":["button","charge","japanese","katakana","service"],"version":0.6,"annotation":"Japanese โ€œservice chargeโ€ button","shortcodes":["ja_service_charge"]},{"emoji":"๐Ÿˆท๏ธ","group":8,"order":4712,"tags":["amount","button","ideograph","japanese","monthly"],"version":0.6,"annotation":"Japanese โ€œmonthly amountโ€ button","shortcodes":["ja_monthly_amount"]},{"emoji":"๐Ÿˆถ","group":8,"order":4713,"tags":["button","charge","free","ideograph","japanese","not"],"version":0.6,"annotation":"Japanese โ€œnot free of chargeโ€ button","shortcodes":["ja_not_free_of_carge"]},{"emoji":"๐Ÿˆฏ๏ธ","group":8,"order":4714,"tags":["button","ideograph","japanese","reserved"],"version":0.6,"annotation":"Japanese โ€œreservedโ€ button","shortcodes":["ja_reserved"]},{"emoji":"๐Ÿ‰","group":8,"order":4715,"tags":["bargain","button","ideograph","japanese"],"version":0.6,"annotation":"Japanese โ€œbargainโ€ button","shortcodes":["ideograph_advantage","ja_bargain"]},{"emoji":"๐Ÿˆน","group":8,"order":4716,"tags":["button","discount","ideograph","japanese"],"version":0.6,"annotation":"Japanese โ€œdiscountโ€ button","shortcodes":["ja_discount"]},{"emoji":"๐Ÿˆš๏ธ","group":8,"order":4717,"tags":["button","charge","free","ideograph","japanese"],"version":0.6,"annotation":"Japanese โ€œfree of chargeโ€ button","shortcodes":["ja_free_of_charge"]},{"emoji":"๐Ÿˆฒ","group":8,"order":4718,"tags":["button","ideograph","japanese","prohibited"],"version":0.6,"annotation":"Japanese โ€œprohibitedโ€ button","shortcodes":["ja_prohibited"]},{"emoji":"๐Ÿ‰‘","group":8,"order":4719,"tags":["acceptable","button","ideograph","japanese"],"version":0.6,"annotation":"Japanese โ€œacceptableโ€ button","shortcodes":["accept","ja_acceptable"]},{"emoji":"๐Ÿˆธ","group":8,"order":4720,"tags":["application","button","ideograph","japanese"],"version":0.6,"annotation":"Japanese โ€œapplicationโ€ button","shortcodes":["ja_application"]},{"emoji":"๐Ÿˆด","group":8,"order":4721,"tags":["button","grade","ideograph","japanese","passing"],"version":0.6,"annotation":"Japanese โ€œpassing gradeโ€ button","shortcodes":["ja_passing_grade"]},{"emoji":"๐Ÿˆณ","group":8,"order":4722,"tags":["button","ideograph","japanese","vacancy"],"version":0.6,"annotation":"Japanese โ€œvacancyโ€ button","shortcodes":["ja_vacancy"]},{"emoji":"ใŠ—๏ธ","group":8,"order":4724,"tags":["button","congratulations","ideograph","japanese"],"version":0.6,"annotation":"Japanese โ€œcongratulationsโ€ button","shortcodes":["congratulations","ja_congratulations"]},{"emoji":"ใŠ™๏ธ","group":8,"order":4726,"tags":["button","ideograph","japanese","secret"],"version":0.6,"annotation":"Japanese โ€œsecretโ€ button","shortcodes":["ja_secret","secret"]},{"emoji":"๐Ÿˆบ","group":8,"order":4727,"tags":["business","button","ideograph","japanese","open"],"version":0.6,"annotation":"Japanese โ€œopen for businessโ€ button","shortcodes":["ja_open_for_business"]},{"emoji":"๐Ÿˆต","group":8,"order":4728,"tags":["button","ideograph","japanese","no","vacancy"],"version":0.6,"annotation":"Japanese โ€œno vacancyโ€ button","shortcodes":["ja_no_vacancy"]},{"emoji":"๐Ÿ”ด","group":8,"order":4729,"tags":["circle","geometric","red"],"version":0.6,"annotation":"red circle","shortcodes":["red_circle"]},{"emoji":"๐ŸŸ ","group":8,"order":4730,"tags":["circle","orange"],"version":12,"annotation":"orange circle","shortcodes":["orange_circle"]},{"emoji":"๐ŸŸก","group":8,"order":4731,"tags":["circle","yellow"],"version":12,"annotation":"yellow circle","shortcodes":["yellow_circle"]},{"emoji":"๐ŸŸข","group":8,"order":4732,"tags":["circle","green"],"version":12,"annotation":"green circle","shortcodes":["green_circle"]},{"emoji":"๐Ÿ”ต","group":8,"order":4733,"tags":["blue","circle","geometric"],"version":0.6,"annotation":"blue circle","shortcodes":["blue_circle"]},{"emoji":"๐ŸŸฃ","group":8,"order":4734,"tags":["circle","purple"],"version":12,"annotation":"purple circle","shortcodes":["purple_circle"]},{"emoji":"๐ŸŸค","group":8,"order":4735,"tags":["brown","circle"],"version":12,"annotation":"brown circle","shortcodes":["brown_circle"]},{"emoji":"โšซ๏ธ","group":8,"order":4736,"tags":["black","circle","geometric"],"version":0.6,"annotation":"black circle","shortcodes":["black_circle"]},{"emoji":"โšช๏ธ","group":8,"order":4737,"tags":["circle","geometric","white"],"version":0.6,"annotation":"white circle","shortcodes":["white_circle"]},{"emoji":"๐ŸŸฅ","group":8,"order":4738,"tags":["card","penalty","red","square"],"version":12,"annotation":"red square","shortcodes":["red_square"]},{"emoji":"๐ŸŸง","group":8,"order":4739,"tags":["orange","square"],"version":12,"annotation":"orange square","shortcodes":["orange_square"]},{"emoji":"๐ŸŸจ","group":8,"order":4740,"tags":["card","penalty","square","yellow"],"version":12,"annotation":"yellow square","shortcodes":["yellow_square"]},{"emoji":"๐ŸŸฉ","group":8,"order":4741,"tags":["green","square"],"version":12,"annotation":"green square","shortcodes":["green_square"]},{"emoji":"๐ŸŸฆ","group":8,"order":4742,"tags":["blue","square"],"version":12,"annotation":"blue square","shortcodes":["blue_square"]},{"emoji":"๐ŸŸช","group":8,"order":4743,"tags":["purple","square"],"version":12,"annotation":"purple square","shortcodes":["purple_square"]},{"emoji":"๐ŸŸซ","group":8,"order":4744,"tags":["brown","square"],"version":12,"annotation":"brown square","shortcodes":["brown_square"]},{"emoji":"โฌ›๏ธ","group":8,"order":4745,"tags":["black","geometric","large","square"],"version":0.6,"annotation":"black large square","shortcodes":["black_large_square"]},{"emoji":"โฌœ๏ธ","group":8,"order":4746,"tags":["geometric","large","square","white"],"version":0.6,"annotation":"white large square","shortcodes":["white_large_square"]},{"emoji":"โ—ผ๏ธ","group":8,"order":4748,"tags":["black","geometric","medium","square"],"version":0.6,"annotation":"black medium square","shortcodes":["black_medium_square"]},{"emoji":"โ—ป๏ธ","group":8,"order":4750,"tags":["geometric","medium","square","white"],"version":0.6,"annotation":"white medium square","shortcodes":["white_medium_square"]},{"emoji":"โ—พ๏ธ","group":8,"order":4751,"tags":["black","geometric","medium-small","square"],"version":0.6,"annotation":"black medium-small square","shortcodes":["black_medium_small_square"]},{"emoji":"โ—ฝ๏ธ","group":8,"order":4752,"tags":["geometric","medium-small","square","white"],"version":0.6,"annotation":"white medium-small square","shortcodes":["white_medium_small_square"]},{"emoji":"โ–ช๏ธ","group":8,"order":4754,"tags":["black","geometric","small","square"],"version":0.6,"annotation":"black small square","shortcodes":["black_small_square"]},{"emoji":"โ–ซ๏ธ","group":8,"order":4756,"tags":["geometric","small","square","white"],"version":0.6,"annotation":"white small square","shortcodes":["white_small_square"]},{"emoji":"๐Ÿ”ถ","group":8,"order":4757,"tags":["diamond","geometric","large","orange"],"version":0.6,"annotation":"large orange diamond","shortcodes":["large_orange_diamond"]},{"emoji":"๐Ÿ”ท","group":8,"order":4758,"tags":["blue","diamond","geometric","large"],"version":0.6,"annotation":"large blue diamond","shortcodes":["large_blue_diamond"]},{"emoji":"๐Ÿ”ธ","group":8,"order":4759,"tags":["diamond","geometric","orange","small"],"version":0.6,"annotation":"small orange diamond","shortcodes":["small_orange_diamond"]},{"emoji":"๐Ÿ”น","group":8,"order":4760,"tags":["blue","diamond","geometric","small"],"version":0.6,"annotation":"small blue diamond","shortcodes":["small_blue_diamond"]},{"emoji":"๐Ÿ”บ","group":8,"order":4761,"tags":["geometric","pointed","red","triangle","up"],"version":0.6,"annotation":"red triangle pointed up","shortcodes":["small_red_triangle"]},{"emoji":"๐Ÿ”ป","group":8,"order":4762,"tags":["down","geometric","pointed","red","triangle"],"version":0.6,"annotation":"red triangle pointed down","shortcodes":["small_red_triangle_down"]},{"emoji":"๐Ÿ’ ","group":8,"order":4763,"tags":["comic","diamond","dot","geometric"],"version":0.6,"annotation":"diamond with a dot","shortcodes":["diamond_shape_with_a_dot_inside","diamond_with_a_dot"]},{"emoji":"๐Ÿ”˜","group":8,"order":4764,"tags":["button","geometric","radio"],"version":0.6,"annotation":"radio button","shortcodes":["radio_button"]},{"emoji":"๐Ÿ”ณ","group":8,"order":4765,"tags":["button","geometric","outlined","square","white"],"version":0.6,"annotation":"white square button","shortcodes":["white_square_button"]},{"emoji":"๐Ÿ”ฒ","group":8,"order":4766,"tags":["black","button","geometric","square"],"version":0.6,"annotation":"black square button","shortcodes":["black_square_button"]},{"emoji":"๐Ÿ","group":9,"order":4767,"tags":["checkered","chequered","finish","flag","flags","game","race","racing","sport","win"],"version":0.6,"annotation":"chequered flag","shortcodes":["checkered_flag"]},{"emoji":"๐Ÿšฉ","group":9,"order":4768,"tags":["construction","flag","golf","post","triangular"],"version":0.6,"annotation":"triangular flag","shortcodes":["triangular_flag","triangular_flag_on_post"]},{"emoji":"๐ŸŽŒ","group":9,"order":4769,"tags":["celebration","cross","crossed","flags","japanese"],"version":0.6,"annotation":"crossed flags","shortcodes":["crossed_flags"]},{"emoji":"๐Ÿด","group":9,"order":4770,"tags":["black","flag","waving"],"version":1,"annotation":"black flag","shortcodes":["black_flag"]},{"emoji":"๐Ÿณ๏ธ","group":9,"order":4772,"tags":["flag","waving","white"],"version":0.7,"annotation":"white flag","shortcodes":["white_flag"]},{"emoji":"๐Ÿณ๏ธโ€๐ŸŒˆ","group":9,"order":4773,"tags":["bisexual","flag","gay","genderqueer","glbt","glbtq","lesbian","lgbt","lgbtq","lgbtqia","pride","queer","rainbow","trans","transgender"],"version":4,"annotation":"rainbow flag","shortcodes":["rainbow_flag"]},{"emoji":"๐Ÿณ๏ธโ€โšง๏ธ","group":9,"order":4775,"tags":["blue","flag","light","pink","transgender","white"],"version":13,"annotation":"transgender flag","shortcodes":["transgender_flag"]},{"emoji":"๐Ÿดโ€โ˜ ๏ธ","group":9,"order":4779,"tags":["flag","jolly","pirate","plunder","roger","treasure"],"version":11,"annotation":"pirate flag","shortcodes":["jolly_roger","pirate_flag"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡จ","group":9,"order":4781,"tags":["AC","flag"],"version":2,"annotation":"flag: Ascension Island","shortcodes":["ascension_island","flag_ac"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฉ","group":9,"order":4782,"tags":["AD","flag"],"version":2,"annotation":"flag: Andorra","shortcodes":["andorra","flag_ad"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ช","group":9,"order":4783,"tags":["AE","flag"],"version":2,"annotation":"flag: United Arab Emirates","shortcodes":["flag_ae","united_arab_emirates"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ซ","group":9,"order":4784,"tags":["AF","flag"],"version":2,"annotation":"flag: Afghanistan","shortcodes":["afghanistan","flag_af"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฌ","group":9,"order":4785,"tags":["AG","flag"],"version":2,"annotation":"flag: Antigua & Barbuda","shortcodes":["antigua_barbuda","flag_ag"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฎ","group":9,"order":4786,"tags":["AI","flag"],"version":2,"annotation":"flag: Anguilla","shortcodes":["anguilla","flag_ai"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฑ","group":9,"order":4787,"tags":["AL","flag"],"version":2,"annotation":"flag: Albania","shortcodes":["albania","flag_al"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฒ","group":9,"order":4788,"tags":["AM","flag"],"version":2,"annotation":"flag: Armenia","shortcodes":["armenia","flag_am"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ด","group":9,"order":4789,"tags":["AO","flag"],"version":2,"annotation":"flag: Angola","shortcodes":["angola","flag_ao"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ถ","group":9,"order":4790,"tags":["AQ","flag"],"version":2,"annotation":"flag: Antarctica","shortcodes":["antarctica","flag_aq"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ท","group":9,"order":4791,"tags":["AR","flag"],"version":2,"annotation":"flag: Argentina","shortcodes":["argentina","flag_ar"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ธ","group":9,"order":4792,"tags":["AS","flag"],"version":2,"annotation":"flag: American Samoa","shortcodes":["american_samoa","flag_as"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡น","group":9,"order":4793,"tags":["AT","flag"],"version":2,"annotation":"flag: Austria","shortcodes":["austria","flag_at"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡บ","group":9,"order":4794,"tags":["AU","flag"],"version":2,"annotation":"flag: Australia","shortcodes":["australia","flag_au"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ผ","group":9,"order":4795,"tags":["AW","flag"],"version":2,"annotation":"flag: Aruba","shortcodes":["aruba","flag_aw"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฝ","group":9,"order":4796,"tags":["AX","flag"],"version":2,"annotation":"flag: ร…land Islands","shortcodes":["aland_islands","flag_ax"]},{"emoji":"๐Ÿ‡ฆ๐Ÿ‡ฟ","group":9,"order":4797,"tags":["AZ","flag"],"version":2,"annotation":"flag: Azerbaijan","shortcodes":["azerbaijan","flag_az"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฆ","group":9,"order":4798,"tags":["BA","flag"],"version":2,"annotation":"flag: Bosnia & Herzegovina","shortcodes":["bosnia_herzegovina","flag_ba"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ง","group":9,"order":4799,"tags":["BB","flag"],"version":2,"annotation":"flag: Barbados","shortcodes":["barbados","flag_bb"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฉ","group":9,"order":4800,"tags":["BD","flag"],"version":2,"annotation":"flag: Bangladesh","shortcodes":["bangladesh","flag_bd"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ช","group":9,"order":4801,"tags":["BE","flag"],"version":2,"annotation":"flag: Belgium","shortcodes":["belgium","flag_be"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ซ","group":9,"order":4802,"tags":["BF","flag"],"version":2,"annotation":"flag: Burkina Faso","shortcodes":["burkina_faso","flag_bf"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฌ","group":9,"order":4803,"tags":["BG","flag"],"version":2,"annotation":"flag: Bulgaria","shortcodes":["bulgaria","flag_bg"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ญ","group":9,"order":4804,"tags":["BH","flag"],"version":2,"annotation":"flag: Bahrain","shortcodes":["bahrain","flag_bh"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฎ","group":9,"order":4805,"tags":["BI","flag"],"version":2,"annotation":"flag: Burundi","shortcodes":["burundi","flag_bi"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฏ","group":9,"order":4806,"tags":["BJ","flag"],"version":2,"annotation":"flag: Benin","shortcodes":["benin","flag_bj"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฑ","group":9,"order":4807,"tags":["BL","flag"],"version":2,"annotation":"flag: St. Barthรฉlemy","shortcodes":["flag_bl","st_barthelemy"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฒ","group":9,"order":4808,"tags":["BM","flag"],"version":2,"annotation":"flag: Bermuda","shortcodes":["bermuda","flag_bm"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ณ","group":9,"order":4809,"tags":["BN","flag"],"version":2,"annotation":"flag: Brunei","shortcodes":["brunei","flag_bn"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ด","group":9,"order":4810,"tags":["BO","flag"],"version":2,"annotation":"flag: Bolivia","shortcodes":["bolivia","flag_bo"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ถ","group":9,"order":4811,"tags":["BQ","flag"],"version":2,"annotation":"flag: Caribbean Netherlands","shortcodes":["caribbean_netherlands","flag_bq"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ท","group":9,"order":4812,"tags":["BR","flag"],"version":2,"annotation":"flag: Brazil","shortcodes":["brazil","flag_br"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ธ","group":9,"order":4813,"tags":["BS","flag"],"version":2,"annotation":"flag: Bahamas","shortcodes":["bahamas","flag_bs"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡น","group":9,"order":4814,"tags":["BT","flag"],"version":2,"annotation":"flag: Bhutan","shortcodes":["bhutan","flag_bt"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ป","group":9,"order":4815,"tags":["BV","flag"],"version":2,"annotation":"flag: Bouvet Island","shortcodes":["bouvet_island","flag_bv"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ผ","group":9,"order":4816,"tags":["BW","flag"],"version":2,"annotation":"flag: Botswana","shortcodes":["botswana","flag_bw"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡พ","group":9,"order":4817,"tags":["BY","flag"],"version":2,"annotation":"flag: Belarus","shortcodes":["belarus","flag_by"]},{"emoji":"๐Ÿ‡ง๐Ÿ‡ฟ","group":9,"order":4818,"tags":["BZ","flag"],"version":2,"annotation":"flag: Belize","shortcodes":["belize","flag_bz"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฆ","group":9,"order":4819,"tags":["CA","flag"],"version":2,"annotation":"flag: Canada","shortcodes":["canada","flag_ca"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡จ","group":9,"order":4820,"tags":["CC","flag"],"version":2,"annotation":"flag: Cocos (Keeling) Islands","shortcodes":["cocos_islands","flag_cc"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฉ","group":9,"order":4821,"tags":["CD","flag"],"version":2,"annotation":"flag: Congo - Kinshasa","shortcodes":["congo_kinshasa","flag_cd"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ซ","group":9,"order":4822,"tags":["CF","flag"],"version":2,"annotation":"flag: Central African Republic","shortcodes":["central_african_republic","flag_cf"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฌ","group":9,"order":4823,"tags":["CG","flag"],"version":2,"annotation":"flag: Congo - Brazzaville","shortcodes":["congo_brazzaville","flag_cg"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ญ","group":9,"order":4824,"tags":["CH","flag"],"version":2,"annotation":"flag: Switzerland","shortcodes":["flag_ch","switzerland"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฎ","group":9,"order":4825,"tags":["CI","flag"],"version":2,"annotation":"flag: Cรดte dโ€™Ivoire","shortcodes":["cote_divoire","flag_ci"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฐ","group":9,"order":4826,"tags":["CK","flag"],"version":2,"annotation":"flag: Cook Islands","shortcodes":["cook_islands","flag_ck"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฑ","group":9,"order":4827,"tags":["CL","flag"],"version":2,"annotation":"flag: Chile","shortcodes":["chile","flag_cl"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฒ","group":9,"order":4828,"tags":["CM","flag"],"version":2,"annotation":"flag: Cameroon","shortcodes":["cameroon","flag_cm"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ณ","group":9,"order":4829,"tags":["CN","flag"],"version":0.6,"annotation":"flag: China","shortcodes":["china","flag_cn"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ด","group":9,"order":4830,"tags":["CO","flag"],"version":2,"annotation":"flag: Colombia","shortcodes":["colombia","flag_co"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ต","group":9,"order":4831,"tags":["CP","flag"],"version":2,"annotation":"flag: Clipperton Island","shortcodes":["clipperton_island","flag_cp"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ถ","group":9,"order":4832,"tags":["CQ","flag"],"version":16,"annotation":"flag: Sark","shortcodes":["flag_cq","sark"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ท","group":9,"order":4833,"tags":["CR","flag"],"version":2,"annotation":"flag: Costa Rica","shortcodes":["costa_rica","flag_cr"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡บ","group":9,"order":4834,"tags":["CU","flag"],"version":2,"annotation":"flag: Cuba","shortcodes":["cuba","flag_cu"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ป","group":9,"order":4835,"tags":["CV","flag"],"version":2,"annotation":"flag: Cape Verde","shortcodes":["cape_verde","flag_cv"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ผ","group":9,"order":4836,"tags":["CW","flag"],"version":2,"annotation":"flag: Curaรงao","shortcodes":["curacao","flag_cw"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฝ","group":9,"order":4837,"tags":["CX","flag"],"version":2,"annotation":"flag: Christmas Island","shortcodes":["christmas_island","flag_cx"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡พ","group":9,"order":4838,"tags":["CY","flag"],"version":2,"annotation":"flag: Cyprus","shortcodes":["cyprus","flag_cy"]},{"emoji":"๐Ÿ‡จ๐Ÿ‡ฟ","group":9,"order":4839,"tags":["CZ","flag"],"version":2,"annotation":"flag: Czechia","shortcodes":["czech_republic","czechia","flag_cz"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ช","group":9,"order":4840,"tags":["DE","flag"],"version":0.6,"annotation":"flag: Germany","shortcodes":["flag_de","germany"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ฌ","group":9,"order":4841,"tags":["DG","flag"],"version":2,"annotation":"flag: Diego Garcia","shortcodes":["diego_garcia","flag_dg"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ฏ","group":9,"order":4842,"tags":["DJ","flag"],"version":2,"annotation":"flag: Djibouti","shortcodes":["djibouti","flag_dj"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ฐ","group":9,"order":4843,"tags":["DK","flag"],"version":2,"annotation":"flag: Denmark","shortcodes":["denmark","flag_dk"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ฒ","group":9,"order":4844,"tags":["DM","flag"],"version":2,"annotation":"flag: Dominica","shortcodes":["dominica","flag_dm"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ด","group":9,"order":4845,"tags":["DO","flag"],"version":2,"annotation":"flag: Dominican Republic","shortcodes":["dominican_republic","flag_do"]},{"emoji":"๐Ÿ‡ฉ๐Ÿ‡ฟ","group":9,"order":4846,"tags":["DZ","flag"],"version":2,"annotation":"flag: Algeria","shortcodes":["algeria","flag_dz"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡ฆ","group":9,"order":4847,"tags":["EA","flag"],"version":2,"annotation":"flag: Ceuta & Melilla","shortcodes":["ceuta_melilla","flag_ea"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡จ","group":9,"order":4848,"tags":["EC","flag"],"version":2,"annotation":"flag: Ecuador","shortcodes":["ecuador","flag_ec"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡ช","group":9,"order":4849,"tags":["EE","flag"],"version":2,"annotation":"flag: Estonia","shortcodes":["estonia","flag_ee"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡ฌ","group":9,"order":4850,"tags":["EG","flag"],"version":2,"annotation":"flag: Egypt","shortcodes":["egypt","flag_eg"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡ญ","group":9,"order":4851,"tags":["EH","flag"],"version":2,"annotation":"flag: Western Sahara","shortcodes":["flag_eh","western_sahara"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡ท","group":9,"order":4852,"tags":["ER","flag"],"version":2,"annotation":"flag: Eritrea","shortcodes":["eritrea","flag_er"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡ธ","group":9,"order":4853,"tags":["ES","flag"],"version":0.6,"annotation":"flag: Spain","shortcodes":["flag_es","spain"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡น","group":9,"order":4854,"tags":["ET","flag"],"version":2,"annotation":"flag: Ethiopia","shortcodes":["ethiopia","flag_et"]},{"emoji":"๐Ÿ‡ช๐Ÿ‡บ","group":9,"order":4855,"tags":["EU","flag"],"version":2,"annotation":"flag: European Union","shortcodes":["european_union","flag_eu"]},{"emoji":"๐Ÿ‡ซ๐Ÿ‡ฎ","group":9,"order":4856,"tags":["FI","flag"],"version":2,"annotation":"flag: Finland","shortcodes":["finland","flag_fi"]},{"emoji":"๐Ÿ‡ซ๐Ÿ‡ฏ","group":9,"order":4857,"tags":["FJ","flag"],"version":2,"annotation":"flag: Fiji","shortcodes":["fiji","flag_fj"]},{"emoji":"๐Ÿ‡ซ๐Ÿ‡ฐ","group":9,"order":4858,"tags":["FK","flag"],"version":2,"annotation":"flag: Falkland Islands","shortcodes":["falkland_islands","flag_fk"]},{"emoji":"๐Ÿ‡ซ๐Ÿ‡ฒ","group":9,"order":4859,"tags":["FM","flag"],"version":2,"annotation":"flag: Micronesia","shortcodes":["flag_fm","micronesia"]},{"emoji":"๐Ÿ‡ซ๐Ÿ‡ด","group":9,"order":4860,"tags":["FO","flag"],"version":2,"annotation":"flag: Faroe Islands","shortcodes":["faroe_islands","flag_fo"]},{"emoji":"๐Ÿ‡ซ๐Ÿ‡ท","group":9,"order":4861,"tags":["FR","flag"],"version":0.6,"annotation":"flag: France","shortcodes":["flag_fr","france"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ฆ","group":9,"order":4862,"tags":["GA","flag"],"version":2,"annotation":"flag: Gabon","shortcodes":["flag_ga","gabon"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ง","group":9,"order":4863,"tags":["GB","flag"],"version":0.6,"annotation":"flag: United Kingdom","shortcodes":["flag_gb","uk","united_kingdom"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ฉ","group":9,"order":4864,"tags":["GD","flag"],"version":2,"annotation":"flag: Grenada","shortcodes":["flag_gd","grenada"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ช","group":9,"order":4865,"tags":["GE","flag"],"version":2,"annotation":"flag: Georgia","shortcodes":["flag_ge","georgia"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ซ","group":9,"order":4866,"tags":["GF","flag"],"version":2,"annotation":"flag: French Guiana","shortcodes":["flag_gf","french_guiana"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ฌ","group":9,"order":4867,"tags":["GG","flag"],"version":2,"annotation":"flag: Guernsey","shortcodes":["flag_gg","guernsey"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ญ","group":9,"order":4868,"tags":["GH","flag"],"version":2,"annotation":"flag: Ghana","shortcodes":["flag_gh","ghana"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ฎ","group":9,"order":4869,"tags":["GI","flag"],"version":2,"annotation":"flag: Gibraltar","shortcodes":["flag_gi","gibraltar"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ฑ","group":9,"order":4870,"tags":["GL","flag"],"version":2,"annotation":"flag: Greenland","shortcodes":["flag_gl","greenland"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ฒ","group":9,"order":4871,"tags":["GM","flag"],"version":2,"annotation":"flag: Gambia","shortcodes":["flag_gm","gambia"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ณ","group":9,"order":4872,"tags":["GN","flag"],"version":2,"annotation":"flag: Guinea","shortcodes":["flag_gn","guinea"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ต","group":9,"order":4873,"tags":["GP","flag"],"version":2,"annotation":"flag: Guadeloupe","shortcodes":["flag_gp","guadeloupe"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ถ","group":9,"order":4874,"tags":["GQ","flag"],"version":2,"annotation":"flag: Equatorial Guinea","shortcodes":["equatorial_guinea","flag_gq"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ท","group":9,"order":4875,"tags":["GR","flag"],"version":2,"annotation":"flag: Greece","shortcodes":["flag_gr","greece"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ธ","group":9,"order":4876,"tags":["GS","flag"],"version":2,"annotation":"flag: South Georgia & South Sandwich Islands","shortcodes":["flag_gs","south_georgia_south_sandwich_islands"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡น","group":9,"order":4877,"tags":["GT","flag"],"version":2,"annotation":"flag: Guatemala","shortcodes":["flag_gt","guatemala"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡บ","group":9,"order":4878,"tags":["GU","flag"],"version":2,"annotation":"flag: Guam","shortcodes":["flag_gu","guam"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡ผ","group":9,"order":4879,"tags":["GW","flag"],"version":2,"annotation":"flag: Guinea-Bissau","shortcodes":["flag_gw","guinea_bissau"]},{"emoji":"๐Ÿ‡ฌ๐Ÿ‡พ","group":9,"order":4880,"tags":["GY","flag"],"version":2,"annotation":"flag: Guyana","shortcodes":["flag_gy","guyana"]},{"emoji":"๐Ÿ‡ญ๐Ÿ‡ฐ","group":9,"order":4881,"tags":["HK","flag"],"version":2,"annotation":"flag: Hong Kong SAR China","shortcodes":["flag_hk","hong_kong"]},{"emoji":"๐Ÿ‡ญ๐Ÿ‡ฒ","group":9,"order":4882,"tags":["HM","flag"],"version":2,"annotation":"flag: Heard & McDonald Islands","shortcodes":["flag_hm","heard_mcdonald_islands"]},{"emoji":"๐Ÿ‡ญ๐Ÿ‡ณ","group":9,"order":4883,"tags":["HN","flag"],"version":2,"annotation":"flag: Honduras","shortcodes":["flag_hn","honduras"]},{"emoji":"๐Ÿ‡ญ๐Ÿ‡ท","group":9,"order":4884,"tags":["HR","flag"],"version":2,"annotation":"flag: Croatia","shortcodes":["croatia","flag_hr"]},{"emoji":"๐Ÿ‡ญ๐Ÿ‡น","group":9,"order":4885,"tags":["HT","flag"],"version":2,"annotation":"flag: Haiti","shortcodes":["flag_ht","haiti"]},{"emoji":"๐Ÿ‡ญ๐Ÿ‡บ","group":9,"order":4886,"tags":["HU","flag"],"version":2,"annotation":"flag: Hungary","shortcodes":["flag_hu","hungary"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡จ","group":9,"order":4887,"tags":["IC","flag"],"version":2,"annotation":"flag: Canary Islands","shortcodes":["canary_islands","flag_ic"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ฉ","group":9,"order":4888,"tags":["ID","flag"],"version":2,"annotation":"flag: Indonesia","shortcodes":["flag_id","indonesia"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ช","group":9,"order":4889,"tags":["IE","flag"],"version":2,"annotation":"flag: Ireland","shortcodes":["flag_ie","ireland"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ฑ","group":9,"order":4890,"tags":["IL","flag"],"version":2,"annotation":"flag: Israel","shortcodes":["flag_il","israel"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ฒ","group":9,"order":4891,"tags":["IM","flag"],"version":2,"annotation":"flag: Isle of Man","shortcodes":["flag_im","isle_of_man"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ณ","group":9,"order":4892,"tags":["IN","flag"],"version":2,"annotation":"flag: India","shortcodes":["flag_in","india"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ด","group":9,"order":4893,"tags":["IO","flag"],"version":2,"annotation":"flag: British Indian Ocean Territory","shortcodes":["british_indian_ocean_territory","flag_io"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ถ","group":9,"order":4894,"tags":["IQ","flag"],"version":2,"annotation":"flag: Iraq","shortcodes":["flag_iq","iraq"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ท","group":9,"order":4895,"tags":["IR","flag"],"version":2,"annotation":"flag: Iran","shortcodes":["flag_ir","iran"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡ธ","group":9,"order":4896,"tags":["IS","flag"],"version":2,"annotation":"flag: Iceland","shortcodes":["flag_is","iceland"]},{"emoji":"๐Ÿ‡ฎ๐Ÿ‡น","group":9,"order":4897,"tags":["IT","flag"],"version":0.6,"annotation":"flag: Italy","shortcodes":["flag_it","italy"]},{"emoji":"๐Ÿ‡ฏ๐Ÿ‡ช","group":9,"order":4898,"tags":["JE","flag"],"version":2,"annotation":"flag: Jersey","shortcodes":["flag_je","jersey"]},{"emoji":"๐Ÿ‡ฏ๐Ÿ‡ฒ","group":9,"order":4899,"tags":["JM","flag"],"version":2,"annotation":"flag: Jamaica","shortcodes":["flag_jm","jamaica"]},{"emoji":"๐Ÿ‡ฏ๐Ÿ‡ด","group":9,"order":4900,"tags":["JO","flag"],"version":2,"annotation":"flag: Jordan","shortcodes":["flag_jo","jordan"]},{"emoji":"๐Ÿ‡ฏ๐Ÿ‡ต","group":9,"order":4901,"tags":["JP","flag"],"version":0.6,"annotation":"flag: Japan","shortcodes":["flag_jp","japan"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ช","group":9,"order":4902,"tags":["KE","flag"],"version":2,"annotation":"flag: Kenya","shortcodes":["flag_ke","kenya"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ฌ","group":9,"order":4903,"tags":["KG","flag"],"version":2,"annotation":"flag: Kyrgyzstan","shortcodes":["flag_kg","kyrgyzstan"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ญ","group":9,"order":4904,"tags":["KH","flag"],"version":2,"annotation":"flag: Cambodia","shortcodes":["cambodia","flag_kh"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ฎ","group":9,"order":4905,"tags":["KI","flag"],"version":2,"annotation":"flag: Kiribati","shortcodes":["flag_ki","kiribati"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ฒ","group":9,"order":4906,"tags":["KM","flag"],"version":2,"annotation":"flag: Comoros","shortcodes":["comoros","flag_km"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ณ","group":9,"order":4907,"tags":["KN","flag"],"version":2,"annotation":"flag: St. Kitts & Nevis","shortcodes":["flag_kn","st_kitts_nevis"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ต","group":9,"order":4908,"tags":["KP","flag"],"version":2,"annotation":"flag: North Korea","shortcodes":["flag_kp","north_korea"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ท","group":9,"order":4909,"tags":["KR","flag"],"version":0.6,"annotation":"flag: South Korea","shortcodes":["flag_kr","south_korea"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ผ","group":9,"order":4910,"tags":["KW","flag"],"version":2,"annotation":"flag: Kuwait","shortcodes":["flag_kw","kuwait"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡พ","group":9,"order":4911,"tags":["KY","flag"],"version":2,"annotation":"flag: Cayman Islands","shortcodes":["cayman_islands","flag_ky"]},{"emoji":"๐Ÿ‡ฐ๐Ÿ‡ฟ","group":9,"order":4912,"tags":["KZ","flag"],"version":2,"annotation":"flag: Kazakhstan","shortcodes":["flag_kz","kazakhstan"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ฆ","group":9,"order":4913,"tags":["LA","flag"],"version":2,"annotation":"flag: Laos","shortcodes":["flag_la","laos"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ง","group":9,"order":4914,"tags":["LB","flag"],"version":2,"annotation":"flag: Lebanon","shortcodes":["flag_lb","lebanon"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡จ","group":9,"order":4915,"tags":["LC","flag"],"version":2,"annotation":"flag: St. Lucia","shortcodes":["flag_lc","st_lucia"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ฎ","group":9,"order":4916,"tags":["LI","flag"],"version":2,"annotation":"flag: Liechtenstein","shortcodes":["flag_li","liechtenstein"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ฐ","group":9,"order":4917,"tags":["LK","flag"],"version":2,"annotation":"flag: Sri Lanka","shortcodes":["flag_lk","sri_lanka"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ท","group":9,"order":4918,"tags":["LR","flag"],"version":2,"annotation":"flag: Liberia","shortcodes":["flag_lr","liberia"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ธ","group":9,"order":4919,"tags":["LS","flag"],"version":2,"annotation":"flag: Lesotho","shortcodes":["flag_ls","lesotho"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡น","group":9,"order":4920,"tags":["LT","flag"],"version":2,"annotation":"flag: Lithuania","shortcodes":["flag_lt","lithuania"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡บ","group":9,"order":4921,"tags":["LU","flag"],"version":2,"annotation":"flag: Luxembourg","shortcodes":["flag_lu","luxembourg"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡ป","group":9,"order":4922,"tags":["LV","flag"],"version":2,"annotation":"flag: Latvia","shortcodes":["flag_lv","latvia"]},{"emoji":"๐Ÿ‡ฑ๐Ÿ‡พ","group":9,"order":4923,"tags":["LY","flag"],"version":2,"annotation":"flag: Libya","shortcodes":["flag_ly","libya"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฆ","group":9,"order":4924,"tags":["MA","flag"],"version":2,"annotation":"flag: Morocco","shortcodes":["flag_ma","morocco"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡จ","group":9,"order":4925,"tags":["MC","flag"],"version":2,"annotation":"flag: Monaco","shortcodes":["flag_mc","monaco"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฉ","group":9,"order":4926,"tags":["MD","flag"],"version":2,"annotation":"flag: Moldova","shortcodes":["flag_md","moldova"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ช","group":9,"order":4927,"tags":["ME","flag"],"version":2,"annotation":"flag: Montenegro","shortcodes":["flag_me","montenegro"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ซ","group":9,"order":4928,"tags":["MF","flag"],"version":2,"annotation":"flag: St. Martin","shortcodes":["flag_mf","st_martin"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฌ","group":9,"order":4929,"tags":["MG","flag"],"version":2,"annotation":"flag: Madagascar","shortcodes":["flag_mg","madagascar"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ญ","group":9,"order":4930,"tags":["MH","flag"],"version":2,"annotation":"flag: Marshall Islands","shortcodes":["flag_mh","marshall_islands"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฐ","group":9,"order":4931,"tags":["MK","flag"],"version":2,"annotation":"flag: North Macedonia","shortcodes":["flag_mk","macedonia"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฑ","group":9,"order":4932,"tags":["ML","flag"],"version":2,"annotation":"flag: Mali","shortcodes":["flag_ml","mali"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฒ","group":9,"order":4933,"tags":["MM","flag"],"version":2,"annotation":"flag: Myanmar (Burma)","shortcodes":["burma","flag_mm","myanmar"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ณ","group":9,"order":4934,"tags":["MN","flag"],"version":2,"annotation":"flag: Mongolia","shortcodes":["flag_mn","mongolia"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ด","group":9,"order":4935,"tags":["MO","flag"],"version":2,"annotation":"flag: Macao SAR China","shortcodes":["flag_mo","macao","macau"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ต","group":9,"order":4936,"tags":["MP","flag"],"version":2,"annotation":"flag: Northern Mariana Islands","shortcodes":["flag_mp","northern_mariana_islands"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ถ","group":9,"order":4937,"tags":["MQ","flag"],"version":2,"annotation":"flag: Martinique","shortcodes":["flag_mq","martinique"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ท","group":9,"order":4938,"tags":["MR","flag"],"version":2,"annotation":"flag: Mauritania","shortcodes":["flag_mr","mauritania"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ธ","group":9,"order":4939,"tags":["MS","flag"],"version":2,"annotation":"flag: Montserrat","shortcodes":["flag_ms","montserrat"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡น","group":9,"order":4940,"tags":["MT","flag"],"version":2,"annotation":"flag: Malta","shortcodes":["flag_mt","malta"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡บ","group":9,"order":4941,"tags":["MU","flag"],"version":2,"annotation":"flag: Mauritius","shortcodes":["flag_mu","mauritius"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ป","group":9,"order":4942,"tags":["MV","flag"],"version":2,"annotation":"flag: Maldives","shortcodes":["flag_mv","maldives"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ผ","group":9,"order":4943,"tags":["MW","flag"],"version":2,"annotation":"flag: Malawi","shortcodes":["flag_mw","malawi"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฝ","group":9,"order":4944,"tags":["MX","flag"],"version":2,"annotation":"flag: Mexico","shortcodes":["flag_mx","mexico"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡พ","group":9,"order":4945,"tags":["MY","flag"],"version":2,"annotation":"flag: Malaysia","shortcodes":["flag_my","malaysia"]},{"emoji":"๐Ÿ‡ฒ๐Ÿ‡ฟ","group":9,"order":4946,"tags":["MZ","flag"],"version":2,"annotation":"flag: Mozambique","shortcodes":["flag_mz","mozambique"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ฆ","group":9,"order":4947,"tags":["NA","flag"],"version":2,"annotation":"flag: Namibia","shortcodes":["flag_na","namibia"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡จ","group":9,"order":4948,"tags":["NC","flag"],"version":2,"annotation":"flag: New Caledonia","shortcodes":["flag_nc","new_caledonia"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ช","group":9,"order":4949,"tags":["NE","flag"],"version":2,"annotation":"flag: Niger","shortcodes":["flag_ne","niger"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ซ","group":9,"order":4950,"tags":["NF","flag"],"version":2,"annotation":"flag: Norfolk Island","shortcodes":["flag_nf","norfolk_island"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ฌ","group":9,"order":4951,"tags":["NG","flag"],"version":2,"annotation":"flag: Nigeria","shortcodes":["flag_ng","nigeria"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ฎ","group":9,"order":4952,"tags":["NI","flag"],"version":2,"annotation":"flag: Nicaragua","shortcodes":["flag_ni","nicaragua"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ฑ","group":9,"order":4953,"tags":["NL","flag"],"version":2,"annotation":"flag: Netherlands","shortcodes":["flag_nl","netherlands"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ด","group":9,"order":4954,"tags":["NO","flag"],"version":2,"annotation":"flag: Norway","shortcodes":["flag_no","norway"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ต","group":9,"order":4955,"tags":["NP","flag"],"version":2,"annotation":"flag: Nepal","shortcodes":["flag_np","nepal"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ท","group":9,"order":4956,"tags":["NR","flag"],"version":2,"annotation":"flag: Nauru","shortcodes":["flag_nr","nauru"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡บ","group":9,"order":4957,"tags":["NU","flag"],"version":2,"annotation":"flag: Niue","shortcodes":["flag_nu","niue"]},{"emoji":"๐Ÿ‡ณ๐Ÿ‡ฟ","group":9,"order":4958,"tags":["NZ","flag"],"version":2,"annotation":"flag: New Zealand","shortcodes":["flag_nz","new_zealand"]},{"emoji":"๐Ÿ‡ด๐Ÿ‡ฒ","group":9,"order":4959,"tags":["OM","flag"],"version":2,"annotation":"flag: Oman","shortcodes":["flag_om","oman"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ฆ","group":9,"order":4960,"tags":["PA","flag"],"version":2,"annotation":"flag: Panama","shortcodes":["flag_pa","panama"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ช","group":9,"order":4961,"tags":["PE","flag"],"version":2,"annotation":"flag: Peru","shortcodes":["flag_pe","peru"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ซ","group":9,"order":4962,"tags":["PF","flag"],"version":2,"annotation":"flag: French Polynesia","shortcodes":["flag_pf","french_polynesia"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ฌ","group":9,"order":4963,"tags":["PG","flag"],"version":2,"annotation":"flag: Papua New Guinea","shortcodes":["flag_pg","papua_new_guinea"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ญ","group":9,"order":4964,"tags":["PH","flag"],"version":2,"annotation":"flag: Philippines","shortcodes":["flag_ph","philippines"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ฐ","group":9,"order":4965,"tags":["PK","flag"],"version":2,"annotation":"flag: Pakistan","shortcodes":["flag_pk","pakistan"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ฑ","group":9,"order":4966,"tags":["PL","flag"],"version":2,"annotation":"flag: Poland","shortcodes":["flag_pl","poland"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ฒ","group":9,"order":4967,"tags":["PM","flag"],"version":2,"annotation":"flag: St. Pierre & Miquelon","shortcodes":["flag_pm","st_pierre_miquelon"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ณ","group":9,"order":4968,"tags":["PN","flag"],"version":2,"annotation":"flag: Pitcairn Islands","shortcodes":["flag_pn","pitcairn_islands"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ท","group":9,"order":4969,"tags":["PR","flag"],"version":2,"annotation":"flag: Puerto Rico","shortcodes":["flag_pr","puerto_rico"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ธ","group":9,"order":4970,"tags":["PS","flag"],"version":2,"annotation":"flag: Palestinian Territories","shortcodes":["flag_ps","palestinian_territories"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡น","group":9,"order":4971,"tags":["PT","flag"],"version":2,"annotation":"flag: Portugal","shortcodes":["flag_pt","portugal"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡ผ","group":9,"order":4972,"tags":["PW","flag"],"version":2,"annotation":"flag: Palau","shortcodes":["flag_pw","palau"]},{"emoji":"๐Ÿ‡ต๐Ÿ‡พ","group":9,"order":4973,"tags":["PY","flag"],"version":2,"annotation":"flag: Paraguay","shortcodes":["flag_py","paraguay"]},{"emoji":"๐Ÿ‡ถ๐Ÿ‡ฆ","group":9,"order":4974,"tags":["QA","flag"],"version":2,"annotation":"flag: Qatar","shortcodes":["flag_qa","qatar"]},{"emoji":"๐Ÿ‡ท๐Ÿ‡ช","group":9,"order":4975,"tags":["RE","flag"],"version":2,"annotation":"flag: Rรฉunion","shortcodes":["flag_re","reunion"]},{"emoji":"๐Ÿ‡ท๐Ÿ‡ด","group":9,"order":4976,"tags":["RO","flag"],"version":2,"annotation":"flag: Romania","shortcodes":["flag_ro","romania"]},{"emoji":"๐Ÿ‡ท๐Ÿ‡ธ","group":9,"order":4977,"tags":["RS","flag"],"version":2,"annotation":"flag: Serbia","shortcodes":["flag_rs","serbia"]},{"emoji":"๐Ÿ‡ท๐Ÿ‡บ","group":9,"order":4978,"tags":["RU","flag"],"version":0.6,"annotation":"flag: Russia","shortcodes":["flag_ru","russia"]},{"emoji":"๐Ÿ‡ท๐Ÿ‡ผ","group":9,"order":4979,"tags":["RW","flag"],"version":2,"annotation":"flag: Rwanda","shortcodes":["flag_rw","rwanda"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฆ","group":9,"order":4980,"tags":["SA","flag"],"version":2,"annotation":"flag: Saudi Arabia","shortcodes":["flag_sa","saudi_arabia"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ง","group":9,"order":4981,"tags":["SB","flag"],"version":2,"annotation":"flag: Solomon Islands","shortcodes":["flag_sb","solomon_islands"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡จ","group":9,"order":4982,"tags":["SC","flag"],"version":2,"annotation":"flag: Seychelles","shortcodes":["flag_sc","seychelles"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฉ","group":9,"order":4983,"tags":["SD","flag"],"version":2,"annotation":"flag: Sudan","shortcodes":["flag_sd","sudan"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ช","group":9,"order":4984,"tags":["SE","flag"],"version":2,"annotation":"flag: Sweden","shortcodes":["flag_se","sweden"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฌ","group":9,"order":4985,"tags":["SG","flag"],"version":2,"annotation":"flag: Singapore","shortcodes":["flag_sg","singapore"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ญ","group":9,"order":4986,"tags":["SH","flag"],"version":2,"annotation":"flag: St. Helena","shortcodes":["flag_sh","st_helena"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฎ","group":9,"order":4987,"tags":["SI","flag"],"version":2,"annotation":"flag: Slovenia","shortcodes":["flag_si","slovenia"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฏ","group":9,"order":4988,"tags":["SJ","flag"],"version":2,"annotation":"flag: Svalbard & Jan Mayen","shortcodes":["flag_sj","svalbard_jan_mayen"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฐ","group":9,"order":4989,"tags":["SK","flag"],"version":2,"annotation":"flag: Slovakia","shortcodes":["flag_sk","slovakia"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฑ","group":9,"order":4990,"tags":["SL","flag"],"version":2,"annotation":"flag: Sierra Leone","shortcodes":["flag_sl","sierra_leone"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฒ","group":9,"order":4991,"tags":["SM","flag"],"version":2,"annotation":"flag: San Marino","shortcodes":["flag_sm","san_marino"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ณ","group":9,"order":4992,"tags":["SN","flag"],"version":2,"annotation":"flag: Senegal","shortcodes":["flag_sn","senegal"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ด","group":9,"order":4993,"tags":["SO","flag"],"version":2,"annotation":"flag: Somalia","shortcodes":["flag_so","somalia"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ท","group":9,"order":4994,"tags":["SR","flag"],"version":2,"annotation":"flag: Suriname","shortcodes":["flag_sr","suriname"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ธ","group":9,"order":4995,"tags":["SS","flag"],"version":2,"annotation":"flag: South Sudan","shortcodes":["flag_ss","south_sudan"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡น","group":9,"order":4996,"tags":["ST","flag"],"version":2,"annotation":"flag: Sรฃo Tomรฉ & Prรญncipe","shortcodes":["flag_st","sao_tome_principe"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ป","group":9,"order":4997,"tags":["SV","flag"],"version":2,"annotation":"flag: El Salvador","shortcodes":["el_salvador","flag_sv"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฝ","group":9,"order":4998,"tags":["SX","flag"],"version":2,"annotation":"flag: Sint Maarten","shortcodes":["flag_sx","sint_maarten"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡พ","group":9,"order":4999,"tags":["SY","flag"],"version":2,"annotation":"flag: Syria","shortcodes":["flag_sy","syria"]},{"emoji":"๐Ÿ‡ธ๐Ÿ‡ฟ","group":9,"order":5000,"tags":["SZ","flag"],"version":2,"annotation":"flag: Eswatini","shortcodes":["eswatini","flag_sz","swaziland"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฆ","group":9,"order":5001,"tags":["TA","flag"],"version":2,"annotation":"flag: Tristan da Cunha","shortcodes":["flag_ta","tristan_da_cunha"]},{"emoji":"๐Ÿ‡น๐Ÿ‡จ","group":9,"order":5002,"tags":["TC","flag"],"version":2,"annotation":"flag: Turks & Caicos Islands","shortcodes":["flag_tc","turks_caicos_islands"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฉ","group":9,"order":5003,"tags":["TD","flag"],"version":2,"annotation":"flag: Chad","shortcodes":["chad","flag_td"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ซ","group":9,"order":5004,"tags":["TF","flag"],"version":2,"annotation":"flag: French Southern Territories","shortcodes":["flag_tf","french_southern_territories"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฌ","group":9,"order":5005,"tags":["TG","flag"],"version":2,"annotation":"flag: Togo","shortcodes":["flag_tg","togo"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ญ","group":9,"order":5006,"tags":["TH","flag"],"version":2,"annotation":"flag: Thailand","shortcodes":["flag_th","thailand"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฏ","group":9,"order":5007,"tags":["TJ","flag"],"version":2,"annotation":"flag: Tajikistan","shortcodes":["flag_tj","tajikistan"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฐ","group":9,"order":5008,"tags":["TK","flag"],"version":2,"annotation":"flag: Tokelau","shortcodes":["flag_tk","tokelau"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฑ","group":9,"order":5009,"tags":["TL","flag"],"version":2,"annotation":"flag: Timor-Leste","shortcodes":["flag_tl","timor_leste"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฒ","group":9,"order":5010,"tags":["TM","flag"],"version":2,"annotation":"flag: Turkmenistan","shortcodes":["flag_tm","turkmenistan"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ณ","group":9,"order":5011,"tags":["TN","flag"],"version":2,"annotation":"flag: Tunisia","shortcodes":["flag_tn","tunisia"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ด","group":9,"order":5012,"tags":["TO","flag"],"version":2,"annotation":"flag: Tonga","shortcodes":["flag_to","tonga"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ท","group":9,"order":5013,"tags":["TR","flag"],"version":2,"annotation":"flag: Tรผrkiye","shortcodes":["flag_tr","turkey_tr"]},{"emoji":"๐Ÿ‡น๐Ÿ‡น","group":9,"order":5014,"tags":["TT","flag"],"version":2,"annotation":"flag: Trinidad & Tobago","shortcodes":["flag_tt","trinidad_tobago"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ป","group":9,"order":5015,"tags":["TV","flag"],"version":2,"annotation":"flag: Tuvalu","shortcodes":["flag_tv","tuvalu"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ผ","group":9,"order":5016,"tags":["TW","flag"],"version":2,"annotation":"flag: Taiwan","shortcodes":["flag_tw","taiwan"]},{"emoji":"๐Ÿ‡น๐Ÿ‡ฟ","group":9,"order":5017,"tags":["TZ","flag"],"version":2,"annotation":"flag: Tanzania","shortcodes":["flag_tz","tanzania"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡ฆ","group":9,"order":5018,"tags":["UA","flag"],"version":2,"annotation":"flag: Ukraine","shortcodes":["flag_ua","ukraine"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡ฌ","group":9,"order":5019,"tags":["UG","flag"],"version":2,"annotation":"flag: Uganda","shortcodes":["flag_ug","uganda"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡ฒ","group":9,"order":5020,"tags":["UM","flag"],"version":2,"annotation":"flag: U.S. Outlying Islands","shortcodes":["flag_um","us_outlying_islands"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡ณ","group":9,"order":5021,"tags":["UN","flag"],"version":4,"annotation":"flag: United Nations","shortcodes":["flag_un","un","united_nations"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡ธ","group":9,"order":5022,"tags":["US","flag"],"version":0.6,"annotation":"flag: United States","shortcodes":["flag_us","united_states","usa"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡พ","group":9,"order":5023,"tags":["UY","flag"],"version":2,"annotation":"flag: Uruguay","shortcodes":["flag_uy","uruguay"]},{"emoji":"๐Ÿ‡บ๐Ÿ‡ฟ","group":9,"order":5024,"tags":["UZ","flag"],"version":2,"annotation":"flag: Uzbekistan","shortcodes":["flag_uz","uzbekistan"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡ฆ","group":9,"order":5025,"tags":["VA","flag"],"version":2,"annotation":"flag: Vatican City","shortcodes":["flag_va","vatican_city"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡จ","group":9,"order":5026,"tags":["VC","flag"],"version":2,"annotation":"flag: St. Vincent & Grenadines","shortcodes":["flag_vc","st_vincent_grenadines"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡ช","group":9,"order":5027,"tags":["VE","flag"],"version":2,"annotation":"flag: Venezuela","shortcodes":["flag_ve","venezuela"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡ฌ","group":9,"order":5028,"tags":["VG","flag"],"version":2,"annotation":"flag: British Virgin Islands","shortcodes":["british_virgin_islands","flag_vg"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡ฎ","group":9,"order":5029,"tags":["VI","flag"],"version":2,"annotation":"flag: U.S. Virgin Islands","shortcodes":["flag_vi","us_virgin_islands"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡ณ","group":9,"order":5030,"tags":["VN","flag"],"version":2,"annotation":"flag: Vietnam","shortcodes":["flag_vn","vietnam"]},{"emoji":"๐Ÿ‡ป๐Ÿ‡บ","group":9,"order":5031,"tags":["VU","flag"],"version":2,"annotation":"flag: Vanuatu","shortcodes":["flag_vu","vanuatu"]},{"emoji":"๐Ÿ‡ผ๐Ÿ‡ซ","group":9,"order":5032,"tags":["WF","flag"],"version":2,"annotation":"flag: Wallis & Futuna","shortcodes":["flag_wf","wallis_futuna"]},{"emoji":"๐Ÿ‡ผ๐Ÿ‡ธ","group":9,"order":5033,"tags":["WS","flag"],"version":2,"annotation":"flag: Samoa","shortcodes":["flag_ws","samoa"]},{"emoji":"๐Ÿ‡ฝ๐Ÿ‡ฐ","group":9,"order":5034,"tags":["XK","flag"],"version":2,"annotation":"flag: Kosovo","shortcodes":["flag_xk","kosovo"]},{"emoji":"๐Ÿ‡พ๐Ÿ‡ช","group":9,"order":5035,"tags":["YE","flag"],"version":2,"annotation":"flag: Yemen","shortcodes":["flag_ye","yemen"]},{"emoji":"๐Ÿ‡พ๐Ÿ‡น","group":9,"order":5036,"tags":["YT","flag"],"version":2,"annotation":"flag: Mayotte","shortcodes":["flag_yt","mayotte"]},{"emoji":"๐Ÿ‡ฟ๐Ÿ‡ฆ","group":9,"order":5037,"tags":["ZA","flag"],"version":2,"annotation":"flag: South Africa","shortcodes":["flag_za","south_africa"]},{"emoji":"๐Ÿ‡ฟ๐Ÿ‡ฒ","group":9,"order":5038,"tags":["ZM","flag"],"version":2,"annotation":"flag: Zambia","shortcodes":["flag_zm","zambia"]},{"emoji":"๐Ÿ‡ฟ๐Ÿ‡ผ","group":9,"order":5039,"tags":["ZW","flag"],"version":2,"annotation":"flag: Zimbabwe","shortcodes":["flag_zw","zimbabwe"]},{"emoji":"๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ","group":9,"order":5040,"tags":["flag","gbeng"],"version":5,"annotation":"flag: England","shortcodes":["england","flag_gbeng"]},{"emoji":"๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ","group":9,"order":5041,"tags":["flag","gbsct"],"version":5,"annotation":"flag: Scotland","shortcodes":["flag_gbsct","scotland"]},{"emoji":"๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ","group":9,"order":5042,"tags":["flag","gbwls"],"version":5,"annotation":"flag: Wales","shortcodes":["flag_gbwls","wales"]}] diff --git a/assets/ckeditor/html_label.js b/assets/ckeditor/html_label.js index 9040f3c7..72d1126e 100644 --- a/assets/ckeditor/html_label.js +++ b/assets/ckeditor/html_label.js @@ -1,66 +1,63 @@ -/** - * @license Copyright (c) 2014-2022, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ -import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; -import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment.js'; -import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat.js'; -import Base64UploadAdapter from '@ckeditor/ckeditor5-upload/src/adapters/base64uploadadapter.js'; -import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote.js'; -import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js'; -import Code from '@ckeditor/ckeditor5-basic-styles/src/code.js'; -import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock.js'; -import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; -import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace.js'; -import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor.js'; -import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor.js'; -import FontFamily from '@ckeditor/ckeditor5-font/src/fontfamily.js'; -import FontSize from '@ckeditor/ckeditor5-font/src/fontsize.js'; -import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport.js'; -import Heading from '@ckeditor/ckeditor5-heading/src/heading.js'; -import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight.js'; -import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline.js'; -import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment.js'; -import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed.js'; -import Image from '@ckeditor/ckeditor5-image/src/image.js'; -import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize.js'; -import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle.js'; -import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar.js'; -import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload.js'; -import Indent from '@ckeditor/ckeditor5-indent/src/indent.js'; -import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock.js'; -import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js'; -import Link from '@ckeditor/ckeditor5-link/src/link.js'; -import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage.js'; -import List from '@ckeditor/ckeditor5-list/src/list.js'; -import ListProperties from '@ckeditor/ckeditor5-list/src/listproperties.js'; -import Markdown from '@ckeditor/ckeditor5-markdown-gfm/src/markdown.js'; -import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed.js'; -import MediaEmbedToolbar from '@ckeditor/ckeditor5-media-embed/src/mediaembedtoolbar.js'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; -import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice.js'; -import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat.js'; -import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; -import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters.js'; -import SpecialCharactersArrows from '@ckeditor/ckeditor5-special-characters/src/specialcharactersarrows.js'; -import SpecialCharactersCurrency from '@ckeditor/ckeditor5-special-characters/src/specialcharacterscurrency.js'; -import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials.js'; -import SpecialCharactersLatin from '@ckeditor/ckeditor5-special-characters/src/specialcharacterslatin.js'; -import SpecialCharactersMathematical from '@ckeditor/ckeditor5-special-characters/src/specialcharactersmathematical.js'; -import SpecialCharactersText from '@ckeditor/ckeditor5-special-characters/src/specialcharacterstext.js'; -import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough.js'; -import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js'; -import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js'; -import Table from '@ckeditor/ckeditor5-table/src/table.js'; -import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption.js'; -import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; -import TableColumnResize from '@ckeditor/ckeditor5-table/src/tablecolumnresize.js'; -import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; -import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar.js'; -import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline.js'; -import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount.js'; -import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog.js'; +import {ClassicEditor} from 'ckeditor5' +import {Alignment} from 'ckeditor5'; +import {Autoformat} from 'ckeditor5'; +import {Base64UploadAdapter} from 'ckeditor5'; +import {BlockQuote} from 'ckeditor5'; +import {Bold} from 'ckeditor5'; +import {Code} from 'ckeditor5'; +import {CodeBlock} from 'ckeditor5'; +import {Essentials} from 'ckeditor5'; +import {FindAndReplace} from 'ckeditor5'; +import {FontBackgroundColor} from 'ckeditor5'; +import {FontColor} from 'ckeditor5'; +import {FontFamily} from 'ckeditor5'; +import {FontSize} from 'ckeditor5'; +import {GeneralHtmlSupport} from 'ckeditor5'; +import {Heading} from 'ckeditor5'; +import {Highlight} from 'ckeditor5'; +import {HorizontalLine} from 'ckeditor5'; +import {HtmlComment} from 'ckeditor5'; +import {HtmlEmbed} from 'ckeditor5'; +import {Image} from 'ckeditor5'; +import {ImageResize} from 'ckeditor5'; +import {ImageStyle} from 'ckeditor5'; +import {ImageToolbar} from 'ckeditor5'; +import {ImageUpload} from 'ckeditor5'; +import {Indent} from 'ckeditor5'; +import {IndentBlock} from 'ckeditor5'; +import {Italic} from 'ckeditor5'; +import {Link} from 'ckeditor5'; +import {LinkImage} from 'ckeditor5'; +import {List} from 'ckeditor5'; +import {ListProperties} from 'ckeditor5'; +import {Markdown} from 'ckeditor5'; +import {MediaEmbed} from 'ckeditor5'; +import {MediaEmbedToolbar} from 'ckeditor5'; +import {Paragraph} from 'ckeditor5'; +import {PasteFromOffice} from 'ckeditor5'; +import {RemoveFormat} from 'ckeditor5'; +import {SourceEditing} from 'ckeditor5'; +import {SpecialCharacters} from 'ckeditor5'; +import {SpecialCharactersArrows} from 'ckeditor5'; +import {SpecialCharactersCurrency} from 'ckeditor5'; +import {SpecialCharactersEssentials} from 'ckeditor5'; +import {SpecialCharactersLatin} from 'ckeditor5'; +import {SpecialCharactersMathematical} from 'ckeditor5'; +import {SpecialCharactersText} from 'ckeditor5'; +import {Strikethrough} from 'ckeditor5'; +import {Subscript} from 'ckeditor5'; +import {Superscript} from 'ckeditor5'; +import {Table} from 'ckeditor5'; +import {TableCaption} from 'ckeditor5'; +import {TableCellProperties} from 'ckeditor5'; +import {TableColumnResize} from 'ckeditor5'; +import {TableProperties} from 'ckeditor5'; +import {TableToolbar} from 'ckeditor5'; +import {Underline} from 'ckeditor5'; +import {WordCount} from 'ckeditor5'; +import {EditorWatchdog} from 'ckeditor5'; import PartDBLabel from "./plugins/PartDBLabel/PartDBLabel"; +import SpecialCharactersGreek from "./plugins/special_characters_emoji"; class Editor extends ClassicEditor {} @@ -122,7 +119,8 @@ Editor.builtinPlugins = [ Underline, WordCount, - PartDBLabel + PartDBLabel, + SpecialCharactersGreek ]; // Editor configuration. diff --git a/assets/ckeditor/markdown_full.js b/assets/ckeditor/markdown_full.js index 784bd688..76944b86 100644 --- a/assets/ckeditor/markdown_full.js +++ b/assets/ckeditor/markdown_full.js @@ -2,68 +2,69 @@ * @license Copyright (c) 2014-2022, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; -import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment.js'; -import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat.js'; -import Base64UploadAdapter from '@ckeditor/ckeditor5-upload/src/adapters/base64uploadadapter.js'; -import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote.js'; -import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js'; -import Code from '@ckeditor/ckeditor5-basic-styles/src/code.js'; -import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock.js'; -import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; -import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace.js'; -import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor.js'; -import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor.js'; -import FontFamily from '@ckeditor/ckeditor5-font/src/fontfamily.js'; -import FontSize from '@ckeditor/ckeditor5-font/src/fontsize.js'; -import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport.js'; -import Heading from '@ckeditor/ckeditor5-heading/src/heading.js'; -import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight.js'; -import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline.js'; -import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment.js'; -import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed.js'; -import Image from '@ckeditor/ckeditor5-image/src/image.js'; -import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize.js'; -import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle.js'; -import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar.js'; -import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload.js'; -import Indent from '@ckeditor/ckeditor5-indent/src/indent.js'; -import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock.js'; -import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js'; -import Link from '@ckeditor/ckeditor5-link/src/link.js'; -import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage.js'; -import List from '@ckeditor/ckeditor5-list/src/list.js'; -import ListProperties from '@ckeditor/ckeditor5-list/src/listproperties.js'; -import Markdown from '@ckeditor/ckeditor5-markdown-gfm/src/markdown.js'; -import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed.js'; -import MediaEmbedToolbar from '@ckeditor/ckeditor5-media-embed/src/mediaembedtoolbar.js'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; -import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice.js'; -import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat.js'; -import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; -import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters.js'; -import SpecialCharactersArrows from '@ckeditor/ckeditor5-special-characters/src/specialcharactersarrows.js'; -import SpecialCharactersCurrency from '@ckeditor/ckeditor5-special-characters/src/specialcharacterscurrency.js'; -import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials.js'; -import SpecialCharactersLatin from '@ckeditor/ckeditor5-special-characters/src/specialcharacterslatin.js'; -import SpecialCharactersMathematical from '@ckeditor/ckeditor5-special-characters/src/specialcharactersmathematical.js'; -import SpecialCharactersText from '@ckeditor/ckeditor5-special-characters/src/specialcharacterstext.js'; -import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough.js'; -import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js'; -import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js'; -import Table from '@ckeditor/ckeditor5-table/src/table.js'; -import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption.js'; -import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; -import TableColumnResize from '@ckeditor/ckeditor5-table/src/tablecolumnresize.js'; -import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; -import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar.js'; -import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline.js'; -import WordCount from '@ckeditor/ckeditor5-word-count/src/wordcount.js'; -import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog.js'; -import TodoList from '@ckeditor/ckeditor5-list/src/todolist'; +import {ClassicEditor} from 'ckeditor5'; +import {Alignment} from 'ckeditor5'; +import {Autoformat} from 'ckeditor5'; +import {Base64UploadAdapter} from 'ckeditor5'; +import {BlockQuote} from 'ckeditor5'; +import {Bold} from 'ckeditor5'; +import {Code} from 'ckeditor5'; +import {CodeBlock} from 'ckeditor5'; +import {Essentials} from 'ckeditor5'; +import {FindAndReplace} from 'ckeditor5'; +import {FontBackgroundColor} from 'ckeditor5'; +import {FontColor} from 'ckeditor5'; +import {FontFamily} from 'ckeditor5'; +import {FontSize} from 'ckeditor5'; +import {GeneralHtmlSupport} from 'ckeditor5'; +import {Heading} from 'ckeditor5'; +import {Highlight} from 'ckeditor5'; +import {HorizontalLine} from 'ckeditor5'; +import {HtmlComment} from 'ckeditor5'; +import {HtmlEmbed} from 'ckeditor5'; +import {Image} from 'ckeditor5'; +import {ImageResize} from 'ckeditor5'; +import {ImageStyle} from 'ckeditor5'; +import {ImageToolbar} from 'ckeditor5'; +import {ImageUpload} from 'ckeditor5'; +import {Indent} from 'ckeditor5'; +import {IndentBlock} from 'ckeditor5'; +import {Italic} from 'ckeditor5'; +import {Link} from 'ckeditor5'; +import {LinkImage} from 'ckeditor5'; +import {List} from 'ckeditor5'; +import {ListProperties} from 'ckeditor5'; +import {Markdown} from 'ckeditor5'; +import {MediaEmbed} from 'ckeditor5'; +import {MediaEmbedToolbar} from 'ckeditor5'; +import {Paragraph} from 'ckeditor5'; +import {PasteFromOffice} from 'ckeditor5'; +import {RemoveFormat} from 'ckeditor5'; +import {SourceEditing} from 'ckeditor5'; +import {SpecialCharacters} from 'ckeditor5'; +import {SpecialCharactersArrows} from 'ckeditor5'; +import {SpecialCharactersCurrency} from 'ckeditor5'; +import {SpecialCharactersEssentials} from 'ckeditor5'; +import {SpecialCharactersLatin} from 'ckeditor5'; +import {SpecialCharactersMathematical} from 'ckeditor5'; +import {SpecialCharactersText} from 'ckeditor5'; +import {Strikethrough} from 'ckeditor5'; +import {Subscript} from 'ckeditor5'; +import {Superscript} from 'ckeditor5'; +import {Table} from 'ckeditor5'; +import {TableCaption} from 'ckeditor5'; +import {TableCellProperties} from 'ckeditor5'; +import {TableColumnResize} from 'ckeditor5'; +import {TableProperties} from 'ckeditor5'; +import {TableToolbar} from 'ckeditor5'; +import {Underline} from 'ckeditor5'; +import {WordCount} from 'ckeditor5'; +import {EditorWatchdog} from 'ckeditor5'; +import {TodoList} from 'ckeditor5'; import ExtendedMarkdown from "./plugins/extendedMarkdown.js"; -import SpecialCharactersEmoji from "./plugins/special_characters_emoji"; +import SpecialCharactersGreek from "./plugins/special_characters_emoji"; +import {Mention, Emoji} from "ckeditor5"; class Editor extends ClassicEditor {} @@ -117,9 +118,11 @@ Editor.builtinPlugins = [ Underline, TodoList, + Mention, Emoji, + //Our own extensions ExtendedMarkdown, - SpecialCharactersEmoji + SpecialCharactersGreek ]; // Editor configuration. @@ -148,6 +151,7 @@ Editor.defaultConfig = { 'indent', '|', 'specialCharacters', + "emoji", 'horizontalLine', '|', 'imageUpload', diff --git a/assets/ckeditor/markdown_single_line.js b/assets/ckeditor/markdown_single_line.js index f7e91aa9..f05983a2 100644 --- a/assets/ckeditor/markdown_single_line.js +++ b/assets/ckeditor/markdown_single_line.js @@ -2,35 +2,36 @@ * @license Copyright (c) 2014-2022, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor.js'; -import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat.js'; -import AutoLink from '@ckeditor/ckeditor5-link/src/autolink.js'; -import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold.js'; -import Code from '@ckeditor/ckeditor5-basic-styles/src/code.js'; -import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials.js'; -import FindAndReplace from '@ckeditor/ckeditor5-find-and-replace/src/findandreplace.js'; -import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight.js'; -import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic.js'; -import Link from '@ckeditor/ckeditor5-link/src/link.js'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js'; -import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat.js'; -import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting.js'; -import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters.js'; -import SpecialCharactersArrows from '@ckeditor/ckeditor5-special-characters/src/specialcharactersarrows.js'; -import SpecialCharactersCurrency from '@ckeditor/ckeditor5-special-characters/src/specialcharacterscurrency.js'; -import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials.js'; -import SpecialCharactersLatin from '@ckeditor/ckeditor5-special-characters/src/specialcharacterslatin.js'; -import SpecialCharactersMathematical from '@ckeditor/ckeditor5-special-characters/src/specialcharactersmathematical.js'; -import SpecialCharactersText from '@ckeditor/ckeditor5-special-characters/src/specialcharacterstext.js'; -import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough.js'; -import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript.js'; -import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript.js'; -import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline.js'; -import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog.js'; +import {ClassicEditor} from 'ckeditor5'; +import {Autoformat} from 'ckeditor5'; +import {AutoLink} from 'ckeditor5'; +import {Bold} from 'ckeditor5'; +import {Code} from 'ckeditor5'; +import {Essentials} from 'ckeditor5'; +import {FindAndReplace} from 'ckeditor5'; +import {Highlight} from 'ckeditor5'; +import {Italic} from 'ckeditor5'; +import {Link} from 'ckeditor5'; +import {Paragraph} from 'ckeditor5'; +import {RemoveFormat} from 'ckeditor5'; +import {SourceEditing} from 'ckeditor5'; +import {SpecialCharacters} from 'ckeditor5'; +import {SpecialCharactersArrows} from 'ckeditor5'; +import {SpecialCharactersCurrency} from 'ckeditor5'; +import {SpecialCharactersEssentials} from 'ckeditor5'; +import {SpecialCharactersLatin} from 'ckeditor5'; +import {SpecialCharactersMathematical} from 'ckeditor5'; +import {SpecialCharactersText} from 'ckeditor5'; +import {Strikethrough} from 'ckeditor5'; +import {Subscript} from 'ckeditor5'; +import {Superscript} from 'ckeditor5'; +import {Underline} from 'ckeditor5'; +import {EditorWatchdog} from 'ckeditor5'; +import {Mention, Emoji} from "ckeditor5"; import ExtendedMarkdownInline from "./plugins/extendedMarkdownInline"; import SingleLinePlugin from "./plugins/singleLine"; -import SpecialCharactersEmoji from "./plugins/special_characters_emoji"; +import SpecialCharactersGreek from "./plugins/special_characters_emoji"; class Editor extends ClassicEditor {} @@ -62,7 +63,8 @@ Editor.builtinPlugins = [ ExtendedMarkdownInline, SingleLinePlugin, - SpecialCharactersEmoji + SpecialCharactersGreek, + Mention, Emoji ]; // Editor configuration. @@ -81,6 +83,7 @@ Editor.defaultConfig = { 'link', 'code', 'specialCharacters', + 'emoji', '|', 'undo', 'redo', diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js index 01e1c7bf..708d4ebb 100644 --- a/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabel.js @@ -22,7 +22,7 @@ import PartDBLabelEditing from "./PartDBLabelEditing"; import "./PartDBLabel.css"; -import Plugin from "@ckeditor/ckeditor5-core/src/plugin"; +import {Plugin} from "ckeditor5"; export default class PartDBLabel extends Plugin { static get requires() { @@ -32,4 +32,4 @@ export default class PartDBLabel extends Plugin { static get pluginName() { return 'PartDBLabel'; } -} \ No newline at end of file +} diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js index 4c3af3ef..7b9797e7 100644 --- a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelCommand.js @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -import Command from '@ckeditor/ckeditor5-core/src/command'; +import {Command} from 'ckeditor5'; export default class PartDBLabelCommand extends Command { execute( { value } ) { @@ -47,4 +47,4 @@ export default class PartDBLabelCommand extends Command { this.isEnabled = isAllowed; } -} \ No newline at end of file +} diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js index e61fb895..5cb4860f 100644 --- a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelEditing.js @@ -17,11 +17,11 @@ * along with this program. If not, see . */ -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import {Plugin} from 'ckeditor5'; import PartDBLabelCommand from "./PartDBLabelCommand"; -import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; -import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import { toWidget } from 'ckeditor5'; +import {Widget} from 'ckeditor5'; export default class PartDBLabelEditing extends Plugin { static get requires() { // ADDED @@ -102,4 +102,4 @@ export default class PartDBLabelEditing extends Plugin { } } -} \ No newline at end of file +} diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js index aa29e889..bb9fcd1f 100644 --- a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js +++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js @@ -17,14 +17,15 @@ * along with this program. If not, see . */ -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import {Plugin} from 'ckeditor5'; require('./lang/de.js'); +require('./lang/en.js'); -import { addListToDropdown, createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils'; +import { addListToDropdown, createDropdown } from 'ckeditor5'; -import Collection from '@ckeditor/ckeditor5-utils/src/collection'; -import Model from '@ckeditor/ckeditor5-ui/src/model'; +import {Collection} from 'ckeditor5'; +import {UIModel} from 'ckeditor5'; export default class PartDBLabelUI extends Plugin { init() { @@ -128,6 +129,8 @@ const PLACEHOLDERS = [ ['[[BARCODE_QR]]', 'QR code linking to this element'], ['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'], ['[[BARCODE_C39]]', 'Code 39 barcode linking to this element'], + ['[[BARCODE_C93]]', 'Code 93 barcode linking to this element'], + ['[[BARCODE_DATAMATRIX]]', 'Datamatrix code linking to this element'], ] }, { @@ -149,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, @@ -171,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], @@ -185,4 +198,4 @@ function getDropdownItemsDefinitions(t) { } return itemDefinitions; -} \ No newline at end of file +} diff --git a/assets/ckeditor/plugins/PartDBLabel/lang/de.js b/assets/ckeditor/plugins/PartDBLabel/lang/de.js index 2220cc0b..e0ca0521 100644 --- a/assets/ckeditor/plugins/PartDBLabel/lang/de.js +++ b/assets/ckeditor/plugins/PartDBLabel/lang/de.js @@ -17,15 +17,9 @@ * along with this program. If not, see . */ -// Make sure that the global object is defined. If not, define it. -window.CKEDITOR_TRANSLATIONS = window.CKEDITOR_TRANSLATIONS || {}; +import {add} from "ckeditor5"; -// Make sure that the dictionary for Polish translations exist. -window.CKEDITOR_TRANSLATIONS[ 'de' ] = window.CKEDITOR_TRANSLATIONS[ 'de' ] || {}; -window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary = window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary || {}; - -// Extend the dictionary for Polish translations with your translations: -Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, { +add( "de", { 'Label Placeholder': 'Label Platzhalter', 'Part': 'Bauteil', @@ -69,6 +63,8 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, { 'QR code linking to this element': 'QR Code verknรผpft mit diesem Element', 'Code 128 barcode linking to this element': 'Code 128 Barcode verknรผpft mit diesem Element', 'Code 39 barcode linking to this element': 'Code 39 Barcode verknรผpft mit diesem Element', + 'Code 93 barcode linking to this element': 'Code 93 Barcode verknรผpft mit diesem Element', + 'Datamatrix code linking to this element': 'Datamatrix Code verknรผpft mit diesem Element', 'Location ID': 'Lagerort ID', 'Name': 'Name', @@ -86,5 +82,4 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, { 'Instance name': 'Instanzname', 'Target type': 'Zieltyp', 'URL of this Part-DB instance': 'URL dieser Part-DB Instanz', - -} ); \ No newline at end of file +}); diff --git a/assets/ckeditor/plugins/PartDBLabel/lang/en.js b/assets/ckeditor/plugins/PartDBLabel/lang/en.js new file mode 100644 index 00000000..8f77aaf1 --- /dev/null +++ b/assets/ckeditor/plugins/PartDBLabel/lang/en.js @@ -0,0 +1,84 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2025 Jan Bรถhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {add} from "ckeditor5"; + +add( "en", { + 'Label Placeholder': 'Label placeholder', + 'Part': 'Part', + + 'Database ID': 'Database ID', + 'Part name': 'Part name', + 'Category': 'Category', + 'Category (Full path)': 'Category (full path)', + 'Manufacturer': 'Manufacturer', + 'Manufacturer (Full path)': 'Manufacturer (full path)', + 'Footprint': 'Footprint', + 'Footprint (Full path)': 'Footprint (full path)', + 'Mass': 'Mass', + 'Manufacturer Product Number (MPN)': 'Manufacturer Product Number (MPN)', + 'Internal Part Number (IPN)': 'Internal Part Number (IPN)', + 'Tags': 'Tags', + 'Manufacturing status': 'Manufacturing status', + 'Description': 'Description', + 'Description (plain text)': 'Description (plain text)', + 'Comment': 'Comment', + 'Comment (plain text)': 'Comment (plain text)', + 'Last modified datetime': 'Last modified datetime', + 'Creation datetime': 'Creation datetime', + 'IPN as QR code': 'IPN as QR code', + 'IPN as Code 128 barcode': 'IPN as Code 128 barcode', + 'IPN as Code 39 barcode': 'IPN as Code 39 barcode', + + 'Lot ID': 'Lot ID', + 'Lot name': 'Lot name', + 'Lot comment': 'Lot comment', + 'Lot expiration date': 'Lot expiration date', + 'Lot amount': 'Lot amount', + 'Storage location': 'Storage location', + 'Storage location (Full path)': 'Storage location (full path)', + 'Full name of the lot owner': 'Full name of the lot owner', + 'Username of the lot owner': 'Username of the lot owner', + + 'Barcodes': 'Barcodes', + 'Content of the 1D barcodes (like Code 39)': 'Content of the 1D barcodes (like Code 39)', + 'Content of the 2D barcodes (QR codes)': 'Content of the 2D barcodes (QR codes)', + 'QR code linking to this element': 'QR code linking to this element', + 'Code 128 barcode linking to this element': 'Code 128 barcode linking to this element', + 'Code 39 barcode linking to this element': 'Code 39 barcode linking to this element', + 'Code 93 barcode linking to this element': 'Code 93 barcode linking to this element', + 'Datamatrix code linking to this element': 'Datamatrix code linking to this element', + + 'Location ID': 'Location ID', + 'Name': 'Name', + 'Full path': 'Full path', + 'Parent name': 'Parent name', + 'Parent full path': 'Parent full path', + 'Full name of the location owner': 'Full name of the location owner', + 'Username of the location owner': 'Username of the location owner', + + 'Username': 'Username', + 'Username (including name)': 'Username (including name)', + 'Current datetime': 'Current datetime', + 'Current date': 'Current date', + 'Current time': 'Current time', + 'Instance name': 'Instance name', + 'Target type': 'Target type', + 'URL of this Part-DB instance': 'URL of this Part-DB instance', +} ); diff --git a/assets/ckeditor/plugins/extendedMarkdown.js b/assets/ckeditor/plugins/extendedMarkdown.js index 987388cd..fc861175 100644 --- a/assets/ckeditor/plugins/extendedMarkdown.js +++ b/assets/ckeditor/plugins/extendedMarkdown.js @@ -17,8 +17,7 @@ * along with this program. If not, see . */ -import { Plugin } from 'ckeditor5/src/core'; -import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataprocessor'; +import { Plugin, MarkdownGfmDataProcessor } from 'ckeditor5'; const ALLOWED_TAGS = [ //Common elements @@ -34,7 +33,6 @@ const ALLOWED_TAGS = [ //Block elements 'span', - 'p', 'img', @@ -57,7 +55,7 @@ export default class ExtendedMarkdown extends Plugin { constructor( editor ) { super( editor ); - editor.data.processor = new GFMDataProcessor( editor.data.viewDocument ); + editor.data.processor = new MarkdownGfmDataProcessor( editor.data.viewDocument ); for (const tag of ALLOWED_TAGS) { editor.data.processor.keepHtml(tag); } diff --git a/assets/ckeditor/plugins/extendedMarkdownInline.js b/assets/ckeditor/plugins/extendedMarkdownInline.js index 21d4074c..695e7089 100644 --- a/assets/ckeditor/plugins/extendedMarkdownInline.js +++ b/assets/ckeditor/plugins/extendedMarkdownInline.js @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -import { Plugin } from 'ckeditor5/src/core'; -import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataprocessor'; +import {Plugin} from 'ckeditor5'; +import {MarkdownGfmDataProcessor} from 'ckeditor5'; const ALLOWED_TAGS = [ //Common elements @@ -46,7 +46,7 @@ export default class ExtendedMarkdownInline extends Plugin { constructor( editor ) { super( editor ); - editor.data.processor = new GFMDataProcessor( editor.data.viewDocument ); + editor.data.processor = new MarkdownGfmDataProcessor( editor.data.viewDocument ); for (const tag of ALLOWED_TAGS) { editor.data.processor.keepHtml(tag); } diff --git a/assets/ckeditor/plugins/singleLine.js b/assets/ckeditor/plugins/singleLine.js index 79ac9c6f..be84dd22 100644 --- a/assets/ckeditor/plugins/singleLine.js +++ b/assets/ckeditor/plugins/singleLine.js @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import {Plugin} from 'ckeditor5'; export default class SingleLinePlugin extends Plugin { init() { @@ -42,7 +42,7 @@ export default class SingleLinePlugin extends Plugin { //We can not use the dataTransfer.setData method because the old object is somehow protected data.dataTransfer = new DataTransfer(); data.dataTransfer.setData("text", cleaned); - + }, { priority: 'high' } ); } -} \ No newline at end of file +} diff --git a/assets/ckeditor/plugins/special_characters_emoji.js b/assets/ckeditor/plugins/special_characters_emoji.js index 1d4ec000..1a57def2 100644 --- a/assets/ckeditor/plugins/special_characters_emoji.js +++ b/assets/ckeditor/plugins/special_characters_emoji.js @@ -17,14 +17,12 @@ * along with this program. If not, see . */ -import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters'; -import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials'; +import SpecialCharacters from 'ckeditor5'; +import SpecialCharactersEssentials from 'ckeditor5'; -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import {Plugin} from 'ckeditor5'; -const emoji = require('emoji.json'); - -export default class SpecialCharactersEmoji extends Plugin { +export default class SpecialCharactersGreek extends Plugin { init() { const editor = this.editor; @@ -32,9 +30,6 @@ export default class SpecialCharactersEmoji extends Plugin { //Add greek characters to special characters specialCharsPlugin.addItems('Greek', this.getGreek()); - - //Add Emojis to special characters - specialCharsPlugin.addItems('Emoji', this.getEmojis()); } getGreek() { @@ -96,14 +91,4 @@ export default class SpecialCharactersEmoji extends Plugin { { title: 'san', character: 'ฯบ' }, ]; } - - getEmojis() { - //Map our emoji data to the format the plugin expects - return emoji.map(emoji => { - return { - title: emoji.name, - character: emoji.char - }; - }); - } -} \ No newline at end of file +} diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js new file mode 100644 index 00000000..49e4d60f --- /dev/null +++ b/assets/controllers/bulk_import_controller.js @@ -0,0 +1,359 @@ +import { Controller } from "@hotwired/stimulus" +import { generateCsrfHeaders } from "./csrf_protection_controller" + +export default class extends Controller { + static targets = ["progressBar", "progressText"] + static values = { + jobId: Number, + partId: Number, + researchUrl: String, + researchAllUrl: String, + markCompletedUrl: String, + markSkippedUrl: String, + markPendingUrl: String + } + + connect() { + // Auto-refresh progress if job is in progress + if (this.hasProgressBarTarget) { + this.startProgressUpdates() + } + + // Restore scroll position after page reload (if any) + this.restoreScrollPosition() + } + + getHeaders() { + const headers = { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + + // Add CSRF headers if available + const form = document.querySelector('form') + if (form) { + const csrfHeaders = generateCsrfHeaders(form) + Object.assign(headers, csrfHeaders) + } + + return headers + } + + async fetchWithErrorHandling(url, options = {}, timeout = 30000) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(url, { + ...options, + headers: { ...this.getHeaders(), ...options.headers }, + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Server error (${response.status}): ${errorText}`) + } + + return await response.json() + } catch (error) { + clearTimeout(timeoutId) + + if (error.name === 'AbortError') { + throw new Error('Request timed out. Please try again.') + } else if (error.message.includes('Failed to fetch')) { + throw new Error('Network error. Please check your connection and try again.') + } else { + throw error + } + } + } + + disconnect() { + if (this.progressInterval) { + clearInterval(this.progressInterval) + } + } + + startProgressUpdates() { + // Progress updates are handled via page reload for better reliability + // No need for periodic updates since state changes trigger page refresh + } + + restoreScrollPosition() { + const savedPosition = sessionStorage.getItem('bulkImportScrollPosition') + if (savedPosition) { + // Restore scroll position after a small delay to ensure page is fully loaded + setTimeout(() => { + window.scrollTo(0, parseInt(savedPosition)) + // Clear the saved position so it doesn't interfere with normal navigation + sessionStorage.removeItem('bulkImportScrollPosition') + }, 100) + } + } + + async markCompleted(event) { + const partId = event.currentTarget.dataset.partId + + try { + const url = this.markCompletedUrlValue.replace('__PART_ID__', partId) + const data = await this.fetchWithErrorHandling(url, { method: 'POST' }) + + if (data.success) { + this.updateProgressDisplay(data) + this.markRowAsCompleted(partId) + + if (data.job_completed) { + this.showJobCompletedMessage() + } + } else { + this.showErrorMessage(data.error || 'Failed to mark part as completed') + } + } catch (error) { + console.error('Error marking part as completed:', error) + this.showErrorMessage(error.message || 'Failed to mark part as completed') + } + } + + async markSkipped(event) { + const partId = event.currentTarget.dataset.partId + const reason = prompt('Reason for skipping (optional):') || '' + + try { + const url = this.markSkippedUrlValue.replace('__PART_ID__', partId) + const data = await this.fetchWithErrorHandling(url, { + method: 'POST', + body: JSON.stringify({ reason }) + }) + + if (data.success) { + this.updateProgressDisplay(data) + this.markRowAsSkipped(partId) + } else { + this.showErrorMessage(data.error || 'Failed to mark part as skipped') + } + } catch (error) { + console.error('Error marking part as skipped:', error) + this.showErrorMessage(error.message || 'Failed to mark part as skipped') + } + } + + async markPending(event) { + const partId = event.currentTarget.dataset.partId + + try { + const url = this.markPendingUrlValue.replace('__PART_ID__', partId) + const data = await this.fetchWithErrorHandling(url, { method: 'POST' }) + + if (data.success) { + this.updateProgressDisplay(data) + this.markRowAsPending(partId) + } else { + this.showErrorMessage(data.error || 'Failed to mark part as pending') + } + } catch (error) { + console.error('Error marking part as pending:', error) + this.showErrorMessage(error.message || 'Failed to mark part as pending') + } + } + + updateProgressDisplay(data) { + if (this.hasProgressBarTarget) { + this.progressBarTarget.style.width = `${data.progress}%` + this.progressBarTarget.setAttribute('aria-valuenow', data.progress) + } + + if (this.hasProgressTextTarget) { + this.progressTextTarget.textContent = `${data.completed_count} / ${data.total_count} completed` + } + } + + markRowAsCompleted(partId) { + // Save scroll position and refresh page to show updated state + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } + + markRowAsSkipped(partId) { + // Save scroll position and refresh page to show updated state + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } + + markRowAsPending(partId) { + // Save scroll position and refresh page to show updated state + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } + + showJobCompletedMessage() { + const alert = document.createElement('div') + alert.className = 'alert alert-success alert-dismissible fade show' + alert.innerHTML = ` + + Job completed! All parts have been processed. + + ` + + const container = document.querySelector('.card-body') + container.insertBefore(alert, container.firstChild) + } + + async researchPart(event) { + event.preventDefault() + event.stopPropagation() + + const partId = event.currentTarget.dataset.partId + const spinner = event.currentTarget.querySelector(`[data-research-spinner="${partId}"]`) + const button = event.currentTarget + + // Show loading state + if (spinner) { + spinner.style.display = 'inline-block' + } + button.disabled = true + + try { + const url = this.researchUrlValue.replace('__PART_ID__', partId) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Server error (${response.status}): ${errorText}`) + } + + const data = await response.json() + + if (data.success) { + this.showSuccessMessage(`Research completed for part. Found ${data.results_count} results.`) + // Save scroll position and reload to show updated results + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } else { + this.showErrorMessage(data.error || 'Research failed') + } + } catch (error) { + console.error('Error researching part:', error) + + if (error.name === 'AbortError') { + this.showErrorMessage('Research timed out. Please try again.') + } else if (error.message.includes('Failed to fetch')) { + this.showErrorMessage('Network error. Please check your connection and try again.') + } else { + this.showErrorMessage(error.message || 'Research failed due to an unexpected error') + } + } finally { + // Hide loading state + if (spinner) { + spinner.style.display = 'none' + } + button.disabled = false + } + } + + async researchAllParts(event) { + event.preventDefault() + event.stopPropagation() + + const spinner = document.getElementById('research-all-spinner') + const button = event.currentTarget + + // Show loading state + if (spinner) { + spinner.style.display = 'inline-block' + } + button.disabled = true + + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 120000) // 2 minute timeout for bulk operations + + const response = await fetch(this.researchAllUrlValue, { + method: 'POST', + headers: this.getHeaders(), + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Server error (${response.status}): ${errorText}`) + } + + const data = await response.json() + + if (data.success) { + this.showSuccessMessage(`Research completed for ${data.researched_count} parts.`) + // Save scroll position and reload to show updated results + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } else { + this.showErrorMessage(data.error || 'Bulk research failed') + } + } catch (error) { + console.error('Error researching all parts:', error) + + if (error.name === 'AbortError') { + this.showErrorMessage('Bulk research timed out. This may happen with large batches. Please try again or process smaller batches.') + } else if (error.message.includes('Failed to fetch')) { + this.showErrorMessage('Network error. Please check your connection and try again.') + } else { + this.showErrorMessage(error.message || 'Bulk research failed due to an unexpected error') + } + } finally { + // Hide loading state + if (spinner) { + spinner.style.display = 'none' + } + button.disabled = false + } + } + + showSuccessMessage(message) { + this.showToast('success', message) + } + + showErrorMessage(message) { + this.showToast('error', message) + } + + showToast(type, message) { + // Create a simple alert that doesn't disrupt layout + const alertId = 'alert-' + Date.now() + const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle' + const alertClass = type === 'success' ? 'alert-success' : 'alert-danger' + + const alertHTML = ` +
+ + ${message} + +
+ ` + + // Add alert to body + document.body.insertAdjacentHTML('beforeend', alertHTML) + + // Auto-remove after 5 seconds + setTimeout(() => { + const alertElement = document.getElementById(alertId) + if (alertElement) { + alertElement.remove() + } + }, 5000) + } +} \ No newline at end of file diff --git a/assets/controllers/bulk_job_manage_controller.js b/assets/controllers/bulk_job_manage_controller.js new file mode 100644 index 00000000..c26e37c6 --- /dev/null +++ b/assets/controllers/bulk_job_manage_controller.js @@ -0,0 +1,92 @@ +import { Controller } from "@hotwired/stimulus" +import { generateCsrfHeaders } from "./csrf_protection_controller" + +export default class extends Controller { + static values = { + deleteUrl: String, + stopUrl: String, + deleteConfirmMessage: String, + stopConfirmMessage: String + } + + connect() { + // Controller initialized + } + getHeaders() { + const headers = { + 'X-Requested-With': 'XMLHttpRequest' + } + + // Add CSRF headers if available + const form = document.querySelector('form') + if (form) { + const csrfHeaders = generateCsrfHeaders(form) + Object.assign(headers, csrfHeaders) + } + + return headers + } + async deleteJob(event) { + const jobId = event.currentTarget.dataset.jobId + const confirmMessage = this.deleteConfirmMessageValue || 'Are you sure you want to delete this job?' + + if (confirm(confirmMessage)) { + try { + const deleteUrl = this.deleteUrlValue.replace('__JOB_ID__', jobId) + + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: this.getHeaders() + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const data = await response.json() + + if (data.success) { + location.reload() + } else { + alert('Error deleting job: ' + (data.error || 'Unknown error')) + } + } catch (error) { + console.error('Error deleting job:', error) + alert('Error deleting job: ' + error.message) + } + } + } + + async stopJob(event) { + const jobId = event.currentTarget.dataset.jobId + const confirmMessage = this.stopConfirmMessageValue || 'Are you sure you want to stop this job?' + + if (confirm(confirmMessage)) { + try { + const stopUrl = this.stopUrlValue.replace('__JOB_ID__', jobId) + + const response = await fetch(stopUrl, { + method: 'POST', + headers: this.getHeaders() + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const data = await response.json() + + if (data.success) { + location.reload() + } else { + alert('Error stopping job: ' + (data.error || 'Unknown error')) + } + } catch (error) { + console.error('Error stopping job:', error) + alert('Error stopping job: ' + error.message) + } + } + } +} \ No newline at end of file diff --git a/assets/controllers/common/markdown_controller.js b/assets/controllers/common/markdown_controller.js index b6ef0034..c6cb97df 100644 --- a/assets/controllers/common/markdown_controller.js +++ b/assets/controllers/common/markdown_controller.js @@ -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, }); }*/ -} \ No newline at end of file +} diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js new file mode 100644 index 00000000..511fffa5 --- /dev/null +++ b/assets/controllers/csrf_protection_controller.js @@ -0,0 +1,81 @@ +const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; +const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; + +// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event +// and thus this event-listener will not be executed. +document.addEventListener('submit', function (event) { + generateCsrfToken(event.target); +}, true); + +// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie +// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked +document.addEventListener('turbo:submit-start', function (event) { + const h = generateCsrfHeaders(event.detail.formSubmission.formElement); + Object.keys(h).map(function (k) { + event.detail.formSubmission.fetchRequest.headers[k] = h[k]; + }); +}); + +// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted +document.addEventListener('turbo:submit-end', function (event) { + removeCsrfToken(event.detail.formSubmission.formElement); +}); + +export function generateCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + let csrfToken = csrfField.value; + + if (!csrfCookie && nameCheck.test(csrfToken)) { + csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); + csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); + } + csrfField.dispatchEvent(new Event('change', { bubbles: true })); + + if (csrfCookie && tokenCheck.test(csrfToken)) { + const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +export function generateCsrfHeaders (formElement) { + const headers = {}; + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return headers; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + headers[csrfCookie] = csrfField.value; + } + + return headers; +} + +export function removeCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0'; + + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +/* stimulusFetch: 'lazy' */ +export default 'csrf-protection-controller'; diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js index f8bc301e..94b01136 100644 --- a/assets/controllers/elements/attachment_autocomplete_controller.js +++ b/assets/controllers/elements/attachment_autocomplete_controller.js @@ -34,6 +34,11 @@ export default class extends Controller { connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { persistent: false, create: true, @@ -42,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 '' + escape(data.label) + ''; diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js index 079ee2ad..b7c87dab 100644 --- a/assets/controllers/elements/ckeditor_controller.js +++ b/assets/controllers/elements/ckeditor_controller.js @@ -23,10 +23,32 @@ import { default as FullEditor } from "../../ckeditor/markdown_full"; import { default as SingleLineEditor} from "../../ckeditor/markdown_single_line"; import { default as HTMLLabelEditor } from "../../ckeditor/html_label"; -import EditorWatchdog from '@ckeditor/ckeditor5-watchdog/src/editorwatchdog'; +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() { @@ -51,9 +73,22 @@ export default class extends Controller { const language = document.body.dataset.locale ?? "en"; + const emojiURL = new URL('../../ckeditor/emojis.json', import.meta.url).href; + const config = { language: language, licenseKey: "GPL", + + emoji: { + definitionsUrl: emojiURL + } + } + + //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(); @@ -71,6 +106,15 @@ export default class extends Controller { editor_div.classList.add(...new_classes.split(",")); } + // Automatic synchronization of source input + editor.model.document.on("change:data", () => { + editor.updateSourceElement(); + + // Dispatch the input event for further treatment + const event = new Event("input"); + this.element.dispatchEvent(event); + }); + //This return is important! Otherwise we get mysterious errors in the console //See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302 return editor; @@ -84,4 +128,4 @@ export default class extends Controller { console.error(error); }); } -} \ No newline at end of file +} diff --git a/assets/controllers/elements/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js index 1fe11a20..c43fa276 100644 --- a/assets/controllers/elements/datatables/parts_controller.js +++ b/assets/controllers/elements/datatables/parts_controller.js @@ -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 diff --git a/assets/controllers/elements/ipn_suggestion_controller.js b/assets/controllers/elements/ipn_suggestion_controller.js new file mode 100644 index 00000000..c8b543cb --- /dev/null +++ b/assets/controllers/elements/ipn_suggestion_controller.js @@ -0,0 +1,250 @@ +import { Controller } from "@hotwired/stimulus"; +import "../../css/components/autocomplete_bootstrap_theme.css"; + +export default class extends Controller { + static targets = ["input"]; + static values = { + partId: Number, + partCategoryId: Number, + partDescription: String, + suggestions: Object, + commonSectionHeader: String, // Dynamic header for common Prefixes + partIncrementHeader: String, // Dynamic header for new possible part increment + suggestUrl: String, + }; + + connect() { + this.configureAutocomplete(); + this.watchCategoryChanges(); + this.watchDescriptionChanges(); + } + + templates = { + commonSectionHeader({ title, html }) { + return html` +
+
+ ${title} +
+
+
+ `; + }, + partIncrementHeader({ title, html }) { + return html` +
+
+ ${title} +
+
+
+ `; + }, + list({ html }) { + return html` +
    + `; + }, + item({ suggestion, description, html }) { + return html` +
  • +
    +
    +
    + + + +
    +
    +
    ${suggestion}
    +
    ${description}
    +
    +
    +
    +
  • + `; + }, + }; + + configureAutocomplete() { + const inputField = this.inputTarget; + const commonPrefixes = this.suggestionsValue.commonPrefixes || []; + const prefixesPartIncrement = this.suggestionsValue.prefixesPartIncrement || []; + const commonHeader = this.commonSectionHeaderValue; + const partIncrementHeader = this.partIncrementHeaderValue; + + if (!inputField || (!commonPrefixes.length && !prefixesPartIncrement.length)) return; + + // Check whether the panel should be created at the update + if (this.isPanelInitialized) { + const existingPanel = inputField.parentNode.querySelector(".aa-Panel"); + if (existingPanel) { + // Only remove the panel in the update phase + + existingPanel.remove(); + } + } + + // Create panel + const panel = document.createElement("div"); + panel.classList.add("aa-Panel"); + panel.style.display = "none"; + + // Create panel layout + const panelLayout = document.createElement("div"); + panelLayout.classList.add("aa-PanelLayout", "aa-Panel--scrollable"); + + // Section for prefixes part increment + if (prefixesPartIncrement.length) { + const partIncrementSection = document.createElement("section"); + partIncrementSection.classList.add("aa-Source"); + + const partIncrementHeaderHtml = this.templates.partIncrementHeader({ + title: partIncrementHeader, + html: String.raw, + }); + partIncrementSection.innerHTML += partIncrementHeaderHtml; + + const partIncrementList = document.createElement("ul"); + partIncrementList.classList.add("aa-List"); + partIncrementList.setAttribute("role", "listbox"); + + prefixesPartIncrement.forEach((prefix) => { + const itemHTML = this.templates.item({ + suggestion: prefix.title, + description: prefix.description, + html: String.raw, + }); + partIncrementList.innerHTML += itemHTML; + }); + + partIncrementSection.appendChild(partIncrementList); + panelLayout.appendChild(partIncrementSection); + } + + // Section for common prefixes + if (commonPrefixes.length) { + const commonSection = document.createElement("section"); + commonSection.classList.add("aa-Source"); + + const commonSectionHeader = this.templates.commonSectionHeader({ + title: commonHeader, + html: String.raw, + }); + commonSection.innerHTML += commonSectionHeader; + + const commonList = document.createElement("ul"); + commonList.classList.add("aa-List"); + commonList.setAttribute("role", "listbox"); + + commonPrefixes.forEach((prefix) => { + const itemHTML = this.templates.item({ + suggestion: prefix.title, + description: prefix.description, + html: String.raw, + }); + commonList.innerHTML += itemHTML; + }); + + commonSection.appendChild(commonList); + panelLayout.appendChild(commonSection); + } + + panel.appendChild(panelLayout); + inputField.parentNode.appendChild(panel); + + inputField.addEventListener("focus", () => { + panel.style.display = "block"; + }); + + inputField.addEventListener("blur", () => { + setTimeout(() => { + panel.style.display = "none"; + }, 100); + }); + + // Selection of an item + panelLayout.addEventListener("mousedown", (event) => { + const target = event.target.closest("li"); + + if (target) { + inputField.value = target.dataset.suggestion; + panel.style.display = "none"; + } + }); + + this.isPanelInitialized = true; + }; + + watchCategoryChanges() { + const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]'); + const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]'); + this.previousCategoryId = Number(this.partCategoryIdValue); + + if (categoryField) { + categoryField.addEventListener("change", () => { + const categoryId = Number(categoryField.value); + const description = String(descriptionField?.value ?? ''); + + // Check whether the category has changed compared to the previous ID + if (categoryId !== this.previousCategoryId) { + this.fetchNewSuggestions(categoryId, description); + this.previousCategoryId = categoryId; + } + }); + } + } + + watchDescriptionChanges() { + const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]'); + const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]'); + this.previousDescription = String(this.partDescriptionValue); + + if (descriptionField) { + descriptionField.addEventListener("input", () => { + const categoryId = Number(categoryField.value); + const description = String(descriptionField?.value ?? ''); + + // Check whether the description has changed compared to the previous one + if (description !== this.previousDescription) { + this.fetchNewSuggestions(categoryId, description); + this.previousDescription = description; + } + }); + } + } + + fetchNewSuggestions(categoryId, description) { + const baseUrl = this.suggestUrlValue; + const partId = this.partIdValue; + const truncatedDescription = description.length > 150 ? description.substring(0, 150) : description; + const encodedDescription = this.base64EncodeUtf8(truncatedDescription); + const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}` + (description !== '' ? `&description=${encodedDescription}` : ''); + + fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Error when calling up the IPN-suggestions: ${response.status}`); + } + return response.json(); + }) + .then((data) => { + this.suggestionsValue = data; + this.configureAutocomplete(); + }) + .catch((error) => { + console.error("Errors when loading the new IPN-suggestions:", error); + }); + }; + + base64EncodeUtf8(text) { + const utf8Bytes = new TextEncoder().encode(text); + return btoa(String.fromCharCode(...utf8Bytes)); + }; +} diff --git a/assets/controllers/elements/part_search_controller.js b/assets/controllers/elements/part_search_controller.js index c33cece0..c1396900 100644 --- a/assets/controllers/elements/part_search_controller.js +++ b/assets/controllers/elements/part_search_controller.js @@ -26,9 +26,6 @@ import {marked} from "marked"; import { trans, - SEARCH_PLACEHOLDER, - SEARCH_SUBMIT, - STATISTICS_PARTS } from '../../translator'; @@ -82,9 +79,9 @@ export default class extends Controller { panelPlacement: this.element.dataset.panelPlacement, plugins: [recentSearchesPlugin], openOnFocus: true, - placeholder: trans(SEARCH_PLACEHOLDER), + placeholder: trans("search.placeholder"), translations: { - submitButtonTitle: trans(SEARCH_SUBMIT) + submitButtonTitle: trans("search.submit") }, // Use a navigator compatible with turbo: @@ -153,7 +150,7 @@ export default class extends Controller { }, templates: { header({ html }) { - return html`${trans(STATISTICS_PARTS)} + return html`${trans("part.labelp")}
    `; }, item({item, components, html}) { @@ -197,4 +194,4 @@ export default class extends Controller { } } -} \ No newline at end of file +} diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index 5abd5ba3..8a4e19b8 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -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(); } -} \ No newline at end of file +} diff --git a/assets/controllers/elements/password_strength_estimate_controller.js b/assets/controllers/elements/password_strength_estimate_controller.js index 0fc9c578..16d18b55 100644 --- a/assets/controllers/elements/password_strength_estimate_controller.js +++ b/assets/controllers/elements/password_strength_estimate_controller.js @@ -25,8 +25,7 @@ import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en'; import * as zxcvbnDePackage from '@zxcvbn-ts/language-de'; import * as zxcvbnFrPackage from '@zxcvbn-ts/language-fr'; import * as zxcvbnJaPackage from '@zxcvbn-ts/language-ja'; -import {trans, USER_PASSWORD_STRENGTH_VERY_WEAK, USER_PASSWORD_STRENGTH_WEAK, USER_PASSWORD_STRENGTH_MEDIUM, - USER_PASSWORD_STRENGTH_STRONG, USER_PASSWORD_STRENGTH_VERY_STRONG} from '../../translator.js'; +import {trans} from '../../translator.js'; /* stimulusFetch: 'lazy' */ export default class extends Controller { @@ -89,23 +88,23 @@ export default class extends Controller { switch (level) { case 0: - text = trans(USER_PASSWORD_STRENGTH_VERY_WEAK); + text = trans("user.password_strength.very_weak"); classes = "bg-danger badge-danger"; break; case 1: - text = trans(USER_PASSWORD_STRENGTH_WEAK); + text = trans("user.password_strength.weak"); classes = "bg-warning badge-warning"; break; case 2: - text = trans(USER_PASSWORD_STRENGTH_MEDIUM) + text = trans("user.password_strength.medium") classes = "bg-info badge-info"; break; case 3: - text = trans(USER_PASSWORD_STRENGTH_STRONG); + text = trans("user.password_strength.strong"); classes = "bg-primary badge-primary"; break; case 4: - text = trans(USER_PASSWORD_STRENGTH_VERY_STRONG); + text = trans("user.password_strength.very_strong"); classes = "bg-success badge-success"; break; default: @@ -120,4 +119,4 @@ export default class extends Controller { this.badgeTarget.classList.add("badge"); this.badgeTarget.classList.add(...classes.split(" ")); } -} \ No newline at end of file +} diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js index a96bca10..d70e588c 100644 --- a/assets/controllers/elements/select_controller.js +++ b/assets/controllers/elements/select_controller.js @@ -38,11 +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), @@ -50,7 +56,24 @@ export default class extends Controller { } }; + //Load the drag_drop plugin if the select is ordered + if (this.element.dataset.orderedValue) { + settings.plugins.push('drag_drop'); + settings.plugins.push("caret_position"); + } + + //If multiple items can be selected, enable the remove_button plugin + if (this.element.multiple) { + settings.plugins.push('remove_button'); + } + this._tomSelect = new TomSelect(this.element, settings); + + //If the select is ordered, we need to update the value field (with the decoded value from the orderedValue field) + if (this.element.dataset.orderedValue) { + const data = JSON.parse(this.element.dataset.orderedValue); + this._tomSelect.setValue(data); + } } getTomSelect() { @@ -90,4 +113,4 @@ export default class extends Controller { //Destroy the TomSelect instance this._tomSelect.destroy(); } -} \ No newline at end of file +} diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js index 85680af0..17e85fae 100644 --- a/assets/controllers/elements/select_multiple_controller.js +++ b/assets/controllers/elements/select_multiple_controller.js @@ -20,13 +20,21 @@ import {Controller} from "@hotwired/stimulus"; import TomSelect from "tom-select"; +// TODO: Merge with select_controller.js + 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'], }); } @@ -37,4 +45,4 @@ export default class extends Controller { this._tomSelect.destroy(); } -} \ No newline at end of file +} diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js index 31ca0314..9703c618 100644 --- a/assets/controllers/elements/static_file_autocomplete_controller.js +++ b/assets/controllers/elements/static_file_autocomplete_controller.js @@ -40,6 +40,11 @@ export default class extends Controller { connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { persistent: false, create: true, @@ -50,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', diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index a1114a97..2666530b 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -22,7 +22,7 @@ import '../../css/components/tom-select_extensions.css'; import TomSelect from "tom-select"; import {Controller} from "@hotwired/stimulus"; -import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js' +import {trans} from '../../translator.js' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) @@ -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}, @@ -200,7 +204,7 @@ export default class extends Controller { if (data.not_in_db_yet) { //Not yet added items are shown italic and with a badge - name += "" + escape(data.text) + "" + "" + trans(ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB) + ""; + name += "" + escape(data.text) + "" + "" + trans("entity.select.group.new_not_added_to_DB") + ""; } else { name += "" + escape(data.text) + ""; } diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js index 1f10c457..14725227 100644 --- a/assets/controllers/elements/tagsinput_controller.js +++ b/assets/controllers/elements/tagsinput_controller.js @@ -33,6 +33,11 @@ export default class extends Controller { _tomSelect; connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { plugins: { remove_button:{}, @@ -43,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(); } -} \ No newline at end of file +} diff --git a/assets/controllers/field_mapping_controller.js b/assets/controllers/field_mapping_controller.js new file mode 100644 index 00000000..9c9c8ac6 --- /dev/null +++ b/assets/controllers/field_mapping_controller.js @@ -0,0 +1,136 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["tbody", "addButton", "submitButton"] + static values = { + mappingIndex: Number, + maxMappings: Number, + prototype: String, + maxMappingsReachedMessage: String + } + + connect() { + this.updateAddButtonState() + this.updateFieldOptions() + this.attachEventListeners() + } + + attachEventListeners() { + // Add event listeners to existing field selects + const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]') + fieldSelects.forEach(select => { + select.addEventListener('change', this.updateFieldOptions.bind(this)) + }) + + // Note: Add button click is handled by Stimulus action in template (data-action="click->field-mapping#addMapping") + // No manual event listener needed + + // Form submit handler + const form = this.element.querySelector('form') + if (form && this.hasSubmitButtonTarget) { + form.addEventListener('submit', this.handleFormSubmit.bind(this)) + } + } + + addMapping() { + const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length + + if (currentMappings >= this.maxMappingsValue) { + alert(this.maxMappingsReachedMessageValue) + return + } + + const newRowHtml = this.prototypeValue.replace(/__name__/g, this.mappingIndexValue) + const tempDiv = document.createElement('div') + tempDiv.innerHTML = newRowHtml + + const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0] + const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1] + const priorityWidget = tempDiv.querySelector('input[name*="[priority]"]') || tempDiv.children[2] + + const newRow = document.createElement('tr') + newRow.className = 'mapping-row' + newRow.innerHTML = ` + ${fieldWidget ? fieldWidget.outerHTML : ''} + ${providerWidget ? providerWidget.outerHTML : ''} + ${priorityWidget ? priorityWidget.outerHTML : ''} + + + + ` + + this.tbodyTarget.appendChild(newRow) + this.mappingIndexValue++ + + const newFieldSelect = newRow.querySelector('select[name*="[field]"]') + if (newFieldSelect) { + newFieldSelect.value = '' + newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this)) + } + + this.updateFieldOptions() + this.updateAddButtonState() + } + + removeMapping(event) { + const row = event.target.closest('tr') + row.remove() + this.updateFieldOptions() + this.updateAddButtonState() + } + + updateFieldOptions() { + const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]') + + const selectedFields = Array.from(fieldSelects) + .map(select => select.value) + .filter(value => value && value !== '') + + fieldSelects.forEach(select => { + Array.from(select.options).forEach(option => { + const isCurrentValue = option.value === select.value + const isEmptyOption = !option.value || option.value === '' + const isAlreadySelected = selectedFields.includes(option.value) + + if (!isEmptyOption && isAlreadySelected && !isCurrentValue) { + option.disabled = true + option.style.display = 'none' + } else { + option.disabled = false + option.style.display = '' + } + }) + }) + } + + updateAddButtonState() { + const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length + + if (this.hasAddButtonTarget) { + if (currentMappings >= this.maxMappingsValue) { + this.addButtonTarget.disabled = true + this.addButtonTarget.title = this.maxMappingsReachedMessageValue + } else { + this.addButtonTarget.disabled = false + this.addButtonTarget.title = '' + } + } + } + + handleFormSubmit(event) { + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.disabled = true + + // Disable the entire form to prevent changes during processing + const form = event.target + const formElements = form.querySelectorAll('input, select, textarea, button') + formElements.forEach(element => { + if (element !== this.submitButtonTarget) { + element.disabled = true + } + }) + } + } +} \ No newline at end of file diff --git a/assets/controllers/pages/latex_preview_controller.js b/assets/controllers/pages/latex_preview_controller.js index 6113393a..7f1e611c 100644 --- a/assets/controllers/pages/latex_preview_controller.js +++ b/assets/controllers/pages/latex_preview_controller.js @@ -33,7 +33,10 @@ export default class extends Controller { { let value = ""; if (this.unitValue) { - value = "\\mathrm{" + this.inputTarget.value + "}"; + //Escape percentage signs + value = this.inputTarget.value.replace(/%/g, '\\%'); + + value = "\\mathrm{" + value + "}"; } else { value = this.inputTarget.value; } diff --git a/assets/controllers/pages/parameters_autocomplete_controller.js b/assets/controllers/pages/parameters_autocomplete_controller.js index cd82875a..e187aa42 100644 --- a/assets/controllers/pages/parameters_autocomplete_controller.js +++ b/assets/controllers/pages/parameters_autocomplete_controller.js @@ -85,7 +85,9 @@ export default class extends Controller tmp += '' + katex.renderToString(data.symbol) + '' } if (data.unit) { - tmp += '' + katex.renderToString('[' + data.unit + ']') + '' + let unit = data.unit.replace(/%/g, '\\%'); + unit = "\\mathrm{" + unit + "}"; + tmp += '' + katex.renderToString('[' + unit + ']') + '' } diff --git a/assets/controllers/pages/synonyms_collection_controller.js b/assets/controllers/pages/synonyms_collection_controller.js new file mode 100644 index 00000000..6b2f4811 --- /dev/null +++ b/assets/controllers/pages/synonyms_collection_controller.js @@ -0,0 +1,68 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2022 Jan Bรถhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['items']; + static values = { + prototype: String, + prototypeName: { type: String, default: '__name__' }, + index: { type: Number, default: 0 }, + }; + + connect() { + if (!this.hasIndexValue || Number.isNaN(this.indexValue)) { + this.indexValue = this.itemsTarget?.children.length || 0; + } + } + + add(event) { + event.preventDefault(); + + const encodedProto = this.prototypeValue || ''; + const placeholder = this.prototypeNameValue || '__name__'; + if (!encodedProto || !this.itemsTarget) return; + + const protoHtml = this._decodeHtmlAttribute(encodedProto); + + const idx = this.indexValue; + const html = protoHtml.replace(new RegExp(placeholder, 'g'), String(idx)); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const newItem = wrapper.firstElementChild; + if (newItem) { + this.itemsTarget.appendChild(newItem); + this.indexValue = idx + 1; + } + } + + remove(event) { + event.preventDefault(); + const row = event.currentTarget.closest('.tc-item'); + if (row) row.remove(); + } + + _decodeHtmlAttribute(str) { + const tmp = document.createElement('textarea'); + tmp.innerHTML = str; + return tmp.value || tmp.textContent || ''; + } +} diff --git a/assets/controllers/toggle_password_controller.js b/assets/controllers/toggle_password_controller.js new file mode 100644 index 00000000..bef87e11 --- /dev/null +++ b/assets/controllers/toggle_password_controller.js @@ -0,0 +1,86 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2025 Jan Bรถhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Controller } from '@hotwired/stimulus'; +import '../css/components/toggle_password.css'; + +export default class extends Controller { + static values = { + visibleLabel: { type: String, default: 'Show' }, + visibleIcon: { type: String, default: 'Default' }, + hiddenLabel: { type: String, default: 'Hide' }, + hiddenIcon: { type: String, default: 'Default' }, + buttonClasses: Array, + }; + + isDisplayed = false; + visibleIcon = ` + + +`; + hiddenIcon = ` + + +`; + + connect() { + if (this.visibleIconValue !== 'Default') { + this.visibleIcon = this.visibleIconValue; + } + + if (this.hiddenIconValue !== 'Default') { + this.hiddenIcon = this.hiddenIconValue; + } + + const button = this.createButton(); + + this.element.insertAdjacentElement('afterend', button); + this.dispatchEvent('connect', { element: this.element, button }); + } + + /** + * @returns {HTMLButtonElement} + */ + createButton() { + const button = document.createElement('button'); + button.type = 'button'; + button.classList.add(...this.buttonClassesValue); + button.setAttribute('tabindex', '-1'); + button.addEventListener('click', this.toggle.bind(this)); + button.innerHTML = `${this.visibleIcon} ${this.visibleLabelValue}`; + return button; + } + + /** + * Toggle input type between "text" or "password" and update label accordingly + */ + toggle(event) { + this.isDisplayed = !this.isDisplayed; + const toggleButtonElement = event.currentTarget; + toggleButtonElement.innerHTML = this.isDisplayed + ? `${this.hiddenIcon} ${this.hiddenLabelValue}` + : `${this.visibleIcon} ${this.visibleLabelValue}`; + this.element.setAttribute('type', this.isDisplayed ? 'text' : 'password'); + this.dispatchEvent(this.isDisplayed ? 'show' : 'hide', { element: this.element, button: toggleButtonElement }); + } + + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'toggle-password' }); + } +} diff --git a/assets/css/app/bs-overrides.css b/assets/css/app/bs-overrides.css index 070f353d..ec5a8f7c 100644 --- a/assets/css/app/bs-overrides.css +++ b/assets/css/app/bs-overrides.css @@ -120,4 +120,11 @@ ins { del { background-color: #f09595; font-weight: bold; -} \ No newline at end of file +} + +/**************************************** + * Password toggle + ****************************************/ +.toggle-password-button { + top: 0.7rem !important; +} diff --git a/assets/css/app/images.css b/assets/css/app/images.css index 214776e7..0212a85b 100644 --- a/assets/css/app/images.css +++ b/assets/css/app/images.css @@ -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; } diff --git a/assets/css/app/layout.css b/assets/css/app/layout.css index 4be123a7..58808926 100644 --- a/assets/css/app/layout.css +++ b/assets/css/app/layout.css @@ -133,7 +133,7 @@ showing the sidebar (on devices with md or higher) */ #sidebar-toggle-button { position: fixed; - left: 3px; + left: 2px; bottom: 50%; } diff --git a/assets/css/app/tables.css b/assets/css/app/tables.css index ae892f50..b2d8882c 100644 --- a/assets/css/app/tables.css +++ b/assets/css/app/tables.css @@ -17,6 +17,16 @@ * along with this program. If not, see . */ +/**************************************** + * 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; -} \ No newline at end of file +} diff --git a/assets/css/components/ckeditor.css b/assets/css/components/ckeditor.css index d6b3def4..5f093bf2 100644 --- a/assets/css/components/ckeditor.css +++ b/assets/css/components/ckeditor.css @@ -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); -} \ No newline at end of file + --ck-content-font-color: var(--ck-color-base-text); + +} diff --git a/assets/css/components/toggle_password.css b/assets/css/components/toggle_password.css new file mode 100644 index 00000000..f1f4a889 --- /dev/null +++ b/assets/css/components/toggle_password.css @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +.toggle-password-container { + position: relative; +} +.toggle-password-icon { + height: 1rem; + width: 1rem; +} +.toggle-password-button { + align-items: center; + background-color: transparent; + border: none; + column-gap: 0.25rem; + display: flex; + flex-direction: row; + font-size: 0.875rem; + justify-items: center; + height: 1rem; + line-height: 1.25rem; + position: absolute; + right: 0.5rem; + top: -1.25rem; +} diff --git a/assets/js/app.js b/assets/js/app.js index 43acec5d..c0550373 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -28,7 +28,7 @@ import '../css/app/treeview.css'; import '../css/app/images.css'; // start the Stimulus application -import '../bootstrap'; +import '../stimulus_bootstrap'; // Need jQuery? Install it with "yarn add jquery", then uncomment to require it. const $ = require('jquery'); @@ -49,7 +49,7 @@ window.$ = window.jQuery = require("jquery"); //Use the local WASM file for the ZXing library import { setZXingModuleOverrides, -} from "barcode-detector/pure"; +} from "barcode-detector/ponyfill"; import wasmFile from "../../node_modules/zxing-wasm/dist/reader/zxing_reader.wasm"; setZXingModuleOverrides({ locateFile: (path, prefix) => { @@ -58,4 +58,4 @@ setZXingModuleOverrides({ } return prefix + path; }, -}); \ No newline at end of file +}); diff --git a/assets/js/lib/datatables.js b/assets/js/lib/datatables.js index 8e39548b..67bab02d 100644 --- a/assets/js/lib/datatables.js +++ b/assets/js/lib/datatables.js @@ -75,11 +75,10 @@ request._dt = config.name; //Try to resolve the original column index when the column was reordered (using the ColReorder plugin) - //Only do this when _ColReorder_iOrigCol is available - if (settings.aoColumns && settings.aoColumns.length && settings.aoColumns[0]._ColReorder_iOrigCol !== undefined) { + if (dt.colReorder && dt.colReorder.transpose) { if (request.order && request.order.length) { request.order.forEach(function (order) { - order.column = settings.aoColumns[order.column]._ColReorder_iOrigCol; + order.column = dt.colReorder.transpose(order.column, "toOriginal"); }); } } diff --git a/assets/bootstrap.js b/assets/stimulus_bootstrap.js similarity index 100% rename from assets/bootstrap.js rename to assets/stimulus_bootstrap.js diff --git a/assets/translator.js b/assets/translator.js index 0d5ae86b..a0181a08 100644 --- a/assets/translator.js +++ b/assets/translator.js @@ -1,5 +1,6 @@ -import { localeFallbacks } from '../var/translations/configuration'; -import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator'; +import { createTranslator } from '@symfony/ux-translator'; +import { messages, localeFallbacks } from '../var/translations/index.js'; + /* * This file is part of the Symfony UX Translator package. * @@ -9,8 +10,9 @@ import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-tra * If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking. */ -setLocaleFallbacks(localeFallbacks); +const translator = createTranslator({ + messages, + localeFallbacks, +}); -export { trans }; - -export * from '../var/translations'; \ No newline at end of file +export const { trans } = translator; diff --git a/bin/phpunit b/bin/phpunit index 692baccb..ac5eef11 100755 --- a/bin/phpunit +++ b/bin/phpunit @@ -1,23 +1,4 @@ #!/usr/bin/env php = 80000) { - require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; - } else { - define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); - require PHPUNIT_COMPOSER_INSTALL; - PHPUnit\TextUI\Command::main(); - } -} else { - if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) { - echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n"; - exit(1); - } - - require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; -} +require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; diff --git a/composer.json b/composer.json index c725b235..bb41a95d 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "project", "license": "AGPL-3.0-or-later", "require": { - "php": "^8.1", + "php": "^8.2", "ext-ctype": "*", "ext-dom": "*", "ext-gd": "*", @@ -12,9 +12,11 @@ "ext-json": "*", "ext-mbstring": "*", "amphp/http-client": "^5.1", - "api-platform/core": "^3.1", + "api-platform/doctrine-orm": "^4.1", + "api-platform/json-api": "^4.0.0", + "api-platform/symfony": "^4.0.0", "beberlei/doctrineextensions": "^1.2", - "brick/math": "0.12.1 as 0.11.0", + "brick/math": "^0.13.1", "composer/ca-bundle": "^1.5", "composer/package-versions-deprecated": "^1.11.99.5", "doctrine/data-fixtures": "^2.0.0", @@ -22,66 +24,69 @@ "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", + "dompdf/dompdf": "^3.1.2", "gregwar/captcha-bundle": "^2.1.0", "hshn/base64-encoded-file": "^5.0", - "jbtronics/2fa-webauthn": "^v2.2.0", + "jbtronics/2fa-webauthn": "^3.0.0", "jbtronics/dompdf-font-loader-bundle": "^1.0.0", + "jbtronics/settings-bundle": "^3.0.0", "jfcherng/php-diff": "^6.14", "knpuniversity/oauth2-client-bundle": "^2.15", + "league/commonmark": "^2.7", "league/csv": "^9.8.0", "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", - "nbgrp/onelogin-saml-bundle": "^1.3", + "maennchen/zipstream-php": "2.1", + "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", "nelmio/security-bundle": "^3.0", "nyholm/psr7": "^1.1", - "omines/datatables-bundle": "^0.9.1", + "omines/datatables-bundle": "^0.10.0", "paragonie/sodium_compat": "^1.21", "part-db/label-fonts": "^1.0", - "runtime/frankenphp-symfony": "^0.2.0", + "part-db/swap-bundle": "^6.0.0", + "phpoffice/phpspreadsheet": "^5.0.0", + "rhukster/dom-sanitizer": "^1.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", + "scheb/2fa-backup-code": "^v7.11.0", + "scheb/2fa-bundle": "^v7.11.0", + "scheb/2fa-google-authenticator": "^v7.11.0", + "scheb/2fa-trusted-device": "^v7.11.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/asset": "7.4.*", + "symfony/console": "7.4.*", + "symfony/css-selector": "7.4.*", + "symfony/dom-crawler": "7.4.*", + "symfony/dotenv": "7.4.*", + "symfony/expression-language": "7.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/form": "7.4.*", + "symfony/framework-bundle": "7.4.*", + "symfony/http-client": "7.4.*", + "symfony/http-kernel": "7.4.*", + "symfony/mailer": "7.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/process": "7.4.*", + "symfony/property-access": "7.4.*", + "symfony/property-info": "7.4.*", + "symfony/rate-limiter": "7.4.*", + "symfony/runtime": "7.4.*", + "symfony/security-bundle": "7.4.*", + "symfony/serializer": "7.4.*", + "symfony/string": "7.4.*", + "symfony/translation": "7.4.*", + "symfony/twig-bundle": "7.4.*", + "symfony/type-info": "7.4.0", + "symfony/ux-translator": "^2.32.0", "symfony/ux-turbo": "^2.0", - "symfony/validator": "6.4.*", - "symfony/web-link": "6.4.*", + "symfony/validator": "7.4.*", + "symfony/web-link": "7.4.*", "symfony/webpack-encore-bundle": "^v2.0.1", - "symfony/yaml": "6.4.*", + "symfony/yaml": "7.4.*", + "symplify/easy-coding-standard": "^12.5.20", "tecnickcom/tc-lib-barcode": "^2.1.4", "twig/cssinliner-extra": "^3.0", "twig/extra-bundle": "^3.8", @@ -90,7 +95,7 @@ "twig/intl-extra": "^3.8", "twig/markdown-extra": "^3.8", "twig/string-extra": "^3.8", - "web-auth/webauthn-symfony-bundle": "^4.0.0" + "web-auth/webauthn-symfony-bundle": "^5.0.0" }, "require-dev": { "dama/doctrine-test-bundle": "^v8.0.0", @@ -102,16 +107,22 @@ "phpstan/phpstan-doctrine": "^2.0.1", "phpstan/phpstan-strict-rules": "^2.0.1", "phpstan/phpstan-symfony": "^2.0.0", - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^11.5.0", "rector/rector": "^2.0.4", "roave/security-advisories": "dev-latest", - "symfony/browser-kit": "6.4.*", - "symfony/debug-bundle": "6.4.*", + "symfony/browser-kit": "7.4.*", + "symfony/debug-bundle": "7.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" + "symfony/phpunit-bridge": "7.4.*", + "symfony/stopwatch": "7.4.*", + "symfony/web-profiler-bundle": "7.4.*" + }, + "replace": { + "symfony/polyfill-mbstring": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*" }, "suggest": { "ext-bcmath": "Used to improve price calculation performance", @@ -122,7 +133,7 @@ "*": "dist" }, "platform": { - "php": "8.1.0" + "php": "8.2.0" }, "sort-packages": true, "allow-plugins": { @@ -154,7 +165,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": "*" @@ -162,7 +173,7 @@ "extra": { "symfony": { "allow-contrib": false, - "require": "6.4.*", + "require": "7.4.*", "docker": true } } diff --git a/composer.lock b/composer.lock index e538a213..9bd01a1a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7fb73581b0074c5a79afb3ffa614ed8e", + "content-hash": "fc3801df89de9d24084329263c36b0d6", "packages": [ { "name": "amphp/amp", - "version": "v3.1.0", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/amphp/amp.git", - "reference": "7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9" + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9", - "reference": "7cf7fef3d667bfe4b2560bc87e67d5387a7bcde9", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", "shasum": "" }, "require": { @@ -77,7 +77,7 @@ ], "support": { "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.1.0" + "source": "https://github.com/amphp/amp/tree/v3.1.1" }, "funding": [ { @@ -85,7 +85,7 @@ "type": "github" } ], - "time": "2025-01-26T16:07:39+00:00" + "time": "2025-08-27T21:42:00+00:00" }, { "name": "amphp/byte-stream", @@ -456,16 +456,16 @@ }, { "name": "amphp/http-client", - "version": "v5.3.0", + "version": "v5.3.4", "source": { "type": "git", "url": "https://github.com/amphp/http-client.git", - "reference": "d50928ec41d4ac3bcec5a01fe1caaf7575cbdc75" + "reference": "75ad21574fd632594a2dd914496647816d5106bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-client/zipball/d50928ec41d4ac3bcec5a01fe1caaf7575cbdc75", - "reference": "d50928ec41d4ac3bcec5a01fe1caaf7575cbdc75", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", "shasum": "" }, "require": { @@ -542,7 +542,7 @@ ], "support": { "issues": "https://github.com/amphp/http-client/issues", - "source": "https://github.com/amphp/http-client/tree/v5.3.0" + "source": "https://github.com/amphp/http-client/tree/v5.3.4" }, "funding": [ { @@ -550,7 +550,7 @@ "type": "github" } ], - "time": "2025-03-16T17:32:21+00:00" + "time": "2025-08-16T20:41:23+00:00" }, { "name": "amphp/parser", @@ -967,194 +967,64 @@ "time": "2024-08-03T19:31:26+00:00" }, { - "name": "api-platform/core", - "version": "v3.4.16", + "name": "api-platform/doctrine-common", + "version": "v4.2.12", "source": { "type": "git", - "url": "https://github.com/api-platform/core.git", - "reference": "64c6e1092cf988ba619907b3e4cce8a229ce4fae" + "url": "https://github.com/api-platform/doctrine-common.git", + "reference": "e3f4ebd57d189a4a2f06929b3c14b162ff5c4750" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/64c6e1092cf988ba619907b3e4cce8a229ce4fae", - "reference": "64c6e1092cf988ba619907b3e4cce8a229ce4fae", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/e3f4ebd57d189a4a2f06929b3c14b162ff5c4750", + "reference": "e3f4ebd57d189a4a2f06929b3c14b162ff5c4750", "shasum": "" }, "require": { - "doctrine/inflector": "^1.0 || ^2.0", - "php": ">=8.1", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "psr/container": "^1.0 || ^2.0", - "symfony/deprecation-contracts": "^3.1", - "symfony/http-foundation": "^6.4 || ^7.1", - "symfony/http-kernel": "^6.4 || ^7.1", - "symfony/property-access": "^6.4 || ^7.1", - "symfony/property-info": "^6.4 || ^7.1", - "symfony/serializer": "^6.4 || ^7.1", - "symfony/translation-contracts": "^3.3", - "symfony/web-link": "^6.4 || ^7.1", - "willdurand/negotiation": "^3.0" + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2.4", + "doctrine/collections": "^2.1", + "doctrine/common": "^3.2.2", + "doctrine/persistence": "^3.2 || ^4.0", + "php": ">=8.2" }, "conflict": { - "doctrine/common": "<3.2.2", - "doctrine/dbal": "<2.10", - "doctrine/mongodb-odm": "<2.4", - "doctrine/orm": "<2.14.0", - "doctrine/persistence": "<1.3", - "elasticsearch/elasticsearch": ">=8.0,<8.4", - "phpspec/prophecy": "<1.15", - "phpunit/phpunit": "<9.5", - "symfony/framework-bundle": "6.4.6 || 7.0.6", - "symfony/var-exporter": "<6.1.1" - }, - "replace": { - "api-platform/doctrine-common": "self.version", - "api-platform/doctrine-odm": "self.version", - "api-platform/doctrine-orm": "self.version", - "api-platform/documentation": "self.version", - "api-platform/elasticsearch": "self.version", - "api-platform/graphql": "self.version", - "api-platform/http-cache": "self.version", - "api-platform/hydra": "self.version", - "api-platform/json-api": "self.version", - "api-platform/json-hal": "self.version", - "api-platform/json-schema": "self.version", - "api-platform/jsonld": "self.version", - "api-platform/laravel": "self.version", - "api-platform/metadata": "self.version", - "api-platform/openapi": "self.version", - "api-platform/parameter-validator": "self.version", - "api-platform/ramsey-uuid": "self.version", - "api-platform/serializer": "self.version", - "api-platform/state": "self.version", - "api-platform/symfony": "self.version", - "api-platform/validator": "self.version" + "doctrine/persistence": "<1.3" }, "require-dev": { - "api-platform/doctrine-common": "^3.4 || ^4.0", - "api-platform/doctrine-odm": "^3.4 || ^4.0", - "api-platform/doctrine-orm": "^3.4 || ^4.0", - "api-platform/documentation": "^3.4 || ^4.0", - "api-platform/elasticsearch": "^3.4 || ^4.0", - "api-platform/graphql": "^3.4 || ^4.0", - "api-platform/http-cache": "^3.4 || ^4.0", - "api-platform/hydra": "^3.4 || ^4.0", - "api-platform/json-api": "^3.3 || ^4.0", - "api-platform/json-schema": "^3.4 || ^4.0", - "api-platform/jsonld": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", - "api-platform/openapi": "^3.4 || ^4.0", - "api-platform/parameter-validator": "^3.4", - "api-platform/ramsey-uuid": "^3.4 || ^4.0", - "api-platform/serializer": "^3.4 || ^4.0", - "api-platform/state": "^3.4 || ^4.0", - "api-platform/validator": "^3.4 || ^4.0", - "behat/behat": "^3.11", - "behat/mink": "^1.9", - "doctrine/cache": "^1.11 || ^2.1", - "doctrine/common": "^3.2.2", - "doctrine/dbal": "^3.4.0 || ^4.0", - "doctrine/doctrine-bundle": "^1.12 || ^2.0", - "doctrine/mongodb-odm": "^2.2", - "doctrine/mongodb-odm-bundle": "^4.0 || ^5.0", - "doctrine/orm": "^2.14 || ^3.0", - "elasticsearch/elasticsearch": "^7.11 || ^8.4", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", - "guzzlehttp/guzzle": "^6.0 || ^7.1", - "jangregor/phpstan-prophecy": "^1.0", - "justinrainbow/json-schema": "^5.2.1", - "phpspec/prophecy-phpunit": "^2.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpdoc-parser": "^1.13|^2.0", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-doctrine": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-symfony": "^1.0", - "phpunit/phpunit": "^9.6", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "ramsey/uuid": "^3.9.7 || ^4.0", - "ramsey/uuid-doctrine": "^1.4 || ^2.0 || ^3.0", - "sebastian/comparator": "<5.0", - "soyuka/contexts": "v3.3.9", - "soyuka/pmu": "^0.0.12", - "soyuka/stubs-mongodb": "^1.0", - "symfony/asset": "^6.4 || ^7.1", - "symfony/browser-kit": "^6.4 || ^7.1", - "symfony/cache": "^6.4 || ^7.1", - "symfony/config": "^6.4 || ^7.1", - "symfony/console": "^6.4 || ^7.1", - "symfony/css-selector": "^6.4 || ^7.1", - "symfony/dependency-injection": "^6.4 || ^7.1", - "symfony/doctrine-bridge": "^6.4 || ^7.1", - "symfony/dom-crawler": "^6.4 || ^7.1", - "symfony/error-handler": "^6.4 || ^7.1", - "symfony/event-dispatcher": "^6.4 || ^7.1", - "symfony/expression-language": "^6.4 || ^7.1", - "symfony/finder": "^6.4 || ^7.1", - "symfony/form": "^6.4 || ^7.1", - "symfony/framework-bundle": "^6.4 || ^7.1", - "symfony/http-client": "^6.4 || ^7.1", - "symfony/intl": "^6.4 || ^7.1", - "symfony/maker-bundle": "^1.24", - "symfony/mercure-bundle": "*", - "symfony/messenger": "^6.4 || ^7.1", - "symfony/phpunit-bridge": "^6.4.1 || ^7.1", - "symfony/routing": "^6.4 || ^7.1", - "symfony/security-bundle": "^6.4 || ^7.1", - "symfony/security-core": "^6.4 || ^7.1", - "symfony/stopwatch": "^6.4 || ^7.1", - "symfony/string": "^6.4 || ^7.1", - "symfony/twig-bundle": "^6.4 || ^7.1", - "symfony/uid": "^6.4 || ^7.1", - "symfony/validator": "^6.4 || ^7.1", - "symfony/web-profiler-bundle": "^6.4 || ^7.1", - "symfony/yaml": "^6.4 || ^7.1", - "twig/twig": "^1.42.3 || ^2.12 || ^3.0", - "webonyx/graphql-php": "^14.0 || ^15.0" + "doctrine/mongodb-odm": "^2.10", + "doctrine/orm": "^2.17 || ^3.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "symfony/type-info": "^7.3 || ^8.0" }, "suggest": { - "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", - "elasticsearch/elasticsearch": "To support Elasticsearch.", - "ocramius/package-versions": "To display the API Platform's version in the debug bar.", - "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", - "psr/cache-implementation": "To use metadata caching.", - "ramsey/uuid": "To support Ramsey's UUID identifiers.", - "symfony/cache": "To have metadata caching when using Symfony integration.", - "symfony/config": "To load XML configuration files.", - "symfony/expression-language": "To use authorization features.", - "symfony/http-client": "To use the HTTP cache invalidation system.", - "symfony/messenger": "To support messenger integration.", - "symfony/security": "To use authorization features.", - "symfony/twig-bundle": "To use the Swagger UI integration.", - "symfony/uid": "To support Symfony UUID/ULID identifiers.", - "symfony/web-profiler-bundle": "To use the data collector.", - "webonyx/graphql-php": "To support GraphQL." + "api-platform/graphql": "For GraphQl mercure subscriptions.", + "api-platform/http-cache": "For HTTP cache invalidation.", + "phpstan/phpdoc-parser": "For PHP documentation support.", + "symfony/config": "For XML resource configuration.", + "symfony/mercure-bundle": "For mercure updates publisher.", + "symfony/yaml": "For YAML resource configuration." }, "type": "library", "extra": { - "pmu": { - "projects": [ - "./src/*/composer.json", - "src/Doctrine/*/composer.json" - ] - }, "thanks": { "url": "https://github.com/api-platform/api-platform", "name": "api-platform/api-platform" }, "symfony": { - "require": "^6.4 || ^7.1" + "require": "^6.4 || ^7.0 || ^8.0" }, "branch-alias": { "dev-3.4": "3.4.x-dev", - "dev-main": "4.0.x-dev" + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" } }, "autoload": { "psr-4": { - "ApiPlatform\\": "src/" + "ApiPlatform\\Doctrine\\Common\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1166,9 +1036,667 @@ "name": "Kรฉvin Dunglas", "email": "kevin@dunglas.fr", "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" } ], - "description": "Build a fully-featured hypermedia or GraphQL API in minutes!", + "description": "Common files used by api-platform/doctrine-orm and api-platform/doctrine-odm", + "homepage": "https://api-platform.com", + "keywords": [ + "doctrine", + "graphql", + "odm", + "orm", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/doctrine-common/tree/v4.2.12" + }, + "time": "2026-01-08T22:41:56+00:00" + }, + { + "name": "api-platform/doctrine-orm", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/doctrine-orm.git", + "reference": "6dd409d7e90b031cc9bddaa397f049da9da0799f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/6dd409d7e90b031cc9bddaa397f049da9da0799f", + "reference": "6dd409d7e90b031cc9bddaa397f049da9da0799f", + "shasum": "" + }, + "require": { + "api-platform/doctrine-common": "^4.2.9", + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2.4", + "doctrine/orm": "^2.17 || ^3.0", + "php": ">=8.2" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.11 || ^3.1", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "ramsey/uuid": "^4.7", + "ramsey/uuid-doctrine": "^2.0", + "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/uid": "^6.4 || ^7.0 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Doctrine\\Orm\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Doctrine ORM bridge", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "doctrine", + "graphql", + "orm", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.2.12" + }, + "time": "2026-01-08T14:27:10+00:00" + }, + { + "name": "api-platform/documentation", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/documentation.git", + "reference": "873543a827df5c25b008bd730f2096701e1943b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/documentation/zipball/873543a827df5c25b008bd730f2096701e1943b8", + "reference": "873543a827df5c25b008bd730f2096701e1943b8", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^12.2" + }, + "type": "project", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Documentation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform documentation controller.", + "support": { + "source": "https://github.com/api-platform/documentation/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/http-cache", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/http-cache.git", + "reference": "7679a23ce4bf8f35a69d94ac060f6d13ab88497b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/http-cache/zipball/7679a23ce4bf8f35a69d94ac060f6d13ab88497b", + "reference": "7679a23ce4bf8f35a69d94ac060f6d13ab88497b", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2.4", + "php": ">=8.2", + "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/http-client": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\HttpCache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/comunnity/contributors" + } + ], + "description": "API Platform HttpCache component", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "cache", + "http", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/http-cache/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/hydra", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/hydra.git", + "reference": "866611a986f4f52da7807b04a0b2cf64e314ab56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/866611a986f4f52da7807b04a0b2cf64e314ab56", + "reference": "866611a986f4f52da7807b04a0b2cf64e314ab56", + "shasum": "" + }, + "require": { + "api-platform/documentation": "^4.2", + "api-platform/json-schema": "^4.2", + "api-platform/jsonld": "^4.2", + "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2.4", + "api-platform/state": "^4.2.4", + "php": ">=8.2", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.2", + "api-platform/doctrine-odm": "^4.2", + "api-platform/doctrine-orm": "^4.2", + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Hydra\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Hydra support", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "jsonapi", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/hydra/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/json-api", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/json-api.git", + "reference": "86f93ac31f20faeeca5cacd74d1318dc273e6b93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/json-api/zipball/86f93ac31f20faeeca5cacd74d1318dc273e6b93", + "reference": "86f93ac31f20faeeca5cacd74d1318dc273e6b93", + "shasum": "" + }, + "require": { + "api-platform/documentation": "^4.2", + "api-platform/json-schema": "^4.2", + "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2.4", + "api-platform/state": "^4.2.4", + "php": ">=8.2", + "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "require-dev": { + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\JsonApi\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API JSON-API support", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "jsonapi", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/json-api/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/json-schema", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/json-schema.git", + "reference": "161d5d2eab6717261155c06de6892064db458fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/json-schema/zipball/161d5d2eab6717261155c06de6892064db458fc0", + "reference": "161d5d2eab6717261155c06de6892064db458fc0", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2", + "php": ">=8.2", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/uid": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\JsonSchema\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Generate a JSON Schema from a PHP class", + "homepage": "https://api-platform.com", + "keywords": [ + "JSON Schema", + "api", + "json", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/json-schema/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/jsonld", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/jsonld.git", + "reference": "b6a35c735e3b4b2342e64ab3172151dcb3aaf78d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/jsonld/zipball/b6a35c735e3b4b2342e64ab3172151dcb3aaf78d", + "reference": "b6a35c735e3b4b2342e64ab3172151dcb3aaf78d", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2.4", + "api-platform/state": "^4.2.4", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^12.2", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "files": [ + "./HydraContext.php" + ], + "psr-4": { + "ApiPlatform\\JsonLd\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API JSON-LD support", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/jsonld/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/metadata", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/metadata.git", + "reference": "429ee219f930efd274a9f4e91e92921add7f988a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/429ee219f930efd274a9f4e91e92921add7f988a", + "reference": "429ee219f930efd274a9f4e91e92921add7f988a", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "php": ">=8.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/string": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "require-dev": { + "api-platform/json-schema": "^4.2", + "api-platform/openapi": "^4.2", + "api-platform/state": "^4.2.4", + "phpspec/prophecy-phpunit": "^2.2", + "phpstan/phpdoc-parser": "^1.29 || ^2.0", + "phpunit/phpunit": "^12.2", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "suggest": { + "phpstan/phpdoc-parser": "For PHP documentation support.", + "symfony/config": "For XML resource configuration.", + "symfony/yaml": "For YAML resource configuration." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Metadata\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Resource-oriented metadata attributes and factories", "homepage": "https://api-platform.com", "keywords": [ "Hydra", @@ -1182,10 +1710,493 @@ "swagger" ], "support": { - "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v3.4.16" + "source": "https://github.com/api-platform/metadata/tree/v4.2.12" }, - "time": "2025-01-17T14:17:26+00:00" + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/openapi", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/openapi.git", + "reference": "3417415125f3ebbcb620f84ef9dd1cd07e2b2621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/openapi/zipball/3417415125f3ebbcb620f84ef9dd1cd07e2b2621", + "reference": "3417415125f3ebbcb620f84ef9dd1cd07e2b2621", + "shasum": "" + }, + "require": { + "api-platform/json-schema": "^4.2", + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2.4", + "php": ">=8.2", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.2", + "api-platform/doctrine-odm": "^4.2", + "api-platform/doctrine-orm": "^4.2", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\OpenApi\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Models to build and serialize an OpenAPI specification.", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/openapi/tree/v4.2.12" + }, + "time": "2026-01-08T18:17:35+00:00" + }, + { + "name": "api-platform/serializer", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/serializer.git", + "reference": "f29558c3e212f3b9f18f222240971ea0a8f09f4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/f29558c3e212f3b9f18f222240971ea0a8f09f4c", + "reference": "f29558c3e212f3b9f18f222240971ea0a8f09f4c", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2.4", + "php": ">=8.2", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.2", + "api-platform/doctrine-odm": "^4.2", + "api-platform/doctrine-orm": "^4.2", + "api-platform/json-schema": "^4.2", + "api-platform/openapi": "^4.2", + "doctrine/collections": "^2.1", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "symfony/mercure-bundle": "*", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "suggest": { + "api-platform/doctrine-odm": "To support Doctrine MongoDB ODM state options.", + "api-platform/doctrine-orm": "To support Doctrine ORM state options." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform core Serializer", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "graphql", + "rest", + "serializer" + ], + "support": { + "source": "https://github.com/api-platform/serializer/tree/v4.2.12" + }, + "time": "2026-01-09T09:40:33+00:00" + }, + { + "name": "api-platform/state", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/state.git", + "reference": "348d19a2e5fedb01aa55ff7b866691777e54ec39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/state/zipball/348d19a2e5fedb01aa55ff7b866691777e54ec39", + "reference": "348d19a2e5fedb01aa55ff7b866691777e54ec39", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2.3", + "php": ">=8.2", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^3.1", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/translation-contracts": "^3.0" + }, + "require-dev": { + "api-platform/serializer": "^4.2.4", + "api-platform/validator": "^4.2.4", + "phpunit/phpunit": "^12.2", + "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "willdurand/negotiation": "^3.1" + }, + "suggest": { + "api-platform/serializer": "To use API Platform serializer.", + "api-platform/validator": "To use API Platform validation.", + "symfony/http-foundation": "To use our HTTP providers and processor.", + "symfony/web-link": "To support adding web links to the response headers.", + "willdurand/negotiation": "To use the API Platform content negoatiation provider." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\State\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform State component ", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/state/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" + }, + { + "name": "api-platform/symfony", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/symfony.git", + "reference": "6d919776f7784bb5dc50b2226375d15e0ac3875b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/6d919776f7784bb5dc50b2226375d15e0ac3875b", + "reference": "6d919776f7784bb5dc50b2226375d15e0ac3875b", + "shasum": "" + }, + "require": { + "api-platform/documentation": "^4.2.3", + "api-platform/http-cache": "^4.2.3", + "api-platform/hydra": "^4.2.3", + "api-platform/json-schema": "^4.2.3", + "api-platform/jsonld": "^4.2.3", + "api-platform/metadata": "^4.2.3", + "api-platform/openapi": "^4.2.3", + "api-platform/serializer": "^4.2.4", + "api-platform/state": "^4.2.4", + "api-platform/validator": "^4.2.3", + "php": ">=8.2", + "symfony/asset": "^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.0 || ^8.0", + "symfony/security-core": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "willdurand/negotiation": "^3.1" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.2.3", + "api-platform/doctrine-odm": "^4.2.3", + "api-platform/doctrine-orm": "^4.2.3", + "api-platform/elasticsearch": "^4.2.3", + "api-platform/graphql": "^4.2.3", + "api-platform/hal": "^4.2.3", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2", + "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/mercure-bundle": "*", + "symfony/object-mapper": "^7.0 || ^8.0", + "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "webonyx/graphql-php": "^15.0" + }, + "suggest": { + "api-platform/doctrine-odm": "To support MongoDB. Only versions 4.0 and later are supported.", + "api-platform/doctrine-orm": "To support Doctrine ORM.", + "api-platform/elasticsearch": "To support Elasticsearch.", + "api-platform/graphql": "To support GraphQL.", + "api-platform/hal": "to support the HAL format", + "api-platform/json-api": "to support the JSON-API format", + "api-platform/ramsey-uuid": "To support Ramsey's UUID identifiers.", + "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", + "psr/cache-implementation": "To use metadata caching.", + "symfony/cache": "To have metadata caching when using Symfony integration.", + "symfony/config": "To load XML configuration files.", + "symfony/expression-language": "To use authorization and mercure advanced features.", + "symfony/http-client": "To use the HTTP cache invalidation system.", + "symfony/mercure-bundle": "To support mercure integration.", + "symfony/messenger": "To support messenger integration and asynchronous Mercure updates.", + "symfony/security": "To use authorization features.", + "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", + "symfony/web-profiler-bundle": "To use the data collector." + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Symfony\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Symfony API Platform integration", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger", + "symfony" + ], + "support": { + "source": "https://github.com/api-platform/symfony/tree/v4.2.12" + }, + "time": "2025-12-31T09:26:07+00:00" + }, + { + "name": "api-platform/validator", + "version": "v4.2.12", + "source": { + "type": "git", + "url": "https://github.com/api-platform/validator.git", + "reference": "f6a4a16ac55a14dfb96d84a46cdce530d2da734c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/validator/zipball/f6a4a16ac55a14dfb96d84a46cdce530d2da734c", + "reference": "f6a4a16ac55a14dfb96d84a46cdce530d2da734c", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2", + "php": ">=8.2", + "symfony/http-kernel": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.1 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Validator\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kรฉvin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform validator component", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "graphql", + "rest", + "validator" + ], + "support": { + "source": "https://github.com/api-platform/validator/tree/v4.2.12" + }, + "time": "2025-12-27T22:15:57+00:00" }, { "name": "beberlei/assert", @@ -1318,16 +2329,16 @@ }, { "name": "brick/math", - "version": "0.12.1", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1" + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { @@ -1336,7 +2347,7 @@ "require-dev": { "php-coveralls/php-coveralls": "^2.2", "phpunit/phpunit": "^10.1", - "vimeo/psalm": "5.16.0" + "vimeo/psalm": "6.8.8" }, "type": "library", "autoload": { @@ -1366,7 +2377,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.1" + "source": "https://github.com/brick/math/tree/0.13.1" }, "funding": [ { @@ -1374,20 +2385,20 @@ "type": "github" } ], - "time": "2023-11-29T23:19:16+00:00" + "time": "2025-03-29T13:50:30+00:00" }, { "name": "composer/ca-bundle", - "version": "1.5.6", + "version": "1.5.10", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "f65c239c970e7f072f067ab78646e9f0b2935175" + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/f65c239c970e7f072f067ab78646e9f0b2935175", - "reference": "f65c239c970e7f072f067ab78646e9f0b2935175", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", "shasum": "" }, "require": { @@ -1434,7 +2445,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.6" + "source": "https://github.com/composer/ca-bundle/tree/1.5.10" }, "funding": [ { @@ -1444,13 +2455,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2025-03-06T14:30:56+00:00" + "time": "2025-12-08T15:06:51+00:00" }, { "name": "composer/package-versions-deprecated", @@ -1525,6 +2532,85 @@ ], "time": "2022-01-17T14:14:24+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "daverandom/libdns", "version": "v2.1.0", @@ -1570,17 +2656,92 @@ "time": "2024-04-12T12:12:48+00:00" }, { - "name": "doctrine/collections", - "version": "2.3.0", + "name": "dflydev/dot-access-data", + "version": "v3.0.3", "source": { "type": "git", - "url": "https://github.com/doctrine/collections.git", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/collections", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "6108e0cd57d7ef125fb84696346a68860403a25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/6108e0cd57d7ef125fb84696346a68860403a25d", + "reference": "6108e0cd57d7ef125fb84696346a68860403a25d", "shasum": "" }, "require": { @@ -1589,11 +2750,11 @@ "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^14", "ext-json": "*", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.5" + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" }, "type": "library", "autoload": { @@ -1637,7 +2798,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.3.0" + "source": "https://github.com/doctrine/collections/tree/2.5.0" }, "funding": [ { @@ -1653,20 +2814,111 @@ "type": "tidelift" } ], - "time": "2025-03-22T10:17:19+00:00" + "time": "2026-01-07T17:26:56+00:00" }, { - "name": "doctrine/data-fixtures", - "version": "2.0.2", + "name": "doctrine/common", + "version": "3.5.0", "source": { "type": "git", - "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "f7f1e12d6bceb58c204b3e77210a103c1c57601e" + "url": "https://github.com/doctrine/common.git", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/f7f1e12d6bceb58c204b3e77210a103c1c57601e", - "reference": "f7f1e12d6bceb58c204b3e77210a103c1c57601e", + "url": "https://api.github.com/repos/doctrine/common/zipball/d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^2.0 || ^3.0 || ^4.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0 || ^10.0", + "doctrine/collections": "^1", + "phpstan/phpstan": "^1.4.1", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5.20 || ^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.0", + "symfony/phpunit-bridge": "^6.1", + "vimeo/psalm": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "PHP Doctrine Common project is a library that provides additional functionality that other Doctrine projects depend on such as better reflection support, proxies and much more.", + "homepage": "https://www.doctrine-project.org/projects/common.html", + "keywords": [ + "common", + "doctrine", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/common/issues", + "source": "https://github.com/doctrine/common/tree/3.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcommon", + "type": "tidelift" + } + ], + "time": "2025-01-01T22:12:03+00:00" + }, + { + "name": "doctrine/data-fixtures", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97", "shasum": "" }, "require": { @@ -1680,14 +2932,14 @@ "doctrine/phpcr-odm": "<1.3.0" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^14", "doctrine/dbal": "^3.5 || ^4", "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", "doctrine/orm": "^2.14 || ^3", "ext-sqlite3": "*", "fig/log-test": "^1", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5.3", + "phpstan/phpstan": "2.1.31", + "phpunit/phpunit": "10.5.45 || 12.4.0", "symfony/cache": "^6.4 || ^7", "symfony/var-exporter": "^6.4 || ^7" }, @@ -1720,7 +2972,7 @@ ], "support": { "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/2.0.2" + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0" }, "funding": [ { @@ -1736,40 +2988,40 @@ "type": "tidelift" } ], - "time": "2025-01-21T13:21:31+00:00" + "time": "2025-10-17T20:06:20+00:00" }, { "name": "doctrine/dbal", - "version": "4.2.3", + "version": "4.4.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "33d2d7fe1269b2301640c44cf2896ea607b30e3e" + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/33d2d7fe1269b2301640c44cf2896ea607b30e3e", - "reference": "33d2d7fe1269b2301640c44cf2896ea607b30e3e", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", "shasum": "" }, "require": { - "doctrine/deprecations": "^0.5.3|^1", - "php": "^8.1", + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "12.0.0", + "doctrine/coding-standard": "14.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.2", - "phpstan/phpstan": "2.1.1", - "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "10.5.39", - "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.10.2", - "symfony/cache": "^6.3.8|^7.0", - "symfony/console": "^5.4|^6.3|^7.0" + "phpunit/phpunit": "11.5.23", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -1826,7 +3078,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.2.3" + "source": "https://github.com/doctrine/dbal/tree/4.4.1" }, "funding": [ { @@ -1842,30 +3094,33 @@ "type": "tidelift" } ], - "time": "2025-03-07T18:29:05+00:00" + "time": "2025-12-04T10:11:03+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1885,26 +3140,27 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "doctrine/doctrine-bundle", - "version": "2.14.0", + "version": "2.18.2", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "ca6a7350b421baf7fbdefbf9f4993292ed18effb" + "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/ca6a7350b421baf7fbdefbf9f4993292ed18effb", - "reference": "ca6a7350b421baf7fbdefbf9f4993292ed18effb", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546", + "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546", "shasum": "" }, "require": { "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/deprecations": "^1.0", "doctrine/persistence": "^3.1 || ^4", "doctrine/sql-formatter": "^1.0.1", "php": "^8.1", @@ -1912,7 +3168,6 @@ "symfony/config": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/deprecation-contracts": "^2.1 || ^3", "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/service-contracts": "^2.5 || ^3" @@ -1927,18 +3182,17 @@ "require-dev": { "doctrine/annotations": "^1 || ^2", "doctrine/cache": "^1.11 || ^2.0", - "doctrine/coding-standard": "^12", - "doctrine/deprecations": "^1.0", - "doctrine/orm": "^2.17 || ^3.0", + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.17 || ^3.1", "friendsofphp/proxy-manager-lts": "^1.0", "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9.6.22", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", "psr/log": "^1.1.4 || ^2.0 || ^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", "symfony/messenger": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^7.2", "symfony/property-info": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", @@ -1948,7 +3202,7 @@ "symfony/var-exporter": "^6.4.1 || ^7.0.1", "symfony/web-profiler-bundle": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0", - "twig/twig": "^2.13 || ^3.0.4" + "twig/twig": "^2.14.7 || ^3.0.4" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -1993,7 +3247,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.14.0" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2" }, "funding": [ { @@ -2009,42 +3263,41 @@ "type": "tidelift" } ], - "time": "2025-03-22T17:28:21+00:00" + "time": "2025-12-20T21:35:32+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "3.4.1", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "e858ce0f5c12b266dce7dce24834448355155da7" + "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/e858ce0f5c12b266dce7dce24834448355155da7", - "reference": "e858ce0f5c12b266dce7dce24834448355155da7", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/1e380c6dd8ac8488217f39cff6b77e367f1a644b", + "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b", "shasum": "" }, "require": { - "doctrine/doctrine-bundle": "^2.4", + "doctrine/doctrine-bundle": "^2.4 || ^3.0", "doctrine/migrations": "^3.2", "php": "^7.2 || ^8.0", "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "composer/semver": "^3.0", - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^12 || ^14", "doctrine/orm": "^2.6 || ^3", - "doctrine/persistence": "^2.0 || ^3", "phpstan/phpstan": "^1.4 || ^2", "phpstan/phpstan-deprecation-rules": "^1 || ^2", "phpstan/phpstan-phpunit": "^1 || ^2", "phpstan/phpstan-strict-rules": "^1.1 || ^2", "phpstan/phpstan-symfony": "^1.3 || ^2", "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/phpunit-bridge": "^6.3 || ^7", - "symfony/var-exporter": "^5.4 || ^6 || ^7" + "symfony/phpunit-bridge": "^6.3 || ^7 || ^8", + "symfony/var-exporter": "^5.4 || ^6 || ^7 || ^8" }, "type": "symfony-bundle", "autoload": { @@ -2079,7 +3332,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.4.1" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.0" }, "funding": [ { @@ -2095,7 +3348,7 @@ "type": "tidelift" } ], - "time": "2025-01-27T22:48:22+00:00" + "time": "2025-11-15T19:02:59+00:00" }, { "name": "doctrine/event-manager", @@ -2190,33 +3443,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2261,7 +3513,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -2277,7 +3529,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/instantiator", @@ -2428,16 +3680,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.0", + "version": "3.9.5", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab" + "reference": "1b823afbc40f932dae8272574faee53f2755eac5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/325b61e41d032f5f7d7e2d11cbefff656eadc9ab", - "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5", + "reference": "1b823afbc40f932dae8272574faee53f2755eac5", "shasum": "" }, "require": { @@ -2447,29 +3699,29 @@ "doctrine/event-manager": "^1.2 || ^2.0", "php": "^8.1", "psr/log": "^1.1.3 || ^2 || ^3", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^6.2 || ^7.0" + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.2 || ^7.0 || ^8.0" }, "conflict": { "doctrine/orm": "<2.12 || >=4" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^14", "doctrine/orm": "^2.13 || ^3", "doctrine/persistence": "^2 || ^3 || ^4", "doctrine/sql-formatter": "^1.0", "ext-pdo_sqlite": "*", "fig/log-test": "^1", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-strict-rules": "^1.4", - "phpstan/phpstan-symfony": "^1.3", - "phpunit/phpunit": "^10.3", - "symfony/cache": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", @@ -2511,7 +3763,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.0" + "source": "https://github.com/doctrine/migrations/tree/3.9.5" }, "funding": [ { @@ -2527,20 +3779,20 @@ "type": "tidelift" } ], - "time": "2025-03-26T06:48:45+00:00" + "time": "2025-11-20T11:15:36+00:00" }, { "name": "doctrine/orm", - "version": "3.3.2", + "version": "3.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "c9557c588b3a70ed93caff069d0aa75737f25609" + "reference": "2148940290e4c44b9101095707e71fb590832fa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/c9557c588b3a70ed93caff069d0aa75737f25609", - "reference": "c9557c588b3a70ed93caff069d0aa75737f25609", + "url": "https://api.github.com/repos/doctrine/orm/zipball/2148940290e4c44b9101095707e71fb590832fa5", + "reference": "2148940290e4c44b9101095707e71fb590832fa5", "shasum": "" }, "require": { @@ -2556,20 +3808,18 @@ "ext-ctype": "*", "php": "^8.1", "psr/cache": "^1 || ^2 || ^3", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^6.3.9 || ^7.0" + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0", + "doctrine/coding-standard": "^14.0", "phpbench/phpbench": "^1.0", - "phpdocumentor/guides-cli": "^1.4", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "2.0.3", + "phpstan/phpstan": "2.1.23", "phpstan/phpstan-deprecation-rules": "^2", - "phpunit/phpunit": "^10.4.0", + "phpunit/phpunit": "^10.5.0 || ^11.5", "psr/log": "^1 || ^2 || ^3", - "squizlabs/php_codesniffer": "3.7.2", - "symfony/cache": "^5.4 || ^6.2 || ^7.0" + "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -2615,22 +3865,22 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.3.2" + "source": "https://github.com/doctrine/orm/tree/3.6.1" }, - "time": "2025-02-04T19:43:15+00:00" + "time": "2026-01-09T05:28:15+00:00" }, { "name": "doctrine/persistence", - "version": "4.0.0", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa" + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/45004aca79189474f113cbe3a53847c2115a55fa", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", "shasum": "" }, "require": { @@ -2638,16 +3888,14 @@ "php": "^8.1", "psr/cache": "^1.0 || ^2.0 || ^3.0" }, - "conflict": { - "doctrine/common": "<2.10" - }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "1.12.7", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^9.6", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" }, "type": "library", "autoload": { @@ -2696,7 +3944,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.0.0" + "source": "https://github.com/doctrine/persistence/tree/4.1.1" }, "funding": [ { @@ -2712,30 +3960,30 @@ "type": "tidelift" } ], - "time": "2024-11-01T21:49:07+00:00" + "time": "2025-10-16T20:13:18+00:00" }, { "name": "doctrine/sql-formatter", - "version": "1.5.2", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8" + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7", + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "ergebnis/phpunit-slow-test-detector": "^2.14", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5" + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" }, "bin": [ "bin/sql-formatter" @@ -2765,22 +4013,22 @@ ], "support": { "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.2" + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.3" }, - "time": "2025-01-24T11:45:48+00:00" + "time": "2025-10-26T09:35:14+00:00" }, { "name": "dompdf/dompdf", - "version": "v3.1.0", + "version": "v3.1.4", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "a51bd7a063a65499446919286fb18b518177155a" + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a", - "reference": "a51bd7a063a65499446919286fb18b518177155a", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", "shasum": "" }, "require": { @@ -2829,9 +4077,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.0" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" }, - "time": "2025-01-15T14:09:04+00:00" + "time": "2025-10-29T12:43:30+00:00" }, { "name": "dompdf/php-font-lib", @@ -2880,25 +4128,25 @@ }, { "name": "dompdf/php-svg-lib", - "version": "1.0.0", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/dompdf/php-svg-lib.git", - "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", - "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^7.1 || ^8.0", - "sabberworm/php-css-parser": "^8.4" + "sabberworm/php-css-parser": "^8.4 || ^9.0" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" }, "type": "library", "autoload": { @@ -2920,9 +4168,9 @@ "homepage": "https://github.com/dompdf/php-svg-lib", "support": { "issues": "https://github.com/dompdf/php-svg-lib/issues", - "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" }, - "time": "2024-04-29T13:26:35+00:00" + "time": "2026-01-02T16:01:13+00:00" }, { "name": "egulias/email-validator", @@ -2992,99 +4240,43 @@ "time": "2025-03-06T22:45:56+00:00" }, { - "name": "erusev/parsedown", - "version": "1.7.4", + "name": "ergebnis/classy", + "version": "1.9.0", "source": { "type": "git", - "url": "https://github.com/erusev/parsedown.git", - "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3" + "url": "https://github.com/ergebnis/classy.git", + "reference": "05c3ac7d8d9d337c4cf1d5602a339f57cb2a27ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3", - "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "url": "https://api.github.com/repos/ergebnis/classy/zipball/05c3ac7d8d9d337c4cf1d5602a339f57cb2a27ef", + "reference": "05c3ac7d8d9d337c4cf1d5602a339f57cb2a27ef", "shasum": "" }, "require": { - "ext-mbstring": "*", - "php": ">=5.3.0" + "ext-tokenizer": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35" + "ergebnis/composer-normalize": "^2.48.1", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.54.0", + "ergebnis/phpstan-rules": "^2.11.0", + "ergebnis/phpunit-slow-test-detector": "^2.20.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^9.6.19", + "rector/rector": "^2.1.4" }, "type": "library", - "autoload": { - "psr-0": { - "Parsedown": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Emanuil Rusev", - "email": "hello@erusev.com", - "homepage": "http://erusev.com" - } - ], - "description": "Parser for Markdown.", - "homepage": "http://parsedown.org", - "keywords": [ - "markdown", - "parser" - ], - "support": { - "issues": "https://github.com/erusev/parsedown/issues", - "source": "https://github.com/erusev/parsedown/tree/1.7.x" - }, - "time": "2019-12-30T22:54:17+00:00" - }, - { - "name": "florianv/exchanger", - "version": "2.8.1", - "source": { - "type": "git", - "url": "https://github.com/florianv/exchanger.git", - "reference": "9214f51665fb907e7aa2397e21a90c456eb0c448" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/florianv/exchanger/zipball/9214f51665fb907e7aa2397e21a90c456eb0c448", - "reference": "9214f51665fb907e7aa2397e21a90c456eb0c448", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-libxml": "*", - "ext-simplexml": "*", - "php": "^7.1.3 || ^8.0", - "php-http/client-implementation": "^1.0", - "php-http/discovery": "^1.6", - "php-http/httplug": "^1.0 || ^2.0", - "psr/http-factory": "^1.0.2", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" - }, - "require-dev": { - "nyholm/psr7": "^1.0", - "php-http/message": "^1.7", - "php-http/mock-client": "^1.0", - "phpunit/phpunit": "^7 || ^8 || ^9.4" - }, - "suggest": { - "php-http/guzzle6-adapter": "Required to use Guzzle for sending HTTP requests", - "php-http/message": "Required to use Guzzle for sending HTTP requests" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, "autoload": { "psr-4": { - "Exchanger\\": "src/" + "Ergebnis\\Classy\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3093,177 +4285,50 @@ ], "authors": [ { - "name": "Florian Voutzinos", - "email": "florian@voutzinos.com", - "homepage": "https://voutzinos.com" + "name": "Andreas Mรถller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "Currency exchange rates framework for PHP", - "homepage": "https://github.com/florianv/exchanger", + "description": "Provides a finder for classy constructs (classes, enums, interfaces, and traits).", + "homepage": "https://github.com/ergebnis/classy", "keywords": [ - "Rate", - "conversion", - "currency", - "exchange rates", - "money" + "classes", + "classy", + "constructs", + "finder", + "interfaces", + "traits" ], "support": { - "issues": "https://github.com/florianv/exchanger/issues", - "source": "https://github.com/florianv/exchanger/tree/2.8.1" + "issues": "https://github.com/ergebnis/classy/issues", + "source": "https://github.com/ergebnis/classy" }, - "time": "2023-11-03T17:11:52+00:00" - }, - { - "name": "florianv/swap", - "version": "4.3.0", - "source": { - "type": "git", - "url": "https://github.com/florianv/swap.git", - "reference": "88edd27fcb95bdc58bbbf9e4b00539a2843d97fd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/florianv/swap/zipball/88edd27fcb95bdc58bbbf9e4b00539a2843d97fd", - "reference": "88edd27fcb95bdc58bbbf9e4b00539a2843d97fd", - "shasum": "" - }, - "require": { - "florianv/exchanger": "^2.0", - "php": "^7.1.3 || ^8.0" - }, - "require-dev": { - "nyholm/psr7": "^1.0", - "php-http/message": "^1.7", - "php-http/mock-client": "^1.0", - "phpunit/phpunit": "^7 || ^8 || ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, - "autoload": { - "psr-4": { - "Swap\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florian Voutzinos", - "email": "florian@voutzinos.com", - "homepage": "https://voutzinos.com" - } - ], - "description": "Exchange rates library for PHP", - "keywords": [ - "Rate", - "conversion", - "currency", - "exchange rates", - "money" - ], - "support": { - "issues": "https://github.com/florianv/swap/issues", - "source": "https://github.com/florianv/swap/tree/4.3.0" - }, - "time": "2020-12-28T10:14:12+00:00" - }, - { - "name": "florianv/swap-bundle", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/florianv/symfony-swap.git", - "reference": "c8cd268ad6e2f636f10b91df9850e3941d7f5807" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/florianv/symfony-swap/zipball/c8cd268ad6e2f636f10b91df9850e3941d7f5807", - "reference": "c8cd268ad6e2f636f10b91df9850e3941d7f5807", - "shasum": "" - }, - "require": { - "florianv/swap": "^4.0", - "php": "^7.1.3|^8.0", - "symfony/framework-bundle": "~3.0|~4.0|~5.0|~6.0|~7.0" - }, - "require-dev": { - "nyholm/psr7": "^1.1", - "php-http/guzzle6-adapter": "^1.0", - "php-http/message": "^1.7", - "phpunit/phpunit": "~5.7|~6.0|~7.0|~8.0|~9.0", - "symfony/cache": "~3.0|~4.0|~5.0|~6.0|~7.0" - }, - "suggest": { - "symfony/cache": "For caching" - }, - "default-branch": true, - "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, - "autoload": { - "psr-4": { - "Florianv\\SwapBundle\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florian Voutzinos", - "email": "florian@voutzinos.com", - "homepage": "http://florian.voutzinos.com" - } - ], - "description": "Integrates the Swap library with Symfony", - "homepage": "https://github.com/florianv/FlorianvSwapBundle", - "keywords": [ - "Rate", - "bundle", - "conversion", - "currency", - "exchange", - "money", - "symfony" - ], - "support": { - "issues": "https://github.com/florianv/symfony-swap/issues", - "source": "https://github.com/florianv/symfony-swap/tree/master" - }, - "time": "2024-07-09T13:51:01+00:00" + "time": "2025-09-04T10:17:22+00:00" }, { "name": "gregwar/captcha", - "version": "v1.2.1", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/Gregwar/Captcha.git", - "reference": "229d3cdfe33d6f1349e0aec94a26e9205a6db08e" + "reference": "4edbcd09fde4353b94ce550f43460eba73baf2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Gregwar/Captcha/zipball/229d3cdfe33d6f1349e0aec94a26e9205a6db08e", - "reference": "229d3cdfe33d6f1349e0aec94a26e9205a6db08e", + "url": "https://api.github.com/repos/Gregwar/Captcha/zipball/4edbcd09fde4353b94ce550f43460eba73baf2cc", + "reference": "4edbcd09fde4353b94ce550f43460eba73baf2cc", "shasum": "" }, "require": { + "ext-fileinfo": "*", "ext-gd": "*", "ext-mbstring": "*", "php": ">=5.3.0", "symfony/finder": "*" }, "require-dev": { - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6.4 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "autoload": { @@ -3295,31 +4360,31 @@ ], "support": { "issues": "https://github.com/Gregwar/Captcha/issues", - "source": "https://github.com/Gregwar/Captcha/tree/v1.2.1" + "source": "https://github.com/Gregwar/Captcha/tree/v1.3.0" }, - "time": "2023-09-26T13:45:37+00:00" + "time": "2025-06-23T12:25:54+00:00" }, { "name": "gregwar/captcha-bundle", - "version": "v2.3.0", + "version": "v2.5.0", "source": { "type": "git", "url": "https://github.com/Gregwar/CaptchaBundle.git", - "reference": "8eb95c0911a1db9e3b2f368f6319e0945b959a6c" + "reference": "b129efda562bf8361ca6eb77043036813f749de6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Gregwar/CaptchaBundle/zipball/8eb95c0911a1db9e3b2f368f6319e0945b959a6c", - "reference": "8eb95c0911a1db9e3b2f368f6319e0945b959a6c", + "url": "https://api.github.com/repos/Gregwar/CaptchaBundle/zipball/b129efda562bf8361ca6eb77043036813f749de6", + "reference": "b129efda562bf8361ca6eb77043036813f749de6", "shasum": "" }, "require": { "ext-gd": "*", "gregwar/captcha": "^1.2.1", "php": ">=8.0.2", - "symfony/form": "~6.0|~7.0", - "symfony/framework-bundle": "~6.0|~7.0", - "symfony/translation": "~6.0|^7.0", + "symfony/form": "~6.0|~7.0|~8.0", + "symfony/framework-bundle": "~6.0|~7.0|~8.0", + "symfony/translation": "~6.0|~7.0|~8.0", "twig/twig": "^3.0" }, "require-dev": { @@ -3330,7 +4395,7 @@ "type": "symfony-bundle", "autoload": { "psr-4": { - "Gregwar\\CaptchaBundle\\": "/" + "Gregwar\\CaptchaBundle\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -3362,28 +4427,28 @@ ], "support": { "issues": "https://github.com/Gregwar/CaptchaBundle/issues", - "source": "https://github.com/Gregwar/CaptchaBundle/tree/v2.3.0" + "source": "https://github.com/Gregwar/CaptchaBundle/tree/v2.5.0" }, - "time": "2024-06-06T13:14:57+00:00" + "time": "2026-01-08T10:51:57+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -3474,7 +4539,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -3490,20 +4555,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -3511,7 +4576,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -3557,7 +4622,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.2.0" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -3573,20 +4638,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:27:01+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -3602,7 +4667,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -3673,7 +4738,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -3689,20 +4754,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "hshn/base64-encoded-file", - "version": "v5.0.1", + "version": "v5.0.3", "source": { "type": "git", "url": "https://github.com/hshn/base64-encoded-file.git", - "reference": "54fa81461ba4fbf5b67ed71d22b43ea5cc8c8748" + "reference": "74984c7e69fbed9378dbf1d64e632522cc1b6d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hshn/base64-encoded-file/zipball/54fa81461ba4fbf5b67ed71d22b43ea5cc8c8748", - "reference": "54fa81461ba4fbf5b67ed71d22b43ea5cc8c8748", + "url": "https://api.github.com/repos/hshn/base64-encoded-file/zipball/74984c7e69fbed9378dbf1d64e632522cc1b6d95", + "reference": "74984c7e69fbed9378dbf1d64e632522cc1b6d95", "shasum": "" }, "require": { @@ -3749,22 +4814,22 @@ "description": "Provides handling base64 encoded files, and the integration of symfony/form", "support": { "issues": "https://github.com/hshn/base64-encoded-file/issues", - "source": "https://github.com/hshn/base64-encoded-file/tree/v5.0.1" + "source": "https://github.com/hshn/base64-encoded-file/tree/v5.0.3" }, - "time": "2023-12-24T07:23:07+00:00" + "time": "2025-10-06T10:34:52+00:00" }, { "name": "imagine/imagine", - "version": "1.5.0", + "version": "1.5.2", "source": { "type": "git", "url": "https://github.com/php-imagine/Imagine.git", - "reference": "80ab21434890dee9ba54969d31c51ac8d4d551e0" + "reference": "f9ed796eefb77c2f0f2167e1d4e36bc2b5ed6b0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/80ab21434890dee9ba54969d31c51ac8d4d551e0", - "reference": "80ab21434890dee9ba54969d31c51ac8d4d551e0", + "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/f9ed796eefb77c2f0f2167e1d4e36bc2b5ed6b0c", + "reference": "f9ed796eefb77c2f0f2167e1d4e36bc2b5ed6b0c", "shasum": "" }, "require": { @@ -3811,34 +4876,34 @@ ], "support": { "issues": "https://github.com/php-imagine/Imagine/issues", - "source": "https://github.com/php-imagine/Imagine/tree/1.5.0" + "source": "https://github.com/php-imagine/Imagine/tree/1.5.2" }, - "time": "2024-12-03T14:37:55+00:00" + "time": "2026-01-09T10:45:12+00:00" }, { "name": "jbtronics/2fa-webauthn", - "version": "v2.2.3", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/jbtronics/2fa-webauthn.git", - "reference": "fda6f39e70784cbf1f93cf758bf798563219d451" + "reference": "542424bcc51f06932cbecfd7b75afbb5e107c9ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/2fa-webauthn/zipball/fda6f39e70784cbf1f93cf758bf798563219d451", - "reference": "fda6f39e70784cbf1f93cf758bf798563219d451", + "url": "https://api.github.com/repos/jbtronics/2fa-webauthn/zipball/542424bcc51f06932cbecfd7b75afbb5e107c9ce", + "reference": "542424bcc51f06932cbecfd7b75afbb5e107c9ce", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7": "^1.5", - "php": "^8.1", + "php": "^8.2", "psr/log": "^3.0.0|^2.0.0", "scheb/2fa-bundle": "^6.0.0|^7.0.0", "symfony/framework-bundle": "^6.0|^7.0", "symfony/psr-http-message-bridge": "^2.1|^6.1|^7.0", "symfony/uid": "^6.0|^7.0", - "web-auth/webauthn-lib": "^4.7" + "web-auth/webauthn-lib": "^5.2" }, "require-dev": { "phpunit/phpunit": "^9.5", @@ -3860,7 +4925,7 @@ "email": "mail@jan-boehmer.de" } ], - "description": "Webauthn Two-Factor-Authentictication Plugin for scheb/2fa", + "description": "Webauthn Two-Factor-Authentication Plugin for scheb/2fa", "keywords": [ "2fa", "scheb-2fa", @@ -3871,30 +4936,30 @@ ], "support": { "issues": "https://github.com/jbtronics/2fa-webauthn/issues", - "source": "https://github.com/jbtronics/2fa-webauthn/tree/v2.2.3" + "source": "https://github.com/jbtronics/2fa-webauthn/tree/v3.0.0" }, - "time": "2025-03-27T19:23:40+00:00" + "time": "2025-08-14T15:12:48+00:00" }, { "name": "jbtronics/dompdf-font-loader-bundle", - "version": "v1.1.3", + "version": "v1.1.6", "source": { "type": "git", "url": "https://github.com/jbtronics/dompdf-font-loader-bundle.git", - "reference": "da01d9655826105d53f9d0e8ba4f9d838201dcb2" + "reference": "5fb434f35544d5757292cd5471768dda3862c932" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/dompdf-font-loader-bundle/zipball/da01d9655826105d53f9d0e8ba4f9d838201dcb2", - "reference": "da01d9655826105d53f9d0e8ba4f9d838201dcb2", + "url": "https://api.github.com/repos/jbtronics/dompdf-font-loader-bundle/zipball/5fb434f35544d5757292cd5471768dda3862c932", + "reference": "5fb434f35544d5757292cd5471768dda3862c932", "shasum": "" }, "require": { "dompdf/dompdf": "^1.0.0|^2.0.0|^3.0.0", "ext-json": "*", "php": "^8.1", - "symfony/finder": "^6.0|^7.0", - "symfony/framework-bundle": "^6.0|^7.0" + "symfony/finder": "^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^6.0|^7.0|^8.0" }, "require-dev": { "phpunit/phpunit": "^9.5", @@ -3926,9 +4991,95 @@ ], "support": { "issues": "https://github.com/jbtronics/dompdf-font-loader-bundle/issues", - "source": "https://github.com/jbtronics/dompdf-font-loader-bundle/tree/v1.1.3" + "source": "https://github.com/jbtronics/dompdf-font-loader-bundle/tree/v1.1.6" }, - "time": "2025-02-07T23:21:03+00:00" + "time": "2025-11-30T22:19:12+00:00" + }, + { + "name": "jbtronics/settings-bundle", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/jbtronics/settings-bundle.git", + "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/a99c6e4cde40b829c1643b89da506b9588b11eaf", + "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf", + "shasum": "" + }, + "require": { + "ergebnis/classy": "^1.6", + "ext-json": "*", + "php": "^8.1", + "symfony/deprecation-contracts": "^3.4", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.0|^6.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.11", + "doctrine/doctrine-fixtures-bundle": "^3.5", + "doctrine/orm": "^3.0", + "ekino/phpstan-banned-code": "^1.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-strict-rules": "^1.5", + "phpstan/phpstan-symfony": "^1.3", + "phpunit/phpunit": "^9.5", + "roave/security-advisories": "dev-latest", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^7.0|^6.4|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "To use the doctrine ORM storage", + "symfony/twig-bridge": "Allows to access settings in twig templates" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Jbtronics\\SettingsBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Bรถhmer", + "email": "mail@jan-boehmer.de" + } + ], + "description": "A symfony bundle to easily create typesafe, user-configurable settings for symfony applications", + "keywords": [ + "Settings", + "config", + "symfony", + "symfony-bundle", + "user-configurable" + ], + "support": { + "issues": "https://github.com/jbtronics/settings-bundle/issues", + "source": "https://github.com/jbtronics/settings-bundle/tree/v3.1.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/do9jhb", + "type": "custom" + }, + { + "url": "https://github.com/jbtronics", + "type": "github" + } + ], + "time": "2026-01-02T23:58:02+00:00" }, { "name": "jfcherng/php-color-output", @@ -4227,31 +5378,32 @@ }, { "name": "knpuniversity/oauth2-client-bundle", - "version": "v2.18.3", + "version": "v2.20.1", "source": { "type": "git", "url": "https://github.com/knpuniversity/oauth2-client-bundle.git", - "reference": "c38ca88a70aae3694ca346a41b13b9a8f6e33ed4" + "reference": "d59e4dc61484e777b6f19df2efcf8b1bcc03828a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/c38ca88a70aae3694ca346a41b13b9a8f6e33ed4", - "reference": "c38ca88a70aae3694ca346a41b13b9a8f6e33ed4", + "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/d59e4dc61484e777b6f19df2efcf8b1bcc03828a", + "reference": "d59e4dc61484e777b6f19df2efcf8b1bcc03828a", "shasum": "" }, "require": { "league/oauth2-client": "^2.0", "php": ">=8.1", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0" + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/framework-bundle": "^6.4|^7.3|^8.0", + "symfony/http-foundation": "^6.4|^7.3|^8.0", + "symfony/routing": "^6.4|^7.3|^8.0", + "symfony/security-core": "^6.4|^7.3|^8.0", + "symfony/security-http": "^6.4|^7.3|^8.0" }, "require-dev": { "league/oauth2-facebook": "^1.1|^2.0", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", - "symfony/security-guard": "^5.4", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/phpunit-bridge": "^7.3", + "symfony/yaml": "^6.4|^7.3|^8.0" }, "suggest": { "symfony/security-guard": "For integration with Symfony's Guard Security layer" @@ -4280,40 +5432,40 @@ ], "support": { "issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues", - "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.18.3" + "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.20.1" }, - "time": "2024-10-02T14:26:09+00:00" + "time": "2025-12-04T15:46:43+00:00" }, { "name": "lcobucci/clock", - "version": "3.0.0", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/lcobucci/clock.git", - "reference": "039ef98c6b57b101d10bd11d8fdfda12cbd996dc" + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/clock/zipball/039ef98c6b57b101d10bd11d8fdfda12cbd996dc", - "reference": "039ef98c6b57b101d10bd11d8fdfda12cbd996dc", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "psr/clock": "^1.0" }, "provide": { "psr/clock-implementation": "1.0" }, "require-dev": { - "infection/infection": "^0.26", - "lcobucci/coding-standard": "^9.0", - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-deprecation-rules": "^1.1.1", - "phpstan/phpstan-phpunit": "^1.3.2", - "phpstan/phpstan-strict-rules": "^1.4.4", - "phpunit/phpunit": "^9.5.27" + "infection/infection": "^0.29", + "lcobucci/coding-standard": "^11.1.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.25", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^11.3.6" }, "type": "library", "autoload": { @@ -4334,7 +5486,7 @@ "description": "Yet another clock abstraction", "support": { "issues": "https://github.com/lcobucci/clock/issues", - "source": "https://github.com/lcobucci/clock/tree/3.0.0" + "source": "https://github.com/lcobucci/clock/tree/3.3.1" }, "funding": [ { @@ -4346,42 +5498,42 @@ "type": "patreon" } ], - "time": "2022-12-19T15:00:24+00:00" + "time": "2024-09-24T20:45:14+00:00" }, { "name": "lcobucci/jwt", - "version": "5.3.0", + "version": "5.6.0", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", "shasum": "" }, "require": { "ext-openssl": "*", "ext-sodium": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/clock": "^1.0" }, "require-dev": { - "infection/infection": "^0.27.0", - "lcobucci/clock": "^3.0", + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", "lcobucci/coding-standard": "^11.0", - "phpbench/phpbench": "^1.2.9", + "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.2", "phpstan/phpstan": "^1.10.7", "phpstan/phpstan-deprecation-rules": "^1.1.3", "phpstan/phpstan-phpunit": "^1.3.10", "phpstan/phpstan-strict-rules": "^1.5.0", - "phpunit/phpunit": "^10.2.6" + "phpunit/phpunit": "^11.1" }, "suggest": { - "lcobucci/clock": ">= 3.0" + "lcobucci/clock": ">= 3.2" }, "type": "library", "autoload": { @@ -4407,7 +5559,7 @@ ], "support": { "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" }, "funding": [ { @@ -4419,39 +5571,235 @@ "type": "patreon" } ], - "time": "2024-04-11T23:07:54+00:00" + "time": "2025-10-17T11:30:53+00:00" }, { - "name": "league/csv", - "version": "9.8.0", + "name": "league/commonmark", + "version": "2.8.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/csv.git", - "reference": "9d2e0265c5d90f5dd601bc65ff717e05cec19b47" + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/9d2e0265c5d90f5dd601bc65ff717e05cec19b47", - "reference": "9d2e0265c5d90f5dd601bc65ff717e05cec19b47", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", "shasum": "" }, "require": { - "ext-json": "*", "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-11-26T21:48:24+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", "php": "^7.4 || ^8.0" }, "require-dev": { - "ext-curl": "*", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/csv", + "version": "9.28.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1.2" + }, + "require-dev": { "ext-dom": "*", - "friendsofphp/php-cs-fixer": "^v3.4.0", - "phpstan/phpstan": "^1.3.0", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpunit/phpunit": "^9.5.11" + "ext-xdebug": "*", + "friendsofphp/php-cs-fixer": "^3.92.3", + "phpbench/phpbench": "^1.4.3", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.2", + "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.5.4", + "symfony/var-dumper": "^6.4.8 || ^7.4.0 || ^8.0" }, "suggest": { - "ext-dom": "Required to use the XMLConverter and or the HTMLConverter classes", - "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters" + "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters", + "ext-mbstring": "Needed to ease transcoding CSV using mb stream filters", + "ext-mysqli": "Requiered to use the package with the MySQLi extension", + "ext-pdo": "Required to use the package with the PDO extension", + "ext-pgsql": "Requiered to use the package with the PgSQL extension", + "ext-sqlite3": "Required to use the package with the SQLite3 extension" }, "type": "library", "extra": { @@ -4503,7 +5851,7 @@ "type": "github" } ], - "time": "2022-01-04T00:13:07+00:00" + "time": "2025-12-27T15:18:42+00:00" }, { "name": "league/html-to-markdown", @@ -4596,22 +5944,22 @@ }, { "name": "league/oauth2-client", - "version": "2.8.1", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "9df2924ca644736c835fc60466a3a60390d334f9" + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/9df2924ca644736c835fc60466a3a60390d334f9", - "reference": "9df2924ca644736c835fc60466a3a60390d334f9", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", - "php": "^7.1 || >=8.0.0 <8.5.0" + "php": "^7.1 || >=8.0.0 <8.6.0" }, "require-dev": { "mockery/mockery": "^1.3.5", @@ -4655,39 +6003,44 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.1" + "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" }, - "time": "2025-02-26T04:37:30+00:00" + "time": "2025-11-25T22:17:17+00:00" }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.7", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", "league/uri-components": "Needed to easily manipulate URI objects components", + "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -4715,6 +6068,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -4727,9 +6081,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -4739,7 +6095,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.7.0" }, "funding": [ { @@ -4747,34 +6103,37 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2025-12-07T16:02:06+00:00" }, { "name": "league/uri-components", - "version": "7.5.1", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-components.git", - "reference": "4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f" + "reference": "005f8693ce8c1f16f80e88a05cbf08da04c1c374" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f", - "reference": "4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/005f8693ce8c1f16f80e88a05cbf08da04c1c374", + "reference": "005f8693ce8c1f16f80e88a05cbf08da04c1c374", "shasum": "" }, "require": { - "league/uri": "^7.5", + "league/uri": "^7.7", "php": "^8.1" }, "suggest": { + "bakame/aide-uri": "A polyfill for PHP8.1 until PHP8.4 to add support to PHP Native URI parser", "ext-bcmath": "to improve IPV4 host parsing", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-mbstring": "to use the sorting algorithm of URLSearchParams", "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -4821,7 +6180,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-components/tree/7.5.1" + "source": "https://github.com/thephpleague/uri-components/tree/7.7.0" }, "funding": [ { @@ -4829,26 +6188,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2025-12-07T16:02:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -4856,6 +6214,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -4880,7 +6239,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -4905,7 +6264,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" }, "funding": [ { @@ -4913,32 +6272,34 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2025-12-07T16:03:21+00:00" }, { "name": "liip/imagine-bundle", - "version": "2.13.3", + "version": "2.17.1", "source": { "type": "git", "url": "https://github.com/liip/LiipImagineBundle.git", - "reference": "3faccde327f91368e51d05ecad49a9cd915abd81" + "reference": "69d2df3c6606495d1878fa190d6c3dc4bc5623b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/3faccde327f91368e51d05ecad49a9cd915abd81", - "reference": "3faccde327f91368e51d05ecad49a9cd915abd81", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/69d2df3c6606495d1878fa190d6c3dc4bc5623b6", + "reference": "69d2df3c6606495d1878fa190d6c3dc4bc5623b6", "shasum": "" }, "require": { "ext-mbstring": "*", "imagine/imagine": "^1.3.2", - "php": "^7.2|^8.0", - "symfony/filesystem": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/finder": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/framework-bundle": "^3.4.23|^4.4|^5.3|^6.0|^7.0", - "symfony/mime": "^4.4|^5.3|^6.0|^7.0", - "symfony/options-resolver": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/process": "^3.4|^4.4|^5.3|^6.0|^7.0", + "php": "^8.0", + "symfony/dependency-injection": "^5.4|^6.4|^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3", + "symfony/filesystem": "^5.4|^6.4|^7.3|^8.0", + "symfony/finder": "^5.4|^6.4|^7.3|^8.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.3|^8.0", + "symfony/mime": "^5.4|^6.4|^7.3|^8.0", + "symfony/options-resolver": "^5.4|^6.4|^7.3|^8.0", + "symfony/process": "^5.4|^6.4|^7.3|^8.0", "twig/twig": "^1.44|^2.9|^3.0" }, "require-dev": { @@ -4952,17 +6313,17 @@ "phpstan/phpstan": "^1.10.0", "psr/cache": "^1.0|^2.0|^3.0", "psr/log": "^1.0", - "symfony/asset": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/browser-kit": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/cache": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/console": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/dependency-injection": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/form": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/messenger": "^4.4|^5.3|^6.0|^7.0", - "symfony/phpunit-bridge": "^7.0.2", - "symfony/templating": "^3.4|^4.4|^5.3|^6.0", - "symfony/validator": "^3.4|^4.4|^5.3|^6.0|^7.0", - "symfony/yaml": "^3.4|^4.4|^5.3|^6.0|^7.0" + "symfony/asset": "^5.4|^6.4|^7.3|^8.0", + "symfony/browser-kit": "^5.4|^6.4|^7.3|^8.0", + "symfony/cache": "^5.4|^6.4|^7.3|^8.0", + "symfony/console": "^5.4|^6.4|^7.3|^8.0", + "symfony/form": "^5.4|^6.4|^7.3|^8.0", + "symfony/messenger": "^5.4|^6.4|^7.3|^8.0", + "symfony/phpunit-bridge": "^7.3", + "symfony/runtime": "^5.4|^6.4|^7.3|^8.0", + "symfony/templating": "^5.4|^6.4|^7.3|^8.0", + "symfony/validator": "^5.4|^6.4|^7.3|^8.0", + "symfony/yaml": "^5.4|^6.4|^7.3|^8.0" }, "suggest": { "alcaeus/mongo-php-adapter": "required for mongodb components", @@ -5017,9 +6378,9 @@ ], "support": { "issues": "https://github.com/liip/LiipImagineBundle/issues", - "source": "https://github.com/liip/LiipImagineBundle/tree/2.13.3" + "source": "https://github.com/liip/LiipImagineBundle/tree/2.17.1" }, - "time": "2024-12-12T09:38:23+00:00" + "time": "2026-01-06T09:34:48+00:00" }, { "name": "lorenzo/pinky", @@ -5075,17 +6436,199 @@ "time": "2023-07-31T13:36:50+00:00" }, { - "name": "masterminds/html5", - "version": "2.9.0", + "name": "maennchen/zipstream-php", + "version": "2.1.0", "source": { "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/c4c5803cc1f93df3d2448478ef79394a5981cc58", + "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58", + "shasum": "" + }, + "require": { + "myclabs/php-enum": "^1.5", + "php": ">= 7.1", + "psr/http-message": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "ext-zip": "*", + "guzzlehttp/guzzle": ">= 6.3", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": ">= 7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Mรคnnchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "Andrรกs Kolesรกr", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/2.1.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + }, + { + "url": "https://opencollective.com/zipstream", + "type": "open_collective" + } + ], + "time": "2020-05-30T13:11:16+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { @@ -5137,22 +6680,22 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2024-03-31T07:05:07+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -5170,7 +6713,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -5230,7 +6773,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -5242,41 +6785,104 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { - "name": "nbgrp/onelogin-saml-bundle", - "version": "v1.4.0", + "name": "myclabs/php-enum", + "version": "1.8.5", "source": { "type": "git", - "url": "https://github.com/nbgrp/onelogin-saml-bundle.git", - "reference": "3341544e72b699ab69357ab38cee9c80941ce1c6" + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nbgrp/onelogin-saml-bundle/zipball/3341544e72b699ab69357ab38cee9c80941ce1c6", - "reference": "3341544e72b699ab69357ab38cee9c80941ce1c6", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2025-01-14T11:49:03+00:00" + }, + { + "name": "nbgrp/onelogin-saml-bundle", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/nbgrp/onelogin-saml-bundle.git", + "reference": "087402c69ef87e0a34d9b708661deecd00fd190a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nbgrp/onelogin-saml-bundle/zipball/087402c69ef87e0a34d9b708661deecd00fd190a", + "reference": "087402c69ef87e0a34d9b708661deecd00fd190a", "shasum": "" }, "require": { "onelogin/php-saml": "^4", - "php": "^8.1", + "php": "^8.2", "psr/log": "^1 || ^2 || ^3", - "symfony/config": "^6.4", - "symfony/dependency-injection": "^6.4", + "symfony/config": "^7", + "symfony/dependency-injection": "^7", "symfony/deprecation-contracts": "^3", "symfony/event-dispatcher-contracts": "^3", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.4", - "symfony/routing": "^6.4", - "symfony/security-bundle": "^6.4", - "symfony/security-core": "^6.4", - "symfony/security-http": "^6.4" + "symfony/http-foundation": "^7", + "symfony/http-kernel": "^7", + "symfony/routing": "^7", + "symfony/security-bundle": "^7", + "symfony/security-core": "^7", + "symfony/security-http": "^7" }, "require-dev": { "doctrine/orm": "^2.3 || ^3", - "symfony/event-dispatcher": "^6.4", - "symfony/phpunit-bridge": "^6.4" + "phpunit/phpunit": "^11", + "symfony/event-dispatcher": "^7" }, "type": "symfony-bundle", "autoload": { @@ -5303,9 +6909,9 @@ ], "support": { "issues": "https://github.com/nbgrp/onelogin-saml-bundle/issues", - "source": "https://github.com/nbgrp/onelogin-saml-bundle/tree/v1.4.0" + "source": "https://github.com/nbgrp/onelogin-saml-bundle/tree/v2.1.0" }, - "time": "2023-11-29T12:22:32+00:00" + "time": "2025-09-26T08:45:17+00:00" }, { "name": "nelexa/zip", @@ -5382,25 +6988,28 @@ }, { "name": "nelmio/cors-bundle", - "version": "2.5.0", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioCorsBundle.git", - "reference": "3a526fe025cd20e04a6a11370cf5ab28dbb5a544" + "reference": "530217472204881cacd3671909f634b960c7b948" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3a526fe025cd20e04a6a11370cf5ab28dbb5a544", - "reference": "3a526fe025cd20e04a6a11370cf5ab28dbb5a544", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/530217472204881cacd3671909f634b960c7b948", + "reference": "530217472204881cacd3671909f634b960c7b948", "shasum": "" }, "require": { "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "mockery/mockery": "^1.3.6", - "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + "phpstan/phpstan": "^1.11.5", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-symfony": "^1.4.4", + "phpunit/phpunit": "^8" }, "type": "symfony-bundle", "extra": { @@ -5438,47 +7047,47 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", - "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.5.0" + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.0" }, - "time": "2024-06-24T21:25:28+00:00" + "time": "2025-10-23T06:57:22+00:00" }, { "name": "nelmio/security-bundle", - "version": "v3.5.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioSecurityBundle.git", - "reference": "b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7" + "reference": "9389ec28cd219d621d3d91c840a3df6f04c9f651" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7", - "reference": "b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7", + "url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/9389ec28cd219d621d3d91c840a3df6f04c9f651", + "reference": "9389ec28cd219d621d3d91c840a3df6f04c9f651", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", "symfony/deprecation-contracts": "^2.5 || ^3", - "symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.3 || ^7.0", - "symfony/security-core": "^5.4 || ^6.3 || ^7.0", - "symfony/security-csrf": "^5.4 || ^6.3 || ^7.0", - "symfony/security-http": "^5.4 || ^6.3 || ^7.0", - "symfony/yaml": "^5.4 || ^6.3 || ^7.0", + "symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/security-core": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/security-csrf": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/security-http": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.3 || ^7.0 || ^8.0", "ua-parser/uap-php": "^3.4.4" }, "require-dev": { - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpstan/phpstan-symfony": "^1.1", - "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^9.5 || ^10.1 || ^11.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/browser-kit": "^5.4 || ^6.3 || ^7.0", - "symfony/cache": "^5.4 || ^6.3 || ^7.0", - "symfony/phpunit-bridge": "^6.3 || ^7.0", - "symfony/twig-bundle": "^5.4 || ^6.3 || ^7.0", + "symfony/browser-kit": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/cache": "^5.4 || ^6.3 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^6.3 || ^7.0 || ^8.0", + "symfony/twig-bundle": "^5.4 || ^6.3 || ^7.0 || ^8.0", "twig/twig": "^2.10 || ^3.0" }, "type": "symfony-bundle", @@ -5512,9 +7121,163 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioSecurityBundle/issues", - "source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.5.1" + "source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.7.0" }, - "time": "2025-03-13T09:17:16+00:00" + "time": "2025-12-30T14:05:13+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.3", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/tester": "^2.5.2", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "๐Ÿ“ Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.3" + }, + "time": "2025-10-30T22:57:59+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.1", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/tester": "^2.5", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "๐Ÿ›  Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.1" + }, + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikolaposa/version", @@ -5657,63 +7420,63 @@ }, { "name": "omines/datatables-bundle", - "version": "0.9.2", + "version": "0.10.7", "source": { "type": "git", "url": "https://github.com/omines/datatables-bundle.git", - "reference": "15974fc7dde750f8a3eff32d9ad4d9de6028583f" + "reference": "4cd6d27b12c79a1ed72b4953a86aedf289e8701d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/omines/datatables-bundle/zipball/15974fc7dde750f8a3eff32d9ad4d9de6028583f", - "reference": "15974fc7dde750f8a3eff32d9ad4d9de6028583f", + "url": "https://api.github.com/repos/omines/datatables-bundle/zipball/4cd6d27b12c79a1ed72b4953a86aedf289e8701d", + "reference": "4cd6d27b12c79a1ed72b4953a86aedf289e8701d", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/event-dispatcher": "^6.4|^7.1", - "symfony/framework-bundle": "^6.4|^7.1", - "symfony/options-resolver": "^6.4|^7.1", + "php": ">=8.2", + "symfony/event-dispatcher": "^6.4|^7.3|^8.0", + "symfony/framework-bundle": "^6.4|^7.3|^8.0", + "symfony/options-resolver": "^6.4|^7.3|^8.0", "symfony/polyfill-mbstring": "^1.31.0", - "symfony/property-access": "^6.4|^7.1", - "symfony/translation": "^6.4|^7.1" + "symfony/property-access": "^6.4|^7.3|^8.0", + "symfony/translation": "^6.4|^7.3|^8.0" }, "conflict": { "doctrine/orm": "^3.0 <3.3" }, "require-dev": { - "doctrine/common": "^3.4.5", - "doctrine/doctrine-bundle": "^2.13.1", - "doctrine/orm": "^2.19.3|^3.3.0", - "doctrine/persistence": "^3.4.0", + "doctrine/common": "^3.5.0", + "doctrine/doctrine-bundle": "^2.18.1|^3.0.0@dev", + "doctrine/orm": "^2.19.3|^3.5.7@dev", + "doctrine/persistence": "^3.4.0|^4.1.1", "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", "ext-mongodb": "*", "ext-pdo_sqlite": "*", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.65.0", - "mongodb/mongodb": "^1.20.0", - "ocramius/package-versions": "^2.9", - "openspout/openspout": "^4.23", - "phpoffice/phpspreadsheet": "^2.3.3|^3.5", + "friendsofphp/php-cs-fixer": "^3.90.0", + "mongodb/mongodb": "^1.20.0|^2.1.2", + "openspout/openspout": "^4.28.5", + "phpoffice/phpspreadsheet": "^2.3.3|^3.9.2|^4.5.0|^5.2.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.0.3", - "phpstan/phpstan-doctrine": "^2.0.1", - "phpstan/phpstan-phpunit": "^2.0.1", - "phpstan/phpstan-symfony": "^2.0.0", - "phpunit/phpunit": "^10.5.38|^11.4.4", - "ruflin/elastica": "^6.2|^7.3.2", - "symfony/browser-kit": "^6.4.13|^7.1", - "symfony/css-selector": "^6.4.13|^7.1", - "symfony/doctrine-bridge": "^6.4.13|^7.1", - "symfony/dom-crawler": "^6.4.13|^7.1", - "symfony/intl": "^6.4.13|^7.1", - "symfony/mime": "^6.4.13|^7.1", - "symfony/phpunit-bridge": "^7.2", - "symfony/twig-bundle": "^6.4|^7.1", - "symfony/var-dumper": "^6.4.13|^7.1", - "symfony/yaml": "^6.4.13|^7.1" + "phpstan/phpstan": "^2.1.32", + "phpstan/phpstan-doctrine": "^2.0.11", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-symfony": "^2.0.8", + "phpunit/phpunit": "^11.5.44|^12.4.4", + "ruflin/elastica": "^7.3.2", + "symfony/browser-kit": "^6.4.13|^7.3|^8.0", + "symfony/css-selector": "^6.4.13|^7.3|^8.0", + "symfony/doctrine-bridge": "^6.4.13|^7.3|^8.0", + "symfony/dom-crawler": "^6.4.13|^7.3|^8.0", + "symfony/intl": "^6.4.13|^7.3|^8.0", + "symfony/mime": "^6.4.13|^7.3|^8.0", + "symfony/phpunit-bridge": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.3|^8.0", + "symfony/var-dumper": "^6.4.13|^7.3|^8.0", + "symfony/var-exporter": "^v6.4.26|^7.3", + "symfony/yaml": "^6.4.13|^7.3|^8.0" }, "suggest": { "doctrine/doctrine-bundle": "For integrated access to Doctrine object managers", @@ -5727,7 +7490,7 @@ "type": "symfony-bundle", "extra": { "branch-alias": { - "dev-master": "0.9-dev" + "dev-master": "0.10-dev" } }, "autoload": { @@ -5765,7 +7528,7 @@ ], "support": { "issues": "https://github.com/omines/datatables-bundle/issues", - "source": "https://github.com/omines/datatables-bundle/tree/0.9.2" + "source": "https://github.com/omines/datatables-bundle/tree/0.10.7" }, "funding": [ { @@ -5773,25 +7536,25 @@ "type": "github" } ], - "time": "2025-01-23T14:53:20+00:00" + "time": "2025-11-28T21:20:14+00:00" }, { "name": "onelogin/php-saml", - "version": "4.2.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/SAML-Toolkits/php-saml.git", - "reference": "d3b5172f137db2f412239432d77253ceaaa1e939" + "reference": "b009f160e4ac11f49366a45e0d45706b48429353" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/d3b5172f137db2f412239432d77253ceaaa1e939", - "reference": "d3b5172f137db2f412239432d77253ceaaa1e939", + "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/b009f160e4ac11f49366a45e0d45706b48429353", + "reference": "b009f160e4ac11f49366a45e0d45706b48429353", "shasum": "" }, "require": { "php": ">=7.3", - "robrichards/xmlseclibs": "^3.1" + "robrichards/xmlseclibs": ">=3.1.4" }, "require-dev": { "pdepend/pdepend": "^2.8.0", @@ -5837,28 +7600,30 @@ "type": "github" } ], - "time": "2024-05-30T15:10:40+00:00" + "time": "2025-12-09T10:50:49+00:00" }, { "name": "paragonie/constant_time_encoding", - "version": "v2.7.0", + "version": "v3.1.3", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105" + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/52a0d99e69f56b9ec27ace92ba56897fe6993105", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", "shasum": "" }, "require": { - "php": "^7|^8" + "php": "^8" }, "require-dev": { - "phpunit/phpunit": "^6|^7|^8|^9", - "vimeo/psalm": "^1|^2|^3|^4" + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" }, "type": "library", "autoload": { @@ -5904,7 +7669,7 @@ "issues": "https://github.com/paragonie/constant_time_encoding/issues", "source": "https://github.com/paragonie/constant_time_encoding" }, - "time": "2024-05-08T12:18:48+00:00" + "time": "2025-09-24T15:06:41+00:00" }, { "name": "paragonie/random_compat", @@ -5958,16 +7723,16 @@ }, { "name": "paragonie/sodium_compat", - "version": "v1.21.1", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37" + "reference": "2cb48f26130919f92f30650bdcc30e6f4ebe45ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/bb312875dcdd20680419564fe42ba1d9564b9e37", - "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/2cb48f26130919f92f30650bdcc30e6f4ebe45ac", + "reference": "2cb48f26130919f92f30650bdcc30e6f4ebe45ac", "shasum": "" }, "require": { @@ -6038,22 +7803,99 @@ ], "support": { "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v1.21.1" + "source": "https://github.com/paragonie/sodium_compat/tree/v1.24.0" }, - "time": "2024-04-22T22:05:04+00:00" + "time": "2025-12-30T16:16:35+00:00" }, { - "name": "part-db/label-fonts", - "version": "v1.1.0", + "name": "part-db/exchanger", + "version": "v3.1.0", "source": { "type": "git", - "url": "https://github.com/Part-DB/label-fonts.git", - "reference": "77c84b70ed3bb005df15f30ff835ddec490394b9" + "url": "https://github.com/Part-DB/exchanger.git", + "reference": "a43fe79a082e331ec2b24f3579e4fba153743757" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Part-DB/label-fonts/zipball/77c84b70ed3bb005df15f30ff835ddec490394b9", - "reference": "77c84b70ed3bb005df15f30ff835ddec490394b9", + "url": "https://api.github.com/repos/Part-DB/exchanger/zipball/a43fe79a082e331ec2b24f3579e4fba153743757", + "reference": "a43fe79a082e331ec2b24f3579e4fba153743757", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "php": "^7.1.3 || ^8.0", + "php-http/client-implementation": "^1.0", + "php-http/discovery": "^1.6", + "php-http/httplug": "^1.0 || ^2.0", + "psr/http-factory": "^1.0.2", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.7", + "php-http/message-factory": "^1.1", + "php-http/mock-client": "^1.0", + "phpunit/phpunit": "^7 || ^8 || ^9.4 || ^10.5", + "symfony/http-client": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "php-http/guzzle6-adapter": "Required to use Guzzle for sending HTTP requests", + "php-http/message": "Required to use Guzzle for sending HTTP requests" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Exchanger\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florian Voutzinos", + "email": "florian@voutzinos.com", + "homepage": "https://voutzinos.com" + }, + { + "name": "Jan Bรถhmer", + "email": "mail@jan-boehmer.de" + } + ], + "description": "Fork of florianv/exchanger, a library to convert currencies using different exchange rate providers. Modernized to be compatible with Part-DB.", + "homepage": "https://github.com/Part-DB/exchanger", + "keywords": [ + "Rate", + "conversion", + "currency", + "exchange rates", + "money" + ], + "support": { + "source": "https://github.com/Part-DB/exchanger/tree/v3.1.0" + }, + "time": "2025-09-05T19:48:23+00:00" + }, + { + "name": "part-db/label-fonts", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/Part-DB/label-fonts.git", + "reference": "c85aeb051d6492961a2c59bc291979f15ce60e88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Part-DB/label-fonts/zipball/c85aeb051d6492961a2c59bc291979f15ce60e88", + "reference": "c85aeb051d6492961a2c59bc291979f15ce60e88", "shasum": "" }, "type": "library", @@ -6076,9 +7918,152 @@ ], "support": { "issues": "https://github.com/Part-DB/label-fonts/issues", - "source": "https://github.com/Part-DB/label-fonts/tree/v1.1.0" + "source": "https://github.com/Part-DB/label-fonts/tree/v1.2.0" }, - "time": "2024-02-08T21:44:38+00:00" + "time": "2025-09-07T15:42:51+00:00" + }, + { + "name": "part-db/swap", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/Part-DB/swap.git", + "reference": "4fa57dec2eb1cbe0f6b8c92a2c250ecbe80688fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Part-DB/swap/zipball/4fa57dec2eb1cbe0f6b8c92a2c250ecbe80688fe", + "reference": "4fa57dec2eb1cbe0f6b8c92a2c250ecbe80688fe", + "shasum": "" + }, + "require": { + "part-db/exchanger": "^3.0", + "php": "^7.1.3 || ^8.0", + "php-http/message-factory": "^1.1" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/discovery": "^1.0", + "php-http/message": "^1.7", + "php-http/mock-client": "^1.0", + "phpunit/phpunit": "^7 || ^8 || ^9", + "symfony/http-client": "^5.4||^6.0||^7.0" + }, + "suggest": { + "php-http/discovery": "If you are not using `useHttpClient` but instead want to auto-discover HttpClient" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "psr-4": { + "Swap\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florian Voutzinos", + "email": "florian@voutzinos.com", + "homepage": "https://voutzinos.com" + }, + { + "name": "Jan Bรถhmer", + "email": "mail@jan-boehmer.de" + } + ], + "description": "Fork of florianv/swap modernized for use in Part-DB. Exchange rates library for PHP", + "keywords": [ + "Rate", + "conversion", + "currency", + "exchange rates", + "money" + ], + "support": { + "source": "https://github.com/Part-DB/swap/tree/v5.0.0" + }, + "time": "2025-09-05T17:10:01+00:00" + }, + { + "name": "part-db/swap-bundle", + "version": "v6.1.0", + "source": { + "type": "git", + "url": "https://github.com/Part-DB/symfony-swap.git", + "reference": "fd78ebfbd762b1d76b4d71f713f39add63dec62b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Part-DB/symfony-swap/zipball/fd78ebfbd762b1d76b4d71f713f39add63dec62b", + "reference": "fd78ebfbd762b1d76b4d71f713f39add63dec62b", + "shasum": "" + }, + "require": { + "part-db/exchanger": "^3.1.0", + "part-db/swap": "^5.0", + "php": "^7.1.3|^8.0", + "psr/http-client": "^1.0", + "symfony/framework-bundle": "~3.0|~4.0|~5.0|~6.0|~7.0" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/guzzle6-adapter": "^1.0", + "php-http/message": "^1.7", + "phpunit/phpunit": "~5.7|~6.0|~7.0|~8.0|~9.0", + "symfony/cache": "~3.0|~4.0|~5.0|~6.0|~7.0", + "symfony/http-client": "~7.0|~6.0|~5.0" + }, + "suggest": { + "symfony/cache": "For caching" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Florianv\\SwapBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florian Voutzinos", + "email": "florian@voutzinos.com", + "homepage": "http://florian.voutzinos.com" + }, + { + "name": "Jan Bรถhmer", + "email": "mail@jan-boehmer.de" + } + ], + "description": "Fork of florianv/swap-bundle, modernized for use with Part-DB. Integrates the Swap library with Symfony", + "homepage": "https://github.com/florianv/FlorianvSwapBundle", + "keywords": [ + "Rate", + "bundle", + "conversion", + "currency", + "exchange", + "money", + "symfony" + ], + "support": { + "source": "https://github.com/Part-DB/symfony-swap/tree/v6.1.0" + }, + "time": "2025-09-05T19:52:56+00:00" }, { "name": "php-http/discovery", @@ -6216,6 +8201,61 @@ }, "time": "2024-09-23T11:39:58+00:00" }, + { + "name": "php-http/message-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/message-factory.git", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mรกrk Sรกgi-Kazรกr", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "stream", + "uri" + ], + "support": { + "issues": "https://github.com/php-http/message-factory/issues", + "source": "https://github.com/php-http/message-factory/tree/1.1.0" + }, + "abandoned": "psr/http-factory", + "time": "2023-04-14T14:16:17+00:00" + }, { "name": "php-http/promise", "version": "1.3.1", @@ -6323,16 +8363,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -6342,7 +8382,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -6381,22 +8421,22 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2024-12-07T09:39:29+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -6439,22 +8479,131 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "name": "phpoffice/phpspreadsheet", + "version": "5.4.0", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/48f2fe37d64c2dece0ef71fb2ac55497566782af", + "reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "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/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "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, required for NumberFormat Wizard and StringHelper::setLocale()", + "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" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.4.0" + }, + "time": "2026-01-11T04:52:00+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", "shasum": "" }, "require": { @@ -6486,9 +8635,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-08-30T15:50:23+00:00" }, { "name": "psr/cache", @@ -6799,16 +8948,16 @@ }, { "name": "psr/http-message", - "version": "2.0", + "version": "1.1", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", "shasum": "" }, "require": { @@ -6817,7 +8966,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -6832,7 +8981,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -6846,9 +8995,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "source": "https://github.com/php-fig/http-message/tree/1.1" }, - "time": "2023-04-04T09:54:51+00:00" + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/link", @@ -7053,16 +9202,16 @@ }, { "name": "revolt/event-loop", - "version": "v1.0.7", + "version": "v1.0.8", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3" + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/09bf1bf7f7f574453efe43044b06fafe12216eb3", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", "shasum": "" }, "require": { @@ -7119,22 +9268,67 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.7" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" }, - "time": "2025-01-25T19:27:39+00:00" + "time": "2025-08-27T21:33:23+00:00" }, { - "name": "robrichards/xmlseclibs", - "version": "3.1.3", + "name": "rhukster/dom-sanitizer", + "version": "1.0.8", "source": { "type": "git", - "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07" + "url": "https://github.com/rhukster/dom-sanitizer.git", + "reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07", - "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07", + "url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/757e4d6ac03afe9afa4f97cbef453fc5c25f0729", + "reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Rhukster\\DomSanitizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Miller", + "email": "rhuk@rhuk.net" + } + ], + "description": "A simple but effective DOM/SVG/MathML Sanitizer for PHP 7.4+", + "support": { + "issues": "https://github.com/rhukster/dom-sanitizer/issues", + "source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.8" + }, + "time": "2024-04-15T08:48:55+00:00" + }, + { + "name": "robrichards/xmlseclibs", + "version": "3.1.4", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/bc87389224c6de95802b505e5265b0ec2c5bcdbd", + "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd", "shasum": "" }, "require": { @@ -7161,61 +9355,9 @@ ], "support": { "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3" + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.4" }, - "time": "2024-11-20T21:13:56+00:00" - }, - { - "name": "runtime/frankenphp-symfony", - "version": "0.2.0", - "source": { - "type": "git", - "url": "https://github.com/php-runtime/frankenphp-symfony.git", - "reference": "56822c3631d9522a3136a4c33082d006bdfe4bad" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-runtime/frankenphp-symfony/zipball/56822c3631d9522a3136a4c33082d006bdfe4bad", - "reference": "56822c3631d9522a3136a4c33082d006bdfe4bad", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", - "symfony/runtime": "^5.4 || ^6.0 || ^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Runtime\\FrankenPhpSymfony\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Kรฉvin Dunglas", - "email": "kevin@dunglas.dev" - } - ], - "description": "FrankenPHP runtime for Symfony", - "support": { - "issues": "https://github.com/php-runtime/frankenphp-symfony/issues", - "source": "https://github.com/php-runtime/frankenphp-symfony/tree/0.2.0" - }, - "funding": [ - { - "url": "https://github.com/nyholm", - "type": "github" - } - ], - "time": "2023-12-12T12:06:11+00:00" + "time": "2025-12-08T11:57:53+00:00" }, { "name": "s9e/regexp-builder", @@ -7307,16 +9449,16 @@ }, { "name": "s9e/text-formatter", - "version": "2.18.0", + "version": "2.19.3", "source": { "type": "git", "url": "https://github.com/s9e/TextFormatter.git", - "reference": "4970711f25d94306b4835b723b9cc5010170ea37" + "reference": "aee579c12d05ca3053f9b9abdb8c479c0f2fbe69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/s9e/TextFormatter/zipball/4970711f25d94306b4835b723b9cc5010170ea37", - "reference": "4970711f25d94306b4835b723b9cc5010170ea37", + "url": "https://api.github.com/repos/s9e/TextFormatter/zipball/aee579c12d05ca3053f9b9abdb8c479c0f2fbe69", + "reference": "aee579c12d05ca3053f9b9abdb8c479c0f2fbe69", "shasum": "" }, "require": { @@ -7344,7 +9486,7 @@ }, "type": "library", "extra": { - "version": "2.18.0" + "version": "2.19.3" }, "autoload": { "psr-4": { @@ -7376,30 +9518,39 @@ ], "support": { "issues": "https://github.com/s9e/TextFormatter/issues", - "source": "https://github.com/s9e/TextFormatter/tree/2.18.0" + "source": "https://github.com/s9e/TextFormatter/tree/2.19.3" }, - "time": "2024-07-24T14:50:52+00:00" + "time": "2025-11-14T21:26:59+00:00" }, { "name": "sabberworm/php-css-parser", - "version": "v8.8.0", + "version": "v9.1.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740" + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/3de493bdddfd1f051249af725c7e0d2c38fed740", - "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", "shasum": "" }, "require": { "ext-iconv": "*", - "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" }, "require-dev": { - "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41" + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.28 || 2.1.25", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", + "phpunit/phpunit": "8.5.46", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.1.7", + "rector/type-perfect": "1.0.0 || 2.1.0" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -7407,7 +9558,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.0.x-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { @@ -7441,26 +9592,26 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.8.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" }, - "time": "2025-03-23T17:59:05+00:00" + "time": "2025-09-14T07:37:21+00:00" }, { "name": "scheb/2fa-backup-code", - "version": "v6.13.1", + "version": "v7.13.1", "source": { "type": "git", "url": "https://github.com/scheb/2fa-backup-code.git", - "reference": "6dceeb5be0f6339d76f8e380ee09631c8bbebc7e" + "reference": "35f1ace4be7be2c10158d2bb8284208499111db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/6dceeb5be0f6339d76f8e380ee09631c8bbebc7e", - "reference": "6dceeb5be0f6339d76f8e380ee09631c8bbebc7e", + "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/35f1ace4be7be2c10158d2bb8284208499111db8", + "reference": "35f1ace4be7be2c10158d2bb8284208499111db8", "shasum": "" }, "require": { - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "scheb/2fa-bundle": "self.version" }, "type": "library", @@ -7490,40 +9641,40 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-backup-code/tree/v6.13.1" + "source": "https://github.com/scheb/2fa-backup-code/tree/v7.13.1" }, - "time": "2024-11-29T19:22:48+00:00" + "time": "2025-11-20T13:35:24+00:00" }, { "name": "scheb/2fa-bundle", - "version": "v6.13.1", + "version": "v7.13.1", "source": { "type": "git", "url": "https://github.com/scheb/2fa-bundle.git", - "reference": "8eadd57ebc2078ef273dca72b1ac4bd283812346" + "reference": "edcc14456b508aab37ec792cfc36793d04226784" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/8eadd57ebc2078ef273dca72b1ac4bd283812346", - "reference": "8eadd57ebc2078ef273dca72b1ac4bd283812346", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/edcc14456b508aab37ec792cfc36793d04226784", + "reference": "edcc14456b508aab37ec792cfc36793d04226784", "shasum": "" }, "require": { "ext-json": "*", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", - "symfony/config": "^5.4 || ^6.0", - "symfony/dependency-injection": "^5.4 || ^6.0", - "symfony/event-dispatcher": "^5.4 || ^6.0", - "symfony/framework-bundle": "^5.4 || ^6.0", - "symfony/http-foundation": "^5.4 || ^6.0", - "symfony/http-kernel": "^5.4 || ^6.0", - "symfony/property-access": "^5.4 || ^6.0", - "symfony/security-bundle": "^5.4 || ^6.0", - "symfony/twig-bundle": "^5.4 || ^6.0" + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/twig-bundle": "^6.4 || ^7.0" }, "conflict": { - "scheb/two-factor-bundle": "*", - "symfony/security-core": "^7" + "scheb/two-factor-bundle": "*" }, "suggest": { "scheb/2fa-backup-code": "Emergency codes when you have no access to other methods", @@ -7558,29 +9709,31 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-bundle/tree/v6.13.1" + "source": "https://github.com/scheb/2fa-bundle/tree/v7.13.1" }, - "time": "2024-11-29T19:29:49+00:00" + "time": "2025-12-18T15:29:07+00:00" }, { "name": "scheb/2fa-google-authenticator", - "version": "v6.13.1", + "version": "v7.13.1", "source": { "type": "git", "url": "https://github.com/scheb/2fa-google-authenticator.git", - "reference": "2c960a5cb32edb4c37f719f10180df378a44fd6f" + "reference": "7ad34bbde343a0770571464127ee072aacb70a58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/scheb/2fa-google-authenticator/zipball/2c960a5cb32edb4c37f719f10180df378a44fd6f", - "reference": "2c960a5cb32edb4c37f719f10180df378a44fd6f", + "url": "https://api.github.com/repos/scheb/2fa-google-authenticator/zipball/7ad34bbde343a0770571464127ee072aacb70a58", + "reference": "7ad34bbde343a0770571464127ee072aacb70a58", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^2.4", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "scheb/2fa-bundle": "self.version", - "spomky-labs/otphp": "^10.0 || ^11.0" + "spomky-labs/otphp": "^11.0" + }, + "suggest": { + "symfony/validator": "Needed if you want to use the Google Authenticator TOTP validator constraint" }, "type": "library", "autoload": { @@ -7609,28 +9762,28 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-google-authenticator/tree/v6.13.1" + "source": "https://github.com/scheb/2fa-google-authenticator/tree/v7.13.1" }, - "time": "2024-11-29T19:22:48+00:00" + "time": "2025-12-04T15:55:14+00:00" }, { "name": "scheb/2fa-trusted-device", - "version": "v6.13.1", + "version": "v7.13.1", "source": { "type": "git", "url": "https://github.com/scheb/2fa-trusted-device.git", - "reference": "38e690325232a4037ff4aec8de926c938906942c" + "reference": "ae3a5819faccbf151af078f432e4e6c97bb44ebf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/scheb/2fa-trusted-device/zipball/38e690325232a4037ff4aec8de926c938906942c", - "reference": "38e690325232a4037ff4aec8de926c938906942c", + "url": "https://api.github.com/repos/scheb/2fa-trusted-device/zipball/ae3a5819faccbf151af078f432e4e6c97bb44ebf", + "reference": "ae3a5819faccbf151af078f432e4e6c97bb44ebf", "shasum": "" }, "require": { - "lcobucci/clock": "^2.0 || ^3.0", - "lcobucci/jwt": "^4.1 || ^5.0", - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "lcobucci/clock": "^3.0", + "lcobucci/jwt": "^5.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "scheb/2fa-bundle": "self.version" }, "type": "library", @@ -7660,9 +9813,9 @@ "two-step" ], "support": { - "source": "https://github.com/scheb/2fa-trusted-device/tree/v6.13.1" + "source": "https://github.com/scheb/2fa-trusted-device/tree/v7.13.1" }, - "time": "2024-11-29T19:22:48+00:00" + "time": "2025-12-01T15:40:59+00:00" }, { "name": "shivas/versioning-bundle", @@ -7726,21 +9879,21 @@ }, { "name": "spatie/db-dumper", - "version": "3.8.0", + "version": "3.8.3", "source": { "type": "git", "url": "https://github.com/spatie/db-dumper.git", - "reference": "91e1fd4dc000aefc9753cda2da37069fc996baee" + "reference": "eac3221fbe27fac51f388600d27b67b1b079406e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/db-dumper/zipball/91e1fd4dc000aefc9753cda2da37069fc996baee", - "reference": "91e1fd4dc000aefc9753cda2da37069fc996baee", + "url": "https://api.github.com/repos/spatie/db-dumper/zipball/eac3221fbe27fac51f388600d27b67b1b079406e", + "reference": "eac3221fbe27fac51f388600d27b67b1b079406e", "shasum": "" }, "require": { "php": "^8.0", - "symfony/process": "^5.0|^6.0|^7.0" + "symfony/process": "^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "pestphp/pest": "^1.22" @@ -7773,7 +9926,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/db-dumper/tree/3.8.0" + "source": "https://github.com/spatie/db-dumper/tree/3.8.3" }, "funding": [ { @@ -7785,44 +9938,32 @@ "type": "github" } ], - "time": "2025-02-14T15:04:22+00:00" + "time": "2026-01-05T16:26:03+00:00" }, { "name": "spomky-labs/cbor-php", - "version": "3.1.0", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "499d9bff0a6d59c4f1b813cc617fc3fd56d6dca4" + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/499d9bff0a6d59c4f1b813cc617fc3fd56d6dca4", - "reference": "499d9bff0a6d59c4f1b813cc617fc3fd56d6dca4", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", "ext-mbstring": "*", "php": ">=8.0" }, "require-dev": { - "ekino/phpstan-banned-code": "^1.0", "ext-json": "*", - "infection/infection": "^0.29", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-beberlei-assert": "^1.0", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^10.1|^11.0", - "qossmic/deptrac": "^2.0", - "rector/rector": "^1.0", "roave/security-advisories": "dev-latest", - "symfony/var-dumper": "^6.0|^7.0", - "symplify/easy-coding-standard": "^12.0" + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" }, "suggest": { "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", @@ -7856,7 +9997,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.1.0" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" }, "funding": [ { @@ -7868,42 +10009,30 @@ "type": "patreon" } ], - "time": "2024-07-18T08:37:03+00:00" + "time": "2025-11-13T13:00:34+00:00" }, { "name": "spomky-labs/otphp", - "version": "11.3.0", + "version": "11.4.1", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/otphp.git", - "reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33" + "reference": "126c99b6cbbc18992cf3fba3b87931ba4e312482" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33", - "reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/126c99b6cbbc18992cf3fba3b87931ba4e312482", + "reference": "126c99b6cbbc18992cf3fba3b87931ba4e312482", "shasum": "" }, "require": { - "ext-mbstring": "*", "paragonie/constant_time_encoding": "^2.0 || ^3.0", "php": ">=8.1", "psr/clock": "^1.0", "symfony/deprecation-contracts": "^3.2" }, "require-dev": { - "ekino/phpstan-banned-code": "^1.0", - "infection/infection": "^0.26|^0.27|^0.28|^0.29", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5.26|^10.0|^11.0", - "qossmic/deptrac-shim": "^1.0", - "rector/rector": "^1.0", - "symfony/phpunit-bridge": "^6.1|^7.0", - "symplify/easy-coding-standard": "^12.0" + "symfony/error-handler": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7938,7 +10067,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/otphp/issues", - "source": "https://github.com/Spomky-Labs/otphp/tree/11.3.0" + "source": "https://github.com/Spomky-Labs/otphp/tree/11.4.1" }, "funding": [ { @@ -7950,24 +10079,24 @@ "type": "patreon" } ], - "time": "2024-06-12T11:22:32+00:00" + "time": "2026-01-05T13:20:36+00:00" }, { "name": "spomky-labs/pki-framework", - "version": "1.2.2", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "5ac374c3e295c8b917208ff41b4d30f76668478c" + "reference": "f0e9a548df4e3942886adc9b7830581a46334631" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/5ac374c3e295c8b917208ff41b4d30f76668478c", - "reference": "5ac374c3e295c8b917208ff41b4d30f76668478c", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", "ext-mbstring": "*", "php": ">=8.1" }, @@ -7975,18 +10104,18 @@ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28|^0.29", + "infection/infection": "^0.28|^0.29|^0.31", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/extension-installer": "^1.3|^2.0", "phpstan/phpstan": "^1.8|^2.0", "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", "phpstan/phpstan-phpunit": "^1.1|^2.0", "phpstan/phpstan-strict-rules": "^1.3|^2.0", - "phpunit/phpunit": "^10.1|^11.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", - "symfony/string": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", "symplify/easy-coding-standard": "^12.0" }, "suggest": { @@ -8047,7 +10176,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.2.2" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1" }, "funding": [ { @@ -8059,7 +10188,7 @@ "type": "patreon" } ], - "time": "2025-01-03T09:35:48+00:00" + "time": "2025-12-20T12:57:40+00:00" }, { "name": "symfony/apache-pack", @@ -8089,28 +10218,28 @@ }, { "name": "symfony/asset", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "2466c17d61d14539cddf77e57ebb9cc971185302" + "reference": "0f7bccb9ffa1f373cbd659774d90629b2773464f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/2466c17d61d14539cddf77e57ebb9cc971185302", - "reference": "2466c17d61d14539cddf77e57ebb9cc971185302", + "url": "https://api.github.com/repos/symfony/asset/zipball/0f7bccb9ffa1f373cbd659774d90629b2773464f", + "reference": "0f7bccb9ffa1f373cbd659774d90629b2773464f", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "conflict": { - "symfony/http-foundation": "<5.4" + "symfony/http-foundation": "<6.4" }, "require-dev": { - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0" + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8138,7 +10267,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v6.4.13" + "source": "https://github.com/symfony/asset/tree/v7.4.0" }, "funding": [ { @@ -8149,40 +10278,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/cache", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "95af448bb7c3d8db02f7b4f5cbf3cb7a6ff1e432" + "reference": "642117d18bc56832e74b68235359ccefab03dd11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/95af448bb7c3d8db02f7b4f5cbf3cb7a6ff1e432", - "reference": "95af448bb7c3d8db02f7b4f5cbf3cb7a6ff1e432", + "url": "https://api.github.com/repos/symfony/cache/zipball/642117d18bc56832e74b68235359ccefab03dd11", + "reference": "642117d18bc56832e74b68235359ccefab03dd11", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/cache": "^2.0|^3.0", "psr/log": "^1.1|^2|^3", - "symfony/cache-contracts": "^2.5|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^6.3.6|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "conflict": { - "doctrine/dbal": "<2.13.1", - "symfony/dependency-injection": "<5.4", - "symfony/http-kernel": "<5.4", - "symfony/var-dumper": "<5.4" + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" }, "provide": { "psr/cache-implementation": "2.0|3.0", @@ -8191,15 +10327,16 @@ }, "require-dev": { "cache/integration-tests": "dev-master", - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8234,7 +10371,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.20" + "source": "https://github.com/symfony/cache/tree/v7.4.3" }, "funding": [ { @@ -8245,25 +10382,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-08T15:51:34+00:00" + "time": "2025-12-28T10:45:24+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "15a4f8e5cd3bce9aeafc882b1acab39ec8de2c1b" + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/15a4f8e5cd3bce9aeafc882b1acab39ec8de2c1b", - "reference": "15a4f8e5cd3bce9aeafc882b1acab39ec8de2c1b", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", "shasum": "" }, "require": { @@ -8277,7 +10418,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -8310,7 +10451,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" }, "funding": [ { @@ -8326,24 +10467,24 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-03-13T15:25:07+00:00" }, { "name": "symfony/clock", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b2bf55c4dd115003309eafa87ee7df9ed3dde81b" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b2bf55c4dd115003309eafa87ee7df9ed3dde81b", - "reference": "b2bf55c4dd115003309eafa87ee7df9ed3dde81b", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/clock": "^1.0", "symfony/polyfill-php83": "^1.28" }, @@ -8384,7 +10525,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v6.4.13" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -8395,43 +10536,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/config", - "version": "v6.4.14", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "4e55e7e4ffddd343671ea972216d4509f46c22ef" + "reference": "800ce889e358a53a9678b3212b0c8cecd8c6aace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/4e55e7e4ffddd343671ea972216d4509f46c22ef", - "reference": "4e55e7e4ffddd343671ea972216d4509f46c22ef", + "url": "https://api.github.com/repos/symfony/config/zipball/800ce889e358a53a9678b3212b0c8cecd8c6aace", + "reference": "800ce889e358a53a9678b3212b0c8cecd8c6aace", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^7.1|^8.0", "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "symfony/finder": "<5.4", + "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8459,7 +10604,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.14" + "source": "https://github.com/symfony/config/tree/v7.4.3" }, "funding": [ { @@ -8470,56 +10615,60 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-04T11:33:53+00:00" + "time": "2025-12-23T14:24:27+00:00" }, { "name": "symfony/console", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36" + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2e4af9c952617cc3f9559ff706aee420a8464c36", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8553,7 +10702,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.20" + "source": "https://github.com/symfony/console/tree/v7.4.3" }, "funding": [ { @@ -8564,29 +10713,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-03T17:16:38+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e" + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/cb23e97813c5837a041b73a6d63a9ddff0778f5e", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -8618,7 +10771,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.13" + "source": "https://github.com/symfony/css-selector/tree/v7.4.0" }, "funding": [ { @@ -8629,49 +10782,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "c49796a9184a532843e78e50df9e55708b92543a" + "reference": "54122901b6d772e94f1e71a75e0533bc16563499" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/c49796a9184a532843e78e50df9e55708b92543a", - "reference": "c49796a9184a532843e78e50df9e55708b92543a", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/54122901b6d772e94f1e71a75e0533bc16563499", + "reference": "54122901b6d772e94f1e71a75e0533bc16563499", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4.20|^7.2.5" + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" }, "conflict": { "ext-psr": "<1.1|>=2", - "symfony/config": "<6.1", - "symfony/finder": "<5.4", - "symfony/proxy-manager-bridge": "<6.3", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "psr/container-implementation": "1.1|2.0", "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.1|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8699,7 +10855,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.20" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.3" }, "funding": [ { @@ -8710,25 +10866,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T09:55:08+00:00" + "time": "2025-12-28T10:55:46+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -8741,7 +10901,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -8766,7 +10926,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -8782,71 +10942,72 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "7205dbc642bac2ecbf108fadbf9a04aa08290a2a" + "reference": "bd338ba08f5c47fe77e0a15e85ec3c5d070f9ceb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/7205dbc642bac2ecbf108fadbf9a04aa08290a2a", - "reference": "7205dbc642bac2ecbf108fadbf9a04aa08290a2a", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/bd338ba08f5c47fe77e0a15e85ec3c5d070f9ceb", + "reference": "bd338ba08f5c47fe77e0a15e85ec3c5d070f9ceb", "shasum": "" }, "require": { - "doctrine/event-manager": "^1.2|^2", - "doctrine/persistence": "^2.5|^3.1|^4", - "php": ">=8.1", + "doctrine/event-manager": "^2", + "doctrine/persistence": "^3.1|^4", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "doctrine/dbal": "<2.13.1", + "doctrine/collections": "<1.8", + "doctrine/dbal": "<3.6", "doctrine/lexer": "<1.1", "doctrine/orm": "<2.15", - "symfony/cache": "<5.4", - "symfony/dependency-injection": "<6.2", - "symfony/form": "<5.4.38|>=6,<6.4.6|>=7,<7.0.6", - "symfony/http-foundation": "<6.3", - "symfony/http-kernel": "<6.2", - "symfony/lock": "<6.3", - "symfony/messenger": "<5.4", - "symfony/property-info": "<5.4", - "symfony/security-bundle": "<5.4", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/form": "<6.4.6|>=7,<7.0.6", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/messenger": "<6.4", + "symfony/property-info": "<6.4", + "symfony/security-bundle": "<6.4", "symfony/security-core": "<6.4", - "symfony/validator": "<6.4" + "symfony/validator": "<7.4" }, "require-dev": { - "doctrine/collections": "^1.0|^2.0", + "doctrine/collections": "^1.8|^2.0", "doctrine/data-fixtures": "^1.1|^2", - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "doctrine/orm": "^2.15|^3", "psr/log": "^1|^2|^3", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^6.2|^7.0", - "symfony/doctrine-messenger": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4.38|^6.4.6|^7.0.6", - "symfony/http-kernel": "^6.3|^7.0", - "symfony/lock": "^6.3|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/proxy-manager-bridge": "^6.4", - "symfony/security-core": "^6.4|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/doctrine-messenger": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.2|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -8874,7 +11035,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.20" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.3" }, "funding": [ { @@ -8885,35 +11046,40 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-28T20:55:44+00:00" + "time": "2025-12-22T13:47:05+00:00" }, { "name": "symfony/dom-crawler", - "version": "v6.4.19", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "19073e3e0bb50cbc1cb286077069b3107085206f" + "reference": "0c5e8f20c74c78172a8ee72b125909b505033597" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/19073e3e0bb50cbc1cb286077069b3107085206f", - "reference": "19073e3e0bb50cbc1cb286077069b3107085206f", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0c5e8f20c74c78172a8ee72b125909b505033597", + "reference": "0c5e8f20c74c78172a8ee72b125909b505033597", "shasum": "" }, "require": { "masterminds/html5": "^2.6", - "php": ">=8.1", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" + "symfony/css-selector": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8941,7 +11107,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.19" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.1" }, "funding": [ { @@ -8952,37 +11118,41 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-14T17:58:34+00:00" + "time": "2025-12-06T15:47:47+00:00" }, { "name": "symfony/dotenv", - "version": "v6.4.16", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "1ac5e7e7e862d4d574258daf08bd569ba926e4a5" + "reference": "1658a4d34df028f3d93bcdd8e81f04423925a364" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/1ac5e7e7e862d4d574258daf08bd569ba926e4a5", - "reference": "1ac5e7e7e862d4d574258daf08bd569ba926e4a5", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/1658a4d34df028f3d93bcdd8e81f04423925a364", + "reference": "1658a4d34df028f3d93bcdd8e81f04423925a364", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "conflict": { - "symfony/console": "<5.4", - "symfony/process": "<5.4" + "symfony/console": "<6.4", + "symfony/process": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9015,7 +11185,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v6.4.16" + "source": "https://github.com/symfony/dotenv/tree/v7.4.0" }, "funding": [ { @@ -9026,40 +11196,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-27T11:08:19+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/error-handler", - "version": "v6.4.20", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "aa3bcf4f7674719df078e61cc8062e5b7f752031" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/aa3bcf4f7674719df078e61cc8062e5b7f752031", - "reference": "aa3bcf4f7674719df078e61cc8062e5b7f752031", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^5.4|^6.0|^7.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -9090,7 +11267,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.20" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -9101,33 +11278,37 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-01T13:00:38+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e" + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -9136,13 +11317,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9170,7 +11352,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" }, "funding": [ { @@ -9181,25 +11363,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-10-28T09:38:46+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -9213,7 +11399,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -9246,7 +11432,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -9262,25 +11448,25 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/expression-language", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "3524904fb026356a5230cd197f9a4e6a61e0e7df" + "reference": "8b9bbbb8c71f79a09638f6ea77c531e511139efa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/3524904fb026356a5230cd197f9a4e6a61e0e7df", - "reference": "3524904fb026356a5230cd197f9a4e6a61e0e7df", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/8b9bbbb8c71f79a09638f6ea77c531e511139efa", + "reference": "8b9bbbb8c71f79a09638f6ea77c531e511139efa", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/cache": "^5.4|^6.0|^7.0", + "php": ">=8.2", + "symfony/cache": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3" }, @@ -9310,7 +11496,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v6.4.13" + "source": "https://github.com/symfony/expression-language/tree/v7.4.0" }, "funding": [ { @@ -9321,34 +11507,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-09T08:40:40+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3" + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/4856c9cf585d5a0313d8d35afd681a526f038dd3", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9376,7 +11566,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.13" + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" }, "funding": [ { @@ -9387,32 +11577,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/finder", - "version": "v6.4.17", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9440,7 +11634,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.17" + "source": "https://github.com/symfony/finder/tree/v7.4.3" }, "funding": [ { @@ -9451,40 +11645,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-29T13:51:37+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/flex", - "version": "v2.5.0", + "version": "v2.10.0", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "8ce1acd9842abe0e9b4c4a0bd3f259859516c018" + "reference": "9cd384775973eabbf6e8b05784dda279fc67c28d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/8ce1acd9842abe0e9b4c4a0bd3f259859516c018", - "reference": "8ce1acd9842abe0e9b4c4a0bd3f259859516c018", + "url": "https://api.github.com/repos/symfony/flex/zipball/9cd384775973eabbf6e8b05784dda279fc67c28d", + "reference": "9cd384775973eabbf6e8b05784dda279fc67c28d", "shasum": "" }, "require": { "composer-plugin-api": "^2.1", - "php": ">=8.0" + "php": ">=8.1" }, "conflict": { - "composer/semver": "<1.7.2" + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" }, "require-dev": { "composer/composer": "^2.1", - "symfony/dotenv": "^5.4|^6.0", - "symfony/filesystem": "^5.4|^6.0", - "symfony/phpunit-bridge": "^5.4|^6.0", - "symfony/process": "^5.4|^6.0" + "symfony/dotenv": "^6.4|^7.4|^8.0", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/phpunit-bridge": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" }, "type": "composer-plugin", "extra": { @@ -9508,7 +11707,7 @@ "description": "Composer plugin for Symfony", "support": { "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.5.0" + "source": "https://github.com/symfony/flex/tree/v2.10.0" }, "funding": [ { @@ -9519,65 +11718,71 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-03T07:50:46+00:00" + "time": "2025-11-16T09:38:19+00:00" }, { "name": "symfony/form", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "3929e2a60a828f39df6765fb49d224cc629fa529" + "reference": "f7e147d3e57198122568f17909bc85266b0b2592" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/3929e2a60a828f39df6765fb49d224cc629fa529", - "reference": "3929e2a60a828f39df6765fb49d224cc629fa529", + "url": "https://api.github.com/repos/symfony/form/zipball/f7e147d3e57198122568f17909bc85266b0b2592", + "reference": "f7e147d3e57198122568f17909bc85266b0b2592", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/options-resolver": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/options-resolver": "^7.3|^8.0", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-icu": "^1.21", "symfony/polyfill-mbstring": "~1.0", - "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", - "symfony/doctrine-bridge": "<5.4.21|>=6,<6.2.7", - "symfony/error-handler": "<5.4", - "symfony/framework-bundle": "<5.4", - "symfony/http-kernel": "<5.4", - "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/error-handler": "<6.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<7.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.3" + "symfony/twig-bridge": "<6.4" }, "require-dev": { "doctrine/collections": "^1.0|^2.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/html-sanitizer": "^6.1|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", - "symfony/security-core": "^6.2|^7.0", - "symfony/security-csrf": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4.12|^7.1.5|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9605,7 +11810,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v6.4.20" + "source": "https://github.com/symfony/form/tree/v7.4.3" }, "funding": [ { @@ -9616,117 +11821,126 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-27T10:21:45+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/framework-bundle", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "51418a20079cb25af3fcb8fa8ae1ed82f7fdd1ce" + "reference": "df908e8f9e5f6cc3c9e0d0172e030a5c1c280582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/51418a20079cb25af3fcb8fa8ae1ed82f7fdd1ce", - "reference": "51418a20079cb25af3fcb8fa8ae1ed82f7fdd1ce", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/df908e8f9e5f6cc3c9e0d0172e030a5c1c280582", + "reference": "df908e8f9e5f6cc3c9e0d0172e030a5c1c280582", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", "ext-xml": "*", - "php": ">=8.1", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/dependency-injection": "^6.4.12|^7.0", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.3|^8.0.3", + "symfony/dependency-injection": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.1|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", "symfony/polyfill-mbstring": "~1.0", - "symfony/routing": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" }, "conflict": { - "doctrine/annotations": "<1.13.1", "doctrine/persistence": "<1.3", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/asset": "<5.4", + "symfony/asset": "<6.4", "symfony/asset-mapper": "<6.4", - "symfony/clock": "<6.3", - "symfony/console": "<5.4|>=7.0", + "symfony/clock": "<6.4", + "symfony/console": "<6.4", "symfony/dom-crawler": "<6.4", - "symfony/dotenv": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<6.3", - "symfony/lock": "<5.4", - "symfony/mailer": "<5.4", - "symfony/messenger": "<6.3", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<7.4", "symfony/mime": "<6.4", - "symfony/property-access": "<5.4", - "symfony/property-info": "<5.4", - "symfony/runtime": "<5.4.45|>=6.0,<6.4.13|>=7.0,<7.1.6", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", - "symfony/security-core": "<5.4", - "symfony/security-csrf": "<5.4", - "symfony/serializer": "<6.4", - "symfony/stopwatch": "<5.4", - "symfony/translation": "<6.4", - "symfony/twig-bridge": "<5.4", - "symfony/twig-bundle": "<5.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", - "symfony/workflow": "<6.4" + "symfony/webhook": "<7.2", + "symfony/workflow": "<7.4" }, "require-dev": { - "doctrine/annotations": "^1.13.1|^2", "doctrine/persistence": "^1.3|^2|^3", "dragonmantank/cron-expression": "^3.1", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "seld/jsonlint": "^1.10", - "symfony/asset": "^5.4|^6.0|^7.0", - "symfony/asset-mapper": "^6.4|^7.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/console": "^5.4.9|^6.0.9|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dom-crawler": "^6.4|^7.0", - "symfony/dotenv": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/html-sanitizer": "^6.1|^7.0", - "symfony/http-client": "^6.3|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/mailer": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.3|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/notifier": "^5.4|^6.0|^7.0", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", "symfony/polyfill-intl-icu": "~1.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0", - "symfony/scheduler": "^6.4.4|^7.0.4", - "symfony/security-bundle": "^5.4|^6.0|^7.0", - "symfony/semaphore": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/string": "^5.4|^6.0|^7.0", - "symfony/translation": "^6.4|^7.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/web-link": "^5.4|^6.0|^7.0", - "symfony/workflow": "^6.4|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0", - "twig/twig": "^2.10|^3.0.4" + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.2|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12" }, "type": "symfony-bundle", "autoload": { @@ -9754,7 +11968,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.20" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.3" }, "funding": [ { @@ -9765,37 +11979,44 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-23T16:46:24+00:00" + "time": "2025-12-29T09:31:36+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.19", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "3294a433fc9d12ae58128174896b5b1822c28dad" + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/3294a433fc9d12ae58128174896b5b1822c28dad", - "reference": "3294a433fc9d12ae58128174896b5b1822c28dad", + "url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", "symfony/service-contracts": "^2.5|^3" }, "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.3" + "symfony/http-foundation": "<6.4" }, "provide": { "php-http/async-client-implementation": "*", @@ -9804,19 +12025,20 @@ "symfony/http-client-implementation": "3.0" }, "require-dev": { - "amphp/amp": "^2.5", - "amphp/http-client": "^4.2.1", - "amphp/http-tunnel": "^1.0", - "amphp/socket": "^1.1", + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", "guzzlehttp/promises": "^1.4|^2.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -9847,7 +12069,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.19" + "source": "https://github.com/symfony/http-client/tree/v7.4.3" }, "funding": [ { @@ -9858,25 +12080,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-13T09:55:13+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.5.2", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" + "reference": "75d7043853a42837e68111812f4d964b01e5101c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", - "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", "shasum": "" }, "require": { @@ -9889,7 +12115,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -9925,7 +12151,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" }, "funding": [ { @@ -9941,40 +12167,41 @@ "type": "tidelift" } ], - "time": "2024-12-07T08:49:48+00:00" + "time": "2025-04-29T11:18:49+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.18", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "d0492d6217e5ab48f51fca76f64cf8e78919d0db" + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d0492d6217e5ab48f51fca76f64cf8e78919d0db", - "reference": "d0492d6217e5ab48f51fca76f64cf8e78919d0db", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { + "doctrine/dbal": "<3.6", "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -10002,7 +12229,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.18" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" }, "funding": [ { @@ -10013,82 +12240,87 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-09T15:48:56+00:00" + "time": "2025-12-23T14:23:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6be6db31bc74693ce5516e1fd5e5ff1171005e37" + "reference": "885211d4bed3f857b8c964011923528a55702aa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6be6db31bc74693ce5516e1fd5e5ff1171005e37", - "reference": "6be6db31bc74693ce5516e1fd5e5ff1171005e37", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", + "reference": "885211d4bed3f857b8c964011923528a55702aa5", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.4", - "symfony/config": "<6.1", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<5.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/translation": "<5.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<5.4", + "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.3", - "twig/twig": "<2.13" + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.5|^6.0.5|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.4|^7.0.4", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.4|^7.0", - "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" }, "type": "library", "autoload": { @@ -10116,7 +12348,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.20" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" }, "funding": [ { @@ -10127,34 +12359,41 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-28T13:27:10+00:00" + "time": "2025-12-31T08:43:57+00:00" }, { "name": "symfony/intl", - "version": "v6.4.15", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "b1d5e8d82615b60f229216edfee0b59e2ef66da6" + "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/b1d5e8d82615b60f229216edfee0b59e2ef66da6", - "reference": "b1d5e8d82615b60f229216edfee0b59e2ef66da6", + "url": "https://api.github.com/repos/symfony/intl/zipball/2fa074de6c7faa6b54f2891fc22708f42245ed5c", + "reference": "2fa074de6c7faa6b54f2891fc22708f42245ed5c", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/string": "<7.1" }, "require-dev": { - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -10199,7 +12438,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v6.4.15" + "source": "https://github.com/symfony/intl/tree/v7.4.0" }, "funding": [ { @@ -10210,48 +12449,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-08T15:28:48+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.18", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e93a6ae2767d7f7578c2b7961d9d8e27580b2b11" + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e93a6ae2767d7f7578c2b7961d9d8e27580b2b11", - "reference": "e93a6ae2767d7f7578c2b7961d9d8e27580b2b11", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.1", + "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/messenger": "<6.2", - "symfony/mime": "<6.2", - "symfony/twig-bridge": "<6.2.1" + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.2|^7.0", - "symfony/twig-bridge": "^6.2|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -10279,7 +12522,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.18" + "source": "https://github.com/symfony/mailer/tree/v7.4.3" }, "funding": [ { @@ -10290,29 +12533,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-24T15:27:15+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/mime", - "version": "v6.4.19", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3", - "reference": "ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" @@ -10321,18 +12568,18 @@ "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<5.4", + "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.4|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -10364,7 +12611,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.19" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -10375,47 +12622,51 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-17T21:23:52+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "9d14621e59f22c2b6d030d92d37ffe5ae1e60452" + "reference": "189d16466ff83d9c51fad26382bf0beeb41bda21" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/9d14621e59f22c2b6d030d92d37ffe5ae1e60452", - "reference": "9d14621e59f22c2b6d030d92d37ffe5ae1e60452", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/189d16466ff83d9c51fad26382bf0beeb41bda21", + "reference": "189d16466ff83d9c51fad26382bf0beeb41bda21", "shasum": "" }, "require": { - "monolog/monolog": "^1.25.1|^2|^3", - "php": ">=8.1", + "monolog/monolog": "^3", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/console": "<5.4", - "symfony/http-foundation": "<5.4", - "symfony/security-core": "<5.4" + "symfony/console": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/security-core": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/mailer": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/security-core": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -10443,7 +12694,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.13" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.0" }, "funding": [ { @@ -10454,53 +12705,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-14T08:49:08+00:00" + "time": "2025-11-01T09:17:33+00:00" }, { "name": "symfony/monolog-bundle", - "version": "v3.10.0", + "version": "v3.11.1", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181" + "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", - "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/0e675a6e08f791ef960dc9c7e392787111a3f0c1", + "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1", "shasum": "" }, "require": { + "composer-runtime-api": "^2.0", "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", - "php": ">=7.2.5", - "symfony/config": "^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", - "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0" + "php": ">=8.1", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/monolog-bridge": "^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/phpunit-bridge": "^6.3 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + "symfony/console": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^7.3.3", + "symfony/yaml": "^6.4 || ^7.0" }, "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "autoload": { "psr-4": { - "Symfony\\Bundle\\MonologBundle\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Bundle\\MonologBundle\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -10524,7 +12774,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0" + "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.1" }, "funding": [ { @@ -10535,29 +12785,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-11-06T17:08:13+00:00" + "time": "2025-12-08T07:58:26+00:00" }, { "name": "symfony/options-resolver", - "version": "v6.4.16", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "368128ad168f20e22c32159b9f761e456cec0c78" + "reference": "b38026df55197f9e39a44f3215788edf83187b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/368128ad168f20e22c32159b9f761e456cec0c78", - "reference": "368128ad168f20e22c32159b9f761e456cec0c78", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -10591,7 +12845,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.4.16" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" }, "funding": [ { @@ -10602,36 +12856,40 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-20T10:57:02+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/password-hasher", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "e97a1b31f60b8bdfc1fdedab4398538da9441d47" + "reference": "aa075ce6f54fe931f03c1e382597912f4fd94e1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/e97a1b31f60b8bdfc1fdedab4398538da9441d47", - "reference": "e97a1b31f60b8bdfc1fdedab4398538da9441d47", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/aa075ce6f54fe931f03c1e382597912f4fd94e1e", + "reference": "aa075ce6f54fe931f03c1e382597912f4fd94e1e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "conflict": { - "symfony/security-core": "<5.4" + "symfony/security-core": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/security-core": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -10663,7 +12921,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v6.4.13" + "source": "https://github.com/symfony/password-hasher/tree/v7.4.0" }, "funding": [ { @@ -10674,16 +12932,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-08-13T16:46:49+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -10742,7 +13004,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -10753,6 +13015,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10762,16 +13028,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -10820,7 +13086,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -10831,25 +13097,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78" + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78", - "reference": "d80a05e9904d2c2b9b95929f3e4b5d3a8f418d78", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", "shasum": "" }, "require": { @@ -10904,7 +13174,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0" }, "funding": [ { @@ -10915,25 +13185,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-20T22:24:30+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { @@ -10987,7 +13261,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -10998,16 +13272,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -11068,7 +13346,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -11080,159 +13358,7 @@ "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-php82", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php82\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/nicolas-grekas", "type": "github" }, { @@ -11244,16 +13370,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -11300,7 +13426,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -11311,25 +13437,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "e5493eb51311ab0b1cc2243416613f06ed8f18bd" + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/e5493eb51311ab0b1cc2243416613f06ed8f18bd", - "reference": "e5493eb51311ab0b1cc2243416613f06ed8f18bd", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", "shasum": "" }, "require": { @@ -11376,7 +13506,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" }, "funding": [ { @@ -11387,16 +13517,100 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T12:04:04+00:00" + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -11455,7 +13669,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" }, "funding": [ { @@ -11466,6 +13680,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -11475,20 +13693,20 @@ }, { "name": "symfony/process", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20" + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e2a61c16af36c9a07e5c9906498b73e091949a20", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20", + "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -11516,7 +13734,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.20" + "source": "https://github.com/symfony/process/tree/v7.4.3" }, "funding": [ { @@ -11527,34 +13745,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-10T17:11:00+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/property-access", - "version": "v6.4.18", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "80e0378f2f058b60d87dedc3c760caec882e992c" + "reference": "537626149d2910ca43eb9ce465654366bf4442f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/80e0378f2f058b60d87dedc3c760caec882e992c", - "reference": "80e0378f2f058b60d87dedc3c760caec882e992c", + "url": "https://api.github.com/repos/symfony/property-access/zipball/537626149d2910ca43eb9ce465654366bf4442f4", + "reference": "537626149d2910ca43eb9ce465654366bf4442f4", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/property-info": "^5.4|^6.0|^7.0" + "php": ">=8.2", + "symfony/property-info": "^6.4|^7.0|^8.0" }, "require-dev": { - "symfony/cache": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" }, "type": "library", "autoload": { @@ -11593,7 +13815,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.4.18" + "source": "https://github.com/symfony/property-access/tree/v7.4.0" }, "funding": [ { @@ -11604,46 +13826,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-16T14:42:05+00:00" + "time": "2025-09-08T21:14:32+00:00" }, { "name": "symfony/property-info", - "version": "v6.4.18", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "94d18e5cc11a37fd92856d38b61d9cdf72536a1e" + "reference": "c3c686e3d3a33a99f6967e69d6d5832acb7c25a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/94d18e5cc11a37fd92856d38b61d9cdf72536a1e", - "reference": "94d18e5cc11a37fd92856d38b61d9cdf72536a1e", + "url": "https://api.github.com/repos/symfony/property-info/zipball/c3c686e3d3a33a99f6967e69d6d5832acb7c25a1", + "reference": "c3c686e3d3a33a99f6967e69d6d5832acb7c25a1", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/string": "^5.4|^6.0|^7.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.3.5|^8.0" }, "conflict": { - "doctrine/annotations": "<1.12", "phpdocumentor/reflection-docblock": "<5.2", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/cache": "<5.4", - "symfony/dependency-injection": "<5.4|>=6.0,<6.4", - "symfony/serializer": "<5.4" + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "phpdocumentor/reflection-docblock": "^5.2", "phpstan/phpdoc-parser": "^1.0|^2.0", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/serializer": "^5.4|^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -11679,7 +13905,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.18" + "source": "https://github.com/symfony/property-info/tree/v7.4.0" }, "funding": [ { @@ -11690,45 +13916,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-21T10:52:27+00:00" + "time": "2025-11-13T08:38:49+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "c9cf83326a1074f83a738fc5320945abf7fb7fec" + "reference": "0101ff8bd0506703b045b1670960302d302a726c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/c9cf83326a1074f83a738fc5320945abf7fb7fec", - "reference": "c9cf83326a1074f83a738fc5320945abf7fb7fec", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/0101ff8bd0506703b045b1670960302d302a726c", + "reference": "0101ff8bd0506703b045b1670960302d302a726c", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/http-message": "^1.0|^2.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0" + "symfony/http-foundation": "^6.4|^7.0|^8.0" }, "conflict": { "php-http/discovery": "<1.15", - "symfony/http-kernel": "<6.2" + "symfony/http-kernel": "<6.4" }, "require-dev": { "nyholm/psr7": "^1.1", "php-http/discovery": "^1.15", "psr/log": "^1.1.4|^2|^3", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^6.2|^7.0", - "symfony/http-kernel": "^6.2|^7.0" + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -11762,7 +13993,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v6.4.13" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.0" }, "funding": [ { @@ -11773,35 +14004,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-11-13T08:38:49+00:00" }, { "name": "symfony/rate-limiter", - "version": "v6.4.15", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/rate-limiter.git", - "reference": "e250d82fc17b277b97cbce94efef5414aff29bf9" + "reference": "5c6df5bc10308505bb0fa8d1388bc6bd8a628ba8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/e250d82fc17b277b97cbce94efef5414aff29bf9", - "reference": "e250d82fc17b277b97cbce94efef5414aff29bf9", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/5c6df5bc10308505bb0fa8d1388bc6bd8a628ba8", + "reference": "5c6df5bc10308505bb0fa8d1388bc6bd8a628ba8", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/options-resolver": "^5.4|^6.0|^7.0" + "php": ">=8.2", + "symfony/options-resolver": "^7.3|^8.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/lock": "^5.4|^6.0|^7.0" + "symfony/lock": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -11833,7 +14067,7 @@ "rate-limiter" ], "support": { - "source": "https://github.com/symfony/rate-limiter/tree/v6.4.15" + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.0" }, "funding": [ { @@ -11844,45 +14078,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-09T07:19:24+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/routing", - "version": "v6.4.18", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e9bfc94953019089acdfb9be51c1b9142c4afa68" + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e9bfc94953019089acdfb9be51c1b9142c4afa68", - "reference": "e9bfc94953019089acdfb9be51c1b9142c4afa68", + "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "doctrine/annotations": "<1.12", - "symfony/config": "<6.2", - "symfony/dependency-injection": "<5.4", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "psr/log": "^1|^2|^3", - "symfony/config": "^6.2|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -11916,7 +14152,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.18" + "source": "https://github.com/symfony/routing/tree/v7.4.3" }, "funding": [ { @@ -11927,40 +14163,44 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-09T08:51:02+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/runtime", - "version": "v6.4.14", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "4facd4174f45cd37c65860403412b67c7381136a" + "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/4facd4174f45cd37c65860403412b67c7381136a", - "reference": "4facd4174f45cd37c65860403412b67c7381136a", + "url": "https://api.github.com/repos/symfony/runtime/zipball/876f902a6cb6b26c003de244188c06b2ba1c172f", + "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f", "shasum": "" }, "require": { "composer-plugin-api": "^1.0|^2.0", - "php": ">=8.1" + "php": ">=8.2" }, "conflict": { - "symfony/dotenv": "<5.4" + "symfony/dotenv": "<6.4" }, "require-dev": { - "composer/composer": "^1.0.2|^2.0", - "symfony/console": "^5.4.9|^6.0.9|^7.0", - "symfony/dotenv": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0" + "composer/composer": "^2.6", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" }, "type": "composer-plugin", "extra": { @@ -11995,7 +14235,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v6.4.14" + "source": "https://github.com/symfony/runtime/tree/v7.4.1" }, "funding": [ { @@ -12006,80 +14246,80 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-05T16:39:55+00:00" + "time": "2025-12-05T14:04:53+00:00" }, { "name": "symfony/security-bundle", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "181d1fcf5f88ef8212ed7f6434e5ff51c9d7dff3" + "reference": "48a64e746857464a5e8fd7bab84b31c9ba967eb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/181d1fcf5f88ef8212ed7f6434e5ff51c9d7dff3", - "reference": "181d1fcf5f88ef8212ed7f6434e5ff51c9d7dff3", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/48a64e746857464a5e8fd7bab84b31c9ba967eb9", + "reference": "48a64e746857464a5e8fd7bab84b31c9ba967eb9", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", "ext-xml": "*", - "php": ">=8.1", - "symfony/clock": "^6.3|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/dependency-injection": "^6.4.11|^7.1.4", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.2|^7.0", - "symfony/http-kernel": "^6.2", - "symfony/password-hasher": "^5.4|^6.0|^7.0", - "symfony/security-core": "^6.2|^7.0", - "symfony/security-csrf": "^5.4|^6.0|^7.0", - "symfony/security-http": "^6.3.6|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/password-hasher": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/console": "<6.4", "symfony/framework-bundle": "<6.4", - "symfony/http-client": "<5.4", - "symfony/ldap": "<5.4", + "symfony/http-client": "<6.4", + "symfony/ldap": "<6.4", "symfony/serializer": "<6.4", - "symfony/twig-bundle": "<5.4", + "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4" }, "require-dev": { - "symfony/asset": "^5.4|^6.0|^7.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/ldap": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", - "symfony/twig-bridge": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4", - "web-token/jwt-checker": "^3.1", - "web-token/jwt-signature-algorithm-ecdsa": "^3.1", - "web-token/jwt-signature-algorithm-eddsa": "^3.1", - "web-token/jwt-signature-algorithm-hmac": "^3.1", - "web-token/jwt-signature-algorithm-none": "^3.1", - "web-token/jwt-signature-algorithm-rsa": "^3.1" + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.15", + "web-token/jwt-library": "^3.3.2|^4.0" }, "type": "symfony-bundle", "autoload": { @@ -12107,7 +14347,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.4.13" + "source": "https://github.com/symfony/security-bundle/tree/v7.4.0" }, "funding": [ { @@ -12118,54 +14358,59 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-11-14T09:57:20+00:00" }, { "name": "symfony/security-core", - "version": "v6.4.18", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "0ae7ae716968e00287ab9b7768405e0dc9cad109" + "reference": "be0b8585f2d69b48a9b1a6372aa48d23c7e7eeb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/0ae7ae716968e00287ab9b7768405e0dc9cad109", - "reference": "0ae7ae716968e00287ab9b7768405e0dc9cad109", + "url": "https://api.github.com/repos/symfony/security-core/zipball/be0b8585f2d69b48a9b1a6372aa48d23c7e7eeb4", + "reference": "be0b8585f2d69b48a9b1a6372aa48d23c7e7eeb4", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3", - "symfony/password-hasher": "^5.4|^6.0|^7.0", + "symfony/password-hasher": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/event-dispatcher": "<5.4", - "symfony/http-foundation": "<5.4", - "symfony/ldap": "<5.4", - "symfony/security-guard": "<5.4", - "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", - "symfony/validator": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/ldap": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/validator": "<6.4" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", "psr/container": "^1.1|^2.0", "psr/log": "^1|^2|^3", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/ldap": "^5.4|^6.0|^7.0", - "symfony/string": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", - "symfony/validator": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -12193,7 +14438,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.4.18" + "source": "https://github.com/symfony/security-core/tree/v7.4.3" }, "funding": [ { @@ -12204,36 +14449,42 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-22T20:59:03+00:00" + "time": "2025-12-19T23:18:26+00:00" }, { "name": "symfony/security-csrf", - "version": "v6.4.13", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "c34421b7d34efbaef5d611ab2e646a0ec464ffe3" + "reference": "d526fa61963d926e91c9fb22edf829d9f8793dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/c34421b7d34efbaef5d611ab2e646a0ec464ffe3", - "reference": "c34421b7d34efbaef5d611ab2e646a0ec464ffe3", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/d526fa61963d926e91c9fb22edf829d9f8793dfe", + "reference": "d526fa61963d926e91c9fb22edf829d9f8793dfe", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/security-core": "^5.4|^6.0|^7.0" + "php": ">=8.2", + "symfony/security-core": "^6.4|^7.0|^8.0" }, "conflict": { - "symfony/http-foundation": "<5.4" + "symfony/http-foundation": "<6.4" }, "require-dev": { - "symfony/http-foundation": "^5.4|^6.0|^7.0" + "psr/log": "^1|^2|^3", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -12261,7 +14512,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v6.4.13" + "source": "https://github.com/symfony/security-csrf/tree/v7.4.3" }, "funding": [ { @@ -12272,56 +14523,60 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-12-23T15:24:11+00:00" }, { "name": "symfony/security-http", - "version": "v6.4.19", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "a57bb00b01036865e6c08d0c8540df429534df19" + "reference": "72f3b3fa9f322c9579d5246895a09f945cc33e36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/a57bb00b01036865e6c08d0c8540df429534df19", - "reference": "a57bb00b01036865e6c08d0c8540df429534df19", + "url": "https://api.github.com/repos/symfony/security-http/zipball/72f3b3fa9f322c9579d5246895a09f945cc33e36", + "reference": "72f3b3fa9f322c9579d5246895a09f945cc33e36", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-foundation": "^6.2|^7.0", - "symfony/http-kernel": "^6.3|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/polyfill-mbstring": "~1.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/security-core": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.3|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/clock": "<6.3", - "symfony/event-dispatcher": "<5.4.9|>=6,<6.0.9", + "symfony/clock": "<6.4", "symfony/http-client-contracts": "<3.0", - "symfony/security-bundle": "<5.4", - "symfony/security-csrf": "<5.4" + "symfony/security-bundle": "<6.4", + "symfony/security-csrf": "<6.4" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.3|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^3.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/security-csrf": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", - "web-token/jwt-checker": "^3.1", - "web-token/jwt-signature-algorithm-ecdsa": "^3.1" + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" }, "type": "library", "autoload": { @@ -12349,7 +14604,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.19" + "source": "https://github.com/symfony/security-http/tree/v7.4.3" }, "funding": [ { @@ -12360,66 +14615,71 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-28T19:24:43+00:00" + "time": "2025-12-19T23:18:26+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.19", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "a221b2f6066af304d760cff7a26f201b4fab4aef" + "reference": "af01e99d6fc63549063fb9e849ce1240cfef5c4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/a221b2f6066af304d760cff7a26f201b4fab4aef", - "reference": "a221b2f6066af304d760cff7a26f201b4fab4aef", + "url": "https://api.github.com/repos/symfony/serializer/zipball/af01e99d6fc63549063fb9e849ce1240cfef5c4a", + "reference": "af01e99d6fc63549063fb9e849ce1240cfef5c4a", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8" + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" }, "conflict": { - "doctrine/annotations": "<1.12", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/dependency-injection": "<5.4", - "symfony/property-access": "<5.4", - "symfony/property-info": "<5.4.24|>=6,<6.2.11", - "symfony/uid": "<5.4", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/uid": "<6.4", "symfony/validator": "<6.4", - "symfony/yaml": "<5.4" + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.26|^6.3|^7.0", - "symfony/property-info": "^5.4.24|^6.2.11|^7.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^7.2|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -12447,7 +14707,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.19" + "source": "https://github.com/symfony/serializer/tree/v7.4.3" }, "funding": [ { @@ -12458,25 +14718,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-24T08:42:36+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -12494,7 +14758,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -12530,7 +14794,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -12541,41 +14805,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/stimulus-bundle", - "version": "v2.23.0", + "version": "v2.32.0", "source": { "type": "git", "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "254f4e05cbaa349d4ae68b9b2e6a22995e0887f9" + "reference": "dfbf6b443bb381cb611e06f64dc23603b614b575" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/254f4e05cbaa349d4ae68b9b2e6a22995e0887f9", - "reference": "254f4e05cbaa349d4ae68b9b2e6a22995e0887f9", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/dfbf6b443bb381cb611e06f64dc23603b614b575", + "reference": "dfbf6b443bb381cb611e06f64dc23603b614b575", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0|^8.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", "symfony/deprecation-contracts": "^2.0|^3.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0|^8.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0", "twig/twig": "^2.15.3|^3.8" }, "require-dev": { - "symfony/asset-mapper": "^6.3|^7.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/asset-mapper": "^6.3|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", "zenstruck/browser": "^1.4" }, "type": "symfony-bundle", @@ -12599,7 +14867,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v2.23.0" + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.32.0" }, "funding": [ { @@ -12610,29 +14878,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-16T21:55:09+00:00" + "time": "2025-12-02T07:12:06+00:00" }, { "name": "symfony/stopwatch", - "version": "v6.4.19", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "dfe1481c12c06266d0c3d58c0cb4b09bd497ab9c" + "reference": "8a24af0a2e8a872fb745047180649b8418303084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/dfe1481c12c06266d0c3d58c0cb4b09bd497ab9c", - "reference": "dfe1481c12c06266d0c3d58c0cb4b09bd497ab9c", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", + "reference": "8a24af0a2e8a872fb745047180649b8418303084", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -12661,7 +14933,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v6.4.19" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" }, "funding": [ { @@ -12672,31 +14944,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-21T10:06:30+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -12704,11 +14981,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -12747,7 +15024,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v7.4.0" }, "funding": [ { @@ -12758,60 +15035,65 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation", - "version": "v6.4.19", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "3b9bf9f33997c064885a7bfc126c14b9daa0e00e" + "reference": "7ef27c65d78886f7599fdd5c93d12c9243ecf44d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/3b9bf9f33997c064885a7bfc126c14b9daa0e00e", - "reference": "3b9bf9f33997c064885a7bfc126c14b9daa0e00e", + "url": "https://api.github.com/repos/symfony/translation/zipball/7ef27c65d78886f7599fdd5c93d12c9243ecf44d", + "reference": "7ef27c65d78886f7599fdd5c93d12c9243ecf44d", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.4", "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -12842,7 +15124,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.19" + "source": "https://github.com/symfony/translation/tree/v7.4.3" }, "funding": [ { @@ -12853,25 +15135,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-13T10:18:43+00:00" + "time": "2025-12-29T09:31:36+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.5.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -12884,7 +15170,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -12920,7 +15206,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -12931,77 +15217,83 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/twig-bridge", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "bb423dfaa51b6d88b1d64197ae695a0c8ac73778" + "reference": "43c922fce020060c65b0fd54bfd8def3b38949b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/bb423dfaa51b6d88b1d64197ae695a0c8ac73778", - "reference": "bb423dfaa51b6d88b1d64197ae695a0c8ac73778", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/43c922fce020060c65b0fd54bfd8def3b38949b6", + "reference": "43c922fce020060c65b0fd54bfd8def3b38949b6", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^3.21" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/console": "<5.4", - "symfony/form": "<6.3", - "symfony/http-foundation": "<5.4", + "symfony/console": "<6.4", + "symfony/form": "<6.4", + "symfony/http-foundation": "<6.4", "symfony/http-kernel": "<6.4", - "symfony/mime": "<6.2", + "symfony/mime": "<6.4", "symfony/serializer": "<6.4", - "symfony/translation": "<5.4", - "symfony/workflow": "<5.4" + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/asset": "^5.4|^6.0|^7.0", - "symfony/asset-mapper": "^6.3|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/form": "^6.4.20|^7.2.5", - "symfony/html-sanitizer": "^6.1|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4.30|~7.3.8|^7.4.1|^8.0.1", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "~1.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/security-acl": "^2.8|^3.0", - "symfony/security-core": "^5.4|^6.0|^7.0", - "symfony/security-csrf": "^5.4|^6.0|^7.0", - "symfony/security-http": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^6.1|^7.0", - "symfony/web-link": "^5.4|^6.0|^7.0", - "symfony/workflow": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0", - "twig/cssinliner-extra": "^2.12|^3", - "twig/inky-extra": "^2.12|^3", - "twig/markdown-extra": "^2.12|^3" + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/workflow": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" }, "type": "symfony-bridge", "autoload": { @@ -13029,7 +15321,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.20" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.3" }, "funding": [ { @@ -13040,52 +15332,58 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-28T13:08:36+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/twig-bundle", - "version": "v6.4.13", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "c3beeb5336aba1ea03c37e526968c2fde3ef25c4" + "reference": "9e1f5fd2668ed26c60d17d63f15fe270ed8da5e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/c3beeb5336aba1ea03c37e526968c2fde3ef25c4", - "reference": "c3beeb5336aba1ea03c37e526968c2fde3ef25c4", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/9e1f5fd2668ed26c60d17d63f15fe270ed8da5e6", + "reference": "9e1f5fd2668ed26c60d17d63f15fe270ed8da5e6", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", - "php": ">=8.1", - "symfony/config": "^6.1|^7.0", - "symfony/dependency-injection": "^6.1|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^6.2", - "symfony/twig-bridge": "^6.4", - "twig/twig": "^2.13|^3.0.4" + "php": ">=8.2", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/twig-bridge": "^7.3|^8.0", + "twig/twig": "^3.12" }, "conflict": { - "symfony/framework-bundle": "<5.4", - "symfony/translation": "<5.4" + "symfony/framework-bundle": "<6.4", + "symfony/translation": "<6.4" }, "require-dev": { - "symfony/asset": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", - "symfony/web-link": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "symfony-bundle", "autoload": { @@ -13113,7 +15411,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v6.4.13" + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.3" }, "funding": [ { @@ -13124,33 +15422,120 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { - "name": "symfony/uid", - "version": "v6.4.13", + "name": "symfony/type-info", + "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/symfony/uid.git", - "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007" + "url": "https://github.com/symfony/type-info.git", + "reference": "7f9743e921abcce92a03fc693530209c59e73076" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/18eb207f0436a993fffbdd811b5b8fa35fa5e007", - "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007", + "url": "https://api.github.com/repos/symfony/type-info/zipball/7f9743e921abcce92a03fc693530209c59e73076", + "reference": "7f9743e921abcce92a03fc693530209c59e73076", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-07T09:36:46+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "shasum": "" + }, + "require": { + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -13187,7 +15572,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.13" + "source": "https://github.com/symfony/uid/tree/v7.4.0" }, "funding": [ { @@ -13198,38 +15583,43 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-09-25T11:02:55+00:00" }, { "name": "symfony/ux-translator", - "version": "v2.23.0", + "version": "v2.32.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-translator.git", - "reference": "f05e243a52258431b69c85ef9de6c0ed0d9c92c1" + "reference": "fde719a87903d9bc6fe60abf7581c1143532c918" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-translator/zipball/f05e243a52258431b69c85ef9de6c0ed0d9c92c1", - "reference": "f05e243a52258431b69c85ef9de6c0ed0d9c92c1", + "url": "https://api.github.com/repos/symfony/ux-translator/zipball/fde719a87903d9bc6fe60abf7581c1143532c918", + "reference": "fde719a87903d9bc6fe60abf7581c1143532c918", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/string": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0" + "symfony/console": "^5.4|^6.0|^7.0|^8.0", + "symfony/filesystem": "^5.4|^6.0|^7.0|^8.0", + "symfony/string": "^5.4|^6.0|^7.0|^8.0", + "symfony/translation": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { - "symfony/framework-bundle": "^5.4|^6.0|^7.0", - "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0|^8.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0", + "symfony/yaml": "^5.4|^6.0|^7.0|^8.0" }, "type": "symfony-bundle", "extra": { @@ -13263,7 +15653,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-translator/tree/v2.23.0" + "source": "https://github.com/symfony/ux-translator/tree/v2.32.0" }, "funding": [ { @@ -13274,25 +15664,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-05T14:25:02+00:00" + "time": "2025-12-26T17:37:51+00:00" }, { "name": "symfony/ux-turbo", - "version": "v2.23.0", + "version": "v2.32.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-turbo.git", - "reference": "db96cf04d70a8c820671ce55530e8bf641ada33f" + "reference": "0deaa8abef20933d11f8bbe9899d950b4333ca1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/db96cf04d70a8c820671ce55530e8bf641ada33f", - "reference": "db96cf04d70a8c820671ce55530e8bf641ada33f", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/0deaa8abef20933d11f8bbe9899d950b4333ca1e", + "reference": "0deaa8abef20933d11f8bbe9899d950b4333ca1e", "shasum": "" }, "require": { @@ -13304,25 +15698,26 @@ }, "require-dev": { "dbrekelmans/bdi": "dev-main", - "doctrine/doctrine-bundle": "^2.4.3", - "doctrine/orm": "^2.8 | 3.0", - "phpstan/phpstan": "^1.10", - "symfony/asset-mapper": "^6.4|^7.0", - "symfony/debug-bundle": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/mercure-bundle": "^0.3.7", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/panther": "^2.1", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|6.3.*|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/security-core": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^6.4|^7.0", + "doctrine/doctrine-bundle": "^2.4.3|^3.0|^4.0", + "doctrine/orm": "^2.8|^3.0", + "php-webdriver/webdriver": "^1.15", + "phpstan/phpstan": "^2.1.17", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/debug-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", + "symfony/form": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/mercure-bundle": "^0.3.7|^0.4.1", + "symfony/messenger": "^5.4|^6.0|^7.0|^8.0", + "symfony/panther": "^2.2", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0", + "symfony/process": "^5.4|6.3.*|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "symfony/security-core": "^5.4|^6.0|^7.0|^8.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", "symfony/ux-twig-component": "^2.21", - "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0" + "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0|^8.0" }, "type": "symfony-bundle", "extra": { @@ -13361,7 +15756,7 @@ "turbo-stream" ], "support": { - "source": "https://github.com/symfony/ux-turbo/tree/v2.23.0" + "source": "https://github.com/symfony/ux-turbo/tree/v2.32.0" }, "funding": [ { @@ -13372,29 +15767,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-06T08:47:30+00:00" + "time": "2025-12-17T06:03:34+00:00" }, { "name": "symfony/validator", - "version": "v6.4.20", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "9314555aceb8d8ce8abda81e1e47e439258d9309" + "reference": "9670bedf4c454b21d1e04606b6c227990da8bebe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/9314555aceb8d8ce8abda81e1e47e439258d9309", - "reference": "9314555aceb8d8ce8abda81e1e47e439258d9309", + "url": "https://api.github.com/repos/symfony/validator/zipball/9670bedf4c454b21d1e04606b6c227990da8bebe", + "reference": "9670bedf4c454b21d1e04606b6c227990da8bebe", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0", @@ -13402,34 +15801,37 @@ "symfony/translation-contracts": "^2.5|^3" }, "conflict": { - "doctrine/annotations": "<1.13", "doctrine/lexer": "<1.1", - "symfony/dependency-injection": "<5.4", - "symfony/expression-language": "<5.4", - "symfony/http-kernel": "<5.4", - "symfony/intl": "<5.4", - "symfony/property-info": "<5.4", - "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", - "symfony/yaml": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/var-exporter": "<6.4.25|>=7.0,<7.3.3", + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.13|^2", "egulias/email-validator": "^2.1.10|^3|^4", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -13458,7 +15860,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.20" + "source": "https://github.com/symfony/validator/tree/v7.4.3" }, "funding": [ { @@ -13469,43 +15871,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-14T14:22:58+00:00" + "time": "2025-12-27T17:05:22+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.18", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "4ad10cf8b020e77ba665305bb7804389884b4837" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/4ad10cf8b020e77ba665305bb7804389884b4837", - "reference": "4ad10cf8b020e77ba665305bb7804389884b4837", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^6.3|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" }, "bin": [ "Resources/bin/var-dump-server" @@ -13543,7 +15947,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.18" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -13554,35 +15958,39 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-17T11:26:11+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.20", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "998df255e9e6a15a36ae35e9c6cd818c17cf92a2" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/998df255e9e6a15a36ae35e9c6cd818c17cf92a2", - "reference": "998df255e9e6a15a36ae35e9c6cd818c17cf92a2", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -13620,7 +16028,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.20" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -13631,39 +16039,43 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T09:55:08+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "symfony/web-link", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/web-link.git", - "reference": "4d188b64bb9a9c5e2e4d20c8d5fdce6bbbb32c94" + "reference": "c62edd6b52e31cf2f6f38fd3386725f364f19942" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-link/zipball/4d188b64bb9a9c5e2e4d20c8d5fdce6bbbb32c94", - "reference": "4d188b64bb9a9c5e2e4d20c8d5fdce6bbbb32c94", + "url": "https://api.github.com/repos/symfony/web-link/zipball/c62edd6b52e31cf2f6f38fd3386725f364f19942", + "reference": "c62edd6b52e31cf2f6f38fd3386725f364f19942", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/link": "^1.1|^2.0" }, "conflict": { - "symfony/http-kernel": "<5.4" + "symfony/http-kernel": "<6.4" }, "provide": { "psr/link-implementation": "1.0|2.0" }, "require-dev": { - "symfony/http-kernel": "^5.4|^6.0|^7.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -13703,7 +16115,7 @@ "push" ], "support": { - "source": "https://github.com/symfony/web-link/tree/v6.4.13" + "source": "https://github.com/symfony/web-link/tree/v7.4.0" }, "funding": [ { @@ -13714,41 +16126,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/webpack-encore-bundle", - "version": "v2.2.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/symfony/webpack-encore-bundle.git", - "reference": "e335394b68a775a9b2bd173a8ba4fd2001f3870c" + "reference": "5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/e335394b68a775a9b2bd173a8ba4fd2001f3870c", - "reference": "e335394b68a775a9b2bd173a8ba4fd2001f3870c", + "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e", + "reference": "5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e", "shasum": "" }, "require": { "php": ">=8.1.0", - "symfony/asset": "^5.4 || ^6.2 || ^7.0", - "symfony/config": "^5.4 || ^6.2 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.2 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.2 || ^7.0", + "symfony/asset": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/config": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.2 || ^7.0 || ^8.0", "symfony/service-contracts": "^1.1.9 || ^2.1.3 || ^3.0" }, "require-dev": { - "symfony/framework-bundle": "^5.4 || ^6.2 || ^7.0", - "symfony/http-client": "^5.4 || ^6.2 || ^7.0", - "symfony/phpunit-bridge": "^5.4 || ^6.2 || ^7.0", - "symfony/twig-bundle": "^5.4 || ^6.2 || ^7.0", - "symfony/web-link": "^5.4 || ^6.2 || ^7.0" + "symfony/framework-bundle": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/http-client": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/twig-bundle": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/web-link": "^5.4 || ^6.2 || ^7.0 || ^8.0" }, "type": "symfony-bundle", "extra": { @@ -13775,7 +16191,7 @@ "description": "Integration of your Symfony app with Webpack Encore", "support": { "issues": "https://github.com/symfony/webpack-encore-bundle/issues", - "source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.2.0" + "source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.0" }, "funding": [ { @@ -13786,37 +16202,41 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-02T07:27:19+00:00" + "time": "2025-11-27T13:41:46+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.20", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "28ee818fce4a73ac1474346b94e4b966f665c53f" + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/28ee818fce4a73ac1474346b94e4b966f665c53f", - "reference": "28ee818fce4a73ac1474346b94e4b966f665c53f", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -13847,7 +16267,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.20" + "source": "https://github.com/symfony/yaml/tree/v7.4.1" }, "funding": [ { @@ -13858,25 +16278,90 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-27T20:15:30+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { - "name": "tecnickcom/tc-lib-barcode", - "version": "2.4.3", + "name": "symplify/easy-coding-standard", + "version": "12.6.2", "source": { "type": "git", - "url": "https://github.com/tecnickcom/tc-lib-barcode.git", - "reference": "8d65345d64cdcf8f5e254bd24f26c7e5f966befa" + "url": "https://github.com/easy-coding-standard/easy-coding-standard.git", + "reference": "7a6798aa424f0ecafb1542b6f5207c5a99704d3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/8d65345d64cdcf8f5e254bd24f26c7e5f966befa", - "reference": "8d65345d64cdcf8f5e254bd24f26c7e5f966befa", + "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/7a6798aa424f0ecafb1542b6f5207c5a99704d3d", + "reference": "7a6798aa424f0ecafb1542b6f5207c5a99704d3d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "friendsofphp/php-cs-fixer": "<3.46", + "phpcsstandards/php_codesniffer": "<3.8", + "symplify/coding-standard": "<12.1" + }, + "suggest": { + "ext-dom": "Needed to support checkstyle output format in class CheckstyleOutputFormatter" + }, + "bin": [ + "bin/ecs" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Use Coding Standard with 0-knowledge of PHP-CS-Fixer and PHP_CodeSniffer", + "keywords": [ + "Code style", + "automation", + "fixer", + "static analysis" + ], + "support": { + "issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues", + "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.6.2" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2025-10-29T08:51:50+00:00" + }, + { + "name": "tecnickcom/tc-lib-barcode", + "version": "2.4.18", + "source": { + "type": "git", + "url": "https://github.com/tecnickcom/tc-lib-barcode.git", + "reference": "338095651126ec4207f98e5221beea30b27c0fe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/338095651126ec4207f98e5221beea30b27c0fe9", + "reference": "338095651126ec4207f98e5221beea30b27c0fe9", "shasum": "" }, "require": { @@ -13885,13 +16370,14 @@ "ext-gd": "*", "ext-pcre": "*", "php": ">=8.1", - "tecnickcom/tc-lib-color": "^2.2" + "tecnickcom/tc-lib-color": "^2.3" }, "require-dev": { "pdepend/pdepend": "2.16.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", "phpmd/phpmd": "2.15.0", - "phpunit/phpunit": "12.0.1 || 11.5.7 || 10.5.40", - "squizlabs/php_codesniffer": "3.11.3" + "phpunit/phpunit": "12.4.4 || 11.5.44 || 10.5.58", + "squizlabs/php_codesniffer": "4.0.1" }, "type": "library", "autoload": { @@ -13955,28 +16441,28 @@ ], "support": { "issues": "https://github.com/tecnickcom/tc-lib-barcode/issues", - "source": "https://github.com/tecnickcom/tc-lib-barcode/tree/2.4.3" + "source": "https://github.com/tecnickcom/tc-lib-barcode/tree/2.4.18" }, "funding": [ { - "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_donations¤cy_code=GBP&business=paypal@tecnick.com&item_name=donation%20for%20tc-lib-barcode%20project", + "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", "type": "custom" } ], - "time": "2025-02-07T10:37:48+00:00" + "time": "2025-12-11T12:48:04+00:00" }, { "name": "tecnickcom/tc-lib-color", - "version": "2.2.8", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/tecnickcom/tc-lib-color.git", - "reference": "77d68d5206aef08ee0f3b69c0c6df4e559ad28bc" + "reference": "4a70cf68cd9fd4082b1b6d16234876a66649be0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/77d68d5206aef08ee0f3b69c0c6df4e559ad28bc", - "reference": "77d68d5206aef08ee0f3b69c0c6df4e559ad28bc", + "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/4a70cf68cd9fd4082b1b6d16234876a66649be0b", + "reference": "4a70cf68cd9fd4082b1b6d16234876a66649be0b", "shasum": "" }, "require": { @@ -13985,9 +16471,10 @@ }, "require-dev": { "pdepend/pdepend": "2.16.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", "phpmd/phpmd": "2.15.0", - "phpunit/phpunit": "12.0.1 || 11.5.7 || 10.5.40", - "squizlabs/php_codesniffer": "3.11.3" + "phpunit/phpunit": "12.4.4 || 11.5.44 || 10.5.58", + "squizlabs/php_codesniffer": "4.0.1" }, "type": "library", "autoload": { @@ -14024,35 +16511,174 @@ ], "support": { "issues": "https://github.com/tecnickcom/tc-lib-color/issues", - "source": "https://github.com/tecnickcom/tc-lib-color/tree/2.2.8" + "source": "https://github.com/tecnickcom/tc-lib-color/tree/2.3.2" }, "funding": [ { - "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_donations¤cy_code=GBP&business=paypal@tecnick.com&item_name=donation%20for%20tc-lib-color%20project", + "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", "type": "custom" } ], - "time": "2025-02-07T10:30:03+00:00" + "time": "2025-12-11T12:13:39+00:00" }, { - "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "name": "thecodingmachine/safe", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2025-05-14T06:15:44+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -14085,22 +16711,22 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "twig/cssinliner-extra", - "version": "v3.20.0", + "version": "v3.22.0", "source": { "type": "git", "url": "https://github.com/twigphp/cssinliner-extra.git", - "reference": "378d29b61d6406c456e3a4afbd15bbeea0b72ea8" + "reference": "9bcbf04ca515e98fcde479fdceaa1d9d9e76173e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/cssinliner-extra/zipball/378d29b61d6406c456e3a4afbd15bbeea0b72ea8", - "reference": "378d29b61d6406c456e3a4afbd15bbeea0b72ea8", + "url": "https://api.github.com/repos/twigphp/cssinliner-extra/zipball/9bcbf04ca515e98fcde479fdceaa1d9d9e76173e", + "reference": "9bcbf04ca515e98fcde479fdceaa1d9d9e76173e", "shasum": "" }, "require": { @@ -14144,7 +16770,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.20.0" + "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.22.0" }, "funding": [ { @@ -14156,30 +16782,30 @@ "type": "tidelift" } ], - "time": "2025-01-31T20:45:36+00:00" + "time": "2025-07-29T08:07:07+00:00" }, { "name": "twig/extra-bundle", - "version": "v3.20.0", + "version": "v3.22.2", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "9df5e1dbb6a68c0665ae5603f6f2c20815647876" + "reference": "09de9be7f6c0d19ede7b5a1dbfcfb2e9d1e0ea9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/9df5e1dbb6a68c0665ae5603f6f2c20815647876", - "reference": "9df5e1dbb6a68c0665ae5603f6f2c20815647876", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/09de9be7f6c0d19ede7b5a1dbfcfb2e9d1e0ea9e", + "reference": "09de9be7f6c0d19ede7b5a1dbfcfb2e9d1e0ea9e", "shasum": "" }, "require": { "php": ">=8.1.0", - "symfony/framework-bundle": "^5.4|^6.4|^7.0", - "symfony/twig-bundle": "^5.4|^6.4|^7.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0", "twig/twig": "^3.2|^4.0" }, "require-dev": { - "league/commonmark": "^1.0|^2.0", + "league/commonmark": "^2.7", "symfony/phpunit-bridge": "^6.4|^7.0", "twig/cache-extra": "^3.0", "twig/cssinliner-extra": "^3.0", @@ -14218,7 +16844,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.20.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.22.2" }, "funding": [ { @@ -14230,26 +16856,26 @@ "type": "tidelift" } ], - "time": "2025-02-08T09:47:15+00:00" + "time": "2025-12-05T08:51:53+00:00" }, { "name": "twig/html-extra", - "version": "v3.20.0", + "version": "v3.22.1", "source": { "type": "git", "url": "https://github.com/twigphp/html-extra.git", - "reference": "f7d54d4de1b64182af745cfb66777f699b599734" + "reference": "d56d33315bce2b19ed815f8feedce85448736568" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/html-extra/zipball/f7d54d4de1b64182af745cfb66777f699b599734", - "reference": "f7d54d4de1b64182af745cfb66777f699b599734", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/d56d33315bce2b19ed815f8feedce85448736568", + "reference": "d56d33315bce2b19ed815f8feedce85448736568", "shasum": "" }, "require": { "php": ">=8.1.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/mime": "^5.4|^6.4|^7.0", + "symfony/mime": "^5.4|^6.4|^7.0|^8.0", "twig/twig": "^3.13|^4.0" }, "require-dev": { @@ -14286,7 +16912,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/html-extra/tree/v3.20.0" + "source": "https://github.com/twigphp/html-extra/tree/v3.22.1" }, "funding": [ { @@ -14298,20 +16924,20 @@ "type": "tidelift" } ], - "time": "2025-01-31T20:45:36+00:00" + "time": "2025-11-02T11:00:49+00:00" }, { "name": "twig/inky-extra", - "version": "v3.20.0", + "version": "v3.22.0", "source": { "type": "git", "url": "https://github.com/twigphp/inky-extra.git", - "reference": "aacd79d94534b4a7fd6533cb5c33c4ee97239a0d" + "reference": "631f42c7123240d9c2497903679ec54bb25f2f52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/inky-extra/zipball/aacd79d94534b4a7fd6533cb5c33c4ee97239a0d", - "reference": "aacd79d94534b4a7fd6533cb5c33c4ee97239a0d", + "url": "https://api.github.com/repos/twigphp/inky-extra/zipball/631f42c7123240d9c2497903679ec54bb25f2f52", + "reference": "631f42c7123240d9c2497903679ec54bb25f2f52", "shasum": "" }, "require": { @@ -14356,7 +16982,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/inky-extra/tree/v3.20.0" + "source": "https://github.com/twigphp/inky-extra/tree/v3.22.0" }, "funding": [ { @@ -14368,25 +16994,25 @@ "type": "tidelift" } ], - "time": "2025-01-31T20:45:36+00:00" + "time": "2025-07-29T08:07:07+00:00" }, { "name": "twig/intl-extra", - "version": "v3.20.0", + "version": "v3.22.1", "source": { "type": "git", "url": "https://github.com/twigphp/intl-extra.git", - "reference": "05bc5d46b9df9e62399eae53e7c0b0633298b146" + "reference": "93ac31e53cdd3f2e541f42690cd0c54ca8138ab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/05bc5d46b9df9e62399eae53e7c0b0633298b146", - "reference": "05bc5d46b9df9e62399eae53e7c0b0633298b146", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/93ac31e53cdd3f2e541f42690cd0c54ca8138ab1", + "reference": "93ac31e53cdd3f2e541f42690cd0c54ca8138ab1", "shasum": "" }, "require": { "php": ">=8.1.0", - "symfony/intl": "^5.4|^6.4|^7.0", + "symfony/intl": "^5.4|^6.4|^7.0|^8.0", "twig/twig": "^3.13|^4.0" }, "require-dev": { @@ -14420,7 +17046,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/intl-extra/tree/v3.20.0" + "source": "https://github.com/twigphp/intl-extra/tree/v3.22.1" }, "funding": [ { @@ -14432,20 +17058,20 @@ "type": "tidelift" } ], - "time": "2025-01-31T20:45:36+00:00" + "time": "2025-11-02T11:00:49+00:00" }, { "name": "twig/markdown-extra", - "version": "v3.20.0", + "version": "v3.22.0", "source": { "type": "git", "url": "https://github.com/twigphp/markdown-extra.git", - "reference": "f4616e1dd375209dacf6026f846e6b537d036ce4" + "reference": "fb6f952082e3a7d62a75c8be2c8c47242d3925fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/f4616e1dd375209dacf6026f846e6b537d036ce4", - "reference": "f4616e1dd375209dacf6026f846e6b537d036ce4", + "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/fb6f952082e3a7d62a75c8be2c8c47242d3925fb", + "reference": "fb6f952082e3a7d62a75c8be2c8c47242d3925fb", "shasum": "" }, "require": { @@ -14455,7 +17081,7 @@ }, "require-dev": { "erusev/parsedown": "dev-master as 1.x-dev", - "league/commonmark": "^1.0|^2.0", + "league/commonmark": "^2.7", "league/html-to-markdown": "^4.8|^5.0", "michelf/php-markdown": "^1.8|^2.0", "symfony/phpunit-bridge": "^6.4|^7.0" @@ -14492,7 +17118,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/markdown-extra/tree/v3.20.0" + "source": "https://github.com/twigphp/markdown-extra/tree/v3.22.0" }, "funding": [ { @@ -14504,25 +17130,25 @@ "type": "tidelift" } ], - "time": "2025-01-31T20:45:36+00:00" + "time": "2025-09-15T05:57:37+00:00" }, { "name": "twig/string-extra", - "version": "v3.20.0", + "version": "v3.22.1", "source": { "type": "git", "url": "https://github.com/twigphp/string-extra.git", - "reference": "4b3337544ac8f76c280def94e32b53acfaec0589" + "reference": "d5f16e0bec548bc96cce255b5f43d90492b8ce13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/string-extra/zipball/4b3337544ac8f76c280def94e32b53acfaec0589", - "reference": "4b3337544ac8f76c280def94e32b53acfaec0589", + "url": "https://api.github.com/repos/twigphp/string-extra/zipball/d5f16e0bec548bc96cce255b5f43d90492b8ce13", + "reference": "d5f16e0bec548bc96cce255b5f43d90492b8ce13", "shasum": "" }, "require": { "php": ">=8.1.0", - "symfony/string": "^5.4|^6.4|^7.0", + "symfony/string": "^5.4|^6.4|^7.0|^8.0", "symfony/translation-contracts": "^1.1|^2|^3", "twig/twig": "^3.13|^4.0" }, @@ -14559,7 +17185,7 @@ "unicode" ], "support": { - "source": "https://github.com/twigphp/string-extra/tree/v3.20.0" + "source": "https://github.com/twigphp/string-extra/tree/v3.22.1" }, "funding": [ { @@ -14571,20 +17197,20 @@ "type": "tidelift" } ], - "time": "2025-01-31T20:45:36+00:00" + "time": "2025-11-02T11:00:49+00:00" }, { "name": "twig/twig", - "version": "v3.20.0", + "version": "v3.22.2", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "3468920399451a384bef53cf7996965f7cd40183" + "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183", - "reference": "3468920399451a384bef53cf7996965f7cd40183", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", + "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", "shasum": "" }, "require": { @@ -14638,7 +17264,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.20.0" + "source": "https://github.com/twigphp/Twig/tree/v3.22.2" }, "funding": [ { @@ -14650,20 +17276,20 @@ "type": "tidelift" } ], - "time": "2025-02-13T08:34:43+00:00" + "time": "2025-12-14T11:28:47+00:00" }, { "name": "ua-parser/uap-php", - "version": "v3.9.14", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/ua-parser/uap-php.git", - "reference": "b796c5ea5df588e65aeb4e2c6cce3811dec4fed6" + "reference": "f44bdd1b38198801cf60b0681d2d842980e47af5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ua-parser/uap-php/zipball/b796c5ea5df588e65aeb4e2c6cce3811dec4fed6", - "reference": "b796c5ea5df588e65aeb4e2c6cce3811dec4fed6", + "url": "https://api.github.com/repos/ua-parser/uap-php/zipball/f44bdd1b38198801cf60b0681d2d842980e47af5", + "reference": "f44bdd1b38198801cf60b0681d2d842980e47af5", "shasum": "" }, "require": { @@ -14711,49 +17337,38 @@ "description": "A multi-language port of Browserscope's user agent parser.", "support": { "issues": "https://github.com/ua-parser/uap-php/issues", - "source": "https://github.com/ua-parser/uap-php/tree/v3.9.14" + "source": "https://github.com/ua-parser/uap-php/tree/v3.10.0" }, - "time": "2020-10-02T23:36:20+00:00" + "time": "2025-07-17T15:43:24+00:00" }, { "name": "web-auth/cose-lib", - "version": "4.4.0", + "version": "4.5.0", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "2166016e48e0214f4f63320a7758a9386d14c92a" + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/2166016e48e0214f4f63320a7758a9386d14c92a", - "reference": "2166016e48e0214f4f63320a7758a9386d14c92a", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", "spomky-labs/pki-framework": "^1.0" }, "require-dev": { - "ekino/phpstan-banned-code": "^1.0", - "infection/infection": "^0.29", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.7", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.2", - "phpunit/phpunit": "^10.1|^11.0", - "qossmic/deptrac": "^2.0", - "rector/rector": "^1.0", - "symfony/phpunit-bridge": "^6.4|^7.0", - "symplify/easy-coding-standard": "^12.0" + "spomky-labs/cbor-php": "^3.2.2" }, "suggest": { "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", - "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension" + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" }, "type": "library", "autoload": { @@ -14783,7 +17398,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.4.0" + "source": "https://github.com/web-auth/cose-lib/tree/4.5.0" }, "funding": [ { @@ -14795,48 +17410,44 @@ "type": "patreon" } ], - "time": "2024-07-18T08:47:32+00:00" + "time": "2026-01-03T14:43:18+00:00" }, { "name": "web-auth/webauthn-lib", - "version": "4.9.2", + "version": "5.2.3", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "008b25171c27cf4813420d0de31cc059bcc71f1a" + "reference": "8782f575032fedc36e2eb27c39c736054e2b6867" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/008b25171c27cf4813420d0de31cc059bcc71f1a", - "reference": "008b25171c27cf4813420d0de31cc059bcc71f1a", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/8782f575032fedc36e2eb27c39c736054e2b6867", + "reference": "8782f575032fedc36e2eb27c39c736054e2b6867", "shasum": "" }, "require": { "ext-json": "*", - "ext-mbstring": "*", "ext-openssl": "*", - "lcobucci/clock": "^2.2|^3.0", "paragonie/constant_time_encoding": "^2.6|^3.0", - "php": ">=8.1", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0", "symfony/deprecation-contracts": "^3.2", - "symfony/uid": "^6.1|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { - "phpdocumentor/reflection-docblock": "As of 4.5.x, the phpdocumentor/reflection-docblock component will become mandatory for converting objects such as the Metadata Statement", - "psr/clock-implementation": "As of 4.5.x, the PSR Clock implementation will replace lcobucci/clock", "psr/log-implementation": "Recommended to receive logs from the library", "symfony/event-dispatcher": "Recommended to use dispatched events", - "symfony/property-access": "As of 4.5.x, the symfony/serializer component will become mandatory for converting objects such as the Metadata Statement", - "symfony/property-info": "As of 4.5.x, the symfony/serializer component will become mandatory for converting objects such as the Metadata Statement", - "symfony/serializer": "As of 4.5.x, the symfony/serializer component will become mandatory for converting objects such as the Metadata Statement", "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" }, "type": "library", @@ -14873,7 +17484,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/4.9.2" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.3" }, "funding": [ { @@ -14885,41 +17496,38 @@ "type": "patreon" } ], - "time": "2025-01-04T09:47:58+00:00" + "time": "2025-12-20T10:54:02+00:00" }, { "name": "web-auth/webauthn-symfony-bundle", - "version": "4.9.2", + "version": "5.2.3", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-symfony-bundle.git", - "reference": "80aa16fa6f16ab8f017a4108ffcd2ecc12264c07" + "reference": "91f0aff70703e20d84251c83e238da1f8fc53b24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/80aa16fa6f16ab8f017a4108ffcd2ecc12264c07", - "reference": "80aa16fa6f16ab8f017a4108ffcd2ecc12264c07", + "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/91f0aff70703e20d84251c83e238da1f8fc53b24", + "reference": "91f0aff70703e20d84251c83e238da1f8fc53b24", "shasum": "" }, "require": { - "nyholm/psr7": "^1.5", - "php": ">=8.1", - "phpdocumentor/reflection-docblock": "^5.3", + "php": ">=8.2", "psr/event-dispatcher": "^1.0", - "symfony/config": "^6.1|^7.0", - "symfony/dependency-injection": "^6.1|^7.0", - "symfony/framework-bundle": "^6.1|^7.0", - "symfony/http-client": "^6.1|^7.0", - "symfony/property-access": "^6.1|^7.0", - "symfony/property-info": "^6.1|^7.0", - "symfony/psr-http-message-bridge": "^2.1|^6.1|^7.0", - "symfony/security-bundle": "^6.1|^7.0", - "symfony/security-core": "^6.1|^7.0", - "symfony/security-http": "^6.1|^7.0", - "symfony/serializer": "^6.1|^7.0", - "symfony/validator": "^6.1|^7.0", - "web-auth/webauthn-lib": "self.version", - "web-token/jwt-library": "^3.3|^4.0" + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/security-bundle": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "web-auth/webauthn-lib": "self.version" + }, + "suggest": { + "symfony/security-bundle": "Symfony firewall using a JSON API (perfect for script applications)" }, "type": "symfony-bundle", "extra": { @@ -14958,7 +17566,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/4.9.2" + "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.2.3" }, "funding": [ { @@ -14970,135 +17578,37 @@ "type": "patreon" } ], - "time": "2025-01-04T09:38:56+00:00" - }, - { - "name": "web-token/jwt-library", - "version": "3.4.7", - "source": { - "type": "git", - "url": "https://github.com/web-token/jwt-library.git", - "reference": "1a25c8ced3e2b3c31d32dcfad215cbd8cb812f28" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/1a25c8ced3e2b3c31d32dcfad215cbd8cb812f28", - "reference": "1a25c8ced3e2b3c31d32dcfad215cbd8cb812f28", - "shasum": "" - }, - "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12", - "ext-json": "*", - "ext-mbstring": "*", - "paragonie/constant_time_encoding": "^2.6|^3.0", - "paragonie/sodium_compat": "^1.20|^2.0", - "php": ">=8.1", - "psr/cache": "^3.0", - "psr/clock": "^1.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "spomky-labs/pki-framework": "^1.2.1", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/polyfill-mbstring": "^1.12" - }, - "conflict": { - "spomky-labs/jose": "*" - }, - "suggest": { - "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", - "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", - "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", - "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", - "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", - "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (A128KW, A192KW, A256KW, A128GCMKW, A192GCMKW, A256GCMKW, PBES2-HS256+A128KW, PBES2-HS384+A192KW, PBES2-HS512+A256KW...)", - "symfony/http-client": "To enable JKU/X5U support." - }, - "type": "library", - "autoload": { - "psr-4": { - "Jose\\Component\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Florent Morselli", - "homepage": "https://github.com/Spomky" - }, - { - "name": "All contributors", - "homepage": "https://github.com/web-token/jwt-framework/contributors" - } - ], - "description": "JWT library", - "homepage": "https://github.com/web-token", - "keywords": [ - "JOSE", - "JWE", - "JWK", - "JWKSet", - "JWS", - "Jot", - "RFC7515", - "RFC7516", - "RFC7517", - "RFC7518", - "RFC7519", - "RFC7520", - "bundle", - "jwa", - "jwt", - "symfony" - ], - "support": { - "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/3.4.7" - }, - "funding": [ - { - "url": "https://github.com/Spomky", - "type": "github" - }, - { - "url": "https://www.patreon.com/FlorentMorselli", - "type": "patreon" - } - ], - "time": "2024-07-02T16:35:11+00:00" + "time": "2025-12-20T10:20:41+00:00" }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "bdbabc199a7ba9965484e4725d66170e5711323b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b", + "reference": "bdbabc199a7ba9965484e4725d66170e5711323b", "shasum": "" }, "require": { "ext-ctype": "*", - "php": "^7.2 || ^8.0" + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -15114,6 +17624,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -15124,9 +17638,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/2.1.1" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2026-01-08T11:28:40+00:00" }, { "name": "willdurand/negotiation", @@ -15188,34 +17702,36 @@ "packages-dev": [ { "name": "dama/doctrine-test-bundle", - "version": "v8.2.2", + "version": "v8.4.1", "source": { "type": "git", "url": "https://github.com/dmaicher/doctrine-test-bundle.git", - "reference": "eefe54fdf00d910f808efea9cfce9cc261064a0a" + "reference": "d9f4fb01a43da2e279ca190fa25ab9e26f15a0c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/eefe54fdf00d910f808efea9cfce9cc261064a0a", - "reference": "eefe54fdf00d910f808efea9cfce9cc261064a0a", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/d9f4fb01a43da2e279ca190fa25ab9e26f15a0c8", + "reference": "d9f4fb01a43da2e279ca190fa25ab9e26f15a0c8", "shasum": "" }, "require": { "doctrine/dbal": "^3.3 || ^4.0", - "doctrine/doctrine-bundle": "^2.11.0", - "php": "^7.4 || ^8.0", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^5.4 || ^6.3 || ^7.0", - "symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0" + "doctrine/doctrine-bundle": "^2.11.0 || ^3.0", + "php": ">= 8.1", + "psr/cache": "^2.0 || ^3.0", + "symfony/cache": "^6.4 || ^7.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<10.0" }, "require-dev": { "behat/behat": "^3.0", "friendsofphp/php-cs-fixer": "^3.27", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0 || ^11.0", - "symfony/phpunit-bridge": "^7.2", - "symfony/process": "^5.4 || ^6.3 || ^7.0", - "symfony/yaml": "^5.4 || ^6.3 || ^7.0" + "phpunit/phpunit": "^10.5.57 || ^11.5.41|| ^12.3.14", + "symfony/dotenv": "^6.4 || ^7.3 || ^8.0", + "symfony/process": "^6.4 || ^7.3 || ^8.0" }, "type": "symfony-bundle", "extra": { @@ -15225,7 +17741,7 @@ }, "autoload": { "psr-4": { - "DAMA\\DoctrineTestBundle\\": "src/DAMA/DoctrineTestBundle" + "DAMA\\DoctrineTestBundle\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -15249,43 +17765,43 @@ ], "support": { "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", - "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.2.2" + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.4.1" }, - "time": "2025-02-04T14:37:36+00:00" + "time": "2025-12-07T21:48:15+00:00" }, { "name": "doctrine/doctrine-fixtures-bundle", - "version": "4.1.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", - "reference": "a06db6b81ff20a2980bf92063d80c013bb8b4b7c" + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/a06db6b81ff20a2980bf92063d80c013bb8b4b7c", - "reference": "a06db6b81ff20a2980bf92063d80c013bb8b4b7c", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", "shasum": "" }, "require": { - "doctrine/data-fixtures": "^2.0", - "doctrine/doctrine-bundle": "^2.2", + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", "doctrine/orm": "^2.14.0 || ^3.0", "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", "php": "^8.1", "psr/log": "^2 || ^3", - "symfony/config": "^6.4 || ^7.0", - "symfony/console": "^6.4 || ^7.0", - "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9", - "symfony/http-kernel": "^6.4 || ^7.0" + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" }, "conflict": { "doctrine/dbal": "< 3" }, "require-dev": { - "doctrine/coding-standard": "13.0.0", + "doctrine/coding-standard": "14.0.0", "phpstan/phpstan": "2.1.11", "phpunit/phpunit": "^10.5.38 || 11.4.14" }, @@ -15321,7 +17837,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", - "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.1.0" + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" }, "funding": [ { @@ -15337,7 +17853,7 @@ "type": "tidelift" } ], - "time": "2025-03-26T10:56:26+00:00" + "time": "2025-12-03T16:05:42+00:00" }, { "name": "ekino/phpstan-banned-code", @@ -15407,27 +17923,27 @@ }, { "name": "jbtronics/translation-editor-bundle", - "version": "v1.1.1", + "version": "v1.1.3", "source": { "type": "git", "url": "https://github.com/jbtronics/translation-editor-bundle.git", - "reference": "fa003c38f3f61060a368734b91aeb40d788b0825" + "reference": "36bfb256e11d231d185bc2491323b041ba731257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/translation-editor-bundle/zipball/fa003c38f3f61060a368734b91aeb40d788b0825", - "reference": "fa003c38f3f61060a368734b91aeb40d788b0825", + "url": "https://api.github.com/repos/jbtronics/translation-editor-bundle/zipball/36bfb256e11d231d185bc2491323b041ba731257", + "reference": "36bfb256e11d231d185bc2491323b041ba731257", "shasum": "" }, "require": { "ext-json": "*", "php": "^8.1", "symfony/deprecation-contracts": "^3.4", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/translation": "^7.0|^6.4", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.0|^6.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/twig-bundle": "^7.0|^6.4", - "symfony/web-profiler-bundle": "^7.0|^6.4" + "symfony/twig-bundle": "^7.0|^6.4|^8.0", + "symfony/web-profiler-bundle": "^7.0|^6.4|^8.0" }, "require-dev": { "ekino/phpstan-banned-code": "^1.0", @@ -15461,7 +17977,7 @@ ], "support": { "issues": "https://github.com/jbtronics/translation-editor-bundle/issues", - "source": "https://github.com/jbtronics/translation-editor-bundle/tree/v1.1.1" + "source": "https://github.com/jbtronics/translation-editor-bundle/tree/v1.1.3" }, "funding": [ { @@ -15473,20 +17989,20 @@ "type": "github" } ], - "time": "2025-03-29T15:14:31+00:00" + "time": "2025-11-30T22:23:47+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -15525,7 +18041,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -15533,20 +18049,20 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -15565,7 +18081,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -15589,9 +18105,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -15761,16 +18277,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.11", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30" - }, + "version": "2.1.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8ca5f79a8f63c49b2359065832a654e1ec70ac30", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", + "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", "shasum": "" }, "require": { @@ -15815,25 +18326,25 @@ "type": "github" } ], - "time": "2025-03-24T13:45:00+00:00" + "time": "2025-12-05T10:24:31+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.2", + "version": "2.0.12", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "a61a04a361b60014ec04881ccb87252d3bf02e94" + "reference": "d20ee0373d22735271f1eb4d631856b5f847d399" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/a61a04a361b60014ec04881ccb87252d3bf02e94", - "reference": "a61a04a361b60014ec04881ccb87252d3bf02e94", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/d20ee0373d22735271f1eb4d631856b5f847d399", + "reference": "d20ee0373d22735271f1eb4d631856b5f847d399", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0.3" + "phpstan/phpstan": "^2.1.13" }, "conflict": { "doctrine/collections": "<1.0", @@ -15857,11 +18368,13 @@ "gedmo/doctrine-extensions": "^3.8", "nesbot/carbon": "^2.49", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.8", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^9.6.20", "ramsey/uuid": "^4.2", - "symfony/cache": "^5.4" + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" }, "type": "phpstan-extension", "extra": { @@ -15884,27 +18397,27 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.2" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.12" }, - "time": "2025-03-03T09:29:16+00:00" + "time": "2025-12-01T11:34:02+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.4", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "3e139cbe67fafa3588e1dbe27ca50f31fdb6236a" + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/3e139cbe67fafa3588e1dbe27ca50f31fdb6236a", - "reference": "3e139cbe67fafa3588e1dbe27ca50f31fdb6236a", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538", + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0.4" + "phpstan/phpstan": "^2.1.29" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", @@ -15932,38 +18445,38 @@ "description": "Extra strict and opinionated rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.4" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7" }, - "time": "2025-03-18T11:42:40+00:00" + "time": "2025-09-26T11:19:08+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.4", + "version": "2.0.9", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "648087fb4dd865a09b1828a3b0396eb447665f2e" + "reference": "24d8c157aa483141b0579d705ef0aac9e1b95436" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/648087fb4dd865a09b1828a3b0396eb447665f2e", - "reference": "648087fb4dd865a09b1828a3b0396eb447665f2e", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/24d8c157aa483141b0579d705ef0aac9e1b95436", + "reference": "24d8c157aa483141b0579d705ef0aac9e1b95436", "shasum": "" }, "require": { "ext-simplexml": "*", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.2" + "phpstan/phpstan": "^2.1.13" }, "conflict": { "symfony/framework-bundle": "<3.0" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-phpunit": "^2.0.8", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^9.6", - "psr/container": "1.0 || 1.1.1", + "psr/container": "1.1.2", "symfony/config": "^5.4 || ^6.1", "symfony/console": "^5.4 || ^6.1", "symfony/dependency-injection": "^5.4 || ^6.1", @@ -16003,41 +18516,41 @@ "description": "Symfony Framework extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.4" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.9" }, - "time": "2025-03-28T12:02:03+00:00" + "time": "2025-11-29T11:17:28+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -16046,7 +18559,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -16075,40 +18588,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-08-22T04:23:01+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -16135,7 +18660,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" }, "funding": [ { @@ -16143,28 +18669,28 @@ "type": "github" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2024-08-27T05:02:59+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -16172,7 +18698,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -16198,7 +18724,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { @@ -16206,32 +18733,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -16257,7 +18784,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { @@ -16265,32 +18793,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -16316,7 +18844,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { @@ -16324,54 +18853,52 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "11.5.46", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.2", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "ext-soap": "To be able to generate mocks based on WSDL files" }, "bin": [ "phpunit" @@ -16379,7 +18906,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -16411,7 +18938,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" }, "funding": [ { @@ -16422,30 +18949,38 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2025-12-06T08:01:15+00:00" }, { "name": "rector/rector", - "version": "2.0.11", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "059b827cc648929711606e9824337e41e2f9ed92" + "reference": "f7166355dcf47482f27be59169b0825995f51c7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/059b827cc648929711606e9824337e41e2f9ed92", - "reference": "059b827cc648929711606e9824337e41e2f9ed92", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f7166355dcf47482f27be59169b0825995f51c7d", + "reference": "f7166355dcf47482f27be59169b0825995f51c7d", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.9" + "phpstan/phpstan": "^2.1.33" }, "conflict": { "rector/rector-doctrine": "*", @@ -16470,6 +19005,7 @@ "MIT" ], "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", "keywords": [ "automation", "dev", @@ -16478,7 +19014,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.0.11" + "source": "https://github.com/rectorphp/rector/tree/2.3.0" }, "funding": [ { @@ -16486,7 +19022,7 @@ "type": "github" } ], - "time": "2025-03-28T10:25:17+00:00" + "time": "2025-12-25T22:00:18+00:00" }, { "name": "roave/security-advisories", @@ -16494,29 +19030,34 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "ea5e66cfcb7a8a5b915924fd8eca542397d6f324" + "reference": "ccfd723dc03e9864008d011603c412910180d7a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ea5e66cfcb7a8a5b915924fd8eca542397d6f324", - "reference": "ea5e66cfcb7a8a5b915924fd8eca542397d6f324", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ccfd723dc03e9864008d011603c412910180d7a6", + "reference": "ccfd723dc03e9864008d011603c412910180d7a6", "shasum": "" }, "conflict": { "3f/pygmentize": "<1.2", - "admidio/admidio": "<4.3.12", - "adodb/adodb-php": "<=5.20.20|>=5.21,<=5.21.3", + "adaptcms/adaptcms": "<=1.3", + "admidio/admidio": "<=4.3.16", + "adodb/adodb-php": "<=5.22.9", "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", "aimeos/ai-admin-jsonadm": "<2020.10.13|>=2021.04.1,<2021.10.6|>=2022.04.1,<2022.10.3|>=2023.04.1,<2023.10.4|==2024.04.1", "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.22|>=2022.04.1,<2022.10.13|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.04.7", + "aimeos/ai-cms-grapesjs": ">=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.9|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.10.8|>=2025.04.1,<2025.10.2", "aimeos/ai-controller-frontend": "<2020.10.15|>=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.8|>=2023.04.1,<2023.10.9|==2024.04.1", "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", "airesvsg/acf-to-rest-api": "<=3.1", "akaunting/akaunting": "<2.1.13", "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", - "alextselegidis/easyappointments": "<=1.5", + "alextselegidis/easyappointments": "<1.5.2.0-beta1", + "alexusmai/laravel-file-manager": "<=3.3.1", + "alt-design/alt-redirect": "<1.6.4", + "altcha-org/altcha": "<1.3.1", "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", "amazing/media2click": ">=1,<1.3.3", "ameos/ameos_tarteaucitron": "<1.2.23", @@ -16526,9 +19067,11 @@ "anchorcms/anchor-cms": "<=0.12.7", "andreapollastri/cipi": "<=3.1.15", "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", + "aoe/restler": "<1.7.1", "apache-solr-for-typo3/solr": "<2.8.3", "apereo/phpcas": "<1.6", - "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6|>=2.6,<2.7.10|>=3,<3.0.12|>=3.1,<3.1.3|>=3.3.8,<3.3.15", + "api-platform/core": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", "appwrite/server-ce": "<=1.2.1", "arc/web": "<3", "area17/twill": "<1.2.5|>=2,<2.5.3", @@ -16538,29 +19081,36 @@ "athlon1600/php-proxy-app": "<=3", "athlon1600/youtube-downloader": "<=4", "austintoddj/canvas": "<=3.4.2", - "auth0/wordpress": "<=4.6", + "auth0/auth0-php": ">=3.3,<8.18", + "auth0/login": "<7.20", + "auth0/symfony": "<=5.5", + "auth0/wordpress": "<=5.4", "automad/automad": "<2.0.0.0-alpha5", "automattic/jetpack": "<9.8", "awesome-support/awesome-support": "<=6.0.7", - "aws/aws-sdk-php": "<3.288.1", - "azuracast/azuracast": "<0.18.3", - "backdrop/backdrop": "<1.27.3|>=1.28,<1.28.2", + "aws/aws-sdk-php": "<3.368", + "azuracast/azuracast": "<=0.23.1", + "b13/seo_basics": "<0.8.2", + "backdrop/backdrop": "<=1.32", "backpack/crud": "<3.4.9", "backpack/filemanager": "<2.0.2|>=3,<3.0.9", - "bacula-web/bacula-web": "<8.0.0.0-RC2-dev", - "badaso/core": "<2.7", - "bagisto/bagisto": "<2.1", + "bacula-web/bacula-web": "<9.7.1", + "badaso/core": "<=2.9.11", + "bagisto/bagisto": "<2.3.10", "barrelstrength/sprout-base-email": "<1.2.7", "barrelstrength/sprout-forms": "<3.9", - "barryvdh/laravel-translation-manager": "<0.6.2", + "barryvdh/laravel-translation-manager": "<0.6.8", "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.1.1", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", "bbpress/bbpress": "<2.6.5", + "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", "bedita/bedita": "<4", + "bednee/cooluri": "<1.0.30", "bigfork/silverstripe-form-capture": ">=3,<3.1.1", - "billz/raspap-webgui": "<=3.1.4", + "billz/raspap-webgui": "<3.3.6", + "binarytorch/larecipe": "<2.8.1", "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", "blueimp/jquery-file-upload": "==6.4.4", "bmarshall511/wordpress_zero_spam": "<5.2.13", @@ -16575,8 +19125,10 @@ "brotkrueml/typo3-matomo-integration": "<1.3.2", "buddypress/buddypress": "<7.2.1", "bugsnag/bugsnag-laravel": ">=2,<2.0.2", + "bvbmedia/multishop": "<2.0.39", "bytefury/crater": "<6.0.2", "cachethq/cachet": "<2.5.1", + "cadmium-org/cadmium-cms": "<=0.4.9", "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", @@ -16590,31 +19142,38 @@ "centreon/centreon": "<22.10.15", "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", + "chrome-php/chrome": "<1.14", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", "ckeditor/ckeditor": "<4.25", - "clickstorm/cs-seo": ">=6,<6.7|>=7,<7.4|>=8,<8.3|>=9,<9.2", - "cockpit-hq/cockpit": "<2.7|==2.7", + "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", + "co-stack/fal_sftp": "<0.2.6", + "cockpit-hq/cockpit": "<2.11.4", + "code16/sharp": "<9.11.1", "codeception/codeception": "<3.1.3|>=4,<4.1.22", - "codeigniter/framework": "<3.1.9", - "codeigniter4/framework": "<4.5.8", + "codeigniter/framework": "<3.1.10", + "codeigniter4/framework": "<4.6.2", "codeigniter4/shield": "<1.0.0.0-beta8", "codiad/codiad": "<=2.8.4", "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", + "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", + "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7", - "concrete5/concrete5": "<9.4.0.0-RC1-dev", + "composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3", + "concrete5/concrete5": "<9.4.3", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", - "contao/contao": "<=5.4.1", + "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.13.56|>=5,<5.3.38|>=5.4.0.0-RC1-dev,<5.6.1", "contao/core": "<3.5.39", - "contao/core-bundle": "<4.13.54|>=5,<5.3.30|>=5.4,<5.5.6", + "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", + "coreshop/core-shop": "<=4.1.7", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", - "craftcms/cms": "<4.13.8|>=5,<5.5.5", - "croogo/croogo": "<4", + "couleurcitron/tarteaucitron-wp": "<0.3", + "craftcms/cms": "<=4.16.16|>=5,<=5.8.20", + "croogo/croogo": "<=4.0.7", "cuyz/valinor": "<0.12", "czim/file-handling": "<1.5|>=2,<2.3", "czproject/git-php": "<4.0.3", @@ -16622,6 +19181,7 @@ "dapphp/securimage": "<3.6.6", "darylldoyle/safe-svg": "<1.9.10", "datadog/dd-trace": ">=0.30,<0.30.2", + "datahihi1/tiny-env": "<1.0.3|>=1.0.9,<1.0.11", "datatables/datatables": "<1.10.10", "david-garcia/phpwhois": "<=4.3.1", "dbrisinajumi/d2files": "<1", @@ -16630,8 +19190,13 @@ "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", "desperado/xml-bundle": "<=0.1.7", "dev-lancer/minecraft-motd-parser": "<=1.0.5", + "devcode-it/openstamanager": "<=2.9.4", "devgroup/dotplant": "<2020.09.14-dev", + "digimix/wp-svg-upload": "<=1", "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", + "dl/yag": "<3.0.1", + "dmk/webkitpdf": "<1.1.4", + "dnadesign/silverstripe-elemental": "<5.3.12", "doctrine/annotations": "<1.2.7", "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", "doctrine/common": "<2.4.3|>=2.5,<2.5.1", @@ -16641,12 +19206,45 @@ "doctrine/mongodb-odm": "<1.0.2", "doctrine/mongodb-odm-bundle": "<3.0.1", "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", - "dolibarr/dolibarr": "<19.0.2|==21.0.0.0-beta", + "dolibarr/dolibarr": "<21.0.3", "dompdf/dompdf": "<2.0.4", "doublethreedigital/guest-entries": "<3.1.2", - "drupal/core": ">=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal-pattern-lab/unified-twig-extensions": "<=0.1", + "drupal/access_code": "<2.0.5", + "drupal/acquia_dam": "<1.1.5", + "drupal/admin_audit_trail": "<1.0.5", + "drupal/ai": "<1.0.5", + "drupal/alogin": "<2.0.6", + "drupal/cache_utility": "<1.2.1", + "drupal/civictheme": "<1.12", + "drupal/commerce_alphabank_redirect": "<1.0.3", + "drupal/commerce_eurobank_redirect": "<2.1.1", + "drupal/config_split": "<1.10|>=2,<2.0.2", + "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8", "drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal/currency": "<3.5", "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8", + "drupal/email_tfa": "<2.0.6", + "drupal/formatter_suite": "<2.1", + "drupal/gdpr": "<3.0.1|>=3.1,<3.1.2", + "drupal/google_tag": "<1.8|>=2,<2.0.8", + "drupal/ignition": "<1.0.4", + "drupal/json_field": "<1.5", + "drupal/lightgallery": "<1.6", + "drupal/link_field_display_mode_formatter": "<1.6", + "drupal/matomo": "<1.24", + "drupal/oauth2_client": "<4.1.3", + "drupal/oauth2_server": "<2.1", + "drupal/obfuscate": "<2.0.1", + "drupal/plausible_tracking": "<1.0.2", + "drupal/quick_node_block": "<2", + "drupal/rapidoc_elements_field_formatter": "<1.0.1", + "drupal/reverse_proxy_header": "<1.1.2", + "drupal/simple_multistep": "<2", + "drupal/simple_oauth": ">=6,<6.0.7", + "drupal/spamspan": "<3.2.1", + "drupal/tfa": "<1.10", + "drupal/umami_analytics": "<1.0.1", "duncanmcclean/guest-entries": "<3.1.2", "dweeves/magmi": "<=0.7.24", "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.1.2", @@ -16656,10 +19254,11 @@ "elefant/cms": "<2.0.7", "elgg/elgg": "<3.3.24|>=4,<4.0.5", "elijaa/phpmemcacheadmin": "<=1.3", + "elmsln/haxcms": "<11.0.14", "encore/laravel-admin": "<=1.8.19", "endroid/qr-code-bundle": "<3.4.2", "enhavo/enhavo-app": "<=0.13.1", - "enshrined/svg-sanitize": "<0.15", + "enshrined/svg-sanitize": "<0.22", "erusev/parsedown": "<1.7.2", "ether/logs": "<3.0.4", "evolutioncms/evolution": "<=3.2.3", @@ -16670,13 +19269,13 @@ "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1-dev", "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1-dev|>=5.4,<5.4.11.1-dev|>=2017.12,<2017.12.0.1-dev", "ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24", - "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.26|>=3.3,<3.3.39", - "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1", + "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.39|>=3.3,<3.3.39", + "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1|>=5.3.0.0-beta1,<5.3.5", "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", "ezsystems/ezplatform-http-cache": "<2.3.16", "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", - "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev|>=3.3,<3.3.40", + "ezsystems/ezplatform-richtext": ">=2.3,<2.3.26|>=3.3,<3.3.40", "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", "ezsystems/ezplatform-user": ">=1,<1.0.1", "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", @@ -16685,12 +19284,13 @@ "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<=2022.08", + "facturascripts/facturascripts": "<=2025.4|==2025.11|==2025.41|==2025.43", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", "filament/actions": ">=3.2,<3.2.123", + "filament/filament": ">=4,<4.3.1", "filament/infolists": ">=3,<3.2.115", "filament/tables": ">=3,<3.2.115", "filegator/filegator": "<7.8", @@ -16699,7 +19299,7 @@ "firebase/php-jwt": "<6", "fisharebest/webtrees": "<=2.1.18", "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", - "fixpunkt/fp-newsletter": "<1.1.1|>=2,<2.1.2|>=2.2,<3.2.6", + "fixpunkt/fp-newsletter": "<1.1.1|>=1.2,<2.1.2|>=2.2,<3.2.6", "flarum/core": "<1.8.10", "flarum/flarum": "<0.1.0.0-beta8", "flarum/framework": "<1.8.10", @@ -16709,6 +19309,7 @@ "floriangaerber/magnesium": "<0.3.1", "fluidtypo3/vhs": "<5.1.1", "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", + "fof/pretty-mail": "<=1.1.2", "fof/upload": "<1.2.3", "foodcoopshop/foodcoopshop": ">=3.2,<3.6.1", "fooman/tcpdf": "<6.2.22", @@ -16730,19 +19331,22 @@ "funadmin/funadmin": "<=5.0.2", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", - "getformwork/formwork": "<1.13.1|>=2.0.0.0-beta1,<2.0.0.0-beta4", - "getgrav/grav": "<1.7.46", - "getkirby/cms": "<=3.6.6.5|>=3.7,<=3.7.5.4|>=3.8,<=3.8.4.3|>=3.9,<=3.9.8.1|>=3.10,<=3.10.1|>=4,<=4.3", - "getkirby/kirby": "<=2.5.12", + "georgringer/news": "<1.3.3", + "geshi/geshi": "<=1.0.9.1", + "getformwork/formwork": "<2.2", + "getgrav/grav": "<1.11.0.0-beta1", + "getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<=5.2.1", + "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", "gilacms/gila": "<=1.15.4", "gleez/cms": "<=1.3|==2", "globalpayments/php-sdk": "<2", - "goalgorilla/open_social": "<12.3.8|>=12.4,<12.4.5|>=13.0.0.0-alpha1,<13.0.0.0-alpha11", + "goalgorilla/open_social": "<12.3.11|>=12.4,<12.4.10|>=13.0.0.0-alpha1,<13.0.0.0-alpha11", "gogentooss/samlbase": "<1.2.7", - "google/protobuf": "<3.15", + "google/protobuf": "<3.4", "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", + "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", "grumpydictator/firefly-iii": "<6.1.17", @@ -16751,6 +19355,7 @@ "guzzlehttp/oauth-subscriber": "<0.8.1", "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5", "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", + "handcraftedinthealps/goodby-csv": "<1.4.3", "harvesthq/chosen": "<1.8.7", "helloxz/imgurl": "<=2.31", "hhxsv5/laravel-s": "<3.7.36", @@ -16760,14 +19365,15 @@ "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hyn/multi-tenant": ">=5.6,<5.7.2", - "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6,<4.6.14", + "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6,<4.6.25|>=5,<5.0.3", + "ibexa/admin-ui-assets": ">=4.6.0.0-alpha1,<4.6.21", "ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2", - "ibexa/fieldtype-richtext": ">=4.6,<4.6.10", + "ibexa/fieldtype-richtext": ">=4.6,<4.6.25|>=5,<5.0.3", "ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3", "ibexa/http-cache": ">=4.6,<4.6.14", "ibexa/post-install": "<1.0.16|>=4.6,<4.6.14", "ibexa/solr": ">=4.5,<4.5.4", - "ibexa/user": ">=4,<4.4.3", + "ibexa/user": ">=4,<4.4.3|>=5,<5.0.4", "icecoder/icecoder": "<=8.1", "idno/known": "<=1.3.1", "ilicmiljan/secure-props": ">=1.2,<1.2.2", @@ -16778,11 +19384,11 @@ "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", "imdbphp/imdbphp": "<=5.1.1", "impresscms/impresscms": "<=1.4.5", - "impresspages/impresspages": "<=1.0.12", - "in2code/femanager": "<5.5.3|>=6,<6.3.4|>=7,<7.2.3", + "impresspages/impresspages": "<1.0.13", + "in2code/femanager": "<6.4.2|>=7,<7.5.3|>=8,<8.3.1", "in2code/ipandlanguageredirect": "<5.1.2", "in2code/lux": "<17.6.1|>=18,<24.0.2", - "in2code/powermail": "<7.5.1|>=8,<8.5.1|>=9,<10.9.1|>=11,<12.4.1", + "in2code/powermail": "<7.5.1|>=8,<8.5.1|>=9,<10.9.1|>=11,<12.5.3|==13", "innologi/typo3-appointments": "<2.0.6", "intelliants/subrion": "<4.2.2", "inter-mediator/inter-mediator": "==5.5", @@ -16791,25 +19397,30 @@ "islandora/islandora": ">=2,<2.4.1", "ivankristianto/phpwhois": "<=4.3", "jackalope/jackalope-doctrine-dbal": "<1.7.4", + "jambagecom/div2007": "<0.10.2", "james-heinrich/getid3": "<1.9.21", - "james-heinrich/phpthumb": "<1.7.12", + "james-heinrich/phpthumb": "<=1.7.23", "jasig/phpcas": "<1.3.3", + "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", "joelbutcher/socialstream": "<5.6|>=6,<6.2", - "johnbillion/wp-crontrol": "<1.16.2", + "johnbillion/wp-crontrol": "<1.16.2|>=1.17,<1.19.2", "joomla/application": "<1.0.13", "joomla/archive": "<1.1.12|>=2,<2.0.1", + "joomla/database": ">=1,<2.2|>=3,<3.4", "joomla/filesystem": "<1.6.2|>=2,<2.0.1", - "joomla/filter": "<1.4.4|>=2,<2.0.1", + "joomla/filter": "<2.0.6|>=3,<3.0.5|==4", "joomla/framework": "<1.5.7|>=2.5.4,<=3.8.12", "joomla/input": ">=2,<2.0.2", - "joomla/joomla-cms": ">=2.5,<3.9.12", + "joomla/joomla-cms": "<3.9.12|>=4,<4.4.13|>=5,<5.2.6", + "joomla/joomla-platform": "<1.5.4", "joomla/session": "<1.3.1", "joyqi/hyper-down": "<=2.4.27", "jsdecena/laracom": "<2.0.9", "jsmitty12/phpwhois": "<5.1", - "juzaweb/cms": "<=3.4", + "juzaweb/cms": "<=3.4.2", "jweiland/events2": "<8.3.8|>=9,<9.0.6", + "jweiland/kk-downloader": "<1.2.2", "kazist/phpwhois": "<=4.2.6", "kelvinmo/simplexrd": "<3.1.1", "kevinpapst/kimai2": "<1.16.7", @@ -16819,6 +19430,7 @@ "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<=1.4.2", "kohana/core": "<3.3.3", + "koillection/koillection": "<1.6.12", "krayin/laravel-crm": "<=1.3", "kreait/firebase-php": ">=3.2,<3.8.1", "kumbiaphp/kumbiapp": "<=1.1.1", @@ -16836,78 +19448,92 @@ "laravel/socialite": ">=1,<2.0.10", "latte/latte": "<2.10.8", "lavalite/cms": "<=9|==10.1", + "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", - "league/commonmark": "<2.6", + "league/commonmark": "<2.7", "league/flysystem": "<1.1.4|>=2,<2.1.1", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "leantime/leantime": "<3.3", "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", "libreform/libreform": ">=2,<=2.0.8", - "librenms/librenms": "<2017.08.18", + "librenms/librenms": "<25.12", "liftkit/database": "<2.13.2", "lightsaml/lightsaml": "<1.3.5", "limesurvey/limesurvey": "<6.5.12", "livehelperchat/livehelperchat": "<=3.91", - "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.5.2", + "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", "livewire/volt": "<1.7", "lms/routes": "<2.1.1", "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", + "lomkit/laravel-rest-api": "<2.13", + "luracast/restler": "<3.1", "luyadev/yii-helpers": "<1.2.1", "macropay-solutions/laravel-crud-wizard-free": "<3.4.17", "maestroerror/php-heic-to-jpg": "<1.0.5", - "magento/community-edition": "<2.4.5|==2.4.5|>=2.4.5.0-patch1,<2.4.5.0-patch11|==2.4.6|>=2.4.6.0-patch1,<2.4.6.0-patch9|>=2.4.7.0-beta1,<2.4.7.0-patch4|>=2.4.8.0-beta1,<2.4.8.0-beta2", + "magento/community-edition": "<2.4.6.0-patch13|>=2.4.7.0-beta1,<2.4.7.0-patch8|>=2.4.8.0-beta1,<2.4.8.0-patch3|>=2.4.9.0-alpha1,<2.4.9.0-alpha3|==2.4.9", "magento/core": "<=1.9.4.5", "magento/magento1ce": "<1.9.4.3-dev", "magento/magento1ee": ">=1,<1.14.4.3-dev", "magento/product-community-edition": "<2.4.4.0-patch9|>=2.4.5,<2.4.5.0-patch8|>=2.4.6,<2.4.6.0-patch6|>=2.4.7,<2.4.7.0-patch1", "magento/project-community-edition": "<=2.0.2", "magneto/core": "<1.9.4.4-dev", + "mahocommerce/maho": "<25.9", "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", - "mantisbt/mantisbt": "<=2.26.3", + "manogi/nova-tiptap": "<=3.2.6", + "mantisbt/mantisbt": "<2.27.2", "marcwillmann/turn": "<0.3.3", + "marshmallow/nova-tiptap": "<5.7", + "matomo/matomo": "<1.11", "matyhtf/framework": "<3.0.6", - "mautic/core": "<5.2.3", + "mautic/core": "<5.2.9|>=6,<6.0.7", "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", + "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mdanter/ecc": "<2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", - "mediawiki/cargo": "<3.6.1", + "mediawiki/cargo": "<3.8.3", "mediawiki/core": "<1.39.5|==1.40", "mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2", "mediawiki/matomo": "<2.4.3", "mediawiki/semantic-media-wiki": "<4.0.2", + "mehrwert/phpmyadmin": "<3.2", "melisplatform/melis-asset-manager": "<5.0.1", - "melisplatform/melis-cms": "<5.0.1", + "melisplatform/melis-cms": "<5.3.4", + "melisplatform/melis-cms-slider": "<5.3.1", + "melisplatform/melis-core": "<5.3.11", "melisplatform/melis-front": "<5.0.1", "mezzio/mezzio-swoole": "<3.7|>=4,<4.3", "mgallegos/laravel-jqgrid": "<=1.3", "microsoft/microsoft-graph": ">=1.16,<1.109.1|>=2,<2.0.1", "microsoft/microsoft-graph-beta": "<2.0.1", "microsoft/microsoft-graph-core": "<2.0.2", - "microweber/microweber": "<=2.0.16", + "microweber/microweber": "<=2.0.19", "mikehaertl/php-shellcommand": "<1.6.1", + "mineadmin/mineadmin": "<=3.0.9", "miniorange/miniorange-saml": "<1.4.3", "mittwald/typo3_forum": "<1.2.1", "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", "mongodb/mongodb": ">=1,<1.9.2", + "mongodb/mongodb-extension": "<1.21.2", "monolog/monolog": ">=1.8,<1.12", - "moodle/moodle": "<4.3.10|>=4.4,<4.4.6|>=4.5.0.0-beta,<4.5.2", + "moodle/moodle": "<4.4.11|>=4.5.0.0-beta,<4.5.7|>=5.0.0.0-beta,<5.0.3", + "moonshine/moonshine": "<=3.12.5", "mos/cimage": "<0.7.19", "movim/moxl": ">=0.8,<=0.10", "movingbytes/social-network": "<=1.2.1", "mpdf/mpdf": "<=7.1.7", - "munkireport/comment": "<4.1", + "munkireport/comment": "<4", "munkireport/managedinstalls": "<2.6", "munkireport/munki_facts": "<1.5", - "munkireport/munkireport": ">=2.5.3,<5.6.3", "munkireport/reportdata": "<3.5", "munkireport/softwareupdate": "<1.6", "mustache/mustache": ">=2,<2.14.1", "mwdelaney/wp-enable-svg": "<=0.2", "namshi/jose": "<2.2", + "nasirkhan/laravel-starter": "<11.11", "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", @@ -16921,11 +19547,14 @@ "netgen/tagsbundle": ">=3.4,<3.4.11|>=4,<4.0.15", "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", + "neuron-core/neuron-ai": "<=2.8.11", "nilsteampassnet/teampass": "<3.1.3.1-dev", + "nitsan/ns-backup": "<13.0.1", "nonfiction/nterchange": "<4.1.1", "notrinos/notrinos-erp": "<=0.7", "noumo/easyii": "<=0.9", "novaksolutions/infusionsoft-php-sdk": "<1", + "novosga/novosga": "<=2.2.12", "nukeviet/nukeviet": "<4.5.02", "nyholm/psr7": "<1.6.1", "nystudio107/craft-seomatic": "<3.4.12", @@ -16933,16 +19562,17 @@ "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": "<1.1.2", "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", - "october/october": "<=3.6.4", + "october/october": "<3.7.5", "october/rain": "<1.0.472|>=1.1,<1.1.2", - "october/system": "<1.0.476|>=1.1,<1.1.12|>=2,<2.2.34|>=3,<3.5.15", + "october/system": "<=3.7.12|>=4,<=4.0.11", + "oliverklee/phpunit": "<3.5.15", "omeka/omeka-s": "<4.0.3", - "onelogin/php-saml": "<2.10.4", + "onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1", "oneup/uploader-bundle": ">=1,<1.9.3|>=2,<2.1.5", - "open-web-analytics/open-web-analytics": "<1.7.4", + "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<20.12.3", + "openmage/magento-lts": "<20.16", "opensolutions/vimbadmin": "<=3.0.15", "opensource-workshop/connect-cms": "<1.8.7|>=2,<2.4.7", "orchid/platform": ">=8,<14.43", @@ -16953,7 +19583,7 @@ "oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3", "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3", "oveleon/contao-cookiebar": "<1.16.3|>=2,<2.1.3", - "oxid-esales/oxideshop-ce": "<4.5", + "oxid-esales/oxideshop-ce": "<=7.0.5", "oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1", "packbackbooks/lti-1-3-php-library": "<5", "padraic/humbug_get_contents": "<1.1.2", @@ -16961,6 +19591,7 @@ "pagekit/pagekit": "<=1.0.18", "paragonie/ecc": "<2.0.1", "paragonie/random_compat": "<2", + "paragonie/sodium_compat": "<1.24|>=2,<2.5", "passbolt/passbolt_api": "<4.6.2", "paypal/adaptivepayments-sdk-php": "<=3.9.2", "paypal/invoice-sdk-php": "<=3.9", @@ -16969,6 +19600,7 @@ "pear/archive_tar": "<1.4.14", "pear/auth": "<1.2.4", "pear/crypt_gpg": "<1.6.7", + "pear/http_request2": "<2.7", "pear/pear": "<=1.10.1", "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", "personnummer/personnummer": "<3.0.2", @@ -16982,10 +19614,12 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<3.2.5|==3.2.5|>=3.2.10,<=4.0.1", + "phpmyfaq/phpmyfaq": "<=4.0.13", "phpoffice/common": "<0.2.9", + "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", - "phpoffice/phpspreadsheet": "<1.29.9|>=2,<2.1.8|>=2.2,<2.3.7|>=3,<3.9", + "phpoffice/phpspreadsheet": "<1.30|>=2,<2.1.12|>=2.2,<2.4|>=3,<3.10|>=4,<5", + "phppgadmin/phppgadmin": "<=7.13", "phpseclib/phpseclib": "<2.0.47|>=3,<3.0.36", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<3.4.3", @@ -16994,7 +19628,7 @@ "phpxmlrpc/extras": "<0.6.1", "phpxmlrpc/phpxmlrpc": "<4.9.2", "pi/pi": "<=2.5", - "pimcore/admin-ui-classic-bundle": "<1.7.4", + "pimcore/admin-ui-classic-bundle": "<1.7.6", "pimcore/customer-management-framework-bundle": "<4.2.1", "pimcore/data-hub": "<1.2.4", "pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3", @@ -17002,10 +19636,11 @@ "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", "pimcore/pimcore": "<11.5.4", + "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", "plotly/plotly.js": "<2.25.2", "pocketmine/bedrock-protocol": "<8.0.2", - "pocketmine/pocketmine-mp": "<5.25.2", + "pocketmine/pocketmine-mp": "<5.32.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", @@ -17013,20 +19648,22 @@ "prestashop/blockwishlist": ">=2,<2.1.1", "prestashop/contactform": ">=1.0.1,<4.3", "prestashop/gamification": "<2.3.2", - "prestashop/prestashop": "<8.1.6", + "prestashop/prestashop": "<8.2.3", "prestashop/productcomments": "<5.0.2", + "prestashop/ps_checkout": "<4.4.1|>=5,<5.0.5", "prestashop/ps_contactinfo": "<=3.3.2", "prestashop/ps_emailsubscription": "<2.6.1", "prestashop/ps_facetedsearch": "<3.4.1", "prestashop/ps_linklist": "<3.1", - "privatebin/privatebin": "<1.4|>=1.5,<1.7.4", - "processwire/processwire": "<=3.0.229", + "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", + "processwire/processwire": "<=3.0.246", "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", "propel/propel1": ">=1,<=1.7.1", - "pterodactyl/panel": "<1.11.8", + "pterodactyl/panel": "<1.12", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", + "punktde/pt_extbase": "<1.5.1", "pusher/pusher-php-server": "<2.2.1", "pwweb/laravel-core": "<=0.3.6.0-beta", "pxlrbt/filament-excel": "<1.1.14|>=2.0.0.0-alpha,<2.3.3", @@ -17040,16 +19677,18 @@ "rap2hpoutre/laravel-log-viewer": "<0.13", "react/http": ">=0.7,<1.9", "really-simple-plugins/complianz-gdpr": "<6.4.2", - "redaxo/source": "<5.18.3", + "redaxo/source": "<=5.20.1", "remdex/livehelperchat": "<4.29", + "renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1", "reportico-web/reportico": "<=8.1", "rhukster/dom-sanitizer": "<1.0.7", "rmccue/requests": ">=1.6,<1.8", - "robrichards/xmlseclibs": ">=1,<3.0.4", + "robrichards/xmlseclibs": "<=3.1.3", "roots/soil": "<4.1", + "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11", "rudloff/alltube": "<3.0.3", "rudloff/rtmpdump-bin": "<=2.3.1", - "s-cart/core": "<6.9", + "s-cart/core": "<=9.0.5", "s-cart/s-cart": "<6.9", "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", "sabre/dav": ">=1.6,<1.7.11|>=1.8,<1.8.9", @@ -17057,14 +19696,15 @@ "scheb/two-factor-bundle": "<3.26|>=4,<4.11", "sensiolabs/connect": "<4.2.3", "serluck/phpwhois": "<=4.2.6", + "setasign/fpdi": "<2.6.4", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", - "shopware/core": "<=6.5.8.12|>=6.6,<=6.6.5", - "shopware/platform": "<=6.5.8.12|>=6.6,<=6.6.5", + "shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.4.1-dev", + "shopware/platform": "<6.6.10.7-dev|>=6.7,<6.7.3.1-dev", "shopware/production": "<=6.3.5.2", - "shopware/shopware": "<=5.7.17", - "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", - "shopxo/shopxo": "<=6.1", + "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", + "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", + "shopxo/shopxo": "<=6.4", "showdoc/showdoc": "<2.10.4", "shuchkin/simplexlsx": ">=1.0.12,<1.1.13", "silverstripe-australia/advancedreports": ">=1,<=2", @@ -17073,7 +19713,7 @@ "silverstripe/cms": "<4.11.3", "silverstripe/comments": ">=1.3,<3.1.1", "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", - "silverstripe/framework": "<5.3.8", + "silverstripe/framework": "<5.3.23", "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", "silverstripe/recipe-cms": ">=4.5,<4.5.3", @@ -17085,6 +19725,7 @@ "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", "silverstripe/userforms": "<3|>=5,<5.4.2", "silverstripe/versioned-admin": ">=1,<1.11.1", + "simogeo/filemanager": "<=2.5", "simple-updates/phpwhois": "<=1", "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19", "simplesamlphp/saml2-legacy": "<=4.16.15", @@ -17096,34 +19737,42 @@ "simplesamlphp/xml-security": "==1.6.11", "simplito/elliptic-php": "<1.0.6", "sitegeist/fluid-components": "<3.5", + "sjbr/sr-feuser-register": "<2.6.2|>=5.1,<12.5", "sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3", + "sjbr/static-info-tables": "<2.3.1", "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", "slim/slim": "<2.6", "slub/slub-events": "<3.0.3", "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<=7.0.13", + "snipe/snipe-it": "<=8.3.4", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", + "solspace/craft-freeform": ">=5,<5.10.16", + "soosyze/soosyze": "<=2", "spatie/browsershot": "<5.0.5", "spatie/image-optimizer": "<1.7.3", "spencer14420/sp-php-email-handler": "<1", "spipu/html2pdf": "<5.2.8", + "spiral/roadrunner": "<2025.1", "spoon/library": "<1.4.1", "spoonity/tcpdf": "<6.2.22", "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", "ssddanbrown/bookstack": "<24.05.1", - "starcitizentools/citizen-skin": ">=2.6.3,<2.31", - "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2", - "statamic/cms": "<=5.16", + "starcitizentools/citizen-skin": ">=1.9.4,<3.9", + "starcitizentools/short-description": ">=4,<4.0.1", + "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", + "starcitizenwiki/embedvideo": "<=4", + "statamic/cms": "<=5.22", "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.64", "studiomitte/friendlycaptcha": "<0.1.4", "subhh/libconnect": "<7.0.8|>=8,<8.1", "sukohi/surpass": "<1", "sulu/form-bundle": ">=2,<2.5.3", - "sulu/sulu": "<1.6.44|>=2,<2.5.21|>=2.6,<2.6.5", + "sulu/sulu": "<1.6.44|>=2,<2.5.25|>=2.6,<2.6.9|>=3.0.0.0-alpha1,<3.0.0.0-alpha3", "sumocoders/framework-user-bundle": "<1.4", "superbig/craft-audit": "<3.0.2", + "svewap/a21glossary": "<=0.4.10", "swag/paypal": "<5.4.4", "swiftmailer/swiftmailer": "<6.2.5", "swiftyedit/swiftyedit": "<1.2", @@ -17144,7 +19793,7 @@ "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4", "symfony/http-client": ">=4.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8", - "symfony/http-foundation": "<5.4.46|>=6,<6.4.14|>=7,<7.1.7", + "symfony/http-foundation": "<5.4.50|>=6,<6.4.29|>=7,<7.3.7", "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", @@ -17163,10 +19812,12 @@ "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7|>=5.1,<5.2.8|>=5.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8", "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", - "symfony/symfony": "<5.4.47|>=6,<6.4.15|>=7,<7.1.8", + "symfony/symfony": "<5.4.50|>=6,<6.4.29|>=7,<7.3.7", "symfony/translation": ">=2,<2.0.17", "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8", "symfony/ux-autocomplete": "<2.11.2", + "symfony/ux-live-component": "<2.25.1", + "symfony/ux-twig-component": "<2.25.1", "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", @@ -17185,7 +19836,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<=4.0.1", + "thorsten/phpmyfaq": "<4.0.16|>=4.1.0.0-alpha,<=4.1.0.0-beta2", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.2", @@ -17196,29 +19847,35 @@ "topthink/framework": "<6.0.17|>=6.1,<=8.0.4", "topthink/think": "<=6.1.1", "topthink/thinkphp": "<=3.2.3|>=6.1.3,<=8.0.4", - "torrentpier/torrentpier": "<=2.4.3", + "torrentpier/torrentpier": "<=2.8.8", "tpwd/ke_search": "<4.0.3|>=4.1,<4.6.6|>=5,<5.0.2", "tribalsystems/zenario": "<=9.7.61188", "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", - "twbs/bootstrap": "<=3.4.1|>=4,<=4.6.2", + "twbs/bootstrap": "<3.4.1|>=4,<4.3.1", "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", - "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<10.4.46|>=11,<11.5.40|>=12,<12.4.21|>=13,<13.3.1", + "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", - "typo3/cms-beuser": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", - "typo3/cms-core": "<=8.7.56|>=9,<=9.5.48|>=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", - "typo3/cms-dashboard": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", "typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-felogin": ">=4.2,<4.2.3", "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1", "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5", "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2", "typo3/cms-lowlevel": ">=11,<=11.5.41", + "typo3/cms-recordlist": ">=11,<11.5.48", + "typo3/cms-recycler": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30", "typo3/cms-scheduler": ">=11,<=11.5.41", + "typo3/cms-setup": ">=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11", + "typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11", + "typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", "typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3", "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", @@ -17228,33 +19885,38 @@ "ua-parser/uap-php": "<3.8", "uasoft-indonesia/badaso": "<=2.9.7", "unisharp/laravel-filemanager": "<2.9.1", - "unopim/unopim": "<0.1.5", + "universal-omega/dynamic-page-list3": "<3.6.4", + "unopim/unopim": "<=0.3", "userfrosting/userfrosting": ">=0.3.1,<4.6.3", "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2", "uvdesk/community-skeleton": "<=1.1.1", "uvdesk/core-framework": "<=1.1.1", "vanilla/safecurl": "<0.9.2", "verbb/comments": "<1.5.5", - "verbb/formie": "<2.1.6", + "verbb/formie": "<=2.1.43", "verbb/image-resizer": "<2.0.9", "verbb/knock-knock": "<1.2.8", "verot/class.upload.php": "<=2.1.6", + "vertexvaar/falsftp": "<0.2.6", "villagedefrance/opencart-overclocked": "<=1.11.1", "vova07/yii2-fileapi-widget": "<0.1.9", - "vrana/adminer": "<4.8.1", + "vrana/adminer": "<=4.8.1", "vufind/vufind": ">=2,<9.1.1", "waldhacker/hcaptcha": "<2.1.2", "wallabag/tcpdf": "<6.2.22", - "wallabag/wallabag": "<2.6.7", + "wallabag/wallabag": "<2.6.11", "wanglelecc/laracms": "<=1.0.3", + "wapplersystems/a21glossary": "<=0.4.10", "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9", "web-auth/webauthn-lib": ">=4.5,<4.9", "web-feet/coastercms": "==5.5", + "web-tp3/wec_map": "<3.0.3", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", "webcoast/deferred-image-processing": "<1.0.2", "webklex/laravel-imap": "<5.3", "webklex/php-imap": "<5.3", "webpa/webpa": "<3.1.2", + "webreinvent/vaahcms": "<=2.3.1", "wikibase/wikibase": "<=1.39.3", "wikimedia/parsoid": "<0.12.2", "willdurand/js-translation-bundle": "<2.1.1", @@ -17275,23 +19937,25 @@ "xataface/xataface": "<3", "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", - "yeswiki/yeswiki": "<=4.4.5", + "yeswiki/yeswiki": "<=4.5.4", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", - "yiisoft/yii": "<1.1.29", - "yiisoft/yii2": "<2.0.49.4-dev", + "yiisoft/yii": "<1.1.31", + "yiisoft/yii2": "<2.0.52", "yiisoft/yii2-authclient": "<2.2.15", "yiisoft/yii2-bootstrap": "<2.0.4", "yiisoft/yii2-dev": "<=2.0.45", "yiisoft/yii2-elasticsearch": "<2.0.5", "yiisoft/yii2-gii": "<=2.2.4", "yiisoft/yii2-jui": "<2.0.4", - "yiisoft/yii2-redis": "<2.0.8", + "yiisoft/yii2-redis": "<2.0.20", "yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6", "yoast-seo-for-typo3/yoast_seo": "<7.2.3", - "yourls/yourls": "<=1.8.2", + "yourls/yourls": "<=1.10.2", "yuan1994/tpadmin": "<=1.3.12", + "yungifez/skuul": "<=2.6.5", + "z-push/z-push-dev": "<2.7.6", "zencart/zencart": "<=1.5.7.0-beta", "zendesk/zendesk_api_client_php": "<2.2.11", "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", @@ -17366,32 +20030,32 @@ "type": "tidelift" } ], - "time": "2025-03-26T15:05:37+00:00" + "time": "2026-01-09T19:06:26+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.2", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -17414,7 +20078,8 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -17422,32 +20087,32 @@ "type": "github" } ], - "time": "2024-03-02T06:27:43+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", - "version": "1.0.8", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -17470,7 +20135,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" }, "funding": [ { @@ -17478,32 +20144,32 @@ "type": "github" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2025-03-19T07:56:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -17525,7 +20191,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -17533,34 +20200,39 @@ "type": "github" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -17599,41 +20271,54 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2025-08-10T08:07:46+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.3", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -17656,7 +20341,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -17664,33 +20350,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "4.0.6", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3", + "phpunit/phpunit": "^11.0", "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -17722,7 +20408,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -17730,27 +20417,27 @@ "type": "github" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "5.1.5", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -17758,7 +20445,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -17777,7 +20464,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -17785,42 +20472,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -17862,46 +20562,56 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -17920,13 +20630,14 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { @@ -17934,33 +20645,33 @@ "type": "github" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.4", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -17983,7 +20694,8 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -17991,34 +20703,34 @@ "type": "github" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -18040,7 +20752,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -18048,32 +20761,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -18095,7 +20808,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -18103,32 +20817,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -18158,94 +20872,53 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2023-02-03T06:07:39+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "3.0.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" - }, - "funding": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "3.2.1", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -18268,37 +20941,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", - "version": "3.0.2", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -18321,7 +21007,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -18329,31 +21016,84 @@ "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { - "name": "symfony/browser-kit", - "version": "v6.4.19", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/symfony/browser-kit.git", - "reference": "ce95f3e3239159e7fa3be7690c6ce95a4714637f" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/ce95f3e3239159e7fa3be7690c6ce95a4714637f", - "reference": "ce95f3e3239159e7fa3be7690c6ce95a4714637f", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/dom-crawler": "^5.4|^6.0|^7.0" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v7.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "d5b5c731005f224fbc25289587a8538e4f62c762" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/d5b5c731005f224fbc25289587a8538e4f62c762", + "reference": "d5b5c731005f224fbc25289587a8538e4f62c762", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/dom-crawler": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -18381,7 +21121,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v6.4.19" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.3" }, "funding": [ { @@ -18392,42 +21132,43 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-14T11:23:16+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/debug-bundle", - "version": "v6.4.13", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/debug-bundle.git", - "reference": "7bcfaff39e094cc09455201916d016d9b2ae08ff" + "reference": "329383fb895353e3c8ab792cc35c4a7e7b17881b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/7bcfaff39e094cc09455201916d016d9b2ae08ff", - "reference": "7bcfaff39e094cc09455201916d016d9b2ae08ff", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/329383fb895353e3c8ab792cc35c4a7e7b17881b", + "reference": "329383fb895353e3c8ab792cc35c4a7e7b17881b", "shasum": "" }, "require": { + "composer-runtime-api": ">=2.1", "ext-xml": "*", - "php": ">=8.1", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/twig-bridge": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" - }, - "conflict": { - "symfony/config": "<5.4", - "symfony/dependency-injection": "<5.4" + "php": ">=8.2", + "symfony/config": "^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "require-dev": { - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0" + "symfony/web-profiler-bundle": "^6.4|^7.0|^8.0" }, "type": "symfony-bundle", "autoload": { @@ -18455,7 +21196,7 @@ "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug-bundle/tree/v6.4.13" + "source": "https://github.com/symfony/debug-bundle/tree/v7.4.0" }, "funding": [ { @@ -18466,40 +21207,44 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-10-24T13:56:35+00:00" }, { "name": "symfony/maker-bundle", - "version": "v1.62.1", + "version": "v1.65.1", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "468ff2708200c95ebc0d85d3174b6c6711b8a590" + "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/468ff2708200c95ebc0d85d3174b6c6711b8a590", - "reference": "468ff2708200c95ebc0d85d3174b6c6711b8a590", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/eba30452d212769c9a5bcf0716959fd8ba1e54e3", + "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3", "shasum": "" }, "require": { "doctrine/inflector": "^2.0", - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "php": ">=8.1", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.2|^3", - "symfony/filesystem": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" }, "conflict": { "doctrine/doctrine-bundle": "<2.10", @@ -18507,12 +21252,14 @@ }, "require-dev": { "composer/semver": "^3.0", - "doctrine/doctrine-bundle": "^2.5.0", + "doctrine/doctrine-bundle": "^2.5.0|^3.0.0", "doctrine/orm": "^2.15|^3", - "symfony/http-client": "^6.4|^7.0", - "symfony/phpunit-bridge": "^6.4.1|^7.0", - "symfony/security-core": "^6.4|^7.0", - "symfony/yaml": "^6.4|^7.0", + "doctrine/persistence": "^3.1|^4.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", "twig/twig": "^3.0|^4.x-dev" }, "type": "symfony-bundle", @@ -18547,7 +21294,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.62.1" + "source": "https://github.com/symfony/maker-bundle/tree/v1.65.1" }, "funding": [ { @@ -18558,37 +21305,37 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-15T00:21:40+00:00" + "time": "2025-12-02T07:14:37+00:00" }, { "name": "symfony/phpunit-bridge", - "version": "v6.4.16", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "cebafe2f1ad2d1e745c1015b7c2519592341e4e6" + "reference": "f933e68bb9df29d08077a37e1515a23fea8562ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/cebafe2f1ad2d1e745c1015b7c2519592341e4e6", - "reference": "cebafe2f1ad2d1e745c1015b7c2519592341e4e6", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f933e68bb9df29d08077a37e1515a23fea8562ab", + "reference": "f933e68bb9df29d08077a37e1515a23fea8562ab", "shasum": "" }, "require": { - "php": ">=7.1.3" - }, - "conflict": { - "phpunit/phpunit": "<7.5|9.1.2" + "php": ">=8.1.0" }, "require-dev": { - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/polyfill-php81": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4.3|^7.0.3|^8.0" }, "bin": [ "bin/simple-phpunit" @@ -18628,8 +21375,11 @@ ], "description": "Provides utilities for PHPUnit, especially user deprecation notices management", "homepage": "https://symfony.com", + "keywords": [ + "testing" + ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v6.4.16" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.3" }, "funding": [ { @@ -18640,47 +21390,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T15:06:22+00:00" + "time": "2025-12-09T15:33:45+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v6.4.19", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "7d1026a8e950d416cb5148ae88ac23db5d264839" + "reference": "5220b59d06f6554658a0dc4d6bd4497a789e51dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/7d1026a8e950d416cb5148ae88ac23db5d264839", - "reference": "7d1026a8e950d416cb5148ae88ac23db5d264839", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5220b59d06f6554658a0dc4d6bd4497a789e51dd", + "reference": "5220b59d06f6554658a0dc4d6bd4497a789e51dd", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^5.4|^6.0", - "twig/twig": "^2.13|^3.0.4" + "composer-runtime-api": ">=2.1", + "php": ">=8.2", + "symfony/config": "^7.3|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "twig/twig": "^3.15" }, "conflict": { - "symfony/form": "<5.4", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/twig-bundle": ">=7.0" + "symfony/form": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/serializer": "<7.2", + "symfony/workflow": "<7.3" }, "require-dev": { - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "symfony-bundle", "autoload": { @@ -18711,7 +21469,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.19" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.3" }, "funding": [ { @@ -18722,86 +21480,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-14T12:21:59+00:00" - }, - { - "name": "symplify/easy-coding-standard", - "version": "12.5.11", - "source": { - "type": "git", - "url": "https://github.com/easy-coding-standard/easy-coding-standard.git", - "reference": "1fa356963594227d0d1a87ed0b2b419d3a42a5d8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/1fa356963594227d0d1a87ed0b2b419d3a42a5d8", - "reference": "1fa356963594227d0d1a87ed0b2b419d3a42a5d8", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "conflict": { - "friendsofphp/php-cs-fixer": "<3.46", - "phpcsstandards/php_codesniffer": "<3.8", - "symplify/coding-standard": "<12.1" - }, - "suggest": { - "ext-dom": "Needed to support checkstyle output format in class CheckstyleOutputFormatter" - }, - "bin": [ - "bin/ecs" - ], - "type": "library", - "autoload": { - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Use Coding Standard with 0-knowledge of PHP-CS-Fixer and PHP_CodeSniffer", - "keywords": [ - "Code style", - "automation", - "fixer", - "static analysis" - ], - "support": { - "issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues", - "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.5.11" - }, - "funding": [ - { - "url": "https://www.paypal.me/rectorphp", - "type": "custom" - }, - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "time": "2025-03-25T10:01:37+00:00" + "time": "2025-12-27T17:05:22+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -18830,7 +21531,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -18838,26 +21539,18 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" - } - ], - "aliases": [ - { - "package": "brick/math", - "version": "0.12.1.0", - "alias": "0.11.0", - "alias_normalized": "0.11.0.0" + "time": "2025-11-17T20:03:58+00:00" } ], + "aliases": [], "minimum-stability": "stable", "stability-flags": { - "florianv/swap-bundle": 20, "roave/security-advisories": 20 }, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.1", + "php": "^8.2", "ext-ctype": "*", "ext-dom": "*", "ext-gd": "*", @@ -18866,9 +21559,9 @@ "ext-json": "*", "ext-mbstring": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { - "php": "8.1.0" + "php": "8.2.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/config/banner.md b/config/banner.md index 997ca15e..1d38a3f3 100644 --- a/config/banner.md +++ b/config/banner.md @@ -1,14 +1,4 @@ -Welcome to Part-DB. - -If you want to change this banner, edit `config/banner.md` file or set the `BANNER` environment variable. +**Attention**: +Since Version 2.0.0 this file is no longer used. -
    -

    -And God said
    -$\nabla \cdot \vec{D} = \rho$, -$\nabla \cdot \vec{B} = 0$, -$\nabla \times \vec{E} = -\frac{\partial \vec{B}}{\partial t}$, -$\nabla \times \vec{H} = \vec{j} + \frac{\partial \vec{D}}{\partial t}$,
    -and then there was light. -

    -
    \ No newline at end of file +You can now set the banner text directly in the admin interface, or by setting the `BANNER` environment variable. diff --git a/config/bundles.php b/config/bundles.php index ea066084..ae7dc9cc 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -30,6 +30,7 @@ return [ Jbtronics\DompdfFontLoaderBundle\DompdfFontLoaderBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], - ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], + Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true], Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true], + ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index d55f91ea..1b679cd1 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -32,10 +32,9 @@ api_platform: pagination_client_items_per_page: true # Allow clients to override the default items per page - keep_legacy_inflector: false # Need to be true, or some tests will fail use_symfony_listeners: true serializer: # Change this to false later, to remove the hydra prefix on the API - hydra_prefix: true \ No newline at end of file + hydra_prefix: true diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml new file mode 100644 index 00000000..01db6267 --- /dev/null +++ b/config/packages/csrf.yaml @@ -0,0 +1,12 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + form: + csrf_protection: + token_id: submit + + csrf_protection: + check_header: true + stateless_token_ids: + - submit + - authenticate + - logout diff --git a/config/packages/datatables.yaml b/config/packages/datatables.yaml index 6076a6c7..f1ea4715 100644 --- a/config/packages/datatables.yaml +++ b/config/packages/datatables.yaml @@ -9,7 +9,8 @@ datatables: # Set options, as documented at https://datatables.net/reference/option/ options: lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]] # We add the "All" option, when part tables are generated - pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default + #pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default + pageLength: 50 #TODO dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>> <'card' rt @@ -17,7 +18,7 @@ datatables: > <'row' <'col mt-2 input-group flex-nowrap' B l > <'col-auto mt-2' < p >>>" pagingType: 'simple_numbers' - searching: true + searching: false stateSave: true diff --git a/config/packages/doctrine.php b/config/packages/doctrine.php new file mode 100644 index 00000000..47584ed7 --- /dev/null +++ b/config/packages/doctrine.php @@ -0,0 +1,33 @@ +. + */ + +declare(strict_types=1); + +/** + * This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+. + * We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version. + */ + +return static function(\Symfony\Config\DoctrineConfig $doctrine) { + //On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies. + if (PHP_VERSION_ID >= 80400) { + $doctrine->orm()->enableNativeLazyObjects(true); + } +}; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 3211fbbe..5261c295 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -25,10 +25,6 @@ doctrine: tinyint: class: App\Doctrine\Types\TinyIntType - # This was removed in doctrine/orm 4.0 but we need it for the WebauthnKey entity - array: - class: App\Doctrine\Types\ArrayType - schema_filter: ~^(?!internal)~ # Only enable this when needed profiling_collect_backtrace: false @@ -39,6 +35,8 @@ doctrine: report_fields_where_declared: true validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + identity_generation_preferences: + Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity auto_mapping: true controller_resolver: auto_mapping: true diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 279c51f5..6843a177 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -1,9 +1,6 @@ # see https://symfony.com/doc/current/reference/configuration/framework.html framework: secret: '%env(APP_SECRET)%' - csrf_protection: true - annotations: false - handle_all_throwables: true # We set this header by ourselves, so we can disable it here disallow_search_engine_index: false @@ -30,8 +27,11 @@ framework: #esi: true #fragments: true - php_errors: - log: true + + + form: { csrf_protection: { token_id: 'submit' } } + csrf_protection: + stateless_token_ids: ['submit', 'authenticate', 'logout'] when@test: framework: diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 7d296a8b..4967684e 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -6,8 +6,8 @@ knpu_oauth2_client: type: generic provider_class: '\League\OAuth2\Client\Provider\GenericProvider' - client_id: '%env(PROVIDER_DIGIKEY_CLIENT_ID)%' - client_secret: '%env(PROVIDER_DIGIKEY_SECRET)%' + client_id: '%env(settings:digikey:clientId)%' + client_secret: '%env(settings:digikey:secret)%' redirect_route: 'oauth_client_check' redirect_params: {name: 'ip_digikey_oauth'} @@ -26,8 +26,8 @@ knpu_oauth2_client: type: generic provider_class: '\League\OAuth2\Client\Provider\GenericProvider' - client_id: '%env(PROVIDER_OCTOPART_CLIENT_ID)%' - client_secret: '%env(PROVIDER_OCTOPART_SECRET)%' + client_id: '%env(settings:octopart:clientId)%' + client_secret: '%env(settings:octopart:secret)%' redirect_route: 'oauth_client_check' redirect_params: { name: 'ip_octopart_oauth' } @@ -35,4 +35,4 @@ knpu_oauth2_client: provider_options: urlAuthorize: 'https://identity.nexar.com/connect/authorize' urlAccessToken: 'https://identity.nexar.com/connect/token' - urlResourceOwnerDetails: '' \ No newline at end of file + urlResourceOwnerDetails: '' diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 44a078b8..387d71ad 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -10,14 +10,6 @@ when@dev: path: "%kernel.logs_dir%/%kernel.environment%.log" level: debug channels: ["!event"] - # uncomment to get logging in your browser - # you may have to allow bigger header sizes in your Web server configuration - #firephp: - # type: firephp - # level: info - #chromephp: - # type: chromephp - # level: info console: type: console process_psr_3_messages: false @@ -45,6 +37,7 @@ when@prod: action_level: error handler: nested excluded_http_codes: [404, 405] + channels: ["!deprecation"] buffer_size: 50 # How many messages should be saved? Prevent memory leaks nested: type: stream @@ -69,6 +62,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" diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 1cb74da7..6b2b7337 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -20,12 +20,6 @@ nelmio_security: - 'digikey.com' - 'nexar.com' - # forces Microsoft's XSS-Protection with - # its block mode - xss_protection: - enabled: true - mode_block: true - # Send a full URL in the `Referer` header when performing a same-origin request, # only send the origin of the document to secure destination (HTTPS->HTTPS), # and send no header to a less secure destination (HTTPS->HTTP). @@ -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 \ No newline at end of file diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 00000000..dd31b9da --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml index df5d98d2..0f34f872 100644 --- a/config/packages/routing.yaml +++ b/config/packages/routing.yaml @@ -1,7 +1,5 @@ framework: router: - utf8: true - # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands default_uri: '%env(DEFAULT_URI)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 95f5c6b1..e7a44e0c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -13,7 +13,7 @@ security: firewalls: dev: - pattern: ^/(_(profiler|wdt)|css|images|js)/ + pattern: ^/(_(profiler|wdt)|css|images|js|\.well-known)/ security: false main: provider: app_user_provider diff --git a/config/packages/settings.yaml b/config/packages/settings.yaml new file mode 100644 index 00000000..c16d1804 --- /dev/null +++ b/config/packages/settings.yaml @@ -0,0 +1,15 @@ +jbtronics_settings: + default_storage_adapter: Jbtronics\SettingsBundle\Storage\ORMStorageAdapter + + cache: + default_cacheable: true + + orm_storage: + default_entity_class: App\Entity\SettingsEntry + + +# Disable caching for development environment +when@dev: + jbtronics_settings: + cache: + default_cacheable: false diff --git a/config/packages/swap.yaml b/config/packages/swap.yaml index 2767f740..4ef8fbdf 100644 --- a/config/packages/swap.yaml +++ b/config/packages/swap.yaml @@ -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(FIXER_API_KEY)%" - #exchange_rates_api: ~ \ No newline at end of file + 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: ~ diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index 7266a176..cbc1cd7e 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -1,11 +1,10 @@ framework: - default_locale: '%partdb.locale%' + default_locale: 'en' # Just enable the locales we need for performance reasons. enabled_locale: '%partdb.locale_menu%' translator: default_path: '%kernel.project_dir%/translations' fallbacks: - - '%partdb.locale%' - 'en' providers: # crowdin: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 5b2d64e5..95ae4f3b 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,21 +1,17 @@ twig: default_path: '%kernel.project_dir%/templates' - form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig'] + form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig', 'form/synonyms_collection.html.twig'] paths: '%kernel.project_dir%/assets/css': css globals: - partdb_title: '%partdb.title%' - default_currency: '%partdb.default_currency%' - global_theme: '%partdb.global_theme%' allow_email_pw_reset: '%partdb.users.email_pw_reset%' locale_menu: '%partdb.locale_menu%' attachment_manager: '@App\Services\Attachments\AttachmentManager' label_profile_dropdown_helper: '@App\Services\LabelSystem\LabelProfileDropdownHelper' error_page_admin_email: '%partdb.error_pages.admin_email%' error_page_show_help: '%partdb.error_pages.show_help%' - sidebar_items: '%partdb.sidebar.items%' sidebar_tree_updater: '@App\Services\Trees\SidebarTreeUpdater' avatar_helper: '@App\Services\UserSystem\UserAvatarHelper' available_themes: '%partdb.available_themes%' @@ -24,4 +20,4 @@ twig: when@test: twig: - strict_variables: true \ No newline at end of file + strict_variables: true diff --git a/config/packages/uid.yaml b/config/packages/uid.yaml deleted file mode 100644 index 01520944..00000000 --- a/config/packages/uid.yaml +++ /dev/null @@ -1,4 +0,0 @@ -framework: - uid: - default_uuid_version: 7 - time_based_uuid_version: 7 diff --git a/config/packages/ux_translator.yaml b/config/packages/ux_translator.yaml index 1c1c7060..c8453a50 100644 --- a/config/packages/ux_translator.yaml +++ b/config/packages/ux_translator.yaml @@ -1,3 +1,9 @@ ux_translator: # The directory where the JavaScript translations are dumped dump_directory: '%kernel.project_dir%/var/translations' + +when@prod: + ux_translator: + # Control whether TypeScript types are dumped alongside translations. + # Disable this if you do not use TypeScript (e.g. in production when using AssetMapper), to speed up cache warmup. + # dump_typescript: false diff --git a/config/packages/ux_turbo.yaml b/config/packages/ux_turbo.yaml new file mode 100644 index 00000000..c2a6a44e --- /dev/null +++ b/config/packages/ux_turbo.yaml @@ -0,0 +1,4 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + csrf_protection: + check_header: true diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml index 0201281d..dd47a6ad 100644 --- a/config/packages/validator.yaml +++ b/config/packages/validator.yaml @@ -1,7 +1,5 @@ framework: validation: - email_validation_mode: html5 - # Enables validator auto-mapping support. # For instance, basic validation constraints will be inferred from Doctrine's metadata. #auto_mapping: diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml index b9461110..15112444 100644 --- a/config/packages/web_profiler.yaml +++ b/config/packages/web_profiler.yaml @@ -1,17 +1,14 @@ when@dev: web_profiler: - toolbar: true - intercept_redirects: false + toolbar: + ajax_replace: true framework: profiler: - only_exceptions: false collect_serializer_data: true when@test: - web_profiler: - toolbar: false - intercept_redirects: false - framework: - profiler: { collect: false } + profiler: + collect: false + collect_serializer_data: true diff --git a/config/parameters.yaml b/config/parameters.yaml index b2c10893..b79e2b88 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -5,16 +5,12 @@ parameters: ###################################################################################################################### # Common ###################################################################################################################### - partdb.locale: '%env(string:DEFAULT_LANG)%' # The default language to use serverwide - partdb.timezone: '%env(string:DEFAULT_TIMEZONE)%' # The default timezone - partdb.title: '%env(trim:string:INSTANCE_NAME)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage) - partdb.banner: '%env(trim:string:BANNER)%' # The info text shown in the homepage, if empty config/banner.md is used - partdb.default_currency: '%env(string:BASE_CURRENCY)%' # The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country - partdb.global_theme: '' # The theme to use globally (see public/build/themes/ for choices, use name without .css). Set to '' for default bootstrap theme - partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] # The languages that are shown in user drop down menu - partdb.enforce_change_comments_for: '%env(csv:ENFORCE_CHANGE_COMMENTS_FOR)%' # The actions for which a change comment is required (e.g. "part_edit", "part_create", etc.). If this is empty, change comments are not required at all. - 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 + # 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', 'hu'] # The languages that are shown in user drop down menu + + partdb.default_uri: '%env(addSlash: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 partdb.db.emulate_natural_sort: '%env(bool:DATABASE_EMULATE_NATURAL_SORT)%' # If this is set to true, natural sorting is emulated on platforms that do not support it natively. This can be slow on large datasets. @@ -22,11 +18,8 @@ parameters: # Users and Privacy ###################################################################################################################### partdb.gdpr_compliance: true # If this option is activated, IP addresses are anonymized to be GDPR compliant - partdb.users.use_gravatar: '%env(bool:USE_GRAVATAR)%' # Set to false, if no Gravatar images should be used for user profiles. partdb.users.email_pw_reset: '%env(bool:ALLOW_EMAIL_PW_RESET)%' # Config if users are able, to reset their password by email. By default this enabled, when a mail server is configured. - partdb.check_for_updates: '%env(bool:CHECK_FOR_UPDATES)' # Set to false, if Part-DB should not contact the GitHub API to check for updates - ###################################################################################################################### # Mail settings ###################################################################################################################### @@ -36,11 +29,8 @@ parameters: ###################################################################################################################### # Attachments and files ###################################################################################################################### - partdb.attachments.allow_downloads: '%env(bool:ALLOW_ATTACHMENT_DOWNLOADS)%' # Allow users to download attachments to server. Warning: This can be dangerous, because via that feature attackers maybe can access ressources on your intranet! - partdb.attachments.download_by_default: '%env(bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT)%' # If this is set the 'download external files' checkbox is set by default for new attachments (only if allow_downloads is set to true) partdb.attachments.dir.media: 'public/media/' # The folder where uploaded attachment files are saved (must be in public folder) partdb.attachments.dir.secure: 'uploads/' # The folder where secured attachment files are saved (must not be in public/) - partdb.attachments.max_file_size: '%env(string:MAX_ATTACHMENT_FILE_SIZE)%' # The maximum size of an attachment file (in bytes, you can use M for megabytes and G for gigabytes) ###################################################################################################################### # Error pages @@ -53,22 +43,6 @@ parameters: ###################################################################################################################### partdb.saml.enabled: '%env(bool:SAML_ENABLED)%' # If this is set to true, SAML authentication is enabled - ###################################################################################################################### - # Table settings - ###################################################################################################################### - partdb.table.default_page_size: '%env(int:TABLE_DEFAULT_PAGE_SIZE)%' # The default number of entries shown per page in tables - partdb.table.parts.default_columns: '%env(trim:string:TABLE_PARTS_DEFAULT_COLUMNS)%' # The default columns in part tables and their order - - ###################################################################################################################### - # Sidebar - ###################################################################################################################### - # You can configures the default shown tree items in the sidebar here. You can add or remove entries here, to change the number of trees in the sidebar. The possible entries are: categories, locations, footprints, manufacturers, suppliers, devices, tools - partdb.sidebar.items: - - categories - - devices - - tools - partdb.sidebar.root_expanded: true # If this is set to true, the root node of the sidebar is expanded by default - partdb.sidebar.root_node_enable: true # Put all entities below a root node in the sidebar ###################################################################################################################### # Miscellaneous @@ -110,30 +84,18 @@ parameters: # Env default values ###################################################################################################################### - env(DEFAULT_LANG): 'en' - env(DEFAULT_TIMEZONE): 'Europe/Berlin' - env(INSTANCE_NAME): 'Part-DB' - env(BASE_CURRENCY): 'EUR' - env(USE_GRAVATAR): '0' - env(MAX_ATTACHMENT_FILE_SIZE): '100M' - env(REDIRECT_TO_HTTPS): 0 - env(ENFORCE_CHANGE_COMMENTS_FOR): '' - env(ERROR_PAGE_ADMIN_EMAIL): '' env(ERROR_PAGE_SHOW_HELP): 1 env(DEMO_MODE): 0 - env(BANNER): '' env(EMAIL_SENDER_EMAIL): 'noreply@partdb.changeme' env(EMAIL_SENDER_NAME): 'Part-DB Mailer' env(ALLOW_EMAIL_PW_RESET): 0 - env(TABLE_DEFAULT_PAGE_SIZE): 50 - env(TRUSTED_PROXIES): '127.0.0.1' #By default trust only our own server env(TRUSTED_HOSTS): '' # Trust all host names by default @@ -141,11 +103,10 @@ parameters: env(SAML_ROLE_MAPPING): '{}' - env(HISTORY_SAVE_CHANGED_DATA): 1 - env(HISTORY_SAVE_CHANGED_FIELDS): 1 - env(HISTORY_SAVE_REMOVED_DATA): 1 - env(HISTORY_SAVE_NEW_DATA): 1 - - env(EDA_KICAD_CATEGORY_DEPTH): 0 - env(DATABASE_EMULATE_NATURAL_SORT): 0 + + ###################################################################################################################### + # Bulk Info Provider Import Configuration + ###################################################################################################################### + partdb.bulk_import.batch_size: 20 # Number of parts to process in each batch during bulk operations + partdb.bulk_import.max_parts_per_operation: 1000 # Maximum number of parts allowed per bulk import operation diff --git a/config/permissions.yaml b/config/permissions.yaml index b8970556..8c6a145e 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -18,13 +18,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co parts: # e.g. this maps to perms_parts in User/Group database group: "data" - label: "perm.parts" + label: "[[Part]]" operations: # Here are all possible operations are listed => the op name is mapped to bit value read: label: "perm.read" # If a part can be read by a user, he can also see all the datastructures (except devices) alsoSet: ['storelocations.read', 'footprints.read', 'categories.read', 'suppliers.read', 'manufacturers.read', - 'currencies.read', 'attachment_types.read', 'measurement_units.read'] + 'currencies.read', 'attachment_types.read', 'measurement_units.read', 'part_custom_states.read'] apiTokenRole: ROLE_API_READ_ONLY edit: label: "perm.edit" @@ -71,7 +71,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co storelocations: &PART_CONTAINING - label: "perm.storelocations" + label: "[[Storage_location]]" group: "data" operations: read: @@ -103,35 +103,39 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co footprints: <<: *PART_CONTAINING - label: "perm.part.footprints" + label: "[[Footprint]]" categories: <<: *PART_CONTAINING - label: "perm.part.categories" + label: "[[Category]]" suppliers: <<: *PART_CONTAINING - label: "perm.part.supplier" + label: "[[Supplier]]" manufacturers: <<: *PART_CONTAINING - label: "perm.part.manufacturers" + label: "[[Manufacturer]]" projects: <<: *PART_CONTAINING - label: "perm.projects" + label: "[[Project]]" attachment_types: <<: *PART_CONTAINING - label: "perm.part.attachment_types" + label: "[[Attachment_type]]" currencies: <<: *PART_CONTAINING - label: "perm.currencies" + label: "[[Currency]]" measurement_units: <<: *PART_CONTAINING - label: "perm.measurement_units" + label: "[[Measurement_unit]]" + + part_custom_states: + <<: *PART_CONTAINING + label: "[[Part_custom_state]]" tools: label: "perm.part.tools" @@ -265,17 +269,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co # label: "perm.database.write_db_settings" # alsoSet: ['read_db_settings', 'see_status'] - #config: - # label: "perm.config" - # group: "system" - # operations: - # read_config: - # label: "perm.config.read_config" - # edit_config: - # label: "perm.config.edit_config" - # alsoSet: 'read_config' - # server_info: - # label: "perm.config.server_info" + config: + label: "perm.config" + group: "system" + operations: + change_system_settings: + label: "perm.config.change_system_settings" + apiTokenRole: ROLE_API_ADMIN system: label: "perm.system" @@ -363,6 +363,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" @@ -373,4 +377,4 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co manage_tokens: label: "perm.api.manage_tokens" alsoSet: ['access_api'] - apiTokenRole: ROLE_API_FULL \ No newline at end of file + apiTokenRole: ROLE_API_FULL diff --git a/config/reference.php b/config/reference.php new file mode 100644 index 00000000..756dc446 --- /dev/null +++ b/config/reference.php @@ -0,0 +1,2901 @@ + [ + * 'App\\' => [ + * 'resource' => '../src/', + * ], + * ], + * ]); + * ``` + * + * @psalm-type ImportsConfig = list + * @psalm-type ParametersConfig = array|null>|null> + * @psalm-type ArgumentsType = list|array + * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} + * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator + * @psalm-type DeprecationType = array{package: string, version: string, message?: string} + * @psalm-type DefaultsType = array{ + * public?: bool, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * } + * @psalm-type InstanceofType = array{ + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type DefinitionType = array{ + * class?: string, + * file?: string, + * parent?: string, + * shared?: bool, + * synthetic?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * configurator?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * decorates?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * from_callable?: CallbackType, + * } + * @psalm-type AliasType = string|array{ + * alias: string, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type PrototypeType = array{ + * resource: string, + * namespace?: string, + * exclude?: string|list, + * parent?: string, + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type StackType = array{ + * stack: list>, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type ServicesConfig = array{ + * _defaults?: DefaultsType, + * _instanceof?: InstanceofType, + * ... + * } + * @psalm-type ExtensionType = array + * @psalm-type FrameworkConfig = array{ + * secret?: scalar|null|Param, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: list|null, + * trust_x_sendfile_type_header?: scalar|null|Param, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|null|Param, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|null|Param, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: list, + * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] + * trusted_headers?: list, + * error_controller?: scalar|null|Param, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|null|Param, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|null|Param, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|null|Param, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * }, + * form?: bool|array{ // Form configuration + * enabled?: bool|Param, // Default: true + * csrf_protection?: array{ + * enabled?: scalar|null|Param, // Default: null + * token_id?: scalar|null|Param, // Default: null + * field_name?: scalar|null|Param, // Default: "_token" + * field_attr?: array, + * }, + * }, + * http_cache?: bool|array{ // HTTP cache configuration + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|null|Param, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, + * }, + * esi?: bool|array{ // ESI configuration + * enabled?: bool|Param, // Default: false + * }, + * ssi?: bool|array{ // SSI configuration + * enabled?: bool|Param, // Default: false + * }, + * fragments?: bool|array{ // Fragments configuration + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|null|Param, // Default: null + * path?: scalar|null|Param, // Default: "/_fragment" + * }, + * profiler?: bool|array{ // Profiler configuration + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|null|Param, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|null|Param, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false + * }, + * workflows?: bool|array{ + * enabled?: bool|Param, // Default: false + * workflows?: array, + * definition_validators?: list, + * support_strategy?: scalar|null|Param, + * initial_marking?: list, + * events_to_dispatch?: list|null, + * places?: list, + * }>, + * transitions: list, + * to?: list, + * weight?: int|Param, // Default: 1 + * metadata?: list, + * }>, + * metadata?: list, + * }>, + * }, + * router?: bool|array{ // Router configuration + * enabled?: bool|Param, // Default: false + * resource: scalar|null|Param, + * type?: scalar|null|Param, + * cache_dir?: scalar|null|Param, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" + * default_uri?: scalar|null|Param, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|null|Param, // Default: 80 + * https_port?: scalar|null|Param, // Default: 443 + * strict_requirements?: scalar|null|Param, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true + * }, + * session?: bool|array{ // Session configuration + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|null|Param, // Default: "session.storage.factory.native" + * handler_id?: scalar|null|Param, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|null|Param, + * cookie_lifetime?: scalar|null|Param, + * cookie_path?: scalar|null|Param, + * cookie_domain?: scalar|null|Param, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|null|Param, + * gc_probability?: scalar|null|Param, + * gc_maxlifetime?: scalar|null|Param, + * save_path?: scalar|null|Param, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * }, + * request?: bool|array{ // Request configuration + * enabled?: bool|Param, // Default: false + * formats?: array>, + * }, + * assets?: bool|array{ // Assets configuration + * enabled?: bool|Param, // Default: true + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|null|Param, // Default: null + * version?: scalar|null|Param, // Default: null + * version_format?: scalar|null|Param, // Default: "%%s?%%s" + * json_manifest_path?: scalar|null|Param, // Default: null + * base_path?: scalar|null|Param, // Default: "" + * base_urls?: list, + * packages?: array, + * }>, + * }, + * asset_mapper?: bool|array{ // Asset Mapper configuration + * enabled?: bool|Param, // Default: false + * paths?: array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|null|Param, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|null|Param, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|null|Param, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|null|Param, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, + * }, + * }, + * translator?: bool|array{ // Translator configuration + * enabled?: bool|Param, // Default: true + * fallbacks?: list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|null|Param, // Default: "translator.formatter.default" + * cache_dir?: scalar|null|Param, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|null|Param, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, + * pseudo_localization?: bool|array{ + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, + * }, + * providers?: array, + * locales?: list, + * }>, + * globals?: array, + * domain?: string|Param, + * }>, + * }, + * validation?: bool|array{ // Validation configuration + * enabled?: bool|Param, // Default: true + * cache?: scalar|null|Param, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. + * enable_attributes?: bool|Param, // Default: true + * static_method?: list, + * translation_domain?: scalar|null|Param, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" + * mapping?: array{ + * paths?: list, + * }, + * not_compromised_password?: bool|array{ + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|null|Param, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * }, + * disable_translation?: bool|Param, // Default: false + * auto_mapping?: array, + * }>, + * }, + * annotations?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * serializer?: bool|array{ // Serializer configuration + * enabled?: bool|Param, // Default: true + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|null|Param, + * circular_reference_handler?: scalar|null|Param, + * max_depth_handler?: scalar|null|Param, + * mapping?: array{ + * paths?: list, + * }, + * default_context?: list, + * named_serializers?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true + * }>, + * }, + * property_access?: bool|array{ // Property access configuration + * enabled?: bool|Param, // Default: true + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true + * }, + * type_info?: bool|array{ // Type info configuration + * enabled?: bool|Param, // Default: true + * aliases?: array, + * }, + * property_info?: bool|array{ // Property info configuration + * enabled?: bool|Param, // Default: true + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. + * }, + * cache?: array{ // Cache configuration + * prefix_seed?: scalar|null|Param, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|null|Param, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|null|Param, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|null|Param, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|null|Param, + * default_redis_provider?: scalar|null|Param, // Default: "redis://localhost" + * default_valkey_provider?: scalar|null|Param, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|null|Param, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|null|Param, // Default: "database_connection" + * default_pdo_provider?: scalar|null|Param, // Default: null + * pools?: array, + * tags?: scalar|null|Param, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|null|Param, // Default lifetime of the pool. + * provider?: scalar|null|Param, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|null|Param, + * clearer?: scalar|null|Param, + * }>, + * }, + * php_errors?: array{ // PHP errors handling configuration + * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true + * }, + * exceptions?: array, + * web_link?: bool|array{ // Web links configuration + * enabled?: bool|Param, // Default: true + * }, + * lock?: bool|string|array{ // Lock configuration + * enabled?: bool|Param, // Default: false + * resources?: array>, + * }, + * semaphore?: bool|string|array{ // Semaphore configuration + * enabled?: bool|Param, // Default: false + * resources?: array, + * }, + * messenger?: bool|array{ // Messenger configuration + * enabled?: bool|Param, // Default: false + * routing?: array, + * }>, + * serializer?: array{ + * default_serializer?: scalar|null|Param, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * symfony_serializer?: array{ + * format?: scalar|null|Param, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * context?: array, + * }, + * }, + * transports?: array, + * failure_transport?: scalar|null|Param, // Transport name to send failed messages to (after all retries have failed). // Default: null + * retry_strategy?: string|array{ + * service?: scalar|null|Param, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * }, + * rate_limiter?: scalar|null|Param, // Rate limiter name to use when processing messages. // Default: null + * }>, + * failure_transport?: scalar|null|Param, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: list, + * default_bus?: scalar|null|Param, // Default: null + * buses?: array, + * }>, + * }>, + * }, + * scheduler?: bool|array{ // Scheduler configuration + * enabled?: bool|Param, // Default: false + * }, + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true + * http_client?: bool|array{ // HTTP Client configuration + * enabled?: bool|Param, // Default: true + * max_host_connections?: int|Param, // The maximum number of connections to a single host. + * default_options?: array{ + * headers?: array, + * vars?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|null|Param, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|null|Param, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|null|Param, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|null|Param, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|null|Param, // A certificate authority file. + * capath?: scalar|null|Param, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|null|Param, // A PEM formatted certificate file. + * local_pk?: scalar|null|Param, // A private key file. + * passphrase?: scalar|null|Param, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|null|Param, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|null|Param, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|null|Param, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|null|Param, // service id to override the retry strategy. // Default: null + * http_codes?: array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }, + * mock_response_factory?: scalar|null|Param, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * scoped_clients?: array, + * headers?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|null|Param, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|null|Param, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|null|Param, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|null|Param, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|null|Param, // A certificate authority file. + * capath?: scalar|null|Param, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|null|Param, // A PEM formatted certificate file. + * local_pk?: scalar|null|Param, // A private key file. + * passphrase?: scalar|null|Param, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|null|Param, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|null|Param, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|null|Param, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|null|Param, // service id to override the retry strategy. // Default: null + * http_codes?: array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }>, + * }, + * mailer?: bool|array{ // Mailer configuration + * enabled?: bool|Param, // Default: true + * message_bus?: scalar|null|Param, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|null|Param, // Default: null + * transports?: array, + * envelope?: array{ // Mailer Envelope configuration + * sender?: scalar|null|Param, + * recipients?: list, + * allowed_recipients?: list, + * }, + * headers?: array, + * dkim_signer?: bool|array{ // DKIM signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|null|Param, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|null|Param, // Default: "" + * select?: scalar|null|Param, // Default: "" + * passphrase?: scalar|null|Param, // The private key passphrase // Default: "" + * options?: array, + * }, + * smime_signer?: bool|array{ // S/MIME signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|null|Param, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|null|Param, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|null|Param, // The private key passphrase // Default: null + * extra_certificates?: scalar|null|Param, // Default: null + * sign_options?: int|Param, // Default: null + * }, + * smime_encrypter?: bool|array{ // S/MIME encrypter configuration + * enabled?: bool|Param, // Default: false + * repository?: scalar|null|Param, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null + * }, + * }, + * secrets?: bool|array{ + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|null|Param, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|null|Param, // Default: "%kernel.project_dir%/.env.%kernel.runtime_environment%.local" + * decryption_env_var?: scalar|null|Param, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * }, + * notifier?: bool|array{ // Notifier configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|null|Param, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, + * admin_recipients?: list, + * }, + * rate_limiter?: bool|array{ // Rate limiter configuration + * enabled?: bool|Param, // Default: true + * limiters?: array, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|null|Param, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". + * interval?: scalar|null|Param, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 + * }, + * }>, + * }, + * uid?: bool|array{ // Uid configuration + * enabled?: bool|Param, // Default: true + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|null|Param, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|null|Param, + * }, + * html_sanitizer?: bool|array{ // HtmlSanitizer configuration + * enabled?: bool|Param, // Default: false + * sanitizers?: array, + * block_elements?: list, + * drop_elements?: list, + * allow_attributes?: array, + * drop_attributes?: array, + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: list, + * allowed_link_hosts?: list|null, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: list, + * allowed_media_hosts?: list|null, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: list, + * without_attribute_sanitizers?: list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 + * }>, + * }, + * webhook?: bool|array{ // Webhook configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|null|Param, // The message bus to use. // Default: "messenger.default_bus" + * routing?: array, + * }, + * remote-event?: bool|array{ // RemoteEvent configuration + * enabled?: bool|Param, // Default: false + * }, + * json_streamer?: bool|array{ // JSON streamer configuration + * enabled?: bool|Param, // Default: false + * }, + * } + * @psalm-type DoctrineConfig = array{ + * dbal?: array{ + * default_connection?: scalar|null|Param, + * types?: array, + * driver_schemes?: array, + * connections?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|null|Param, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|null|Param, + * slaves?: array, + * replicas?: array, + * }>, + * }, + * orm?: array{ + * default_entity_manager?: scalar|null|Param, + * auto_generate_proxy_classes?: scalar|null|Param, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false + * enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true + * enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false + * proxy_dir?: scalar|null|Param, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies" + * proxy_namespace?: scalar|null|Param, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies" + * controller_resolver?: bool|array{ + * enabled?: bool|Param, // Default: true + * auto_mapping?: bool|null|Param, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null + * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false + * }, + * entity_managers?: array, + * }>, + * }>, + * }, + * connection?: scalar|null|Param, + * class_metadata_factory_name?: scalar|null|Param, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|null|Param, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|null|Param, // Default: false + * naming_strategy?: scalar|null|Param, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|null|Param, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|null|Param, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|null|Param, // Default: null + * fetch_mode_subselect_batch_size?: scalar|null|Param, + * repository_factory?: scalar|null|Param, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false + * second_level_cache?: array{ + * region_cache_driver?: string|array{ + * type?: scalar|null|Param, // Default: null + * id?: scalar|null|Param, + * pool?: scalar|null|Param, + * }, + * region_lock_lifetime?: scalar|null|Param, // Default: 60 + * log_enabled?: bool|Param, // Default: true + * region_lifetime?: scalar|null|Param, // Default: 3600 + * enabled?: bool|Param, // Default: true + * factory?: scalar|null|Param, + * regions?: array, + * loggers?: array, + * }, + * hydrators?: array, + * mappings?: array, + * dql?: array{ + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, + * }, + * filters?: array, + * }>, + * identity_generation_preferences?: array, + * }>, + * resolve_target_entities?: array, + * }, + * } + * @psalm-type DoctrineMigrationsConfig = array{ + * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false + * migrations_paths?: array, + * services?: array, + * factories?: array, + * storage?: array{ // Storage to use for migration status metadata. + * table_storage?: array{ // The default metadata storage, implemented as a table in the database. + * table_name?: scalar|null|Param, // Default: null + * version_column_name?: scalar|null|Param, // Default: null + * version_column_length?: scalar|null|Param, // Default: null + * executed_at_column_name?: scalar|null|Param, // Default: null + * execution_time_column_name?: scalar|null|Param, // Default: null + * }, + * }, + * migrations?: list, + * connection?: scalar|null|Param, // Connection name to use for the migrations database. // Default: null + * em?: scalar|null|Param, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null + * all_or_nothing?: scalar|null|Param, // Run all migrations in a transaction. // Default: false + * check_database_platform?: scalar|null|Param, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true + * custom_template?: scalar|null|Param, // Custom template path for generated migration classes. // Default: null + * organize_migrations?: scalar|null|Param, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false + * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false + * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true + * } + * @psalm-type SecurityConfig = array{ + * access_denied_url?: scalar|null|Param, // Default: null + * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" + * hide_user_not_found?: bool|Param, // Deprecated: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead. + * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" + * erase_credentials?: bool|Param, // Default: true + * access_decision_manager?: array{ + * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, + * service?: scalar|null|Param, + * strategy_service?: scalar|null|Param, + * allow_if_all_abstain?: bool|Param, // Default: false + * allow_if_equal_granted_denied?: bool|Param, // Default: true + * }, + * password_hashers?: array, + * hash_algorithm?: scalar|null|Param, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" + * key_length?: scalar|null|Param, // Default: 40 + * ignore_case?: bool|Param, // Default: false + * encode_as_base64?: bool|Param, // Default: true + * iterations?: scalar|null|Param, // Default: 5000 + * cost?: int|Param, // Default: null + * memory_cost?: scalar|null|Param, // Default: null + * time_cost?: scalar|null|Param, // Default: null + * id?: scalar|null|Param, + * }>, + * providers?: array, + * }, + * entity?: array{ + * class: scalar|null|Param, // The full entity class name of your user class. + * property?: scalar|null|Param, // Default: null + * manager_name?: scalar|null|Param, // Default: null + * }, + * memory?: array{ + * users?: array, + * }>, + * }, + * ldap?: array{ + * service: scalar|null|Param, + * base_dn: scalar|null|Param, + * search_dn?: scalar|null|Param, // Default: null + * search_password?: scalar|null|Param, // Default: null + * extra_fields?: list, + * default_roles?: list, + * role_fetcher?: scalar|null|Param, // Default: null + * uid_key?: scalar|null|Param, // Default: "sAMAccountName" + * filter?: scalar|null|Param, // Default: "({uid_key}={user_identifier})" + * password_attribute?: scalar|null|Param, // Default: null + * }, + * saml?: array{ + * user_class: scalar|null|Param, + * default_roles?: list, + * }, + * }>, + * firewalls: array, + * security?: bool|Param, // Default: true + * user_checker?: scalar|null|Param, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" + * request_matcher?: scalar|null|Param, + * access_denied_url?: scalar|null|Param, + * access_denied_handler?: scalar|null|Param, + * entry_point?: scalar|null|Param, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". + * provider?: scalar|null|Param, + * stateless?: bool|Param, // Default: false + * lazy?: bool|Param, // Default: false + * context?: scalar|null|Param, + * logout?: array{ + * enable_csrf?: bool|null|Param, // Default: null + * csrf_token_id?: scalar|null|Param, // Default: "logout" + * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" + * csrf_token_manager?: scalar|null|Param, + * path?: scalar|null|Param, // Default: "/logout" + * target?: scalar|null|Param, // Default: "/" + * invalidate_session?: bool|Param, // Default: true + * clear_site_data?: list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, + * delete_cookies?: array, + * }, + * switch_user?: array{ + * provider?: scalar|null|Param, + * parameter?: scalar|null|Param, // Default: "_switch_user" + * role?: scalar|null|Param, // Default: "ROLE_ALLOWED_TO_SWITCH" + * target_route?: scalar|null|Param, // Default: null + * }, + * required_badges?: list, + * custom_authenticators?: list, + * login_throttling?: array{ + * limiter?: scalar|null|Param, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". + * max_attempts?: int|Param, // Default: 5 + * interval?: scalar|null|Param, // Default: "1 minute" + * lock_factory?: scalar|null|Param, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" + * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null + * }, + * two_factor?: array{ + * check_path?: scalar|null|Param, // Default: "/2fa_check" + * post_only?: bool|Param, // Default: true + * auth_form_path?: scalar|null|Param, // Default: "/2fa" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|null|Param, // Default: "/" + * success_handler?: scalar|null|Param, // Default: null + * failure_handler?: scalar|null|Param, // Default: null + * authentication_required_handler?: scalar|null|Param, // Default: null + * auth_code_parameter_name?: scalar|null|Param, // Default: "_auth_code" + * trusted_parameter_name?: scalar|null|Param, // Default: "_trusted" + * remember_me_sets_trusted?: scalar|null|Param, // Default: false + * multi_factor?: bool|Param, // Default: false + * prepare_on_login?: bool|Param, // Default: false + * prepare_on_access_denied?: bool|Param, // Default: false + * enable_csrf?: scalar|null|Param, // Default: false + * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" + * csrf_token_id?: scalar|null|Param, // Default: "two_factor" + * csrf_header?: scalar|null|Param, // Default: null + * csrf_token_manager?: scalar|null|Param, // Default: "scheb_two_factor.csrf_token_manager" + * provider?: scalar|null|Param, // Default: null + * }, + * webauthn?: array{ + * user_provider?: scalar|null|Param, // Default: null + * options_storage?: scalar|null|Param, // Deprecated: The child node "options_storage" at path "security.firewalls..webauthn.options_storage" is deprecated. Please use the root option "options_storage" instead. // Default: null + * success_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultSuccessHandler" + * failure_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultFailureHandler" + * secured_rp_ids?: array, + * authentication?: bool|array{ + * enabled?: bool|Param, // Default: true + * profile?: scalar|null|Param, // Default: "default" + * options_builder?: scalar|null|Param, // Default: null + * routes?: array{ + * host?: scalar|null|Param, // Default: null + * options_method?: scalar|null|Param, // Default: "POST" + * options_path?: scalar|null|Param, // Default: "/login/options" + * result_method?: scalar|null|Param, // Default: "POST" + * result_path?: scalar|null|Param, // Default: "/login" + * }, + * options_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultRequestOptionsHandler" + * }, + * registration?: bool|array{ + * enabled?: bool|Param, // Default: false + * profile?: scalar|null|Param, // Default: "default" + * options_builder?: scalar|null|Param, // Default: null + * routes?: array{ + * host?: scalar|null|Param, // Default: null + * options_method?: scalar|null|Param, // Default: "POST" + * options_path?: scalar|null|Param, // Default: "/register/options" + * result_method?: scalar|null|Param, // Default: "POST" + * result_path?: scalar|null|Param, // Default: "/register" + * }, + * options_handler?: scalar|null|Param, // Default: "Webauthn\\Bundle\\Security\\Handler\\DefaultCreationOptionsHandler" + * }, + * }, + * x509?: array{ + * provider?: scalar|null|Param, + * user?: scalar|null|Param, // Default: "SSL_CLIENT_S_DN_Email" + * credentials?: scalar|null|Param, // Default: "SSL_CLIENT_S_DN" + * user_identifier?: scalar|null|Param, // Default: "emailAddress" + * }, + * remote_user?: array{ + * provider?: scalar|null|Param, + * user?: scalar|null|Param, // Default: "REMOTE_USER" + * }, + * saml?: array{ + * provider?: scalar|null|Param, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|null|Param, // Default: "Nbgrp\\OneloginSamlBundle\\Security\\Http\\Authentication\\SamlAuthenticationSuccessHandler" + * failure_handler?: scalar|null|Param, + * check_path?: scalar|null|Param, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|null|Param, // Default: "/login" + * identifier_attribute?: scalar|null|Param, // Default: null + * use_attribute_friendly_name?: bool|Param, // Default: false + * user_factory?: scalar|null|Param, // Default: null + * token_factory?: scalar|null|Param, // Default: null + * persist_user?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|null|Param, // Default: "/" + * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|null|Param, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" + * }, + * login_link?: array{ + * check_route: scalar|null|Param, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_post_only?: scalar|null|Param, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false + * signature_properties: list, + * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 + * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null + * used_link_cache?: scalar|null|Param, // Cache service id used to expired links of max_uses is set. + * success_handler?: scalar|null|Param, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. + * failure_handler?: scalar|null|Param, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. + * provider?: scalar|null|Param, // The user provider to load users from. + * secret?: scalar|null|Param, // Default: "%kernel.secret%" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|null|Param, // Default: "/" + * login_path?: scalar|null|Param, // Default: "/login" + * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|null|Param, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" + * }, + * form_login?: array{ + * provider?: scalar|null|Param, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|null|Param, + * failure_handler?: scalar|null|Param, + * check_path?: scalar|null|Param, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|null|Param, // Default: "/login" + * username_parameter?: scalar|null|Param, // Default: "_username" + * password_parameter?: scalar|null|Param, // Default: "_password" + * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" + * csrf_token_id?: scalar|null|Param, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|null|Param, // Default: "/" + * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|null|Param, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" + * }, + * form_login_ldap?: array{ + * provider?: scalar|null|Param, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|null|Param, + * failure_handler?: scalar|null|Param, + * check_path?: scalar|null|Param, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|null|Param, // Default: "/login" + * username_parameter?: scalar|null|Param, // Default: "_username" + * password_parameter?: scalar|null|Param, // Default: "_password" + * csrf_parameter?: scalar|null|Param, // Default: "_csrf_token" + * csrf_token_id?: scalar|null|Param, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|null|Param, // Default: "/" + * target_path_parameter?: scalar|null|Param, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|null|Param, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|null|Param, // Default: "_failure_path" + * service?: scalar|null|Param, // Default: "ldap" + * dn_string?: scalar|null|Param, // Default: "{user_identifier}" + * query_string?: scalar|null|Param, + * search_dn?: scalar|null|Param, // Default: "" + * search_password?: scalar|null|Param, // Default: "" + * }, + * json_login?: array{ + * provider?: scalar|null|Param, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|null|Param, + * failure_handler?: scalar|null|Param, + * check_path?: scalar|null|Param, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|null|Param, // Default: "/login" + * username_path?: scalar|null|Param, // Default: "username" + * password_path?: scalar|null|Param, // Default: "password" + * }, + * json_login_ldap?: array{ + * provider?: scalar|null|Param, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|null|Param, + * failure_handler?: scalar|null|Param, + * check_path?: scalar|null|Param, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|null|Param, // Default: "/login" + * username_path?: scalar|null|Param, // Default: "username" + * password_path?: scalar|null|Param, // Default: "password" + * service?: scalar|null|Param, // Default: "ldap" + * dn_string?: scalar|null|Param, // Default: "{user_identifier}" + * query_string?: scalar|null|Param, + * search_dn?: scalar|null|Param, // Default: "" + * search_password?: scalar|null|Param, // Default: "" + * }, + * access_token?: array{ + * provider?: scalar|null|Param, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|null|Param, + * failure_handler?: scalar|null|Param, + * realm?: scalar|null|Param, // Default: null + * token_extractors?: list, + * token_handler: string|array{ + * id?: scalar|null|Param, + * oidc_user_info?: string|array{ + * base_uri: scalar|null|Param, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * discovery?: array{ // Enable the OIDC discovery. + * cache?: array{ + * id: scalar|null|Param, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|null|Param, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" + * client?: scalar|null|Param, // HttpClient service id to use to call the OIDC server. + * }, + * oidc?: array{ + * discovery?: array{ // Enable the OIDC discovery. + * base_uri: list, + * cache?: array{ + * id: scalar|null|Param, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|null|Param, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" + * audience: scalar|null|Param, // Audience set in the token, for validation purpose. + * issuers: list, + * algorithm?: array, + * algorithms: list, + * key?: scalar|null|Param, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key). + * keyset?: scalar|null|Param, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false + * algorithms: list, + * keyset: scalar|null|Param, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * }, + * }, + * cas?: array{ + * validation_url: scalar|null|Param, // CAS server validation URL + * prefix?: scalar|null|Param, // CAS prefix // Default: "cas" + * http_client?: scalar|null|Param, // HTTP Client service // Default: null + * }, + * oauth2?: scalar|null|Param, + * }, + * }, + * http_basic?: array{ + * provider?: scalar|null|Param, + * realm?: scalar|null|Param, // Default: "Secured Area" + * }, + * http_basic_ldap?: array{ + * provider?: scalar|null|Param, + * realm?: scalar|null|Param, // Default: "Secured Area" + * service?: scalar|null|Param, // Default: "ldap" + * dn_string?: scalar|null|Param, // Default: "{user_identifier}" + * query_string?: scalar|null|Param, + * search_dn?: scalar|null|Param, // Default: "" + * search_password?: scalar|null|Param, // Default: "" + * }, + * remember_me?: array{ + * secret?: scalar|null|Param, // Default: "%kernel.secret%" + * service?: scalar|null|Param, + * user_providers?: list, + * catch_exceptions?: bool|Param, // Default: true + * signature_properties?: list, + * token_provider?: string|array{ + * service?: scalar|null|Param, // The service ID of a custom remember-me token provider. + * doctrine?: bool|array{ + * enabled?: bool|Param, // Default: false + * connection?: scalar|null|Param, // Default: null + * }, + * }, + * token_verifier?: scalar|null|Param, // The service ID of a custom rememberme token verifier. + * name?: scalar|null|Param, // Default: "REMEMBERME" + * lifetime?: int|Param, // Default: 31536000 + * path?: scalar|null|Param, // Default: "/" + * domain?: scalar|null|Param, // Default: null + * secure?: true|false|"auto"|Param, // Default: null + * httponly?: bool|Param, // Default: true + * samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * always_remember_me?: bool|Param, // Default: false + * remember_me_parameter?: scalar|null|Param, // Default: "_remember_me" + * }, + * }>, + * access_control?: list, + * attributes?: array, + * route?: scalar|null|Param, // Default: null + * methods?: list, + * allow_if?: scalar|null|Param, // Default: null + * roles?: list, + * }>, + * role_hierarchy?: array>, + * } + * @psalm-type TwigConfig = array{ + * form_themes?: list, + * globals?: array, + * autoescape_service?: scalar|null|Param, // Default: null + * autoescape_service_method?: scalar|null|Param, // Default: null + * base_template_class?: scalar|null|Param, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated. + * cache?: scalar|null|Param, // Default: true + * charset?: scalar|null|Param, // Default: "%kernel.charset%" + * debug?: bool|Param, // Default: "%kernel.debug%" + * strict_variables?: bool|Param, // Default: "%kernel.debug%" + * auto_reload?: scalar|null|Param, + * optimizations?: int|Param, + * default_path?: scalar|null|Param, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" + * file_name_pattern?: list, + * paths?: array, + * date?: array{ // The default format options used by the date filter. + * format?: scalar|null|Param, // Default: "F j, Y H:i" + * interval_format?: scalar|null|Param, // Default: "%d days" + * timezone?: scalar|null|Param, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null + * }, + * number_format?: array{ // The default format options for the number_format filter. + * decimals?: int|Param, // Default: 0 + * decimal_point?: scalar|null|Param, // Default: "." + * thousands_separator?: scalar|null|Param, // Default: "," + * }, + * mailer?: array{ + * html_to_text_converter?: scalar|null|Param, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null + * }, + * } + * @psalm-type WebProfilerConfig = array{ + * toolbar?: bool|array{ // Profiler toolbar configuration + * enabled?: bool|Param, // Default: false + * ajax_replace?: bool|Param, // Replace toolbar on AJAX requests // Default: false + * }, + * intercept_redirects?: bool|Param, // Default: false + * excluded_ajax_paths?: scalar|null|Param, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" + * } + * @psalm-type MonologConfig = array{ + * use_microseconds?: scalar|null|Param, // Default: true + * channels?: list, + * handlers?: array, + * excluded_http_codes?: list, + * }>, + * accepted_levels?: list, + * min_level?: scalar|null|Param, // Default: "DEBUG" + * max_level?: scalar|null|Param, // Default: "EMERGENCY" + * buffer_size?: scalar|null|Param, // Default: 0 + * flush_on_overflow?: bool|Param, // Default: false + * handler?: scalar|null|Param, + * url?: scalar|null|Param, + * exchange?: scalar|null|Param, + * exchange_name?: scalar|null|Param, // Default: "log" + * room?: scalar|null|Param, + * message_format?: scalar|null|Param, // Default: "text" + * api_version?: scalar|null|Param, // Default: null + * channel?: scalar|null|Param, // Default: null + * bot_name?: scalar|null|Param, // Default: "Monolog" + * use_attachment?: scalar|null|Param, // Default: true + * use_short_attachment?: scalar|null|Param, // Default: false + * include_extra?: scalar|null|Param, // Default: false + * icon_emoji?: scalar|null|Param, // Default: null + * webhook_url?: scalar|null|Param, + * exclude_fields?: list, + * team?: scalar|null|Param, + * notify?: scalar|null|Param, // Default: false + * nickname?: scalar|null|Param, // Default: "Monolog" + * token?: scalar|null|Param, + * region?: scalar|null|Param, + * source?: scalar|null|Param, + * use_ssl?: bool|Param, // Default: true + * user?: mixed, + * title?: scalar|null|Param, // Default: null + * host?: scalar|null|Param, // Default: null + * port?: scalar|null|Param, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|null|Param, + * timeout?: scalar|null|Param, + * time?: scalar|null|Param, // Default: 60 + * deduplication_level?: scalar|null|Param, // Default: 400 + * store?: scalar|null|Param, // Default: null + * connection_timeout?: scalar|null|Param, + * persistent?: bool|Param, + * dsn?: scalar|null|Param, + * hub_id?: scalar|null|Param, // Default: null + * client_id?: scalar|null|Param, // Default: null + * auto_log_stacks?: scalar|null|Param, // Default: false + * release?: scalar|null|Param, // Default: null + * environment?: scalar|null|Param, // Default: null + * message_type?: scalar|null|Param, // Default: 0 + * parse_mode?: scalar|null|Param, // Default: null + * disable_webpage_preview?: bool|null|Param, // Default: null + * disable_notification?: bool|null|Param, // Default: null + * split_long_messages?: bool|Param, // Default: false + * delay_between_messages?: bool|Param, // Default: false + * topic?: int|Param, // Default: null + * factor?: int|Param, // Default: 1 + * tags?: list, + * console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead. + * console_formatter_options?: mixed, // Default: [] + * formatter?: scalar|null|Param, + * nested?: bool|Param, // Default: false + * publisher?: string|array{ + * id?: scalar|null|Param, + * hostname?: scalar|null|Param, + * port?: scalar|null|Param, // Default: 12201 + * chunk_size?: scalar|null|Param, // Default: 1420 + * encoder?: "json"|"compressed_json"|Param, + * }, + * mongo?: string|array{ + * id?: scalar|null|Param, + * host?: scalar|null|Param, + * port?: scalar|null|Param, // Default: 27017 + * user?: scalar|null|Param, + * pass?: scalar|null|Param, + * database?: scalar|null|Param, // Default: "monolog" + * collection?: scalar|null|Param, // Default: "logs" + * }, + * mongodb?: string|array{ + * id?: scalar|null|Param, // ID of a MongoDB\Client service + * uri?: scalar|null|Param, + * username?: scalar|null|Param, + * password?: scalar|null|Param, + * database?: scalar|null|Param, // Default: "monolog" + * collection?: scalar|null|Param, // Default: "logs" + * }, + * elasticsearch?: string|array{ + * id?: scalar|null|Param, + * hosts?: list, + * host?: scalar|null|Param, + * port?: scalar|null|Param, // Default: 9200 + * transport?: scalar|null|Param, // Default: "Http" + * user?: scalar|null|Param, // Default: null + * password?: scalar|null|Param, // Default: null + * }, + * index?: scalar|null|Param, // Default: "monolog" + * document_type?: scalar|null|Param, // Default: "logs" + * ignore_error?: scalar|null|Param, // Default: false + * redis?: string|array{ + * id?: scalar|null|Param, + * host?: scalar|null|Param, + * password?: scalar|null|Param, // Default: null + * port?: scalar|null|Param, // Default: 6379 + * database?: scalar|null|Param, // Default: 0 + * key_name?: scalar|null|Param, // Default: "monolog_redis" + * }, + * predis?: string|array{ + * id?: scalar|null|Param, + * host?: scalar|null|Param, + * }, + * from_email?: scalar|null|Param, + * to_email?: list, + * subject?: scalar|null|Param, + * content_type?: scalar|null|Param, // Default: null + * headers?: list, + * mailer?: scalar|null|Param, // Default: null + * email_prototype?: string|array{ + * id: scalar|null|Param, + * method?: scalar|null|Param, // Default: null + * }, + * lazy?: bool|Param, // Default: true + * verbosity_levels?: array{ + * VERBOSITY_QUIET?: scalar|null|Param, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|null|Param, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|null|Param, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|null|Param, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|null|Param, // Default: "DEBUG" + * }, + * channels?: string|array{ + * type?: scalar|null|Param, + * elements?: list, + * }, + * }>, + * } + * @psalm-type DebugConfig = array{ + * max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500 + * min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1 + * max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1 + * dump_destination?: scalar|null|Param, // A stream URL where dumps should be written to. // Default: null + * theme?: "dark"|"light"|Param, // Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light". // Default: "dark" + * } + * @psalm-type MakerConfig = array{ + * root_namespace?: scalar|null|Param, // Default: "App" + * generate_final_classes?: bool|Param, // Default: true + * generate_final_entities?: bool|Param, // Default: false + * } + * @psalm-type WebpackEncoreConfig = array{ + * output_path: scalar|null|Param, // The path where Encore is building the assets - i.e. Encore.setOutputPath() + * crossorigin?: false|"anonymous"|"use-credentials"|Param, // crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials // Default: false + * preload?: bool|Param, // preload all rendered script and link tags automatically via the http2 Link header. // Default: false + * cache?: bool|Param, // Enable caching of the entry point file(s) // Default: false + * strict_mode?: bool|Param, // Throw an exception if the entrypoints.json file is missing or an entry is missing from the data // Default: true + * builds?: array, + * script_attributes?: array, + * link_attributes?: array, + * } + * @psalm-type DatatablesConfig = array{ + * language_from_cdn?: bool|Param, // Load i18n data from DataTables CDN or locally // Default: true + * persist_state?: "none"|"query"|"fragment"|"local"|"session"|Param, // Where to persist the current table state automatically // Default: "fragment" + * method?: "GET"|"POST"|Param, // Default HTTP method to be used for callbacks // Default: "POST" + * options?: array, + * renderer?: scalar|null|Param, // Default service used to render templates, built-in TwigRenderer uses global Twig environment // Default: "Omines\\DataTablesBundle\\Twig\\TwigRenderer" + * template?: scalar|null|Param, // Default template to be used for DataTables HTML // Default: "@DataTables/datatable_html.html.twig" + * template_parameters?: array{ // Default parameters to be passed to the template + * className?: scalar|null|Param, // Default class attribute to apply to the root table elements // Default: "table table-bordered" + * columnFilter?: "thead"|"tfoot"|"both"|null|Param, // If and where to enable the DataTables Filter module // Default: null + * ... + * }, + * translation_domain?: scalar|null|Param, // Default translation domain to be used // Default: "messages" + * } + * @psalm-type LiipImagineConfig = array{ + * resolvers?: array, + * get_options?: array, + * put_options?: array, + * proxies?: array, + * }, + * flysystem?: array{ + * filesystem_service: scalar|null|Param, + * cache_prefix?: scalar|null|Param, // Default: "" + * root_url: scalar|null|Param, + * visibility?: "public"|"private"|"noPredefinedVisibility"|Param, // Default: "public" + * }, + * }>, + * loaders?: array, + * allow_unresolvable_data_roots?: bool|Param, // Default: false + * bundle_resources?: array{ + * enabled?: bool|Param, // Default: false + * access_control_type?: "blacklist"|"whitelist"|Param, // Sets the access control method applied to bundle names in "access_control_list" into a blacklist or whitelist. // Default: "blacklist" + * access_control_list?: list, + * }, + * }, + * flysystem?: array{ + * filesystem_service: scalar|null|Param, + * }, + * asset_mapper?: array, + * chain?: array{ + * loaders: list, + * }, + * }>, + * driver?: scalar|null|Param, // Default: "gd" + * cache?: scalar|null|Param, // Default: "default" + * cache_base_path?: scalar|null|Param, // Default: "" + * data_loader?: scalar|null|Param, // Default: "default" + * default_image?: scalar|null|Param, // Default: null + * default_filter_set_settings?: array{ + * quality?: scalar|null|Param, // Default: 100 + * jpeg_quality?: scalar|null|Param, // Default: null + * png_compression_level?: scalar|null|Param, // Default: null + * png_compression_filter?: scalar|null|Param, // Default: null + * format?: scalar|null|Param, // Default: null + * animated?: bool|Param, // Default: false + * cache?: scalar|null|Param, // Default: null + * data_loader?: scalar|null|Param, // Default: null + * default_image?: scalar|null|Param, // Default: null + * filters?: array>, + * post_processors?: array>, + * }, + * controller?: array{ + * filter_action?: scalar|null|Param, // Default: "Liip\\ImagineBundle\\Controller\\ImagineController::filterAction" + * filter_runtime_action?: scalar|null|Param, // Default: "Liip\\ImagineBundle\\Controller\\ImagineController::filterRuntimeAction" + * redirect_response_code?: int|Param, // Default: 302 + * }, + * filter_sets?: array>, + * post_processors?: array>, + * }>, + * twig?: array{ + * mode?: "none"|"lazy"|"legacy"|Param, // Twig mode: none/lazy/legacy (default) // Default: "legacy" + * assets_version?: scalar|null|Param, // Default: null + * }, + * enqueue?: bool|Param, // Enables integration with enqueue if set true. Allows resolve image caches in background by sending messages to MQ. // Default: false + * messenger?: bool|array{ // Enables integration with symfony/messenger if set true. Warmup image caches in background by sending messages to MQ. + * enabled?: bool|Param, // Default: false + * }, + * templating?: bool|Param, // Enables integration with symfony/templating component // Default: true + * webp?: array{ + * generate?: bool|Param, // Default: false + * quality?: int|Param, // Default: 100 + * cache?: scalar|null|Param, // Default: null + * data_loader?: scalar|null|Param, // Default: null + * post_processors?: array>, + * }, + * } + * @psalm-type DamaDoctrineTestConfig = array{ + * enable_static_connection?: mixed, // Default: true + * enable_static_meta_data_cache?: bool|Param, // Default: true + * enable_static_query_cache?: bool|Param, // Default: true + * connection_keys?: list, + * } + * @psalm-type TwigExtraConfig = array{ + * cache?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * html?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * markdown?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * intl?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * cssinliner?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * inky?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * string?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * commonmark?: array{ + * renderer?: array{ // Array of options for rendering HTML. + * block_separator?: scalar|null|Param, + * inner_separator?: scalar|null|Param, + * soft_break?: scalar|null|Param, + * }, + * html_input?: "strip"|"allow"|"escape"|Param, // How to handle HTML input. + * allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true + * max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807 + * max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807 + * slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created. + * instance?: mixed, + * max_length?: int|Param, // Default: 255 + * unique?: mixed, + * }, + * commonmark?: array{ // Array of options for configuring the CommonMark core extension. + * enable_em?: bool|Param, // Default: true + * enable_strong?: bool|Param, // Default: true + * use_asterisk?: bool|Param, // Default: true + * use_underscore?: bool|Param, // Default: true + * unordered_list_markers?: list, + * }, + * ... + * }, + * } + * @psalm-type GregwarCaptchaConfig = array{ + * length?: scalar|null|Param, // Default: 5 + * width?: scalar|null|Param, // Default: 130 + * height?: scalar|null|Param, // Default: 50 + * font?: scalar|null|Param, // Default: "C:\\Users\\mail\\Documents\\PHP\\Part-DB-server\\vendor\\gregwar\\captcha-bundle\\DependencyInjection/../Generator/Font/captcha.ttf" + * keep_value?: scalar|null|Param, // Default: false + * charset?: scalar|null|Param, // Default: "abcdefhjkmnprstuvwxyz23456789" + * as_file?: scalar|null|Param, // Default: false + * as_url?: scalar|null|Param, // Default: false + * reload?: scalar|null|Param, // Default: false + * image_folder?: scalar|null|Param, // Default: "captcha" + * web_path?: scalar|null|Param, // Default: "%kernel.project_dir%/public" + * gc_freq?: scalar|null|Param, // Default: 100 + * expiration?: scalar|null|Param, // Default: 60 + * quality?: scalar|null|Param, // Default: 50 + * invalid_message?: scalar|null|Param, // Default: "Bad code value" + * bypass_code?: scalar|null|Param, // Default: null + * whitelist_key?: scalar|null|Param, // Default: "captcha_whitelist_key" + * humanity?: scalar|null|Param, // Default: 0 + * distortion?: scalar|null|Param, // Default: true + * max_front_lines?: scalar|null|Param, // Default: null + * max_behind_lines?: scalar|null|Param, // Default: null + * interpolation?: scalar|null|Param, // Default: true + * text_color?: list, + * background_color?: list, + * background_images?: list, + * disabled?: scalar|null|Param, // Default: false + * ignore_all_effects?: scalar|null|Param, // Default: false + * session_key?: scalar|null|Param, // Default: "captcha" + * } + * @psalm-type FlorianvSwapConfig = array{ + * cache?: array{ + * ttl?: int|Param, // Default: 3600 + * type?: scalar|null|Param, // A cache type or service id // Default: null + * }, + * providers?: array{ + * apilayer_fixer?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * apilayer_currency_data?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * apilayer_exchange_rates_data?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * abstract_api?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * fixer?: array{ + * priority?: int|Param, // Default: 0 + * access_key: scalar|null|Param, + * enterprise?: bool|Param, // Default: false + * }, + * cryptonator?: array{ + * priority?: int|Param, // Default: 0 + * }, + * exchange_rates_api?: array{ + * priority?: int|Param, // Default: 0 + * access_key: scalar|null|Param, + * enterprise?: bool|Param, // Default: false + * }, + * webservicex?: array{ + * priority?: int|Param, // Default: 0 + * }, + * central_bank_of_czech_republic?: array{ + * priority?: int|Param, // Default: 0 + * }, + * central_bank_of_republic_turkey?: array{ + * priority?: int|Param, // Default: 0 + * }, + * european_central_bank?: array{ + * priority?: int|Param, // Default: 0 + * }, + * national_bank_of_romania?: array{ + * priority?: int|Param, // Default: 0 + * }, + * russian_central_bank?: array{ + * priority?: int|Param, // Default: 0 + * }, + * frankfurter?: array{ + * priority?: int|Param, // Default: 0 + * }, + * fawazahmed_currency_api?: array{ + * priority?: int|Param, // Default: 0 + * }, + * bulgarian_national_bank?: array{ + * priority?: int|Param, // Default: 0 + * }, + * national_bank_of_ukraine?: array{ + * priority?: int|Param, // Default: 0 + * }, + * currency_data_feed?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * currency_layer?: array{ + * priority?: int|Param, // Default: 0 + * access_key: scalar|null|Param, + * enterprise?: bool|Param, // Default: false + * }, + * forge?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * open_exchange_rates?: array{ + * priority?: int|Param, // Default: 0 + * app_id: scalar|null|Param, + * enterprise?: bool|Param, // Default: false + * }, + * xignite?: array{ + * priority?: int|Param, // Default: 0 + * token: scalar|null|Param, + * }, + * xchangeapi?: array{ + * priority?: int|Param, // Default: 0 + * api_key: scalar|null|Param, + * }, + * currency_converter?: array{ + * priority?: int|Param, // Default: 0 + * access_key: scalar|null|Param, + * enterprise?: bool|Param, // Default: false + * }, + * array?: array{ + * priority?: int|Param, // Default: 0 + * latestRates: mixed, + * historicalRates?: mixed, + * }, + * }, + * } + * @psalm-type NelmioSecurityConfig = array{ + * signed_cookie?: array{ + * names?: list, + * secret?: scalar|null|Param, // Default: "%kernel.secret%" + * hash_algo?: scalar|null|Param, + * legacy_hash_algo?: scalar|null|Param, // Fallback algorithm to allow for frictionless hash algorithm upgrades. Use with caution and as a temporary measure as it allows for downgrade attacks. // Default: null + * separator?: scalar|null|Param, // Default: "." + * }, + * clickjacking?: array{ + * hosts?: list, + * paths?: array, + * content_types?: list, + * }, + * external_redirects?: array{ + * abort?: bool|Param, // Default: false + * override?: scalar|null|Param, // Default: null + * forward_as?: scalar|null|Param, // Default: null + * log?: bool|Param, // Default: false + * allow_list?: list, + * }, + * flexible_ssl?: bool|array{ + * enabled?: bool|Param, // Default: false + * cookie_name?: scalar|null|Param, // Default: "auth" + * unsecured_logout?: bool|Param, // Default: false + * }, + * forced_ssl?: bool|array{ + * enabled?: bool|Param, // Default: false + * hsts_max_age?: scalar|null|Param, // Default: null + * hsts_subdomains?: bool|Param, // Default: false + * hsts_preload?: bool|Param, // Default: false + * allow_list?: list, + * hosts?: list, + * redirect_status_code?: scalar|null|Param, // Default: 302 + * }, + * content_type?: array{ + * nosniff?: bool|Param, // Default: false + * }, + * xss_protection?: array{ // Deprecated: The "xss_protection" option is deprecated, use Content Security Policy without allowing "unsafe-inline" scripts instead. + * enabled?: bool|Param, // Default: false + * mode_block?: bool|Param, // Default: false + * report_uri?: scalar|null|Param, // Default: null + * }, + * csp?: bool|array{ + * enabled?: bool|Param, // Default: true + * request_matcher?: scalar|null|Param, // Default: null + * hosts?: list, + * content_types?: list, + * report_endpoint?: array{ + * log_channel?: scalar|null|Param, // Default: null + * log_formatter?: scalar|null|Param, // Default: "nelmio_security.csp_report.log_formatter" + * log_level?: "alert"|"critical"|"debug"|"emergency"|"error"|"info"|"notice"|"warning"|Param, // Default: "notice" + * filters?: array{ + * domains?: bool|Param, // Default: true + * schemes?: bool|Param, // Default: true + * browser_bugs?: bool|Param, // Default: true + * injected_scripts?: bool|Param, // Default: true + * }, + * dismiss?: list>, + * }, + * compat_headers?: bool|Param, // Default: true + * report_logger_service?: scalar|null|Param, // Default: "logger" + * hash?: array{ + * algorithm?: "sha256"|"sha384"|"sha512"|Param, // The algorithm to use for hashes // Default: "sha256" + * }, + * report?: array{ + * level1_fallback?: bool|Param, // Provides CSP Level 1 fallback when using hash or nonce (CSP level 2) by adding 'unsafe-inline' source. See https://www.w3.org/TR/CSP2/#directive-script-src and https://www.w3.org/TR/CSP2/#directive-style-src // Default: true + * browser_adaptive?: bool|array{ // Do not send directives that browser do not support + * enabled?: bool|Param, // Default: false + * parser?: scalar|null|Param, // Default: "nelmio_security.ua_parser.ua_php" + * }, + * default-src?: list, + * base-uri?: list, + * block-all-mixed-content?: bool|Param, // Default: false + * child-src?: list, + * connect-src?: list, + * font-src?: list, + * form-action?: list, + * frame-ancestors?: list, + * frame-src?: list, + * img-src?: list, + * manifest-src?: list, + * media-src?: list, + * object-src?: list, + * plugin-types?: list, + * script-src?: list, + * style-src?: list, + * upgrade-insecure-requests?: bool|Param, // Default: false + * report-uri?: list, + * worker-src?: list, + * prefetch-src?: list, + * report-to?: scalar|null|Param, + * }, + * enforce?: array{ + * level1_fallback?: bool|Param, // Provides CSP Level 1 fallback when using hash or nonce (CSP level 2) by adding 'unsafe-inline' source. See https://www.w3.org/TR/CSP2/#directive-script-src and https://www.w3.org/TR/CSP2/#directive-style-src // Default: true + * browser_adaptive?: bool|array{ // Do not send directives that browser do not support + * enabled?: bool|Param, // Default: false + * parser?: scalar|null|Param, // Default: "nelmio_security.ua_parser.ua_php" + * }, + * default-src?: list, + * base-uri?: list, + * block-all-mixed-content?: bool|Param, // Default: false + * child-src?: list, + * connect-src?: list, + * font-src?: list, + * form-action?: list, + * frame-ancestors?: list, + * frame-src?: list, + * img-src?: list, + * manifest-src?: list, + * media-src?: list, + * object-src?: list, + * plugin-types?: list, + * script-src?: list, + * style-src?: list, + * upgrade-insecure-requests?: bool|Param, // Default: false + * report-uri?: list, + * worker-src?: list, + * prefetch-src?: list, + * report-to?: scalar|null|Param, + * }, + * }, + * referrer_policy?: bool|array{ + * enabled?: bool|Param, // Default: false + * policies?: list, + * }, + * permissions_policy?: bool|array{ + * enabled?: bool|Param, // Default: false + * policies?: array{ + * accelerometer?: mixed, // Default: null + * ambient_light_sensor?: mixed, // Default: null + * attribution_reporting?: mixed, // Default: null + * autoplay?: mixed, // Default: null + * bluetooth?: mixed, // Default: null + * browsing_topics?: mixed, // Default: null + * camera?: mixed, // Default: null + * captured_surface_control?: mixed, // Default: null + * compute_pressure?: mixed, // Default: null + * cross_origin_isolated?: mixed, // Default: null + * deferred_fetch?: mixed, // Default: null + * deferred_fetch_minimal?: mixed, // Default: null + * display_capture?: mixed, // Default: null + * encrypted_media?: mixed, // Default: null + * fullscreen?: mixed, // Default: null + * gamepad?: mixed, // Default: null + * geolocation?: mixed, // Default: null + * gyroscope?: mixed, // Default: null + * hid?: mixed, // Default: null + * identity_credentials_get?: mixed, // Default: null + * idle_detection?: mixed, // Default: null + * interest_cohort?: mixed, // Default: null + * language_detector?: mixed, // Default: null + * local_fonts?: mixed, // Default: null + * magnetometer?: mixed, // Default: null + * microphone?: mixed, // Default: null + * midi?: mixed, // Default: null + * otp_credentials?: mixed, // Default: null + * payment?: mixed, // Default: null + * picture_in_picture?: mixed, // Default: null + * publickey_credentials_create?: mixed, // Default: null + * publickey_credentials_get?: mixed, // Default: null + * screen_wake_lock?: mixed, // Default: null + * serial?: mixed, // Default: null + * speaker_selection?: mixed, // Default: null + * storage_access?: mixed, // Default: null + * summarizer?: mixed, // Default: null + * translator?: mixed, // Default: null + * usb?: mixed, // Default: null + * web_share?: mixed, // Default: null + * window_management?: mixed, // Default: null + * xr_spatial_tracking?: mixed, // Default: null + * }, + * }, + * } + * @psalm-type TurboConfig = array{ + * broadcast?: bool|array{ + * enabled?: bool|Param, // Default: true + * entity_template_prefixes?: list, + * doctrine_orm?: bool|array{ // Enable the Doctrine ORM integration + * enabled?: bool|Param, // Default: true + * }, + * }, + * default_transport?: scalar|null|Param, // Default: "default" + * } + * @psalm-type TfaWebauthnConfig = array{ + * enabled?: scalar|null|Param, // Default: false + * timeout?: int|Param, // Default: 60000 + * rpID?: scalar|null|Param, // Default: null + * rpName?: scalar|null|Param, // Default: "Webauthn Application" + * rpIcon?: scalar|null|Param, // Default: null + * template?: scalar|null|Param, // Default: "@TFAWebauthn/Authentication/form.html.twig" + * U2FAppID?: scalar|null|Param, // Default: null + * } + * @psalm-type SchebTwoFactorConfig = array{ + * persister?: scalar|null|Param, // Default: "scheb_two_factor.persister.doctrine" + * model_manager_name?: scalar|null|Param, // Default: null + * security_tokens?: list, + * ip_whitelist?: list, + * ip_whitelist_provider?: scalar|null|Param, // Default: "scheb_two_factor.default_ip_whitelist_provider" + * two_factor_token_factory?: scalar|null|Param, // Default: "scheb_two_factor.default_token_factory" + * two_factor_provider_decider?: scalar|null|Param, // Default: "scheb_two_factor.default_provider_decider" + * two_factor_condition?: scalar|null|Param, // Default: null + * code_reuse_cache?: scalar|null|Param, // Default: null + * code_reuse_cache_duration?: int|Param, // Default: 60 + * code_reuse_default_handler?: scalar|null|Param, // Default: null + * trusted_device?: bool|array{ + * enabled?: scalar|null|Param, // Default: false + * manager?: scalar|null|Param, // Default: "scheb_two_factor.default_trusted_device_manager" + * lifetime?: int|Param, // Default: 5184000 + * extend_lifetime?: bool|Param, // Default: false + * key?: scalar|null|Param, // Default: null + * cookie_name?: scalar|null|Param, // Default: "trusted_device" + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_domain?: scalar|null|Param, // Default: null + * cookie_path?: scalar|null|Param, // Default: "/" + * cookie_same_site?: scalar|null|Param, // Default: "lax" + * }, + * backup_codes?: bool|array{ + * enabled?: scalar|null|Param, // Default: false + * manager?: scalar|null|Param, // Default: "scheb_two_factor.default_backup_code_manager" + * }, + * google?: bool|array{ + * enabled?: scalar|null|Param, // Default: false + * form_renderer?: scalar|null|Param, // Default: null + * issuer?: scalar|null|Param, // Default: null + * server_name?: scalar|null|Param, // Default: null + * template?: scalar|null|Param, // Default: "@SchebTwoFactor/Authentication/form.html.twig" + * digits?: int|Param, // Default: 6 + * leeway?: int|Param, // Default: 0 + * }, + * } + * @psalm-type WebauthnConfig = array{ + * fake_credential_generator?: scalar|null|Param, // A service that implements the FakeCredentialGenerator to generate fake credentials for preventing username enumeration. // Default: "Webauthn\\SimpleFakeCredentialGenerator" + * clock?: scalar|null|Param, // PSR-20 Clock service. // Default: "webauthn.clock.default" + * options_storage?: scalar|null|Param, // Service responsible of the options/user entity storage during the ceremony // Default: "Webauthn\\Bundle\\Security\\Storage\\SessionStorage" + * event_dispatcher?: scalar|null|Param, // PSR-14 Event Dispatcher service. // Default: "Psr\\EventDispatcher\\EventDispatcherInterface" + * http_client?: scalar|null|Param, // A Symfony HTTP client. // Default: "webauthn.http_client.default" + * logger?: scalar|null|Param, // A PSR-3 logger to receive logs during the processes // Default: "webauthn.logger.default" + * credential_repository?: scalar|null|Param, // This repository is responsible of the credential storage // Default: "Webauthn\\Bundle\\Repository\\DummyPublicKeyCredentialSourceRepository" + * user_repository?: scalar|null|Param, // This repository is responsible of the user storage // Default: "Webauthn\\Bundle\\Repository\\DummyPublicKeyCredentialUserEntityRepository" + * allowed_origins?: array, + * allow_subdomains?: bool|Param, // Default: false + * secured_rp_ids?: array, + * counter_checker?: scalar|null|Param, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid" + * top_origin_validator?: scalar|null|Param, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null + * creation_profiles?: array, + * public_key_credential_parameters?: list, + * attestation_conveyance?: scalar|null|Param, // Default: "none" + * }>, + * request_profiles?: array, + * }>, + * metadata?: bool|array{ // Enable the support of the Metadata Statements. Please read the documentation for this feature. + * enabled?: bool|Param, // Default: false + * mds_repository: scalar|null|Param, // The Metadata Statement repository. + * status_report_repository: scalar|null|Param, // The Status Report repository. + * certificate_chain_checker?: scalar|null|Param, // A Certificate Chain checker. // Default: "Webauthn\\MetadataService\\CertificateChain\\PhpCertificateChainValidator" + * }, + * controllers?: bool|array{ + * enabled?: bool|Param, // Default: false + * creation?: array, + * allow_subdomains?: bool|Param, // Default: false + * secured_rp_ids?: array, + * }>, + * request?: array, + * allow_subdomains?: bool|Param, // Default: false + * secured_rp_ids?: array, + * }>, + * }, + * } + * @psalm-type NbgrpOneloginSamlConfig = array{ // nb:group OneLogin PHP Symfony Bundle configuration + * onelogin_settings?: array/saml/" + * strict?: bool|Param, + * debug?: bool|Param, + * idp: array{ + * entityId: scalar|null|Param, + * singleSignOnService: array{ + * url: scalar|null|Param, + * binding?: scalar|null|Param, + * }, + * singleLogoutService?: array{ + * url?: scalar|null|Param, + * responseUrl?: scalar|null|Param, + * binding?: scalar|null|Param, + * }, + * x509cert?: scalar|null|Param, + * certFingerprint?: scalar|null|Param, + * certFingerprintAlgorithm?: "sha1"|"sha256"|"sha384"|"sha512"|Param, + * x509certMulti?: array{ + * signing?: list, + * encryption?: list, + * }, + * }, + * sp?: array{ + * entityId?: scalar|null|Param, // Default: "/saml/metadata" + * assertionConsumerService?: array{ + * url?: scalar|null|Param, // Default: "/saml/acs" + * binding?: scalar|null|Param, + * }, + * attributeConsumingService?: array{ + * serviceName?: scalar|null|Param, + * serviceDescription?: scalar|null|Param, + * requestedAttributes?: list, + * }>, + * }, + * singleLogoutService?: array{ + * url?: scalar|null|Param, // Default: "/saml/logout" + * binding?: scalar|null|Param, + * }, + * NameIDFormat?: scalar|null|Param, + * x509cert?: scalar|null|Param, + * privateKey?: scalar|null|Param, + * x509certNew?: scalar|null|Param, + * }, + * compress?: array{ + * requests?: bool|Param, + * responses?: bool|Param, + * }, + * security?: array{ + * nameIdEncrypted?: bool|Param, + * authnRequestsSigned?: bool|Param, + * logoutRequestSigned?: bool|Param, + * logoutResponseSigned?: bool|Param, + * signMetadata?: bool|Param, + * wantMessagesSigned?: bool|Param, + * wantAssertionsEncrypted?: bool|Param, + * wantAssertionsSigned?: bool|Param, + * wantNameId?: bool|Param, + * wantNameIdEncrypted?: bool|Param, + * requestedAuthnContext?: mixed, + * requestedAuthnContextComparison?: "exact"|"minimum"|"maximum"|"better"|Param, + * wantXMLValidation?: bool|Param, + * relaxDestinationValidation?: bool|Param, + * destinationStrictlyMatches?: bool|Param, + * allowRepeatAttributeName?: bool|Param, + * rejectUnsolicitedResponsesWithInResponseTo?: bool|Param, + * signatureAlgorithm?: "http://www.w3.org/2000/09/xmldsig#rsa-sha1"|"http://www.w3.org/2000/09/xmldsig#dsa-sha1"|"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"|"http://www.w3.org/2001/04/xmldsig-more#rsa-sha384"|"http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"|Param, + * digestAlgorithm?: "http://www.w3.org/2000/09/xmldsig#sha1"|"http://www.w3.org/2001/04/xmlenc#sha256"|"http://www.w3.org/2001/04/xmldsig-more#sha384"|"http://www.w3.org/2001/04/xmlenc#sha512"|Param, + * encryption_algorithm?: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc"|"http://www.w3.org/2001/04/xmlenc#aes128-cbc"|"http://www.w3.org/2001/04/xmlenc#aes192-cbc"|"http://www.w3.org/2001/04/xmlenc#aes256-cbc"|"http://www.w3.org/2009/xmlenc11#aes128-gcm"|"http://www.w3.org/2009/xmlenc11#aes192-gcm"|"http://www.w3.org/2009/xmlenc11#aes256-gcm"|Param, + * lowercaseUrlencoding?: bool|Param, + * }, + * contactPerson?: array{ + * technical?: array{ + * givenName: scalar|null|Param, + * emailAddress: scalar|null|Param, + * }, + * support?: array{ + * givenName: scalar|null|Param, + * emailAddress: scalar|null|Param, + * }, + * administrative?: array{ + * givenName: scalar|null|Param, + * emailAddress: scalar|null|Param, + * }, + * billing?: array{ + * givenName: scalar|null|Param, + * emailAddress: scalar|null|Param, + * }, + * other?: array{ + * givenName: scalar|null|Param, + * emailAddress: scalar|null|Param, + * }, + * }, + * organization?: list, + * }>, + * use_proxy_vars?: bool|Param, // Default: false + * idp_parameter_name?: scalar|null|Param, // Default: "idp" + * entity_manager_name?: scalar|null|Param, + * authn_request?: array{ + * parameters?: list, + * forceAuthn?: bool|Param, // Default: false + * isPassive?: bool|Param, // Default: false + * setNameIdPolicy?: bool|Param, // Default: true + * nameIdValueReq?: scalar|null|Param, // Default: null + * }, + * } + * @psalm-type StimulusConfig = array{ + * controller_paths?: list, + * controllers_json?: scalar|null|Param, // Default: "%kernel.project_dir%/assets/controllers.json" + * } + * @psalm-type UxTranslatorConfig = array{ + * dump_directory?: scalar|null|Param, // The directory where translations and TypeScript types are dumped. // Default: "%kernel.project_dir%/var/translations" + * dump_typescript?: bool|Param, // Control whether TypeScript types are dumped alongside translations. Disable this if you do not use TypeScript (e.g. in production when using AssetMapper). // Default: true + * domains?: string|array{ // List of domains to include/exclude from the generated translations. Prefix with a `!` to exclude a domain. + * type?: scalar|null|Param, + * elements?: list, + * }, + * keys_patterns?: list, + * } + * @psalm-type DompdfFontLoaderConfig = array{ + * autodiscovery?: bool|array{ + * paths?: list, + * exclude_patterns?: list, + * file_pattern?: scalar|null|Param, // Default: "/\\.(ttf)$/" + * enabled?: bool|Param, // Default: true + * }, + * auto_install?: bool|Param, // Default: false + * fonts?: list, + * } + * @psalm-type KnpuOauth2ClientConfig = array{ + * http_client?: scalar|null|Param, // Service id of HTTP client to use (must implement GuzzleHttp\ClientInterface) // Default: null + * http_client_options?: array{ + * timeout?: int|Param, + * proxy?: scalar|null|Param, + * verify?: bool|Param, // Use only with proxy option set + * }, + * clients?: array>, + * } + * @psalm-type NelmioCorsConfig = array{ + * defaults?: array{ + * allow_credentials?: bool|Param, // Default: false + * allow_origin?: list, + * allow_headers?: list, + * allow_methods?: list, + * allow_private_network?: bool|Param, // Default: false + * expose_headers?: list, + * max_age?: scalar|null|Param, // Default: 0 + * hosts?: list, + * origin_regex?: bool|Param, // Default: false + * forced_allow_origin_value?: scalar|null|Param, // Default: null + * skip_same_as_origin?: bool|Param, // Default: true + * }, + * paths?: array, + * allow_headers?: list, + * allow_methods?: list, + * allow_private_network?: bool|Param, + * expose_headers?: list, + * max_age?: scalar|null|Param, // Default: 0 + * hosts?: list, + * origin_regex?: bool|Param, + * forced_allow_origin_value?: scalar|null|Param, // Default: null + * skip_same_as_origin?: bool|Param, + * }>, + * } + * @psalm-type JbtronicsSettingsConfig = array{ + * search_paths?: list, + * proxy_dir?: scalar|null|Param, // Default: "%kernel.cache_dir%/jbtronics_settings/proxies" + * proxy_namespace?: scalar|null|Param, // Default: "Jbtronics\\SettingsBundle\\Proxies" + * default_storage_adapter?: scalar|null|Param, // Default: null + * save_after_migration?: bool|Param, // Default: true + * file_storage?: array{ + * storage_directory?: scalar|null|Param, // Default: "%kernel.project_dir%/var/jbtronics_settings/" + * default_filename?: scalar|null|Param, // Default: "settings" + * }, + * orm_storage?: array{ + * default_entity_class?: scalar|null|Param, // Default: null + * prefetch_all?: bool|Param, // Default: true + * }, + * cache?: array{ + * service?: scalar|null|Param, // Default: "cache.app.taggable" + * default_cacheable?: bool|Param, // Default: false + * ttl?: int|Param, // Default: 0 + * invalidate_on_env_change?: bool|Param, // Default: true + * }, + * } + * @psalm-type JbtronicsTranslationEditorConfig = array{ + * translations_path?: scalar|null|Param, // Default: "%translator.default_path%" + * format?: scalar|null|Param, // Default: "xlf" + * xliff_version?: scalar|null|Param, // Default: "2.0" + * use_intl_icu_format?: bool|Param, // Default: false + * writer_options?: list, + * } + * @psalm-type ApiPlatformConfig = array{ + * title?: scalar|null|Param, // The title of the API. // Default: "" + * description?: scalar|null|Param, // The description of the API. // Default: "" + * version?: scalar|null|Param, // The version of the API. // Default: "0.0.0" + * show_webby?: bool|Param, // If true, show Webby on the documentation page // Default: true + * use_symfony_listeners?: bool|Param, // Uses Symfony event listeners instead of the ApiPlatform\Symfony\Controller\MainController. // Default: false + * name_converter?: scalar|null|Param, // Specify a name converter to use. // Default: null + * asset_package?: scalar|null|Param, // Specify an asset package name to use. // Default: null + * path_segment_name_generator?: scalar|null|Param, // Specify a path name generator to use. // Default: "api_platform.metadata.path_segment_name_generator.underscore" + * inflector?: scalar|null|Param, // Specify an inflector to use. // Default: "api_platform.metadata.inflector" + * validator?: array{ + * serialize_payload_fields?: mixed, // Set to null to serialize all payload fields when a validation error is thrown, or set the fields you want to include explicitly. // Default: [] + * query_parameter_validation?: bool|Param, // Deprecated: Will be removed in API Platform 5.0. // Default: true + * }, + * eager_loading?: bool|array{ + * enabled?: bool|Param, // Default: true + * fetch_partial?: bool|Param, // Fetch only partial data according to serialization groups. If enabled, Doctrine ORM entities will not work as expected if any of the other fields are used. // Default: false + * max_joins?: int|Param, // Max number of joined relations before EagerLoading throws a RuntimeException // Default: 30 + * force_eager?: bool|Param, // Force join on every relation. If disabled, it will only join relations having the EAGER fetch mode. // Default: true + * }, + * handle_symfony_errors?: bool|Param, // Allows to handle symfony exceptions. // Default: false + * enable_swagger?: bool|Param, // Enable the Swagger documentation and export. // Default: true + * enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false + * enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true + * enable_re_doc?: bool|Param, // Enable ReDoc // Default: true + * enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true + * enable_docs?: bool|Param, // Enable the docs // Default: true + * enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true + * enable_phpdoc_parser?: bool|Param, // Enable resource metadata collector using PHPStan PhpDocParser. // Default: true + * enable_link_security?: bool|Param, // Enable security for Links (sub resources) // Default: false + * collection?: array{ + * exists_parameter_name?: scalar|null|Param, // The name of the query parameter to filter on nullable field values. // Default: "exists" + * order?: scalar|null|Param, // The default order of results. // Default: "ASC" + * order_parameter_name?: scalar|null|Param, // The name of the query parameter to order results. // Default: "order" + * order_nulls_comparison?: "nulls_smallest"|"nulls_largest"|"nulls_always_first"|"nulls_always_last"|null|Param, // The nulls comparison strategy. // Default: null + * pagination?: bool|array{ + * enabled?: bool|Param, // Default: true + * page_parameter_name?: scalar|null|Param, // The default name of the parameter handling the page number. // Default: "page" + * enabled_parameter_name?: scalar|null|Param, // The name of the query parameter to enable or disable pagination. // Default: "pagination" + * items_per_page_parameter_name?: scalar|null|Param, // The name of the query parameter to set the number of items per page. // Default: "itemsPerPage" + * partial_parameter_name?: scalar|null|Param, // The name of the query parameter to enable or disable partial pagination. // Default: "partial" + * }, + * }, + * mapping?: array{ + * imports?: list, + * paths?: list, + * }, + * resource_class_directories?: list, + * serializer?: array{ + * hydra_prefix?: bool|Param, // Use the "hydra:" prefix. // Default: false + * }, + * doctrine?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * doctrine_mongodb_odm?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * oauth?: bool|array{ + * enabled?: bool|Param, // Default: false + * clientId?: scalar|null|Param, // The oauth client id. // Default: "" + * clientSecret?: scalar|null|Param, // The OAuth client secret. Never use this parameter in your production environment. It exposes crucial security information. This feature is intended for dev/test environments only. Enable "oauth.pkce" instead // Default: "" + * pkce?: bool|Param, // Enable the oauth PKCE. // Default: false + * type?: scalar|null|Param, // The oauth type. // Default: "oauth2" + * flow?: scalar|null|Param, // The oauth flow grant type. // Default: "application" + * tokenUrl?: scalar|null|Param, // The oauth token url. // Default: "" + * authorizationUrl?: scalar|null|Param, // The oauth authentication url. // Default: "" + * refreshUrl?: scalar|null|Param, // The oauth refresh url. // Default: "" + * scopes?: list, + * }, + * graphql?: bool|array{ + * enabled?: bool|Param, // Default: false + * default_ide?: scalar|null|Param, // Default: "graphiql" + * graphiql?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * introspection?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * max_query_depth?: int|Param, // Default: 20 + * graphql_playground?: array, + * max_query_complexity?: int|Param, // Default: 500 + * nesting_separator?: scalar|null|Param, // The separator to use to filter nested fields. // Default: "_" + * collection?: array{ + * pagination?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * }, + * }, + * swagger?: array{ + * persist_authorization?: bool|Param, // Persist the SwaggerUI Authorization in the localStorage. // Default: false + * versions?: list, + * api_keys?: array, + * http_auth?: array, + * swagger_ui_extra_configuration?: mixed, // To pass extra configuration to Swagger UI, like docExpansion or filter. // Default: [] + * }, + * http_cache?: array{ + * public?: bool|null|Param, // To make all responses public by default. // Default: null + * invalidation?: bool|array{ // Enable the tags-based cache invalidation system. + * enabled?: bool|Param, // Default: false + * varnish_urls?: list, + * urls?: list, + * scoped_clients?: list, + * max_header_length?: int|Param, // Max header length supported by the cache server. // Default: 7500 + * request_options?: mixed, // To pass options to the client charged with the request. // Default: [] + * purger?: scalar|null|Param, // Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin"). // Default: "api_platform.http_cache.purger.varnish" + * xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters. + * glue?: scalar|null|Param, // xkey glue between keys // Default: " " + * }, + * }, + * }, + * mercure?: bool|array{ + * enabled?: bool|Param, // Default: false + * hub_url?: scalar|null|Param, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null + * include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false + * }, + * messenger?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * elasticsearch?: bool|array{ + * enabled?: bool|Param, // Default: false + * hosts?: list, + * }, + * openapi?: array{ + * contact?: array{ + * name?: scalar|null|Param, // The identifying name of the contact person/organization. // Default: null + * url?: scalar|null|Param, // The URL pointing to the contact information. MUST be in the format of a URL. // Default: null + * email?: scalar|null|Param, // The email address of the contact person/organization. MUST be in the format of an email address. // Default: null + * }, + * termsOfService?: scalar|null|Param, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null + * tags?: list, + * license?: array{ + * name?: scalar|null|Param, // The license name used for the API. // Default: null + * url?: scalar|null|Param, // URL to the license used for the API. MUST be in the format of a URL. // Default: null + * identifier?: scalar|null|Param, // An SPDX license expression for the API. The identifier field is mutually exclusive of the url field. // Default: null + * }, + * swagger_ui_extra_configuration?: mixed, // To pass extra configuration to Swagger UI, like docExpansion or filter. // Default: [] + * overrideResponses?: bool|Param, // Whether API Platform adds automatic responses to the OpenAPI documentation. // Default: true + * error_resource_class?: scalar|null|Param, // The class used to represent errors in the OpenAPI documentation. // Default: null + * validation_error_resource_class?: scalar|null|Param, // The class used to represent validation errors in the OpenAPI documentation. // Default: null + * }, + * maker?: bool|array{ + * enabled?: bool|Param, // Default: true + * }, + * exception_to_status?: array, + * formats?: array, + * }>, + * patch_formats?: array, + * }>, + * docs_formats?: array, + * }>, + * error_formats?: array, + * }>, + * jsonschema_formats?: list, + * defaults?: array{ + * uri_template?: mixed, + * short_name?: mixed, + * description?: mixed, + * types?: mixed, + * operations?: mixed, + * formats?: mixed, + * input_formats?: mixed, + * output_formats?: mixed, + * uri_variables?: mixed, + * route_prefix?: mixed, + * defaults?: mixed, + * requirements?: mixed, + * options?: mixed, + * stateless?: mixed, + * sunset?: mixed, + * accept_patch?: mixed, + * status?: mixed, + * host?: mixed, + * schemes?: mixed, + * condition?: mixed, + * controller?: mixed, + * class?: mixed, + * url_generation_strategy?: mixed, + * deprecation_reason?: mixed, + * headers?: mixed, + * cache_headers?: mixed, + * normalization_context?: mixed, + * denormalization_context?: mixed, + * collect_denormalization_errors?: mixed, + * hydra_context?: mixed, + * openapi?: mixed, + * validation_context?: mixed, + * filters?: mixed, + * mercure?: mixed, + * messenger?: mixed, + * input?: mixed, + * output?: mixed, + * order?: mixed, + * fetch_partial?: mixed, + * force_eager?: mixed, + * pagination_client_enabled?: mixed, + * pagination_client_items_per_page?: mixed, + * pagination_client_partial?: mixed, + * pagination_via_cursor?: mixed, + * pagination_enabled?: mixed, + * pagination_fetch_join_collection?: mixed, + * pagination_use_output_walkers?: mixed, + * pagination_items_per_page?: mixed, + * pagination_maximum_items_per_page?: mixed, + * pagination_partial?: mixed, + * pagination_type?: mixed, + * security?: mixed, + * security_message?: mixed, + * security_post_denormalize?: mixed, + * security_post_denormalize_message?: mixed, + * security_post_validation?: mixed, + * security_post_validation_message?: mixed, + * composite_identifier?: mixed, + * exception_to_status?: mixed, + * query_parameter_validation_enabled?: mixed, + * links?: mixed, + * graph_ql_operations?: mixed, + * provider?: mixed, + * processor?: mixed, + * state_options?: mixed, + * rules?: mixed, + * policy?: mixed, + * middleware?: mixed, + * parameters?: mixed, + * strict_query_parameter_validation?: mixed, + * hide_hydra_operation?: mixed, + * json_stream?: mixed, + * extra_properties?: mixed, + * map?: mixed, + * route_name?: mixed, + * errors?: mixed, + * read?: mixed, + * deserialize?: mixed, + * validate?: mixed, + * write?: mixed, + * serialize?: mixed, + * priority?: mixed, + * name?: mixed, + * allow_create?: mixed, + * item_uri_template?: mixed, + * ... + * }, + * } + * @psalm-type ConfigType = array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * monolog?: MonologConfig, + * webpack_encore?: WebpackEncoreConfig, + * datatables?: DatatablesConfig, + * liip_imagine?: LiipImagineConfig, + * twig_extra?: TwigExtraConfig, + * gregwar_captcha?: GregwarCaptchaConfig, + * florianv_swap?: FlorianvSwapConfig, + * nelmio_security?: NelmioSecurityConfig, + * turbo?: TurboConfig, + * tfa_webauthn?: TfaWebauthnConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * webauthn?: WebauthnConfig, + * nbgrp_onelogin_saml?: NbgrpOneloginSamlConfig, + * stimulus?: StimulusConfig, + * ux_translator?: UxTranslatorConfig, + * dompdf_font_loader?: DompdfFontLoaderConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * nelmio_cors?: NelmioCorsConfig, + * jbtronics_settings?: JbtronicsSettingsConfig, + * api_platform?: ApiPlatformConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * web_profiler?: WebProfilerConfig, + * monolog?: MonologConfig, + * debug?: DebugConfig, + * maker?: MakerConfig, + * webpack_encore?: WebpackEncoreConfig, + * datatables?: DatatablesConfig, + * liip_imagine?: LiipImagineConfig, + * twig_extra?: TwigExtraConfig, + * gregwar_captcha?: GregwarCaptchaConfig, + * florianv_swap?: FlorianvSwapConfig, + * nelmio_security?: NelmioSecurityConfig, + * turbo?: TurboConfig, + * tfa_webauthn?: TfaWebauthnConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * webauthn?: WebauthnConfig, + * nbgrp_onelogin_saml?: NbgrpOneloginSamlConfig, + * stimulus?: StimulusConfig, + * ux_translator?: UxTranslatorConfig, + * dompdf_font_loader?: DompdfFontLoaderConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * nelmio_cors?: NelmioCorsConfig, + * jbtronics_settings?: JbtronicsSettingsConfig, + * jbtronics_translation_editor?: JbtronicsTranslationEditorConfig, + * api_platform?: ApiPlatformConfig, + * }, + * "when@docker"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * monolog?: MonologConfig, + * webpack_encore?: WebpackEncoreConfig, + * datatables?: DatatablesConfig, + * liip_imagine?: LiipImagineConfig, + * twig_extra?: TwigExtraConfig, + * gregwar_captcha?: GregwarCaptchaConfig, + * florianv_swap?: FlorianvSwapConfig, + * nelmio_security?: NelmioSecurityConfig, + * turbo?: TurboConfig, + * tfa_webauthn?: TfaWebauthnConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * webauthn?: WebauthnConfig, + * nbgrp_onelogin_saml?: NbgrpOneloginSamlConfig, + * stimulus?: StimulusConfig, + * ux_translator?: UxTranslatorConfig, + * dompdf_font_loader?: DompdfFontLoaderConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * nelmio_cors?: NelmioCorsConfig, + * jbtronics_settings?: JbtronicsSettingsConfig, + * api_platform?: ApiPlatformConfig, + * }, + * "when@prod"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * monolog?: MonologConfig, + * webpack_encore?: WebpackEncoreConfig, + * datatables?: DatatablesConfig, + * liip_imagine?: LiipImagineConfig, + * twig_extra?: TwigExtraConfig, + * gregwar_captcha?: GregwarCaptchaConfig, + * florianv_swap?: FlorianvSwapConfig, + * nelmio_security?: NelmioSecurityConfig, + * turbo?: TurboConfig, + * tfa_webauthn?: TfaWebauthnConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * webauthn?: WebauthnConfig, + * nbgrp_onelogin_saml?: NbgrpOneloginSamlConfig, + * stimulus?: StimulusConfig, + * ux_translator?: UxTranslatorConfig, + * dompdf_font_loader?: DompdfFontLoaderConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * nelmio_cors?: NelmioCorsConfig, + * jbtronics_settings?: JbtronicsSettingsConfig, + * api_platform?: ApiPlatformConfig, + * }, + * "when@test"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * twig?: TwigConfig, + * web_profiler?: WebProfilerConfig, + * monolog?: MonologConfig, + * debug?: DebugConfig, + * webpack_encore?: WebpackEncoreConfig, + * datatables?: DatatablesConfig, + * liip_imagine?: LiipImagineConfig, + * dama_doctrine_test?: DamaDoctrineTestConfig, + * twig_extra?: TwigExtraConfig, + * gregwar_captcha?: GregwarCaptchaConfig, + * florianv_swap?: FlorianvSwapConfig, + * nelmio_security?: NelmioSecurityConfig, + * turbo?: TurboConfig, + * tfa_webauthn?: TfaWebauthnConfig, + * scheb_two_factor?: SchebTwoFactorConfig, + * webauthn?: WebauthnConfig, + * nbgrp_onelogin_saml?: NbgrpOneloginSamlConfig, + * stimulus?: StimulusConfig, + * ux_translator?: UxTranslatorConfig, + * dompdf_font_loader?: DompdfFontLoaderConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * nelmio_cors?: NelmioCorsConfig, + * jbtronics_settings?: JbtronicsSettingsConfig, + * api_platform?: ApiPlatformConfig, + * }, + * ..., + * }> + * } + */ +final class App +{ + /** + * @param ConfigType $config + * + * @psalm-return ConfigType + */ + public static function config(array $config): array + { + return AppReference::config($config); + } +} + +namespace Symfony\Component\Routing\Loader\Configurator; + +/** + * This class provides array-shapes for configuring the routes of an application. + * + * Example: + * + * ```php + * // config/routes.php + * namespace Symfony\Component\Routing\Loader\Configurator; + * + * return Routes::config([ + * 'controllers' => [ + * 'resource' => 'routing.controllers', + * ], + * ]); + * ``` + * + * @psalm-type RouteConfig = array{ + * path: string|array, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type ImportConfig = array{ + * resource: string, + * type?: string, + * exclude?: string|list, + * prefix?: string|array, + * name_prefix?: string, + * trailing_slash_on_root?: bool, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type AliasConfig = array{ + * alias: string, + * deprecated?: array{package:string, version:string, message?:string}, + * } + * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, + * "when@docker"?: array, + * "when@prod"?: array, + * "when@test"?: array, + * ... + * } + */ +final class Routes +{ + /** + * @param RoutesConfig $config + * + * @psalm-return RoutesConfig + */ + public static function config(array $config): array + { + return $config; + } +} diff --git a/config/routes.yaml b/config/routes.yaml index 8b38fa71..4830b774 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -1,3 +1,12 @@ +# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json + +# This file is the entry point to configure the routes of your app. +# Methods with the #[Route] attribute are automatically imported. +# See also https://symfony.com/doc/current/routing.html + +# To list all registered routes, run the following command: +# bin/console debug:router + # Redirect every url without an locale to the locale of the user/the global base locale scan_qr: @@ -16,4 +25,4 @@ redirector: url: ".*" controller: App\Controller\RedirectController::addLocalePart # Dont match localized routes (no redirection loop, if no root with that name exists) or API prefixed routes - condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')" \ No newline at end of file + condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')" diff --git a/config/routes/framework.yaml b/config/routes/framework.yaml index 0fc74bba..bc1feace 100644 --- a/config/routes/framework.yaml +++ b/config/routes/framework.yaml @@ -1,4 +1,4 @@ when@dev: _errors: - resource: '@FrameworkBundle/Resources/config/routing/errors.xml' + resource: '@FrameworkBundle/Resources/config/routing/errors.php' prefix: /_error diff --git a/config/routes/web_profiler.yaml b/config/routes/web_profiler.yaml index 8d85319f..b3b7b4b0 100644 --- a/config/routes/web_profiler.yaml +++ b/config/routes/web_profiler.yaml @@ -1,8 +1,8 @@ when@dev: web_profiler_wdt: - resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' prefix: /_wdt web_profiler_profiler: - resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + resource: '@WebProfilerBundle/Resources/config/routing/profiler.php' prefix: /_profiler diff --git a/config/services.yaml b/config/services.yaml index b2342edd..5021c577 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,5 +1,8 @@ +# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json + # This file is the entry point to configure your own services. # Files in the packages/ subdirectory configure your dependencies. +# See also https://symfony.com/doc/current/service_container/import.html # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration @@ -17,8 +20,6 @@ services: bool $gdpr_compliance: '%partdb.gdpr_compliance%' bool $kernel_debug_enabled: '%kernel.debug%' string $kernel_cache_dir: '%kernel.cache_dir%' - string $partdb_title: '%partdb.title%' - string $base_currency: '%partdb.default_currency%' _instanceof: App\Services\LabelSystem\PlaceholderProviders\PlaceholderProviderInterface: @@ -32,9 +33,8 @@ services: App\: resource: '../src/' exclude: - - '../src/DependencyInjection/' - '../src/Entity/' - - '../src/Kernel.php' + - '../src/Helpers/' # controllers are imported separately to make sure services can be injected # as action arguments even if you don't extend any base controller class @@ -76,28 +76,10 @@ services: # Only the event classes specified here are saved to DB (set to []) to log all events $whitelist: [] - App\EventListener\LogSystem\EventLoggerListener: - arguments: - $save_changed_fields: '%env(bool:HISTORY_SAVE_CHANGED_FIELDS)%' - $save_changed_data: '%env(bool:HISTORY_SAVE_CHANGED_DATA)%' - $save_removed_data: '%env(bool:HISTORY_SAVE_REMOVED_DATA)%' - $save_new_data: '%env(bool:HISTORY_SAVE_NEW_DATA)%' - - App\Form\AttachmentFormType: - arguments: - $allow_attachments_download: '%partdb.attachments.allow_downloads%' - $max_file_size: '%partdb.attachments.max_file_size%' - $download_by_default: '%partdb.attachments.download_by_default%' - App\Services\Attachments\AttachmentSubmitHandler: arguments: - $allow_attachments_downloads: '%partdb.attachments.allow_downloads%' $mimeTypes: '@mime_types' - $max_upload_size: '%partdb.attachments.max_file_size%' - App\Services\LogSystem\EventCommentNeededHelper: - arguments: - $enforce_change_comments_for: '%partdb.enforce_change_comments_for%' #################################################################################################################### # Attachment system @@ -156,29 +138,6 @@ services: tags: - { name: doctrine.orm.entity_listener } - #################################################################################################################### - # Price system - #################################################################################################################### - App\Command\Currencies\UpdateExchangeRatesCommand: - arguments: - $base_current: '%partdb.default_currency%' - - App\Form\Type\CurrencyEntityType: - arguments: - $base_currency: '%partdb.default_currency%' - - App\Services\Parts\PricedetailHelper: - arguments: - $base_currency: '%partdb.default_currency%' - - App\Services\Formatters\MoneyFormatter: - arguments: - $base_currency: '%partdb.default_currency%' - - App\Services\Tools\ExchangeRateUpdater: - arguments: - $base_currency: '%partdb.default_currency%' - ################################################################################################################### # User system #################################################################################################################### @@ -186,10 +145,6 @@ services: arguments: $demo_mode: '%partdb.demo_mode%' - App\EventSubscriber\UserSystem\SetUserTimezoneSubscriber: - arguments: - $default_timezone: '%partdb.timezone%' - App\Controller\SecurityController: arguments: $allow_email_pw_reset: '%partdb.users.email_pw_reset%' @@ -203,10 +158,6 @@ services: tags: - { name: 'translation.extractor', alias: 'permissionExtractor'} - App\Services\UserSystem\UserAvatarHelper: - arguments: - $use_gravatar: '%partdb.users.use_gravatar%' - App\Form\Type\ThemeChoiceType: arguments: $available_themes: '%partdb.available_themes%' @@ -222,9 +173,6 @@ services: #################################################################################################################### # Table settings #################################################################################################################### - App\DataTables\PartsDataTable: - arguments: - $visible_columns: '%partdb.table.parts.default_columns%' App\DataTables\Helpers\ColumnSortHelper: shared: false # Service has a state so not share it between different tables @@ -246,14 +194,6 @@ services: $fontDirectory: '%kernel.project_dir%/var/dompdf/fonts/' $tmpDirectory: '%kernel.project_dir%/var/dompdf/tmp/' - #################################################################################################################### - # Trees - #################################################################################################################### - App\Services\Trees\TreeViewGenerator: - arguments: - $rootNodeExpandedByDefault: '%partdb.sidebar.root_expanded%' - $rootNodeEnabled: '%partdb.sidebar.root_node_enable%' - #################################################################################################################### # Part info provider system #################################################################################################################### @@ -261,76 +201,12 @@ services: arguments: $providers: !tagged_iterator 'app.info_provider' - App\Services\InfoProviderSystem\Providers\Element14Provider: - arguments: - $api_key: '%env(string:PROVIDER_ELEMENT14_KEY)%' - $store_id: '%env(string:PROVIDER_ELEMENT14_STORE_ID)%' - - App\Services\InfoProviderSystem\Providers\DigikeyProvider: - arguments: - $clientId: '%env(string:PROVIDER_DIGIKEY_CLIENT_ID)%' - $currency: '%env(string:PROVIDER_DIGIKEY_CURRENCY)%' - $language: '%env(string:PROVIDER_DIGIKEY_LANGUAGE)%' - $country: '%env(string:PROVIDER_DIGIKEY_COUNTRY)%' - - App\Services\InfoProviderSystem\Providers\TMEClient: - arguments: - $secret: '%env(string:PROVIDER_TME_SECRET)%' - $token: '%env(string:PROVIDER_TME_KEY)%' - - App\Services\InfoProviderSystem\Providers\TMEProvider: - arguments: - $currency: '%env(string:PROVIDER_TME_CURRENCY)%' - $country: '%env(string:PROVIDER_TME_COUNTRY)%' - $language: '%env(string:PROVIDER_TME_LANGUAGE)%' - $get_gross_prices: '%env(bool:PROVIDER_TME_GET_GROSS_PRICES)%' - - App\Services\InfoProviderSystem\Providers\OctopartProvider: - arguments: - $clientId: '&env(string:PROVIDER_OCTOPART_CLIENT_ID)%' - $secret: '%env(string:PROVIDER_OCTOPART_SECRET)%' - $country: '%env(string:PROVIDER_OCTOPART_COUNTRY)%' - $currency: '%env(string:PROVIDER_OCTOPART_CURRENCY)%' - $search_limit: '%env(int:PROVIDER_OCTOPART_SEARCH_LIMIT)%' - $onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%' - - App\Services\InfoProviderSystem\Providers\MouserProvider: - arguments: - $api_key: '%env(string:PROVIDER_MOUSER_KEY)%' - $language: '%env(string:PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE)%' - $options: '%env(string:PROVIDER_MOUSER_SEARCH_OPTION)%' - $search_limit: '%env(int:PROVIDER_MOUSER_SEARCH_LIMIT)%' - - App\Services\InfoProviderSystem\Providers\LCSCProvider: - arguments: - $enabled: '%env(bool:PROVIDER_LCSC_ENABLED)%' - $currency: '%env(string:PROVIDER_LCSC_CURRENCY)%' - - App\Services\InfoProviderSystem\Providers\OEMSecretsProvider: - arguments: - $api_key: '%env(string:PROVIDER_OEMSECRETS_KEY)%' - $country_code: '%env(string:PROVIDER_OEMSECRETS_COUNTRY_CODE)%' - $currency: '%env(PROVIDER_OEMSECRETS_CURRENCY)%' - $zero_price: '%env(PROVIDER_OEMSECRETS_ZERO_PRICE)%' - $set_param: '%env(PROVIDER_OEMSECRETS_SET_PARAM)%' - $sort_criteria: '%env(PROVIDER_OEMSECRETS_SORT_CRITERIA)%' - - #################################################################################################################### # API system #################################################################################################################### App\State\PartDBInfoProvider: arguments: $default_uri: '%partdb.default_uri%' - $global_locale: '%partdb.locale%' - $global_timezone: '%partdb.timezone%' - - #################################################################################################################### - # EDA system - #################################################################################################################### - App\Services\EDA\KiCadHelper: - arguments: - $category_depth: '%env(int:EDA_KICAD_CATEGORY_DEPTH)%' #################################################################################################################### # Symfony overrides @@ -355,12 +231,17 @@ services: #################################################################################################################### App\Controller\RedirectController: arguments: - $default_locale: '%partdb.locale%' $enforce_index_php: '%env(bool:NO_URL_REWRITE_AVAILABLE)%' - App\Doctrine\Purger\ResetAutoIncrementPurgerFactory: + App\Repository\PartRepository: + arguments: + $translator: '@translator' + tags: ['doctrine.repository_service'] + + App\EventSubscriber\UserSystem\PartUniqueIpnSubscriber: tags: - - { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' } + - { name: doctrine.event_listener, event: onFlush, connection: default } + # We are needing this service inside a migration, where only the container is injected. So we need to define it as public, to access it from the container. App\Services\UserSystem\PermissionPresetsHelper: @@ -370,14 +251,6 @@ services: arguments: $project_dir: '%kernel.project_dir%' - App\Services\System\UpdateAvailableManager: - arguments: - $check_for_updates: '%partdb.check_for_updates%' - - App\Services\System\BannerHelper: - arguments: - $partdb_banner: '%partdb.banner%' - $project_dir: '%kernel.project_dir%' App\Doctrine\Middleware\MySQLSSLConnectionMiddlewareWrapper: arguments: @@ -401,7 +274,11 @@ services: tags: - { name: monolog.processor } -when@test: + App\Doctrine\Purger\ResetAutoIncrementPurgerFactory: + tags: + - { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' } + +when@test: &test services: # Decorate the doctrine fixtures load command to use our custom purger by default doctrine.fixtures_load_command.custom: diff --git a/docs/api/intro.md b/docs/api/intro.md index 78a8d2c1..283afbe6 100644 --- a/docs/api/intro.md +++ b/docs/api/intro.md @@ -17,7 +17,7 @@ This allows external applications to interact with Part-DB, extend it or integra > Some features might be missing or not working yet. > Also be aware, that there might be security issues in the API, which could allow attackers to access or edit data via > the API, which -> they normally should be able to access. So currently you should only use the API with trusted users and trusted +> they normally should not be able to access. So currently you should only use the API with trusted users and trusted > applications. Part-DB uses [API Platform](https://api-platform.com/) to provide the API, which allows for easy creation of REST APIs @@ -46,7 +46,7 @@ See [Authentication chapter]({% link api/authentication.md %}) for more details. The API is split into different endpoints, which are reachable under the `/api/` path of your Part-DB instance ( e.g. `https://your-part-db.local/api/`). -There are various endpoints for each entity type (like `part`, `manufacturer`, etc.), which allow you to read and write data, and some special endpoints like `search` or `statistics`. +There are various endpoints for each entity type (like `parts`, `manufacturers`, etc.), which allow you to read and write data, and some special endpoints like `search` or `statistics`. For example, all API endpoints for managing categories are available under `/api/categories/`. Depending on the exact path and the HTTP method used, you can read, create, update or delete categories. @@ -56,7 +56,7 @@ For most entities, there are endpoints like this: * **POST**: `/api/categories/` - Create a new category * **GET**: `/api/categories/{id}` - Get a specific category by its ID * **DELETE**: `/api/categories/{id}` - Delete a specific category by its ID -* **UPDATE**: `/api/categories/{id}` - Update a specific category by its ID. Only the fields which are sent in the +* **PATCH**: `/api/categories/{id}` - Update a specific category by its ID. Only the fields which are sent in the request are updated, all other fields are left unchanged. Be aware that you have to set the [JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7386) content type header (`Content-Type: application/merge-patch+json`) for this to work. @@ -106,11 +106,11 @@ This is a great way to test the API and see how it works, without having to writ By default, all list endpoints are paginated, which means only a certain number of results is returned per request. To get another page of the results, you have to use the `page` query parameter, which contains the page number you want -to get (e.g. `/api/categoues/?page=2`). +to get (e.g. `/api/categories/?page=2`). When using JSONLD, the links to the next page are also included in the `hydra:view` property of the response. To change the size of the pages (the number of items in a single page) use the `itemsPerPage` query parameter ( -e.g. `/api/categoues/?itemsPerPage=50`). +e.g. `/api/categories/?itemsPerPage=50`). See [API Platform docs](https://api-platform.com/docs/core/pagination) for more infos. diff --git a/docs/assets/getting_started/system_settings.png b/docs/assets/getting_started/system_settings.png new file mode 100644 index 00000000..5a7d7380 Binary files /dev/null and b/docs/assets/getting_started/system_settings.png differ diff --git a/docs/assets/usage/import_export/part_import_example.csv b/docs/assets/usage/import_export/part_import_example.csv index 08701426..14d4500f 100644 --- a/docs/assets/usage/import_export/part_import_example.csv +++ b/docs/assets/usage/import_export/part_import_example.csv @@ -1,4 +1,7 @@ -name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status -BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;; -BC557;PNP transistor;HTML;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active -Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter; \ No newline at end of file +name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint +"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric +"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric +"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123 +BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder +BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder +Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical diff --git a/docs/concepts.md b/docs/concepts.md index ddf38633..8a3551bd 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -23,14 +23,14 @@ each other so that it does not matter which one of your 1000 things of Part you A part entity has many fields, which can be used to describe it better. Most of the fields are optional: * **Name** (Required): The name of the part or how you want to call it. This could be a manufacturer-provided name, or a - name you thought of yourself. Each name needs to be unique and must exist in a single category. + name you thought of yourself. Each name needs to be unique and must exist in a single category only. * **Description**: A short (single-line) description of what this part is/does. For longer information, you should use the comment field or the specifications * **Category** (Required): The category (see there) to which this part belongs to. * **Tags**: The list of tags this part belongs to. Tags can be used to group parts logically (similar to the category), - but tags are much less strict and formal (they don't have to be defined forehands) and you can assign multiple tags to + but tags are much less strict and formal (they don't have to be defined beforehand) and you can assign multiple tags to a part. When clicking on a tag, a list with all parts which have the same tag, is shown. -* **Min Instock**: *Not really implemented yet*. Parts where the total instock is below this value, will show up for +* **Min Instock**: *Not fully implemented yet*. Parts where the total instock is below this value will show up for ordering. * **Footprint**: See there. Useful especially for electronic parts, which have one of the common electronic footprints ( like DIP8, SMD0805 or similar). If a part has no explicitly defined preview picture, the preview picture of its @@ -48,9 +48,9 @@ A part entity has many fields, which can be used to describe it better. Most of completely trustworthy. * **Favorite**: Parts with this flag are highlighted in parts lists * **Mass**: The mass of a single piece of this part (so of a single transistor). Given in grams. -* **Internal Part number** (IPN): Each part is automatically assigned a numerical ID that identifies a part in the - database. This ID depends on when a part was created and can not be changed. If you want to assign your own unique - identifiers, or sync parts identifiers with the identifiers of another database you can use this field. +* **Internal Part Number** (IPN): Each part is automatically assigned a numerical ID that identifies a part in the + database. This ID depends on when a part was created and cannot be changed. If you want to assign your own unique + identifiers, or sync parts identifiers with the identifiers of another database, you can use this field. ### Stock / Part lot @@ -99,12 +99,12 @@ possible category tree could look like this: ### Supplier -A Supplier is a vendor/distributor where you can buy/order parts. Price information of parts is associated with a +A supplier is a vendor/distributor where you can buy/order parts. Price information of parts is associated with a supplier. ### Manufacturer -A manufacturer represents the company that manufacturers/builds various parts (not necessarily sell them). If the +A manufacturer represents the company that manufactures/builds various parts (not necessarily sells them). If the manufacturer also sells the parts, you have to create a supplier for that. ### Storage location diff --git a/docs/configuration.md b/docs/configuration.md index 0ad30a00..709c39b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,11 +6,11 @@ nav_order: 5 # Configuration -Part-DBs behavior can be configured to your needs. There are different kinds of configuration options: Options, which are +Part-DB's behavior can be configured to your needs. There are different kinds of configuration options: Options that are user-changeable (changeable dynamically via frontend), options that can be configured by environment variables, and options that are only configurable via Symfony config files. -## User changeable +## User configuration The following things can be changed for every user and a user can change it for himself (if he has the correct permission for it). Configuration is either possible via the user's own settings page (where you can also change the password) or via @@ -24,15 +24,34 @@ the user admin page: * **Preferred currency**: One of the defined currencies, in which all prices should be shown, if possible. Prices with other currencies will be converted to the price selected here +## System configuration (via web interface) + +Many common configuration options can be changed via the web interface. You can find the settings page in the sidebar under +"System" -> "Settings". You need to have the "Change system settings" permission to access this page. + +If a setting is greyed out and cannot be changed, it means that this setting is currently overwritten by an environment +variable. You can either change the environment variable to change the setting, or you can migrate the setting to the +database, so that it can be changed via the web interface. To do this, you can use the `php bin/console settings:migrate-env-to-settings` command +and remove the environment variable afterward. + ## Environment variables (.env.local) The following configuration options can only be changed by the server administrator, by either changing the server variables, changing the `.env.local` file or setting env for your docker container. Here are just the most important options listed, see `.env` file for the full list of possible env variables. +Environment variables allow you to overwrite settings in the web interface. This is useful if you want to enforce certain +settings to be unchangeable by users, or if you want to configure settings in a central place in a deployed environment. +On the settings page, you can hover over a setting to see, which environment variable can be used to overwrite it, it +is shown as tooltip. API keys or similar sensitive data which is overwritten by env variables, are redacted on the web +interface, so that even administrators cannot see them (only the last 2 characters and the length). + +For technical and security reasons some settings can only be configured via environment variables and not via the web +interface. These settings are marked with "(env only)" in the description below. + ### General options -* `DATABASE_URL`: Configures the database which Part-DB uses: +* `DATABASE_URL` (env only): Configures the database which Part-DB uses: * For MySQL (or MariaDB) use a string in the form of `mysql://:@:/` here (e.g. `DATABASE_URL=mysql://user:password@127.0.0.1:3306/part-db`). * For SQLite use the following format to specify the @@ -42,10 +61,10 @@ options listed, see `.env` file for the full list of possible env variables. Please note that **`serverVersion=x.y`** variable is required due to dependency of Symfony framework. -* `DATABASE_MYSQL_USE_SSL_CA`: If this value is set to `1` or `true` and a MySQL connection is used, then the connection +* `DATABASE_MYSQL_USE_SSL_CA` (env only): If this value is set to `1` or `true` and a MySQL connection is used, then the connection is encrypted by SSL/TLS and the server certificate is verified against the system CA certificates or the CA certificate bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept all certificates. -* `DATABASE_EMULATE_NATURAL_SORT` (default 0): If set to 1, Part-DB will emulate natural sorting, even if the database +* `DATABASE_EMULATE_NATURAL_SORT` (default 0) (env only): If set to 1, Part-DB will emulate natural sorting, even if the database does not support it natively. However this is much slower than the native sorting, and contain bugs or quirks, so use it only, if you have to. * `DEFAULT_LANG`: The default language to use server-wide (when no language is explicitly specified by a user or via @@ -74,7 +93,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept to specify the size in kilobytes, megabytes or gigabytes. By default `100M` (100 megabytes). Please note that this is only the limit of Part-DB. You still need to configure the php.ini `upload_max_filesize` and `post_max_size` to allow bigger files to be uploaded. -* `DEFAULT_URI`: The default URI base to use for the Part-DB, when no URL can be determined from the browser request. +* `DEFAULT_URI` (env only): The default URI base to use for the Part-DB, when no URL can be determined from the browser request. This should be the primary URL/Domain, which is used to access Part-DB. This value is used to create correct links in emails and other places, where the URL is needed. It is also used, when SAML is enabled.s If you are using a reverse proxy, you should set this to the URL of the reverse proxy (e.g. `https://part-db.example.com`). **This value must end @@ -86,17 +105,29 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept * `part_delete`: Delete operation of an existing part * `part_create`: Creation of a new part * `part_stock_operation`: Stock operation on a part (therefore withdraw, add or move stock) - * `datastructure_edit`: Edit operation of an existing datastructure (e.g. category, manufacturer, ...) - * `datastructure_delete`: Delete operation of a existing datastructure (e.g. category, manufacturer, ...) - * `datastructure_create`: Creation of a new datastructure (e.g. category, manufacturer, ...) -* `CHECK_FOR_UPDATES` (default `1`): Set this to 0, if you do not want Part-DB to connect to GitHub to check for new - versions, or if your server can not connect to the internet. -* `APP_SECRET`: This variable is a configuration parameter used for various security-related purposes, + * `datastructure_edit`: Edit operation of an existing data structure (e.g. category, manufacturer, ...) + * `datastructure_delete`: Delete operation of an existing data structure (e.g. category, manufacturer, ...) + * `datastructure_create`: Creation of a new data structure (e.g. category, manufacturer, ...) +* `CHECK_FOR_UPDATES` (default `1`): Set this to 0 if you do not want Part-DB to connect to GitHub to check for new + versions, or if your server cannot connect to the internet. +* `APP_SECRET` (env only): This variable is a configuration parameter used for various security-related purposes, particularly for securing and protecting various aspects of your application. It's a secret key that is used for cryptographic operations and security measures (session management, CSRF protection, etc..). Therefore this value should be handled as confidential data and not shared publicly. +* `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the + part image gallery +* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fulfill. Enforce your own format for your users. +* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification. +* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing +* IPN again upon saving. +* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number). + IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs. + These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign + unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation. +* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the partโ€™s description is used to find existing parts with the same + description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list. -### E-Mail settings +### E-Mail settings (all env only) * `MAILER_DSN`: You can configure the mail provider which should be used for email delivery ( see https://symfony.com/doc/current/components/mailer.html for full documentation). If you just want to use an SMTP @@ -115,7 +146,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept * `TABLE_PARTS_DEFAULT_COLUMNS`: The columns in parts tables, which are visible by default (when loading table for first time). Also specify the default order of the columns. This is a comma separated list of column names. Available columns - are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. + are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. ### History/Eventlog-related settings @@ -136,7 +167,7 @@ The following options are used to configure, which (and how much) data is writte If you want to use want to revert changes or view older revisions of entities, then `HISTORY_SAVE_CHANGED_FIELDS`, `HISTORY_SAVE_CHANGED_DATA` and `HISTORY_SAVE_REMOVED_DATA` all have to be true. -### Error pages settings +### Error pages settings (all env only) * `ERROR_PAGE_ADMIN_EMAIL`: You can set an email address here, which is shown on the error page, who should be contacted about the issue (e.g. an IT support email of your company) @@ -151,7 +182,7 @@ then `HISTORY_SAVE_CHANGED_FIELDS`, `HISTORY_SAVE_CHANGED_DATA` and `HISTORY_SAV All parts in the selected category and all subcategories are shown in KiCad. Set this to a higher value, if you want to show more categories in KiCad. When you set this value to -1, all parts are shown inside a single category in KiCad. -### SAML SSO settings +### SAML SSO settings (all env only) The following settings can be used to enable and configure Single-Sign on via SAML. This allows users to log in to Part-DB without entering a username and password, but instead they are redirected to a SAML Identity Provider (IdP) and @@ -199,26 +230,26 @@ See the [information providers]({% link usage/information_provider_system.md %}) ### Other / less-used options -* `TRUSTED_PROXIES`: Set the IP addresses (or IP blocks) of trusted reverse proxies here. This is needed to get correct +* `TRUSTED_PROXIES` (env only): Set the IP addresses (or IP blocks) of trusted reverse proxies here. This is needed to get correct IP information (see [here](https://symfony.com/doc/current/deployment/proxies.html) for more info). -* `TRUSTED_HOSTS`: To prevent `HTTP Host header attacks` you can set a regex containing all host names via which Part-DB +* `TRUSTED_HOSTS` (env only): To prevent `HTTP Host header attacks` you can set a regex containing all host names via which Part-DB should be accessible. If accessed via the wrong hostname, an error will be shown. -* `DEMO_MODE`: Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo +* `DEMO_MODE` (env only): Set Part-DB into demo mode, which forbids users to change their passwords and settings. Used for the demo instance. This should not be needed for normal installations. -* `NO_URL_REWRITE_AVAILABLE` (allowed values `true` or `false`): Set this value to true, if your webserver does not +* `NO_URL_REWRITE_AVAILABLE` (allowed values `true` or `false`) (env only): Set this value to true, if your webserver does not support rewrite. In this case, all URL paths will contain index.php/, which is needed then. Normally this setting does not need to be changed. -* `REDIRECT_TO_HTTPS`: If this is set to true, all requests to http will be redirected to https. This is useful if your +* `REDIRECT_TO_HTTPS` (env only): If this is set to true, all requests to http will be redirected to https. This is useful if your web server does not already do this (like the one used in the demo instance). If your web server already redirects to https, you don't need to set this. Ensure that Part-DB is accessible via HTTPS before you enable this setting. * `FIXER_API_KEY`: If you want to automatically retrieve exchange rates for base currencies other than euros, you have to configure an exchange rate provider API. [Fixer.io](https://fixer.io/) is preconfigured, and you just have to register there and set the retrieved API key in this environment variable. -* `APP_ENV`: This value should always be set to `prod` in normal use. Set it to `dev` to enable debug/development +* `APP_ENV` (env only): This value should always be set to `prod` in normal use. Set it to `dev` to enable debug/development mode. (**You should not do this on a publicly accessible server, as it will leak sensitive information!**) * `BANNER`: You can configure the text that should be shown as the banner on the homepage. Useful especially for docker containers. In all other applications you can just change the `config/banner.md` file. -* `DISABLE_YEAR2038_BUG_CHECK`: If set to `1`, the year 2038 bug check is disabled on 32-bit systems, and dates after +* `DISABLE_YEAR2038_BUG_CHECK` (env only): If set to `1`, the year 2038 bug check is disabled on 32-bit systems, and dates after 2038 are no longer forbidden. However this will lead to 500 error messages when rendering dates after 2038 as all current 32-bit PHP versions can not format these dates correctly. This setting is for the case that future PHP versions will handle this correctly on 32-bit systems. 64-bit systems are not affected by this bug, and the check is always disabled. @@ -226,23 +257,21 @@ handle this correctly on 32-bit systems. 64-bit systems are not affected by this ## Banner To change the banner you can find on the homepage, you can either set the `BANNER` environment variable to the text you -want to show, or you can edit the `config/banner.md` file. The banner is written in markdown, so you can use all +want to show, or change it in the system settings webinterface. The banner is written in markdown, so you can use all markdown (and even some subset of HTML) syntax to format the text. ## parameters.yaml -You can also configure some options via the `config/parameters.yaml` file. This should normally not need, -and you should know what you are doing, when you change something here. You should expect, that you will have to do some -manual merge, when you have changed something here and update to a newer version of Part-DB. It is possible that -configuration options here will change or be completely removed in future versions of Part-DB. +You can also configure some options via the `config/parameters.yaml` file. This should normally not be needed, +and you should know what you are doing when you change something here. You should expect that you will have to do some +manual merges when you have changed something here and update to a newer version of Part-DB. It is possible that +configuration options here will change or be completely removed in future versions of Part-DB. If you change something here, you have to clear the cache, before the changes will take effect with the command `bin/console cache:clear`. The following options are available: -* `partdb.global_theme`: The default theme to use, when no user specific theme is set. Should be one of the themes from - the `partdb.available_themes` config option. * `partdb.locale_menu`: The codes of the languages, which should be shown in the language chooser menu (the one with the user icon in the navbar). The first language in the list will be the default language. * `partdb.gdpr_compliance`: When set to true (default value), IP addresses which are saved in the database will be diff --git a/docs/index.md b/docs/index.md index d732f31d..c2128946 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,8 +18,7 @@ It is installed on a web server and so can be accessed with any browser without > You can log in with username: **user** and password: **user**, to change/create data. > > Every change to the master branch gets automatically deployed, so it represents the current development progress and -> is -> maybe not completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading +> may not be completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading > the page > for the first time. @@ -28,32 +27,32 @@ It is installed on a web server and so can be accessed with any browser without * Inventory management of your electronic parts. Each part can be assigned to a category, footprint, manufacturer, and multiple store locations and price information. Parts can be grouped using tags. You can associate various files like datasheets or pictures with the parts. -* Multi-language support (currently German, English, Russian, Japanese and French (experimental)) -* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the builtin barcode scanner -* User system with groups and detailed (fine granular) permissions. +* Multi-language support (currently German, English, Russian, Japanese, French, Czech, Danish, and Chinese) +* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the built-in barcode scanner +* User system with groups and detailed (fine-grained) permissions. Two-factor authentication is supported (Google Authenticator and Webauthn/U2F keys) and can be enforced for groups. - Password reset via email can be setup. + Password reset via email can be set up. * Optional support for single sign-on (SSO) via SAML (using an intermediate service like [Keycloak](https://www.keycloak.org/) you can connect Part-DB to an existing LDAP or Active Directory server) * Import/Export system * Project management: Create projects and assign parts to the bill of material (BOM), to show how often you could build this project and directly withdraw all components needed from DB -* Event log: Track what changes happens to your inventory, track which user does what. Revert your parts to older +* Event log: Track what changes happen to your inventory, track which user does what. Revert your parts to older versions. -* Responsive design: You can use Part-DB on your PC, your tablet and your smartphone using the same interface. +* Responsive design: You can use Part-DB on your PC, your tablet, and your smartphone using the same interface. * MySQL, SQLite and PostgreSQL are supported as database backends * Support for rich text descriptions and comments in parts * Support for multiple currencies and automatic update of exchange rates supported * Powerful search and filter function, including parametric search (search for parts according to some specifications) * Easy migration from an existing PartKeepr instance (see [here]({%link partkeepr_migration.md %})) -* Use cloud providers (like Octopart, Digikey, Farnell or TME) to automatically get part information, datasheets and +* Use cloud providers (like Octopart, Digikey, Farnell, Mouser, or TME) to automatically get part information, datasheets, and prices for parts (see [here]({% link usage/information_provider_system.md %})) * API to access Part-DB from other applications/scripts -* [Integration with KiCad]({%link usage/eda_integration.md %}): Use Part-DB as central datasource for your - KiCad and see available parts from Part-DB directly inside KiCad. +* [Integration with KiCad]({%link usage/eda_integration.md %}): Use Part-DB as the central datasource for your + KiCad and see available parts from Part-DB directly inside KiCad. -With these features Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory, -or makerspaces, where many users have should have (controlled) access to the shared inventory. +With these features, Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory, +or makerspaces, where many users should have (controlled) access to the shared inventory. Part-DB is also used by small companies and universities for managing their inventory. @@ -68,11 +67,11 @@ See [LICENSE](https://github.com/Part-DB/Part-DB-symfony/blob/master/LICENSE) fo ## Donate for development If you want to donate to the Part-DB developer, see the sponsor button in the top bar (next to the repo name). -There you will find various methods to support development on a monthly or a one time base. +There you will find various methods to support development on a monthly or a one-time basis. ## Built with -* [Symfony 5](https://symfony.com/): The main framework used for the serverside PHP +* [Symfony 6](https://symfony.com/): The main framework used for the serverside PHP * [Bootstrap 5](https://getbootstrap.com/) and [Bootswatch](https://bootswatch.com/): Used as website theme * [Fontawesome](https://fontawesome.com/): Used as icon set * [Hotwire Stimulus](https://stimulus.hotwired.dev/) and [Hotwire Turbo](https://turbo.hotwired.dev/): Frontend diff --git a/docs/installation/choosing_database.md b/docs/installation/choosing_database.md index cd9657d4..27d70e54 100644 --- a/docs/installation/choosing_database.md +++ b/docs/installation/choosing_database.md @@ -21,8 +21,8 @@ differences between them, which might be important for you. Therefore the pros a are listed here. {: .important } -You have to choose between the database types before you start using Part-DB and **you can not change it (easily) after -you have started creating data**. So you should choose the database type for your use case (and possible future uses). +While you can change the database platform later (see below), it is still experimental and not recommended. +So you should choose the database type for your use case (and possible future uses). ## Comparison @@ -38,7 +38,7 @@ you have started creating data**. So you should choose the database type for you * **Performance**: SQLite is not as fast as MySQL or PostgreSQL, especially when using complex queries or many users. * **Emulated RegEx search**: SQLite does not support RegEx search natively. Part-DB can emulate it, however that is pretty slow. -* **Emualted natural sorting**: SQLite does not support natural sorting natively. Part-DB can emulate it, but it is pretty slow. +* **Emulated natural sorting**: SQLite does not support natural sorting natively. Part-DB can emulate it, but it is pretty slow. * **Limitations with Unicode**: SQLite has limitations in comparisons and sorting of Unicode characters, which might lead to unexpected behavior when using non-ASCII characters in your data. For example `ยต` (micro sign) is not seen as equal to `ฮผ` (greek minuscule mu), therefore searching for `ยต` (micro sign) will not find parts containing `ฮผ` (mu) and vice versa. @@ -131,7 +131,7 @@ The host (here 127.0.0.1) and port should also be specified according to your My In the `serverVersion` parameter you can specify the version of the MySQL/MariaDB server you are using, in the way the server returns it (e.g. `8.0.37` for MySQL and `10.4.14-MariaDB`). If you do not know it, you can leave the default value. -If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `unix_socket` parameter. +If you want to use a unix socket for the connection instead of a TCP connection, you can specify the socket path in the `unix_socket` parameter. ```shell DATABASE_URL="mysql://user:password@localhost/database?serverVersion=8.0.37&unix_socket=/var/run/mysqld/mysqld.sock" ``` @@ -150,7 +150,7 @@ In the `serverVersion` parameter you can specify the version of the PostgreSQL s The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly. -If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `host` parameter. +If you want to use a unix socket for the connection instead of a TCP connection, you can specify the socket path in the `host` parameter. ```shell DATABASE_URL="postgresql://db_user@localhost/db_name?serverVersion=16.6&charset=utf8&host=/var/run/postgresql" ``` @@ -177,6 +177,26 @@ In natural sorting, it would be sorted as: Part-DB can sort names in part tables and tree views naturally. PostgreSQL and MariaDB 10.7+ support natural sorting natively, and it is automatically used if available. -For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicity enabled by setting the +For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicitly enabled by setting the `DATABASE_EMULATE_NATURAL_SORT` environment variable to `1`. If it is 0 the classical binary sorting is used, on these databases. The emulations might have some quirks and issues, so it is recommended to use a database which supports natural sorting natively, if you want to use it. + +## Converting between database platforms + +{: .important } +The database conversion is still experimental. Therefore it is recommended to backup your database before performing a conversion, and check if everything works as expected afterwards. + +If you want to change the database platform of your Part-DB installation (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa), there is the `partdb:migrations:convert-db-platform` console command, which can help you with that: + +1. Make a backup of your current database to be safe if something goes wrong (see the backup documentation). +2. Ensure that your database is at the latest schema by running the migrations: `php bin/console doctrine:migrations:migrate` +3. Change the `DATABASE_URL` environment variable to the new database platform and connection information. Copy the old `DATABASE_URL` as you will need it later. +4. Run `php bin/console doctrine:migrations:migrate` again to create the database schema in the new database. You will not need the admin password, that is shown when running the migrations. +5. Run the conversion command, where you have to provide the old `DATABASE_URL` as parameter: `php bin/console partdb:migrations:convert-db-platform ` + Replace `with a line break # If you use a reverse proxy in front of Part-DB, you must configure the trusted proxies IP addresses here (see reverse proxy documentation for more information): - # - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + # - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + + # If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here: + # The packages will be installed automatically when the container starts + # - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer ``` 4. Customize the settings by changing the environment variables (or adding new ones). See [Configuration]({% link @@ -136,35 +140,22 @@ services: # In docker env logs will be redirected to stderr - APP_ENV=docker - # Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to + # Uncomment this, if you want to use the automatic database migration feature. With this you do not have to # run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/ # folder (under .automigration-backup), so you can restore it, if the migration fails. # This feature is currently experimental, so use it at your own risk! # - DB_AUTOMIGRATE=true - # You can configure Part-DB using environment variables - # Below you can find the most essential ones predefined - # However you can add add any other environment configuration you want here + # You can configure Part-DB using the webUI or environment variables + # However you can add any other environment configuration you want here # See .env file for all available options or https://docs.part-db.de/configuration.html - # The language to use serverwide as default (en, de, ru, etc.) - - DEFAULT_LANG=en - # The default timezone to use serverwide (e.g. Europe/Berlin) - - DEFAULT_TIMEZONE=Europe/Berlin - # The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country - - BASE_CURRENCY=EUR - # The name of this installation. This will be shown as title in the browser and in the header of the website - - INSTANCE_NAME=Part-DB - - # Allow users to download attachments to the server by providing an URL - # This could be a potential security issue, as the user can retrieve any file the server has access to (via internet) - - ALLOW_ATTACHMENT_DOWNLOADS=0 - # Use gravatars for user avatars, when user has no own avatar defined - - USE_GRAVATAR=0 - - # Override value if you want to show to show a given text on homepage. - # When this is empty the content of config/banner.md is used as banner + # Override value if you want to show a given text on homepage. + # When this is commented out the webUI can be used to configure the banner #- BANNER=This is a test banner
    with a line break + + # If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here: + # - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer database: container_name: partdb_database @@ -185,6 +176,38 @@ services: ``` +### Installing additional composer packages + +If you need to use specific mailer transports or other functionality that requires additional composer packages, you can +install them automatically at container startup using the `COMPOSER_EXTRA_PACKAGES` environment variable. + +For example, if you want to use Mailgun as your email provider, you need to install the `symfony/mailgun-mailer` package. +Add the following to your docker-compose.yaml environment section: + +```yaml +environment: + - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer + - MAILER_DSN=mailgun+api://API_KEY:DOMAIN@default +``` + +You can specify multiple packages by separating them with spaces: + +```yaml +environment: + - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer +``` + +{: .info } +> The packages will be installed when the container starts. This may increase the container startup time on the first run. +> The installed packages will persist in the container until it is recreated. + +Common mailer packages you might need: +- `symfony/mailgun-mailer` - For Mailgun email service +- `symfony/sendgrid-mailer` - For SendGrid email service +- `symfony/brevo-mailer` - For Brevo (formerly Sendinblue) email service +- `symfony/amazon-mailer` - For Amazon SES email service +- `symfony/postmark-mailer` - For Postmark email service + ### Update Part-DB You can update Part-DB by pulling the latest image and restarting the container. diff --git a/docs/installation/installation_guide-debian.md b/docs/installation/installation_guide-debian.md index 885eea90..b3c61126 100644 --- a/docs/installation/installation_guide-debian.md +++ b/docs/installation/installation_guide-debian.md @@ -1,13 +1,13 @@ --- -title: Direct Installation on Debian 11 +title: Direct Installation on Debian 12 layout: default parent: Installation nav_order: 4 --- -# Part-DB installation guide for Debian 11 (Bullseye) +# Part-DB installation guide for Debian 12 (Bookworm) -This guide shows you how to install Part-DB directly on Debian 11 using apache2 and SQLite. This guide should work with +This guide shows you how to install Part-DB directly on Debian 12 using apache2 and SQLite. This guide should work with recent Ubuntu and other Debian-based distributions with little to no changes. Depending on what you want to do, using the prebuilt docker images may be a better choice, as you don't need to install this many dependencies. See [here]({% link installation/installation_docker.md %}) for more information on the docker @@ -28,40 +28,32 @@ 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.1 or +Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or higher. However, it is recommended to use the most recent version of PHP for performance reasons and future compatibility. -As Debian 11 does not ship PHP 8.1 in its default repositories, we have to add a repository for it. You can skip this -step if your distribution is shipping a recent version of PHP or you want to use the built-in PHP version. If you are -using Debian 12, you can skip this step, as PHP 8.1 is already included in the default repositories. +Install PHP with required extensions and apache2: ```bash -# Add sury repository for PHP 8.1 -sudo curl -sSL https://packages.sury.org/php/README.txt | sudo bash -x - -# Update package list -sudo apt update && sudo apt upgrade +sudo apt install apache2 php8.2 libapache2-mod-php8.2 \ + php8.2-opcache php8.2-curl php8.2-gd php8.2-mbstring \ + php8.2-xml php8.2-bcmath php8.2-intl php8.2-zip php8.2-xsl \ + php8.2-sqlite3 php8.2-mysql ``` -Now you can install PHP 8.1 and the required packages (change the 8.1 in the package version according to the version you -want to use): - -```bash -sudo apt install php8.1 libapache2-mod-php8.1 php8.1-opcache php8.1-curl php8.1-gd php8.1-mbstring php8.1-xml php8.1-bcmath php8.1-intl php8.1-zip php8.1-xsl php8.1-sqlite3 php8.1-mysql -``` - -The apache2 webserver should be already installed with this command and configured basically. - ### Install composer -Part-DB uses [composer](https://getcomposer.org/) to install required PHP libraries. As the version shipped in the -repositories is pretty old, we will install it manually: +Part-DB uses [composer](https://getcomposer.org/) to install required PHP libraries. Install the latest version manually: ```bash # Download composer installer script @@ -78,10 +70,9 @@ To build the front end (the user interface) Part-DB uses [yarn](https://yarnpkg. shipped versions are pretty old, we install new versions from the official Node.js repository: ```bash -# Add recent node repository (nodejs 18 is supported until 2025) -curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash - -# Install nodejs -sudo apt install nodejs +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs + ``` We can install yarn with the following commands: @@ -117,8 +108,8 @@ Alternatively, you can check out a specific version by running ( see [GitHub Releases page](https://github.com/Part-DB/Part-DB-server/releases) for a list of available versions): ```bash -# This checks out the version 1.5.2 -git checkout v1.5.2 +# This checks out the version 2.0.0 +git checkout v2.0.0 ``` Change ownership of the files to the apache user: @@ -142,11 +133,10 @@ configuration: cp .env .env.local ``` -In your `.env.local` you can configure Part-DB according to your wishes. A full list of configuration options can be -found [here](../configuration.md). -Other configuration options like the default language or default currency can be found in `config/parameters.yaml`. +In your `.env.local` you can configure Part-DB according to your wishes and overwrite web interface settings. +A full list of configuration options can be found [here](../configuration.md). -Please check that the `partdb.default_currency` value in `config/parameters.yaml` matches your mainly used currency, as +Please check that the configured base currency matches your mainly used currency, as this can not be changed after creating price information. ### Install dependencies for Part-DB and build frontend @@ -256,6 +246,7 @@ network to point to the server). Navigate to the Part-DB web interface and login via the user icon in the top right corner. You can log in using the username `admin` and the password you have written down earlier. +As first steps, you should check out the system settings and check if everything is correct. ## Update Part-DB @@ -291,7 +282,7 @@ sudo -u www-data php bin/console cache:clear ## MySQL/MariaDB database To use a MySQL database, follow the steps from above (except the creation of the database, we will do this later). -Debian 11 does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead: +Debian 12 does not ship MySQL in its repositories anymore, so we use the compatible MariaDB instead: 1. Install maria-db with: diff --git a/docs/installation/nginx.md b/docs/installation/nginx.md index 82a2e4cf..db209d92 100644 --- a/docs/installation/nginx.md +++ b/docs/installation/nginx.md @@ -7,7 +7,7 @@ nav_order: 10 # Nginx -You can also use [nginx](https://www.nginx.com/) as webserver for Part-DB. Setup Part-DB with apache is a bit easier, so +You can also use [nginx](https://www.nginx.com/) as webserver for Part-DB. Setting up Part-DB with Apache is a bit easier, so this is the method shown in the guides. This guide assumes that you already have a working nginx installation with PHP configured. @@ -52,6 +52,11 @@ server { location ~ \.php$ { return 404; } + + # Set Content-Security-Policy for svg files, to block embedded javascript in there + location ~* \.svg$ { + add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';"; + } error_log /var/log/nginx/parts.error.log; access_log /var/log/nginx/parts.access.log; diff --git a/docs/installation/saml_sso.md b/docs/installation/saml_sso.md index d2e65e7f..f9752546 100644 --- a/docs/installation/saml_sso.md +++ b/docs/installation/saml_sso.md @@ -21,7 +21,7 @@ LDAP or Active Directory server. {: .warning } > This feature is currently in beta. Please report any bugs you find. -> So far it has only tested with Keycloak, but it should work with any SAML 2.0 compatible identity provider. +> So far it has only been tested with Keycloak, but it should work with any SAML 2.0 compatible identity provider. This guide will show you how to configure Part-DB with [Keycloak](https://www.keycloak.org/) as the SAML identity provider, but it should work with any SAML 2.0 compatible identity provider. @@ -75,8 +75,8 @@ the [Keycloak Getting Started Guide](https://www.keycloak.org/docs/latest/gettin ### Configure Part-DB to use SAML -1. Open the `.env.local` file of Part-DB (or the docker-compose.yaml) for edit -2. Set the `SAMLP_SP_PRIVATE_KEY` environment variable to the content of the private key file you downloaded in the +1. Open the `.env.local` file of Part-DB (or the docker-compose.yaml) for editing +2. Set the `SAML_SP_PRIVATE_KEY` environment variable to the content of the private key file you downloaded in the previous step. It should start with `MIEE` and end with `=`. 3. Set the `SAML_SP_X509_CERT` environment variable to the content of the Certificate field shown in the `Keys` tab of the SAML client in Keycloak. It should start with `MIIC` and end with `=`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f20a7f22..a5b1b1c8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,7 +9,7 @@ Sometimes things go wrong and Part-DB shows an error message. This page should h ## Error messages -When a common, easy fixable error occurs (like a non-up-to-date database), Part-DB will show you some short instructions +When a common, easily fixable error occurs (like a non-up-to-date database), Part-DB will show you some short instructions on how to fix the problem. If you have a problem that is not listed here, please open an issue on GitHub. ## General procedure @@ -28,9 +28,9 @@ php bin/console cache:clear php bin/console doctrine:migrations:migrate ``` -If this does not help, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-symfony). +If this does not help, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-server). -## Search for the user and reset the password: +## Search for a user and reset the password You can list all users with the following command: `php bin/console partdb:users:list` To reset the password of a user you can use the following @@ -50,6 +50,21 @@ docker-compose logs -f Please include the error logs in your issue on GitHub, if you open an issue. +## KiCad Integration Issues + +### "API responded with error code: 0: Unknown" + +If you get this error when trying to connect KiCad to Part-DB, it is most likely caused by KiCad not trusting your SSL/TLS certificate. + +**Cause:** KiCad does not trust self-signed SSL/TLS certificates. + +**Solutions:** +- Use HTTP instead of HTTPS for the `root_url` in your KiCad library configuration (only recommended for local networks) +- Use a certificate from a trusted Certificate Authority (CA) like [Let's Encrypt](https://letsencrypt.org/) +- Add your self-signed certificate to the system's trusted certificate store on the computer running KiCad (the exact steps depend on your operating system) + +For more information about KiCad integration, see the [EDA / KiCad integration](../usage/eda_integration.md) documentation. + ## Report Issue -If an error occurs, or you found a bug, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-symfony). +If an error occurs, or you found a bug, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-server). diff --git a/docs/upgrade/1_to_2.md b/docs/upgrade/1_to_2.md new file mode 100644 index 00000000..ef0f4575 --- /dev/null +++ b/docs/upgrade/1_to_2.md @@ -0,0 +1,102 @@ +--- +layout: default +title: Upgrade from Part-DB 1.x to 2.x +nav_order: 1 +has_children: false +parent: Upgrade +--- + +# Upgrade from Part-DB 1.x to 2.x + +Part-DB 2.0 is a major release that changes a lot of things internally, but it is still compatible with Part-DB 1.x. +Depending on your preferences, you will have to do some changes to your Part-DB installation, this document will guide +you through the upgrade process. + +## New requirements +*If you are running Part-DB inside a docker container, you can skip this section, as the new requirements are already +fulfilled by the official Part-DB docker image.* + +Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still +running PHP 8.1, you will have to upgrade your PHP version first. +The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too. + +Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to +easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which +ships all required dependencies and is always up to date with the latest requirements, so that you do not have to worry +about the requirements at all. + +## Changes +* Configuration is now preferably done via a web settings interface. You can still use environment variables, these overwrite +the settings in the web interface. Existing configuration will still work, but you should consider migrating them to the +web interface as described below. +* The `config/banner.md` file that could been used to customize the banner text, was removed. You can now set the banner text + directly in the admin interface, or by setting the `BANNER` environment variable. If you want to keep your existing + banner text, you will have to copy it from the `config/banner.md` file to the admin interface or set the `BANNER` + environment variable. +* The parameters `partdb.sidebar.items`, `partdb.sidebar.root_node_enable` and `partdb.sidebar.root_expanded` in `config/parameters.yaml`, +were removed. You can configure them now directly in the admin interface. +* Updated icon set. As fontawesome 7 is now used, some icons have changed slightly. + +## Upgrade installation + +The upgrade process works very similar to a normal (minor release) upgrade. + +### Direct installation + +**Be sure to execute the following steps as the user that owns the Part-DB files (e.g. `www-data`, or your webserver user). So prepend a `sudo -u www-data` where necessary.** + +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. 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. Run `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 +1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables. +2. Stop the existing Part-DB container with `docker compose down` +3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag). +4. Pull the new images with `docker compose pull` and start the container with `docker compose up -d` +5. If you have database automigration disabled, run `docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate` to update the database schema. +6. Open your Part-DB instance in the browser and log in as an admin user. +7. Go to the user or group permissions page, and give yourself (and other administrators) +the right to change system settings (under "System" and "Configuration"). +8. You can now go to the settings page (under "System" and "Settings") +9. 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. + +## Migrate environment variable configuration to settings interface +As described above, configuration can now be done via the web interface, and can be overwritten by environment variables, so +that existing configuration should still work. However, if a parameter is set via an environment variable, it cannot be changed in the web interface. +To change it, you must migrate your environment variable configuration to the new system. + +For this there is the new console command `settings:migrate-env-to-settings`, which reads in all environment variables used to overwrite +settings and write them to the database, so that you can safely delete them from your environment variable configuration afterwards, without +losing your configuration. + +To run the command, execute `php bin/console settings:migrate-env-to-settings --all` as webserver user (or run `docker exec --user=www-data -it partdb php bin/console settings:migrate-env-to-settings --all` for docker containers). +It will list you all environment variables, it found and ask you for confirmation to migrate them. Answer with `yes` to migrate them and hit enter. + +After the migration run successfully, the contents of your environment variables are now stored in the database and you can safely remove them from your environment variable configuration. +Go through the environment variables listed by the command and remove them from your environment variable configuration (e.g. `.env.local` file or docker compose file), or just comment them out for now. + +If you want to keep some environment variables, just leave them as they are, they will still work as before, the migration command only affects the settings stored in the database. + + +## Troubleshooting + +### cache:clear fails: You have requested a non-existent parameter "jbtronics.settings.proxy_dir". +If you receive an error like +``` +In App_KernelProdContainer.php line 2839: +You have requested a non-existent parameter "jbtronics.settings.proxy_dir". +``` +when running `php bin/console cache:clear` or `composer install`. You have to manually delete the `var/cache/` +directory inside your Part-DB installation and try again. diff --git a/docs/upgrade/index.md b/docs/upgrade/index.md new file mode 100644 index 00000000..4462f0dd --- /dev/null +++ b/docs/upgrade/index.md @@ -0,0 +1,11 @@ +--- +layout: default +title: Upgrade +nav_order: 7 +has_children: true +--- + +# Upgrade + +This section provides information on how to upgrade Part-DB to the latest version. +This is intended for major release upgrades, where requirements or things change significantly. diff --git a/docs/upgrade_legacy.md b/docs/upgrade/upgrade_legacy.md similarity index 96% rename from docs/upgrade_legacy.md rename to docs/upgrade/upgrade_legacy.md index e1e43831..b83661f3 100644 --- a/docs/upgrade_legacy.md +++ b/docs/upgrade/upgrade_legacy.md @@ -2,6 +2,8 @@ layout: default title: Upgrade from legacy Part-DB version (<1.0) nav_order: 100 +redirect_from: /upgrade_legacy +parent: Upgrade --- # Upgrade from legacy Part-DB version @@ -16,13 +18,13 @@ sections carefully before proceeding to upgrade. ## Changes -* PHP 8.1 or higher is required now (Part-DB 0.5 required PHP 5.4+, Part-DB 0.6 PHP 7.0). - Releases are available for Windows too, so almost everybody should be able to use PHP 8.1 +* PHP 8.2 or higher is required now (Part-DB 0.5 required PHP 5.4+, Part-DB 0.6 PHP 7.0). + Releases are available for Windows too, so almost everybody should be able to use PHP 8.2 * **Console access is highly recommended.** The installation of composer and frontend dependencies require console access, also more sensitive stuff like database migration works via CLI now, so you should have console access on your server. * Markdown/HTML is now used instead of BBCode for rich text in description and command fields. It is possible to migrate your existing BBCode to Markdown - via `php bin/console php bin/console partdb:migrations:convert-bbcode`. + via `php bin/console partdb:migrations:convert-bbcode`. * Server exceptions are not logged into event log anymore. For security reasons (exceptions can contain sensitive information) exceptions are only logged to server log (by default under './var/log'), so only the server admins can access it. * Profile labels are now saved in the database (before they were saved in a separate JSON file). **The profiles of legacy diff --git a/docs/usage/backup_restore.md b/docs/usage/backup_restore.md index bef3792d..c4444d24 100644 --- a/docs/usage/backup_restore.md +++ b/docs/usage/backup_restore.md @@ -6,7 +6,7 @@ parent: Usage # Backup and Restore Data -When working productively you should back up the data and configuration of Part-DB regularly to prevent data loss. This +When working productively, you should back up the data and configuration of Part-DB regularly to prevent data loss. This is also useful if you want to migrate your Part-DB instance from one server to another. In that case, you just have to back up the data on server 1, move the backup to server 2, install Part-DB on server 2, and restore the backup. @@ -27,7 +27,7 @@ for more info about these options. ## Backup (manual) -3 parts have to be backup-ed: The configuration files, which contain the instance-specific options, the +Three parts have to be backed up: The configuration files, which contain the instance-specific options, the uploaded files of attachments, and the database containing the most data of Part-DB. Everything else like thumbnails and cache files, are recreated automatically when needed. @@ -44,7 +44,7 @@ You have to recursively copy the `uploads/` folder and the `public/media` folder #### SQLite -If you are using sqlite, it is sufficient to just copy your `app.db` from your database location (normally `var/app.db`) +If you are using SQLite, it is sufficient to just copy your `app.db` from your database location (normally `var/app.db`) to your backup location. #### MySQL / MariaDB @@ -56,7 +56,7 @@ interface (`mysqldump -uBACKUP -pPASSWORD DATABASE`) ## Restore Install Part-DB as usual as described in the installation section, except for the database creation/migration part. You -have to use the same database type (SQLite or MySQL) as on the backuped server instance. +have to use the same database type (SQLite or MySQL) as on the backed up server instance. ### Restore configuration @@ -71,7 +71,7 @@ Copy the `uploads/` and the `public/media/` folder from your backup into your ne #### SQLite -Copy the backup-ed `app.db` into the database folder normally `var/app.db` in Part-DB root folder. +Copy the backed up `app.db` into the database folder normally `var/app.db` in Part-DB root folder. #### MySQL / MariaDB diff --git a/docs/usage/bom_import.md b/docs/usage/bom_import.md index 94a06d55..b4bcb2be 100644 --- a/docs/usage/bom_import.md +++ b/docs/usage/bom_import.md @@ -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. diff --git a/docs/usage/console_commands.md b/docs/usage/console_commands.md index e5197251..b42bb757 100644 --- a/docs/usage/console_commands.md +++ b/docs/usage/console_commands.md @@ -8,7 +8,7 @@ parent: Usage Part-DB provides some console commands to display various information or perform some tasks. The commands are invoked from the main directory of Part-DB with the command `php bin/console [command]` in the context -of the database user (so usually the webserver user), so you maybe have to use `sudo` or `su` to execute the commands: +of the web server user (so usually the webserver user), so you may have to use `sudo` or `su` to execute the commands: ```bash sudo -u www-data php bin/console [command] @@ -17,8 +17,8 @@ sudo -u www-data php bin/console [command] You can get help for every command with the parameter `--help`. See `php bin/console` for a list of all available commands. -If you are running Part-DB in a docker container, you must either execute the commands from a shell inside a container, -or use the `docker exec` command to execute the command directly inside the container. For example if you docker container +If you are running Part-DB in a Docker container, you must either execute the commands from a shell inside the container, +or use the `docker exec` command to execute the command directly inside the container. For example, if your Docker container is named `partdb`, you can execute the command `php bin/console cache:clear` with the following command: ```bash @@ -61,11 +61,14 @@ docker exec --user=www-data partdb php bin/console cache:clear * `partdb:attachments:clean-unused`: Remove all attachments which are not used by any database entry (e.g. orphaned attachments) * `partdb:cache:clear`: Clears all caches, so the next page load will be slower, but the cache will be rebuilt. This can - maybe fix some issues, when the cache were corrupted. This command is also needed after changing things in + maybe fix some issues when the cache was corrupted. This command is also needed after changing things in the `parameters.yaml` file or upgrading Part-DB. * `partdb:migrations:import-partkeepr`: Imports a mysqldump XML dump of a PartKeepr database into Part-DB. This is only needed for users, which want to migrate from PartKeepr to Part-DB. *All existing data in the Part-DB database is deleted!* +* `settings:migrate-env-to-settings`: Migrate configuration from environment variables to the settings interface. +The value of the environment variable is copied to the settings database, so the environment variable can be removed afterwards without losing the configuration. +* `partdb:migrations:convert-db-platform`: Convert the database platform (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa). ## Database commands @@ -74,6 +77,6 @@ docker exec --user=www-data partdb php bin/console cache:clear ## Attachment commands -* `php bin/console partdb:attachments:download`: Download all attachments, which are not already downloaded, to the - local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote and - also makes pictures thumbnails available for the frontend for them \ No newline at end of file +* `php bin/console partdb:attachments:download`: Download all attachments that are not already downloaded to the + local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote, and + also makes picture thumbnails available for the frontend for them. diff --git a/docs/usage/eda_integration.md b/docs/usage/eda_integration.md index 9444e55f..28386a91 100644 --- a/docs/usage/eda_integration.md +++ b/docs/usage/eda_integration.md @@ -17,14 +17,24 @@ This also allows to configure available and usable parts and their properties in ## KiCad Setup {: .important } -> Part-DB uses the HTTP library feature of KiCad, which is experimental and not part of the stable KiCad 7 releases. If you want to use this feature, you need to install a KiCad nightly build (7.99 version). This feature will most likely also be part of KiCad 8. +> Part-DB uses the HTTP library feature of KiCad, which was experimental in earlier versions. If you want to use this feature, you need to install KiCad 8 or newer. -Part-DB should be accessible from the PCs with KiCAD. The URL should be stable (so no dynamically changing IP). -You require a user account in Part-DB, which has permission to access Part-DB API and create API tokens. Every user can have its own account, or you set up a shared read-only account. +Part-DB should be accessible from the PCs with KiCad. The URL should be stable (so no dynamically changing IP). +You require a user account in Part-DB, which has permission to access the Part-DB API and create API tokens. Every user can have their own account, or you set up a shared read-only account. + +{: .warning } +> **HTTPS with Self-Signed Certificates** +> +> KiCad does not trust self-signed SSL/TLS certificates. If your Part-DB instance uses HTTPS with a self-signed certificate, KiCad will fail to connect and show an error like: `API responded with error code: 0: Unknown`. +> +> To resolve this issue, you have the following options: +> - Use HTTP instead of HTTPS for the `root_url` (only recommended for local networks) +> - Use a certificate from a trusted Certificate Authority (CA) like [Let's Encrypt](https://letsencrypt.org/) +> - Add your self-signed certificate to the system's trusted certificate store on the computer running KiCad (the exact steps depend on your operating system) To connect KiCad with Part-DB do the following steps: -1. Create an API token on the user settings page for the KiCAD application and copy/save it, when it is shown. Currently, KiCad can only read Part-DB database, so a token with a read-only scope is enough. +1. Create an API token on the user settings page for the KiCad application and copy/save it when it is shown. Currently, KiCad can only read the Part-DB database, so a token with a read-only scope is enough. 2. Add some EDA metadata to parts, categories, or footprints. Only parts with usable info will show up in KiCad. See below for more info. 3. Create a file `partd.kicad_httplib` (or similar, only the extension is important) with the following content: ``` @@ -54,18 +64,18 @@ Part-DB doesn't save any concrete footprints or symbols for the part. Instead, P You can define this on a per-part basis using the KiCad symbol and KiCad footprint field in the EDA tab of the part editor. Or you can define it at a category (symbol) or footprint level, to assign this value to all parts with this category and footprint. -For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` on the parts Kicad symbol to give it the right schematic symbol in EEschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in PcbNew. +For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew. If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value. ### Parts and category visibility -Only parts and their categories, on which there is any kind of EDA metadata are defined show up in KiCad. So if you want to see parts in KiCad, +Only parts and their categories on which there is any kind of EDA metadata defined show up in KiCad. So if you want to see parts in KiCad, you need to define at least a symbol, footprint, reference prefix, or value on a part, category or footprint. You can use the "Force visibility" checkbox on a part or category to override this behavior and force parts to be visible or hidden in KiCad. -*Please note that KiCad caches the library categories. So if you change something, which would change the visible categories in KiCad, you have to reload EEschema to see the changes.* +*Please note that KiCad caches the library categories. So if you change something that would change the visible categories in KiCad, you have to reload Eeschema to see the changes.* ### Category depth in KiCad diff --git a/docs/usage/getting_started.md b/docs/usage/getting_started.md index 4bb8afb9..8772130c 100644 --- a/docs/usage/getting_started.md +++ b/docs/usage/getting_started.md @@ -6,17 +6,25 @@ nav_order: 4 # Getting started -After Part-DB you should begin with customizing the settings, and setting up the basic structures. +After installing Part-DB, you should begin with customizing the settings and setting up the basic structures. Before starting, it's useful to read a bit about the [concepts of Part-DB]({% link concepts.md %}). 1. TOC {:toc} -## Customize config files +## Customize system settings -Before you start creating data structures, you should configure Part-DB to your needs by changing possible configuration -options. -This is done either via changing the `.env.local` file in a direct installation or by changing the env variables in +Before starting creating data structures, you should check the system settings to ensure that they fit your needs. +After logging in as an administrator, you can find the settings in the sidebar under `Tools -> System -> Settings`. +![image]({% link assets/getting_started/system_settings.png %}) + +Here you can change various settings, like the name of your Part-DB instance (which is shown in the title bar of the +browser), the default language (which is used if no user preference is set), the default timezone (which is used to +display times correctly), the default currency (which is used to display prices correctly), and many more. + +Some more fundamental settings like database connection, mail server settings, SSO, etc. are configured via environment variables. +Environment variables also allow to overwrite various settings from the web interface. +Environment variables can be changed by editing the `.env.local` file in a direct installation or by changing the env variables in your `docker-compose.yaml` file. A list of possible configuration options can be found [here]({% link configuration.md %}). @@ -27,9 +35,9 @@ the navigation bar drop-down with the user symbol). ![image]({% link assets/getting_started/change_password.png %}) -There you can also find the option, to set up Two-Factor Authentication methods like Google Authenticator. Using this is +There you can also find the option to set up Two-Factor Authentication methods like Google Authenticator. Using this is highly recommended (especially if you have admin permissions) to increase the security of your account. (Two-factor authentication -even can be enforced for all members of a user group) +can even be enforced for all members of a user group) In the user settings panel, you can change account info like your username, your first and last name (which will be shown alongside your username to identify you better), department information, and your email address. The email address @@ -44,8 +52,8 @@ used. ## (Optional) Customize homepage banner -The banner which is shown on the homepage, can be customized/changed by changing the `config/banner.md` file with a text -editor. You can use markdown and (safe) HTML here, to style and customize the banner. +The banner which is shown on the homepage, can be customized/changed via the homepage banner setting in system settings. +You can use markdown and (safe) HTML here, to style and customize the banner. You can even use LaTeX-style equations by wrapping the expressions into `$` (like `$E=mc^2$`, which is rendered inline: $E=mc^2$) or `$$` (like `$$E=mc^2$$`) which will be rendered as a block, like so: $$E=mc^2$$ @@ -56,7 +64,7 @@ $E=mc^2$) or `$$` (like `$$E=mc^2$$`) which will be rendered as a block, like so When logged in as administrator, you can open the users menu in the `Tools` section of the sidebar under `System -> Users`. On this page you can create new users, change their passwords and settings, and change their permissions. -For each user who should use Part-DB you should set up their own account so that tracking of what user did works +For each user who should use Part-DB, you should set up their own account so that tracking of what each user did works properly. ![image]({% link assets/getting_started/user_admin.png %}) @@ -199,7 +207,7 @@ You have to enter at least a name for the part and choose a category for it, the However, it is recommended to fill out as much information as possible, as this will make it easier to find the part later. -You can choose from your created datastructures to add manufacturer information, supplier information, etc. to the part. -You can also create new datastructures on the fly, if you want to add additional information to the part, by typing the -name of the new datastructure in the field and select the "New ..." option in the dropdown menu. See [tips]({% link -usage/tips_tricks.md %}) for more information. \ No newline at end of file +You can choose from your created data structures to add manufacturer information, supplier information, etc. to the part. +You can also create new data structures on the fly if you want to add additional information to the part, by typing the +name of the new data structure in the field and selecting the "New ..." option in the dropdown menu. See [tips]({% link +usage/tips_tricks.md %}) for more information. diff --git a/docs/usage/import_export.md b/docs/usage/import_export.md index e43936cc..f4d8d91c 100644 --- a/docs/usage/import_export.md +++ b/docs/usage/import_export.md @@ -20,7 +20,7 @@ Part-DB. Data can also be exported from Part-DB into various formats. > individually in the permissions settings. If you want to import data from PartKeepr you might want to look into the [PartKeepr migration guide]({% link -upgrade_legacy.md %}). +partkeepr_migration.md %}). ### Import parts @@ -47,9 +47,9 @@ You can upload the file that should be imported here and choose various options the import file (or the export will error, if no category is specified). * **Mark all imported parts as "Needs review"**: If this is selected, all imported parts will be marked as "Needs review" after the import. This can be useful if you want to review all imported parts before using them. -* **Create unknown data structures**: If this is selected Part-DB will create new data structures (like categories, - manufacturers, etc.) if no data structure(s) with the same name and path already exists. If this is not selected, only - existing data structures will be used and if no matching data strucure is found, the imported parts field will be empty. +* **Create unknown data structures**: If this is selected, Part-DB will create new data structures (like categories, + manufacturers, etc.) if no data structure(s) with the same name and path already exist. If this is not selected, only + existing data structures will be used, and if no matching data structure is found, the imported parts field will be empty. * **Path delimiter**: Part-DB allows you to create/select nested data structures (like categories, manufacturers, etc.) by using a path (e.g. `Category 1->Category 1.1`, which will select/create the `Category 1.1` whose parent is `Category 1`). This path is separated by the path delimiter. If you want to use a different path delimiter than the @@ -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: @@ -158,4 +161,4 @@ information, this can lead to very large export files. You can export parts in all part tables. Select the parts you want via the checkbox in the table line and select the export format and level in the appearing menu. -See the section about exporting data structures for more information about the export formats and levels. \ No newline at end of file +See the section about exporting data structures for more information about the export formats and levels. diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 015a9eb3..13df7f10 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -68,10 +68,17 @@ 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. -Each information source is called am "info provider" and handles the communication with the external source. +Each information source is called an "info provider" and handles the communication with the external source. The providers are just a driver that handles the communication with the different external sources and converts them into a common format Part-DB understands. That way it is pretty easy to create new providers as they just need to do very little work. @@ -80,6 +87,11 @@ Normally the providers utilize an API of a service, and you need to create an ac Also, there are limits on how many requests you can do per day or month, depending on the provider and your contract with them. +Data providers can be either configured in the system settings (in the info provider tab) or on the settings page which is +reachable via the cogwheel symbol next to the provider in the provider list. It is also possible to configure them via +environment variables. See below for the available configuration options. API keys configured via environment variables +are redacted in the settings interface. + The following providers are currently available and shipped with Part-DB: (All trademarks are property of their respective owners. Part-DB is not affiliated with any of the companies.) @@ -127,9 +139,6 @@ You must create an organization there and create a "Production app". Most settin grant access to the "Product Information" API. You will get a Client ID and a Client Secret, which you have to put in the Part-DB env configuration (see below). -**Attention**: Currently only the "Product Information V3 (Deprecated)" is supported by Part-DB. -Using "Product Information V4" will not work. - The following env configuration options are available: * `PROVIDER_DIGIKEY_CLIENT_ID`: The client ID you got from Digi-Key (mandatory) @@ -148,7 +157,7 @@ again, to establish a new connection. ### TME -The TME provider uses the API of [TME](https://www.tme.eu/) to search for parts and getting shopping information from +The TME provider uses the API of [TME](https://www.tme.eu/) to search for parts and get shopping information from them. To use it you have to create an account at TME and get an API key on the [TME API page](https://developers.tme.eu/en/). You have to generate a new anonymous key there and enter the key and secret in the Part-DB env configuration (see @@ -167,10 +176,10 @@ The following env configuration options are available: ### Farnell / Element14 / Newark -The Farnell provider uses the [Farnell API](https://partner.element14.com/) to search for parts and getting shopping +The Farnell provider uses the [Farnell API](https://partner.element14.com/) to search for parts and get shopping information from [Farnell](https://www.farnell.com/). You have to create an account at Farnell and get an API key on the [Farnell API page](https://partner.element14.com/). -Register a new application there (settings does not matter, as long as you select the "Product Search API") and you will +Register a new application there (settings do not matter, as long as you select the "Product Search API") and you will get an API key. The following env configuration options are available: @@ -182,17 +191,13 @@ The following env configuration options are available: ### Mouser -The Mouser provider uses the [Mouser API](https://www.mouser.de/api-home/) to search for parts and getting shopping +The Mouser provider uses the [Mouser API](https://www.mouser.de/api-home/) to search for parts and get shopping information from [Mouser](https://www.mouser.com/). You have to create an account at Mouser and register for an API key for the Search API on the [Mouser API page](https://www.mouser.de/api-home/). You will receive an API token, which you have to put in the Part-DB env configuration (see below): At the registration you choose a country, language, and currency in which you want to get the results. -*Attention*: Currently (January 2024) the mouser API seems to be somewhat broken, in the way that it does not return any -information about datasheets and part specifications. Therefore Part-DB can not retrieve them, even if they are shown -at the mouser page. See [issue #503](https://github.com/Part-DB/Part-DB-server/issues/503) for more info. - Following env configuration options are available: * `PROVIDER_MOUSER_KEY`: The API key you got from Mouser (mandatory) @@ -208,7 +213,7 @@ Following env configuration options are available: webshop uses an internal JSON based API to render the page. Part-DB can use this inofficial API to get part information from LCSC. -**Please note, that the use of this internal API is not intended or endorsed by LCS and it could break at any time. So use it at your own risk.** +**Please note that the use of this internal API is not intended or endorsed by LCSC and it could break at any time. So use it at your own risk.** An API key is not required, it is enough to enable the provider using the following env configuration options: @@ -217,7 +222,7 @@ An API key is not required, it is enough to enable the provider using the follow ### OEMsecrets -The oemsecrets provider uses the [oemsecrets API](https://www.oemsecrets.com/) to search for parts and getting shopping +The oemsecrets provider uses the [oemsecrets API](https://www.oemsecrets.com/) to search for parts and get shopping information from them. Similar to octopart it aggregates offers from different distributors. You can apply for a free API key on the [oemsecrets API page](https://www.oemsecrets.com/api/) and put the key you get @@ -255,6 +260,24 @@ This is not an official API and could break at any time. So use it at your own r The following env configuration options are available: * `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider +### Buerklin + +The Buerklin provider uses the [Buerklin API](https://www.buerklin.com/en/services/eprocurement/) to search for parts and get information. +To use it you have to request access to the API. +You will get an e-mail with the client ID and client secret, which you have to put in the Part-DB configuration (see below). + +Please note that the Buerklin API is limited to 100 requests/minute per IP address and +access to the Authentication server is limited to 10 requests/minute per IP address + +The following env configuration options are available: + +* `PROVIDER_BUERKLIN_CLIENT_ID`: The client ID you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_SECRET`: The client secret you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_USERNAME`: The username you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_PASSWORD`: The password you got from Buerklin (mandatory) +* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`). +* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`) + ### Custom provider To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long diff --git a/docs/usage/labels.md b/docs/usage/labels.md index e84f4d7f..4c3f8b32 100644 --- a/docs/usage/labels.md +++ b/docs/usage/labels.md @@ -6,10 +6,10 @@ parent: Usage # Labels -Part-DB support the generation and printing of labels for parts, part lots and storage locations. +Part-DB supports the generation and printing of labels for parts, part lots and storage locations. You can use the "Tools -> Label generator" menu entry to create labels or click the label generation link on the part. -You can define label templates by creating Label profiles. This way you can create many similar-looking labels with for +You can define label templates by creating label profiles. This way you can create many similar-looking labels for many parts. The content of the labels is defined by the template's content field. You can use the WYSIWYG editor to create and style diff --git a/docs/usage/tips_tricks.md b/docs/usage/tips_tricks.md index d033cbe8..cab05620 100644 --- a/docs/usage/tips_tricks.md +++ b/docs/usage/tips_tricks.md @@ -65,7 +65,7 @@ $$E=mc^2$$ ## Update currency exchange rates automatically Part-DB can update the currency exchange rates of all defined currencies programmatically -by calling the `php bin/console partdb:currencies:update-exchange-rates`. +by calling `php bin/console partdb:currencies:update-exchange-rates`. If you call this command regularly (e.g. with a cronjob), you can keep the exchange rates up-to-date. @@ -88,11 +88,16 @@ the user as "owner" of a part lot. This way, only he is allowed to add or remove ## Update notifications -Part-DB can show you a notification that there is a newer version than currently installed available. The notification +Part-DB can show you a notification that there is a newer version than currently installed. The notification will be shown on the homepage and the server info page. -It is only be shown to users which has the `Show available Part-DB updates` permission. +It is only shown to users which have the `Show available Part-DB updates` permission. For the notification to work, Part-DB queries the GitHub API every 2 days to check for new releases. No data is sent to GitHub besides the metadata required for the connection (so the public IP address of your computer running Part-DB). If you don't want Part-DB to query the GitHub API, or if your server can not reach the internet, you can disable the -update notifications by setting the `CHECK_FOR_UPDATES` option to `false`. \ No newline at end of file +update notifications by setting the `CHECK_FOR_UPDATES` option to `false`. + +## Internet access via proxy +If your server running Part-DB does not have direct access to the internet, but has to use a proxy server, you can configure +the proxy settings in the `.env.local` file (or docker env config). You can set the `HTTP_PROXY` and `HTTPS_PROXY` environment +variables to the URL of your proxy server. If your proxy server requires authentication, you can include the username and password in the URL. diff --git a/makefile b/makefile new file mode 100644 index 00000000..bc4d0bf3 --- /dev/null +++ b/makefile @@ -0,0 +1,91 @@ +# PartDB Makefile for Test Environment Management + +.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ +test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ +section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset + +# Default target +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +# Dependencies +deps-install: ## Install PHP dependencies with unlimited memory + @echo "๐Ÿ“ฆ Installing PHP dependencies..." + COMPOSER_MEMORY_LIMIT=-1 composer install + yarn install + @echo "โœ… Dependencies installed" + +# Complete test environment setup +test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) + @echo "โœ… Test environment setup complete!" + +# Clean test environment +test-clean: ## Clean test cache and database files + @echo "๐Ÿงน Cleaning test environment..." + rm -rf var/cache/test + rm -f var/app_test.db + @echo "โœ… Test environment cleaned" + +# Create test database +test-db-create: ## Create test database (if not exists) + @echo "๐Ÿ—„๏ธ Creating test database..." + -php bin/console doctrine:database:create --if-not-exists --env test || echo "โš ๏ธ Database creation failed (expected for SQLite) - continuing..." + +# Run database migrations for test environment +test-db-migrate: ## Run database migrations for test environment + @echo "๐Ÿ”„ Running database migrations..." + COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test + +# Clear test cache +test-cache-clear: ## Clear test cache + @echo "๐Ÿ—‘๏ธ Clearing test cache..." + rm -rf var/cache/test + @echo "โœ… Test cache cleared" + +# Load test fixtures +test-fixtures: ## Load test fixtures + @echo "๐Ÿ“ฆ Loading test fixtures..." + php bin/console partdb:fixtures:load -n --env test + +# Run PHPUnit tests +test-run: ## Run PHPUnit tests + @echo "๐Ÿงช Running tests..." + php bin/phpunit + +# Quick test reset (clean + migrate + fixtures, skip DB creation) +test-reset: test-cache-clear test-db-migrate test-fixtures + @echo "โœ… Test environment reset complete!" + +test-typecheck: ## Run static analysis (PHPStan) + @echo "๐Ÿงช Running type checks..." + COMPOSER_MEMORY_LIMIT=-1 composer phpstan + +# Development helpers +dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) + @echo "โœ… Development environment setup complete!" + +dev-clean: ## Clean development cache and database files + @echo "๐Ÿงน Cleaning development environment..." + rm -rf var/cache/dev + rm -f var/app_dev.db + @echo "โœ… Development environment cleaned" + +dev-db-create: ## Create development database (if not exists) + @echo "๐Ÿ—„๏ธ Creating development database..." + -php bin/console doctrine:database:create --if-not-exists --env dev || echo "โš ๏ธ Database creation failed (expected for SQLite) - continuing..." + +dev-db-migrate: ## Run database migrations for development environment + @echo "๐Ÿ”„ Running database migrations..." + COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev + +dev-cache-clear: ## Clear development cache + @echo "๐Ÿ—‘๏ธ Clearing development cache..." + rm -rf var/cache/dev + @echo "โœ… Development cache cleared" + +dev-warmup: ## Warm up development cache + @echo "๐Ÿ”ฅ Warming up development cache..." + COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n + +dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) + @echo "โœ… Development environment reset complete!" \ No newline at end of file diff --git a/migrations/Version20221114193325.php b/migrations/Version20221114193325.php index 9766ccf3..bc2a97fa 100644 --- a/migrations/Version20221114193325.php +++ b/migrations/Version20221114193325.php @@ -4,18 +4,15 @@ declare(strict_types=1); namespace DoctrineMigrations; +use App\Doctrine\Migration\ContainerAwareMigrationInterface; use App\Migration\AbstractMultiPlatformMigration; use App\Migration\WithPermPresetsTrait; use App\Services\UserSystem\PermissionPresetsHelper; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Schema; use Psr\Log\LoggerInterface; -use Symfony\Component\DependencyInjection\ContainerAwareInterface; -/** - * Auto-generated Migration: Please modify to your needs! - */ -final class Version20221114193325 extends AbstractMultiPlatformMigration implements ContainerAwareInterface +final class Version20221114193325 extends AbstractMultiPlatformMigration implements ContainerAwareMigrationInterface { use WithPermPresetsTrait; diff --git a/migrations/Version20240606203053.php b/migrations/Version20240606203053.php index 83370ad6..1c7d2bf9 100644 --- a/migrations/Version20240606203053.php +++ b/migrations/Version20240606203053.php @@ -4,16 +4,16 @@ declare(strict_types=1); namespace DoctrineMigrations; +use App\Doctrine\Migration\ContainerAwareMigrationInterface; use App\Migration\AbstractMultiPlatformMigration; use App\Migration\WithPermPresetsTrait; use App\Services\UserSystem\PermissionPresetsHelper; use Doctrine\DBAL\Schema\Schema; -use Symfony\Component\DependencyInjection\ContainerAwareInterface; /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20240606203053 extends AbstractMultiPlatformMigration implements ContainerAwareInterface +final class Version20240606203053 extends AbstractMultiPlatformMigration implements ContainerAwareMigrationInterface { use WithPermPresetsTrait; diff --git a/migrations/Version20250321075747.php b/migrations/Version20250321075747.php new file mode 100644 index 00000000..14bcb8a9 --- /dev/null +++ b/migrations/Version20250321075747.php @@ -0,0 +1,605 @@ +addSql(<<<'SQL' + CREATE TABLE part_custom_states ( + id INT AUTO_INCREMENT NOT NULL, + parent_id INT DEFAULT NULL, + id_preview_attachment INT DEFAULT NULL, + name VARCHAR(255) NOT NULL, + comment LONGTEXT NOT NULL, + not_selectable TINYINT(1) NOT NULL, + alternative_names LONGTEXT DEFAULT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + INDEX IDX_F552745D727ACA70 (parent_id), + INDEX IDX_F552745DEA7100A1 (id_preview_attachment), + INDEX part_custom_state_name (name), + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states ADD CONSTRAINT FK_F552745D727ACA70 FOREIGN KEY (parent_id) REFERENCES part_custom_states (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states ADD CONSTRAINT FK_F552745DEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON DELETE SET NULL + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD id_part_custom_state INT DEFAULT NULL AFTER id_part_unit + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state) + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE parts DROP FOREIGN KEY FK_6940A7FEA3ED1215 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX IDX_6940A7FEA3ED1215 ON parts + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts DROP id_part_custom_state + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states DROP FOREIGN KEY FK_F552745D727ACA70 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states DROP FOREIGN KEY FK_F552745DEA7100A1 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE part_custom_states + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE "part_custom_states" ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + name VARCHAR(255) NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + alternative_names CLOB DEFAULT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT FK_F552745D727ACA70 FOREIGN KEY (parent_id) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_F5AF83CFEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_F552745D727ACA70 ON "part_custom_states" (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX part_custom_state_name ON "part_custom_states" (name) + SQL); + + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__parts AS + SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM parts + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE parts + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE parts ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + id_category INTEGER NOT NULL, + id_footprint INTEGER DEFAULT NULL, + id_part_unit INTEGER DEFAULT NULL, + id_manufacturer INTEGER DEFAULT NULL, + id_part_custom_state INTEGER DEFAULT NULL, + order_orderdetails_id INTEGER DEFAULT NULL, + built_project_id INTEGER DEFAULT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + needs_review BOOLEAN NOT NULL, + tags CLOB NOT NULL, + mass DOUBLE PRECISION DEFAULT NULL, + description CLOB NOT NULL, + comment CLOB NOT NULL, + visible BOOLEAN NOT NULL, + favorite BOOLEAN NOT NULL, + minamount DOUBLE PRECISION NOT NULL, + manufacturer_product_url CLOB NOT NULL, + manufacturer_product_number VARCHAR(255) NOT NULL, + manufacturing_status VARCHAR(255) DEFAULT NULL, + order_quantity INTEGER NOT NULL, + manual_order BOOLEAN NOT NULL, + ipn VARCHAR(100) DEFAULT NULL, + provider_reference_provider_key VARCHAR(255) DEFAULT NULL, + provider_reference_provider_id VARCHAR(255) DEFAULT NULL, + provider_reference_provider_url VARCHAR(255) DEFAULT NULL, + provider_reference_last_updated DATETIME DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_value VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO parts ( + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint) + SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM __temp__parts + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE __temp__parts + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_name ON parts (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_ipn ON parts (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state) + SQL); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__parts AS + SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM "parts" + SQL); + $this->addSql(<<<'SQL' + DROP TABLE "parts" + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE "parts" ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + id_category INTEGER NOT NULL, + id_footprint INTEGER DEFAULT NULL, + id_part_unit INTEGER DEFAULT NULL, + id_manufacturer INTEGER DEFAULT NULL, + order_orderdetails_id INTEGER DEFAULT NULL, + built_project_id INTEGER DEFAULT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + needs_review BOOLEAN NOT NULL, + tags CLOB NOT NULL, + mass DOUBLE PRECISION DEFAULT NULL, + description CLOB NOT NULL, + comment CLOB NOT NULL, + visible BOOLEAN NOT NULL, + favorite BOOLEAN NOT NULL, + minamount DOUBLE PRECISION NOT NULL, + manufacturer_product_url CLOB NOT NULL, + manufacturer_product_number VARCHAR(255) NOT NULL, + manufacturing_status VARCHAR(255) DEFAULT NULL, + order_quantity INTEGER NOT NULL, + manual_order BOOLEAN NOT NULL, + ipn VARCHAR(100) DEFAULT NULL, + provider_reference_provider_key VARCHAR(255) DEFAULT NULL, + provider_reference_provider_id VARCHAR(255) DEFAULT NULL, + provider_reference_provider_url VARCHAR(255) DEFAULT NULL, + provider_reference_last_updated DATETIME DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_value VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO "parts" ( + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + ) SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM __temp__parts + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE __temp__parts + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_name ON "parts" (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_ipn ON "parts" (ipn) + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE "part_custom_states" + SQL); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE "part_custom_states" ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + parent_id INT DEFAULT NULL, + id_preview_attachment INT DEFAULT NULL, PRIMARY KEY(id), + name VARCHAR(255) NOT NULL, + comment TEXT NOT NULL, + not_selectable BOOLEAN NOT NULL, + alternative_names TEXT DEFAULT NULL, + last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_F552745D727ACA70 ON "part_custom_states" (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_F552745DEA7100A1 ON "part_custom_states" (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" + ADD CONSTRAINT FK_F552745D727ACA70 + FOREIGN KEY (parent_id) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" + ADD CONSTRAINT FK_F552745DEA7100A1 + FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + + + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD id_part_custom_state INT DEFAULT NULL + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state) + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE "parts" DROP CONSTRAINT FK_6940A7FEA3ED1215 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX IDX_6940A7FEA3ED1215 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "parts" DROP id_part_custom_state + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" DROP CONSTRAINT FK_F552745D727ACA70 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" DROP CONSTRAINT FK_F552745DEA7100A1 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE "part_custom_states" + SQL); + } +} diff --git a/migrations/Version20250325073036.php b/migrations/Version20250325073036.php new file mode 100644 index 00000000..3bae80ab --- /dev/null +++ b/migrations/Version20250325073036.php @@ -0,0 +1,307 @@ +addSql(<<<'SQL' + ALTER TABLE categories ADD COLUMN part_ipn_prefix VARCHAR(255) NOT NULL DEFAULT '' + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE categories DROP part_ipn_prefix + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__categories AS + SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM categories + SQL); + + $this->addSql('DROP TABLE categories'); + + $this->addSql(<<<'SQL' + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + partname_hint CLOB NOT NULL, + partname_regex CLOB NOT NULL, + part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL, + disable_footprints BOOLEAN NOT NULL, + disable_manufacturers BOOLEAN NOT NULL, + disable_autodatasheets BOOLEAN NOT NULL, + disable_properties BOOLEAN NOT NULL, + default_description CLOB NOT NULL, + default_comment CLOB NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + alternative_names CLOB DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO categories ( + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + ) SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM __temp__categories + SQL); + + $this->addSql('DROP TABLE __temp__categories'); + + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668EA7100A1 ON categories (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_name ON categories (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_parent_name ON categories (parent_id, name) + SQL); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__categories AS + SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM categories + SQL); + + $this->addSql('DROP TABLE categories'); + + $this->addSql(<<<'SQL' + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + partname_hint CLOB NOT NULL, + partname_regex CLOB NOT NULL, + disable_footprints BOOLEAN NOT NULL, + disable_manufacturers BOOLEAN NOT NULL, + disable_autodatasheets BOOLEAN NOT NULL, + disable_properties BOOLEAN NOT NULL, + default_description CLOB NOT NULL, + default_comment CLOB NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + alternative_names CLOB DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO categories ( + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + ) SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM __temp__categories + SQL); + + $this->addSql('DROP TABLE __temp__categories'); + + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668EA7100A1 ON categories (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_name ON categories (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_parent_name ON categories (parent_id, name) + SQL); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE categories ADD part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE "categories" DROP part_ipn_prefix + SQL); + } +} diff --git a/migrations/Version20250706201121.php b/migrations/Version20250706201121.php new file mode 100644 index 00000000..b7563978 --- /dev/null +++ b/migrations/Version20250706201121.php @@ -0,0 +1,49 @@ +addSql('CREATE TABLE settings_entry (`key` VARCHAR(255) NOT NULL, `data` JSON DEFAULT NULL, id INT AUTO_INCREMENT NOT NULL, UNIQUE INDEX UNIQ_93F8DB394E645A7E (`key`), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE settings_entry'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('CREATE TABLE settings_entry ("key" VARCHAR(255) NOT NULL, "data" CLOB DEFAULT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_93F8DB39F48571EB ON settings_entry ("key")'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('DROP TABLE settings_entry'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql('CREATE TABLE settings_entry ("key" VARCHAR(255) NOT NULL, "data" JSON DEFAULT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_93F8DB39F48571EB ON settings_entry ("key")'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE settings_entry'); + } +} diff --git a/migrations/Version20250802205143.php b/migrations/Version20250802205143.php new file mode 100644 index 00000000..5eb09a77 --- /dev/null +++ b/migrations/Version20250802205143.php @@ -0,0 +1,70 @@ +addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)'); + + $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)'); + $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)'); + $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE bulk_info_provider_import_job_parts'); + $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)'); + + $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)'); + $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)'); + $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('DROP TABLE bulk_info_provider_import_job_parts'); + $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)'); + + $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)'); + $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)'); + $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE bulk_info_provider_import_job_parts'); + $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); + } +} diff --git a/migrations/Version20250813214628.php b/migrations/Version20250813214628.php new file mode 100644 index 00000000..5b9350b2 --- /dev/null +++ b/migrations/Version20250813214628.php @@ -0,0 +1,75 @@ +connection; + $rows = $connection->fetchAllAssociative('SELECT id, transports, other_ui FROM webauthn_keys'); + + foreach ($rows as $row) { + $id = $row['id']; + $new_transports = json_encode(unserialize($row['transports'], ['allowed_classes' => false]), + JSON_THROW_ON_ERROR); + $new_other_ui = json_encode(unserialize($row['other_ui'], ['allowed_classes' => false]), + JSON_THROW_ON_ERROR); + + $connection->executeStatement( + 'UPDATE webauthn_keys SET transports = :transports, other_ui = :other_ui WHERE id = :id', + [ + 'transports' => $new_transports, + 'other_ui' => $new_other_ui, + 'id' => $id, + ] + ); + } + } + + public function mySQLUp(Schema $schema): void + { + $this->convertArrayToJson(); + $this->addSql('ALTER TABLE webauthn_keys CHANGE transports transports JSON NOT NULL, CHANGE other_ui other_ui JSON DEFAULT NULL'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE webauthn_keys CHANGE transports transports LONGTEXT NOT NULL, CHANGE other_ui other_ui LONGTEXT DEFAULT NULL'); + } + + public function sqLiteUp(Schema $schema): void + { + //As there is no JSON type in SQLite, we only need to convert the data. + $this->convertArrayToJson(); + } + + public function sqLiteDown(Schema $schema): void + { + //Nothing to do here, as SQLite does not support JSON type and we are not changing the column type. + } + + public function postgreSQLUp(Schema $schema): void + { + $this->convertArrayToJson(); + $this->addSql('ALTER TABLE webauthn_keys ALTER transports TYPE JSON USING transports::JSON'); + $this->addSql('ALTER TABLE webauthn_keys ALTER other_ui TYPE JSON USING other_ui::JSON'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE webauthn_keys ALTER transports TYPE TEXT'); + $this->addSql('ALTER TABLE webauthn_keys ALTER other_ui TYPE TEXT'); + } +} diff --git a/migrations/Version20251204215443.php b/migrations/Version20251204215443.php new file mode 100644 index 00000000..3cee0035 --- /dev/null +++ b/migrations/Version20251204215443.php @@ -0,0 +1,156 @@ +addSql('ALTER TABLE attachments CHANGE external_path external_path VARCHAR(2048) DEFAULT NULL'); + $this->addSql('ALTER TABLE manufacturers CHANGE website website VARCHAR(2048) NOT NULL, CHANGE auto_product_url auto_product_url VARCHAR(2048) NOT NULL'); + $this->addSql('ALTER TABLE parts CHANGE provider_reference_provider_url provider_reference_provider_url VARCHAR(2048) DEFAULT NULL'); + $this->addSql('ALTER TABLE suppliers CHANGE website website VARCHAR(2048) NOT NULL, CHANGE auto_product_url auto_product_url VARCHAR(2048) NOT NULL'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE `attachments` CHANGE external_path external_path VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE `manufacturers` CHANGE website website VARCHAR(255) NOT NULL, CHANGE auto_product_url auto_product_url VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE `parts` CHANGE provider_reference_provider_url provider_reference_provider_url VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE `suppliers` CHANGE website website VARCHAR(255) NOT NULL, CHANGE auto_product_url auto_product_url VARCHAR(255) NOT NULL'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__attachments AS SELECT id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, internal_path, external_path FROM attachments'); + $this->addSql('DROP TABLE attachments'); + $this->addSql('CREATE TABLE attachments (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, type_id INTEGER NOT NULL, original_filename VARCHAR(255) DEFAULT NULL, show_in_table BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, class_name VARCHAR(255) NOT NULL, element_id INTEGER NOT NULL, internal_path VARCHAR(255) DEFAULT NULL, external_path VARCHAR(2048) DEFAULT NULL, CONSTRAINT FK_47C4FAD6C54C8C93 FOREIGN KEY (type_id) REFERENCES attachment_types (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO attachments (id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, internal_path, external_path) SELECT id, type_id, original_filename, show_in_table, name, last_modified, datetime_added, class_name, element_id, internal_path, external_path FROM __temp__attachments'); + $this->addSql('DROP TABLE __temp__attachments'); + $this->addSql('CREATE INDEX attachment_element_idx ON attachments (class_name, element_id)'); + $this->addSql('CREATE INDEX attachment_name_idx ON attachments (name)'); + $this->addSql('CREATE INDEX attachments_idx_class_name_id ON attachments (class_name, id)'); + $this->addSql('CREATE INDEX attachments_idx_id_element_id_class_name ON attachments (id, element_id, class_name)'); + $this->addSql('CREATE INDEX IDX_47C4FAD6C54C8C93 ON attachments (type_id)'); + $this->addSql('CREATE INDEX IDX_47C4FAD61F1F2A24 ON attachments (element_id)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__manufacturers AS SELECT id, parent_id, id_preview_attachment, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added, alternative_names FROM manufacturers'); + $this->addSql('DROP TABLE manufacturers'); + $this->addSql('CREATE TABLE manufacturers (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(2048) NOT NULL, auto_product_url VARCHAR(2048) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, alternative_names CLOB DEFAULT NULL, CONSTRAINT FK_94565B12727ACA70 FOREIGN KEY (parent_id) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_94565B12EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO manufacturers (id, parent_id, id_preview_attachment, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added, alternative_names) SELECT id, parent_id, id_preview_attachment, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added, alternative_names FROM __temp__manufacturers'); + $this->addSql('DROP TABLE __temp__manufacturers'); + $this->addSql('CREATE INDEX IDX_94565B12EA7100A1 ON manufacturers (id_preview_attachment)'); + $this->addSql('CREATE INDEX IDX_94565B12727ACA70 ON manufacturers (parent_id)'); + $this->addSql('CREATE INDEX manufacturer_name ON manufacturers (name)'); + $this->addSql('CREATE INDEX manufacturer_idx_parent_name ON manufacturers (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts'); + $this->addSql('DROP TABLE parts'); + $this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)'); + $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)'); + $this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)'); + $this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)'); + $this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)'); + $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)'); + $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)'); + $this->addSql('CREATE INDEX parts_idx_name ON parts (name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added, alternative_names FROM suppliers'); + $this->addSql('DROP TABLE suppliers'); + $this->addSql('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(2048) NOT NULL, auto_product_url VARCHAR(2048) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, alternative_names CLOB DEFAULT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES suppliers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO suppliers (id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added, alternative_names) SELECT id, parent_id, default_currency_id, id_preview_attachment, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added, alternative_names FROM __temp__suppliers'); + $this->addSql('DROP TABLE __temp__suppliers'); + $this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON suppliers (default_currency_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON suppliers (parent_id)'); + $this->addSql('CREATE INDEX supplier_idx_name ON suppliers (name)'); + $this->addSql('CREATE INDEX supplier_idx_parent_name ON suppliers (parent_id, name)'); + $this->addSql('CREATE INDEX IDX_AC28B95CEA7100A1 ON suppliers (id_preview_attachment)'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__attachments AS SELECT id, name, last_modified, datetime_added, original_filename, internal_path, external_path, show_in_table, type_id, class_name, element_id FROM "attachments"'); + $this->addSql('DROP TABLE "attachments"'); + $this->addSql('CREATE TABLE "attachments" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, original_filename VARCHAR(255) DEFAULT NULL, internal_path VARCHAR(255) DEFAULT NULL, external_path VARCHAR(255) DEFAULT NULL, show_in_table BOOLEAN NOT NULL, type_id INTEGER NOT NULL, class_name VARCHAR(255) NOT NULL, element_id INTEGER NOT NULL, CONSTRAINT FK_47C4FAD6C54C8C93 FOREIGN KEY (type_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "attachments" (id, name, last_modified, datetime_added, original_filename, internal_path, external_path, show_in_table, type_id, class_name, element_id) SELECT id, name, last_modified, datetime_added, original_filename, internal_path, external_path, show_in_table, type_id, class_name, element_id FROM __temp__attachments'); + $this->addSql('DROP TABLE __temp__attachments'); + $this->addSql('CREATE INDEX IDX_47C4FAD6C54C8C93 ON "attachments" (type_id)'); + $this->addSql('CREATE INDEX IDX_47C4FAD61F1F2A24 ON "attachments" (element_id)'); + $this->addSql('CREATE INDEX attachments_idx_id_element_id_class_name ON "attachments" (id, element_id, class_name)'); + $this->addSql('CREATE INDEX attachments_idx_class_name_id ON "attachments" (class_name, id)'); + $this->addSql('CREATE INDEX attachment_name_idx ON "attachments" (name)'); + $this->addSql('CREATE INDEX attachment_element_idx ON "attachments" (class_name, element_id)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__manufacturers AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, address, phone_number, fax_number, email_address, website, auto_product_url, parent_id, id_preview_attachment FROM "manufacturers"'); + $this->addSql('DROP TABLE "manufacturers"'); + $this->addSql('CREATE TABLE "manufacturers" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_94565B12727ACA70 FOREIGN KEY (parent_id) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_94565B12EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "manufacturers" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, address, phone_number, fax_number, email_address, website, auto_product_url, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, address, phone_number, fax_number, email_address, website, auto_product_url, parent_id, id_preview_attachment FROM __temp__manufacturers'); + $this->addSql('DROP TABLE __temp__manufacturers'); + $this->addSql('CREATE INDEX IDX_94565B12727ACA70 ON "manufacturers" (parent_id)'); + $this->addSql('CREATE INDEX IDX_94565B12EA7100A1 ON "manufacturers" (id_preview_attachment)'); + $this->addSql('CREATE INDEX manufacturer_name ON "manufacturers" (name)'); + $this->addSql('CREATE INDEX manufacturer_idx_parent_name ON "manufacturers" (parent_id, name)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"'); + $this->addSql('DROP TABLE "parts"'); + $this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(255) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts'); + $this->addSql('DROP TABLE __temp__parts'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)'); + $this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)'); + $this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)'); + $this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)'); + $this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)'); + $this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)'); + $this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)'); + $this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)'); + $this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)'); + $this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, address, phone_number, fax_number, email_address, website, auto_product_url, shipping_costs, parent_id, default_currency_id, id_preview_attachment FROM "suppliers"'); + $this->addSql('DROP TABLE "suppliers"'); + $this->addSql('CREATE TABLE "suppliers" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO "suppliers" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, address, phone_number, fax_number, email_address, website, auto_product_url, shipping_costs, parent_id, default_currency_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, address, phone_number, fax_number, email_address, website, auto_product_url, shipping_costs, parent_id, default_currency_id, id_preview_attachment FROM __temp__suppliers'); + $this->addSql('DROP TABLE __temp__suppliers'); + $this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON "suppliers" (parent_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON "suppliers" (default_currency_id)'); + $this->addSql('CREATE INDEX IDX_AC28B95CEA7100A1 ON "suppliers" (id_preview_attachment)'); + $this->addSql('CREATE INDEX supplier_idx_name ON "suppliers" (name)'); + $this->addSql('CREATE INDEX supplier_idx_parent_name ON "suppliers" (parent_id, name)'); + } + + public function postgreSQLUp(Schema $schema): void + { + // this up() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE attachments ALTER external_path TYPE VARCHAR(2048)'); + $this->addSql('ALTER TABLE manufacturers ALTER website TYPE VARCHAR(2048)'); + $this->addSql('ALTER TABLE manufacturers ALTER auto_product_url TYPE VARCHAR(2048)'); + $this->addSql('ALTER TABLE parts ALTER provider_reference_provider_url TYPE VARCHAR(2048)'); + $this->addSql('ALTER TABLE suppliers ALTER website TYPE VARCHAR(2048)'); + $this->addSql('ALTER TABLE suppliers ALTER auto_product_url TYPE VARCHAR(2048)'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE "attachments" ALTER external_path TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE "manufacturers" ALTER website TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE "manufacturers" ALTER auto_product_url TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE "parts" ALTER provider_reference_provider_url TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE "suppliers" ALTER website TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE "suppliers" ALTER auto_product_url TYPE VARCHAR(255)'); + } +} diff --git a/package.json b/package.json index 38656c72..a58b3aa4 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,16 @@ "devDependencies": { "@babel/core": "^7.19.6", "@babel/preset-env": "^7.19.4", - "@fortawesome/fontawesome-free": "^6.1.1", + "@fortawesome/fontawesome-free": "^7.0.0", "@hotwired/stimulus": "^3.0.0", "@hotwired/turbo": "^8.0.1", "@popperjs/core": "^2.10.2", - "@symfony/stimulus-bridge": "^3.2.0", + "@symfony/stimulus-bridge": "^4.0.0", "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", - "@symfony/webpack-encore": "^5.0.0", + "@symfony/webpack-encore": "^5.1.0", "bootstrap": "^5.1.3", - "core-js": "^3.23.0", + "core-js": "^3.38.0", "intl-messageformat": "^10.2.5", "jquery": "^3.5.1", "popper.js": "^1.14.7", @@ -29,54 +29,28 @@ "watch": "encore dev --watch", "build": "encore production --progress" }, + "engines": { + "node": ">=20.0.0" + }, "dependencies": { "@algolia/autocomplete-js": "^1.17.0", "@algolia/autocomplete-plugin-recent-searches": "^1.17.0", "@algolia/autocomplete-theme-classic": "^1.17.0", - "@ckeditor/ckeditor5-alignment": "^44.0.0", - "@ckeditor/ckeditor5-autoformat": "^44.0.0", - "@ckeditor/ckeditor5-basic-styles": "^44.0.0", - "@ckeditor/ckeditor5-block-quote": "^44.0.0", - "@ckeditor/ckeditor5-code-block": "^44.0.0", "@ckeditor/ckeditor5-dev-translations": "^43.0.1", "@ckeditor/ckeditor5-dev-utils": "^43.0.1", - "@ckeditor/ckeditor5-editor-classic": "^44.0.0", - "@ckeditor/ckeditor5-essentials": "^44.0.0", - "@ckeditor/ckeditor5-find-and-replace": "^44.0.0", - "@ckeditor/ckeditor5-font": "^44.0.0", - "@ckeditor/ckeditor5-heading": "^44.0.0", - "@ckeditor/ckeditor5-highlight": "^44.0.0", - "@ckeditor/ckeditor5-horizontal-line": "^44.0.0", - "@ckeditor/ckeditor5-html-embed": "^44.0.0", - "@ckeditor/ckeditor5-html-support": "^44.0.0", - "@ckeditor/ckeditor5-image": "^44.0.0", - "@ckeditor/ckeditor5-indent": "^44.0.0", - "@ckeditor/ckeditor5-link": "^44.0.0", - "@ckeditor/ckeditor5-list": "^44.0.0", - "@ckeditor/ckeditor5-markdown-gfm": "^44.0.0", - "@ckeditor/ckeditor5-media-embed": "^44.0.0", - "@ckeditor/ckeditor5-paragraph": "^44.0.0", - "@ckeditor/ckeditor5-paste-from-office": "^44.0.0", - "@ckeditor/ckeditor5-remove-format": "^44.0.0", - "@ckeditor/ckeditor5-source-editing": "^44.0.0", - "@ckeditor/ckeditor5-special-characters": "^44.0.0", - "@ckeditor/ckeditor5-table": "^44.0.0", - "@ckeditor/ckeditor5-theme-lark": "^44.0.0", - "@ckeditor/ckeditor5-upload": "^44.0.0", - "@ckeditor/ckeditor5-watchdog": "^44.0.0", - "@ckeditor/ckeditor5-word-count": "^44.0.0", "@jbtronics/bs-treeview": "^1.0.1", - "@part-db/html5-qrcode": "^3.1.0", + "@part-db/html5-qrcode": "^4.0.0", "@zxcvbn-ts/core": "^3.0.2", "@zxcvbn-ts/language-common": "^3.0.3", "@zxcvbn-ts/language-de": "^3.0.1", "@zxcvbn-ts/language-en": "^3.0.1", "@zxcvbn-ts/language-fr": "^3.0.1", "@zxcvbn-ts/language-ja": "^3.0.1", - "barcode-detector": "^2.3.1", + "barcode-detector": "^3.0.5", "bootbox": "^6.0.0", "bootswatch": "^5.1.3", "bs-custom-file-input": "^1.3.4", + "ckeditor5": "^47.0.0", "clipboard": "^2.0.4", "compression-webpack-plugin": "^11.1.0", "datatables.net": "^2.0.0", @@ -85,14 +59,13 @@ "datatables.net-colreorder-bs5": "^2.0.0", "datatables.net-fixedheader-bs5": "^4.0.0", "datatables.net-responsive-bs5": "^3.0.0", - "datatables.net-select-bs5": "^2.0.0", + "datatables.net-select-bs5": "^3.0.1", "dompurify": "^3.0.3", - "emoji.json": "^15.0.0", "exports-loader": "^5.0.0", "json-formatter-js": "^2.3.4", "jszip": "^3.2.0", "katex": "^0.16.0", - "marked": "^15.0.4", + "marked": "^16.1.1", "marked-gfm-heading-id": "^4.1.1", "marked-mangle": "^1.0.1", "pdfmake": "^0.2.2", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7ee7596f..3feb4940 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,36 +1,42 @@ - - - - - - - - - - - - src - - - - - tests - - - - - - - - + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" + colors="true" + failOnDeprecation="false" + failOnNotice="true" + failOnWarning="true" + bootstrap="tests/bootstrap.php" + cacheDirectory=".phpunit.cache" + backupGlobals="false" +> + + + + + + + + + + + + + src + + + + + + tests + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess index ee3b5450..a13baeee 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -86,7 +86,7 @@ DirectoryIndex index.php # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the # following RewriteCond (best solution) RewriteCond %{ENV:REDIRECT_STATUS} ="" - RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] + RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=308,L] # If the requested filename exists, simply serve it. # We only want to let Apache serve files and not directories. @@ -118,3 +118,10 @@ DirectoryIndex index.php # RedirectTemp cannot be used instead + +# Set Content-Security-Policy for svg files (and compressed variants), to block embedded javascript in there + + + Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" + + \ No newline at end of file diff --git a/public/img/calculator/ratio.png b/public/img/calculator/ratio.png deleted file mode 100644 index d6decff3..00000000 Binary files a/public/img/calculator/ratio.png and /dev/null differ diff --git a/public/img/calculator/v1.png b/public/img/calculator/v1.png deleted file mode 100644 index c98d3ad4..00000000 Binary files a/public/img/calculator/v1.png and /dev/null differ diff --git a/public/img/calculator/v2.png b/public/img/calculator/v2.png deleted file mode 100644 index 081386fe..00000000 Binary files a/public/img/calculator/v2.png and /dev/null differ diff --git a/public/img/labels/100.png b/public/img/labels/100.png deleted file mode 100644 index f68a23a9..00000000 Binary files a/public/img/labels/100.png and /dev/null differ diff --git a/public/img/labels/1001.png b/public/img/labels/1001.png deleted file mode 100644 index c87e4ceb..00000000 Binary files a/public/img/labels/1001.png and /dev/null differ diff --git a/public/img/labels/1002.png b/public/img/labels/1002.png deleted file mode 100644 index 68b6594c..00000000 Binary files a/public/img/labels/1002.png and /dev/null differ diff --git a/public/img/labels/1003.png b/public/img/labels/1003.png deleted file mode 100644 index 2abbd616..00000000 Binary files a/public/img/labels/1003.png and /dev/null differ diff --git a/public/img/labels/100R.png b/public/img/labels/100R.png deleted file mode 100644 index 34fb8fa8..00000000 Binary files a/public/img/labels/100R.png and /dev/null differ diff --git a/public/img/labels/101.png b/public/img/labels/101.png deleted file mode 100644 index dd07aa39..00000000 Binary files a/public/img/labels/101.png and /dev/null differ diff --git a/public/img/labels/102.png b/public/img/labels/102.png deleted file mode 100644 index a54e16b7..00000000 Binary files a/public/img/labels/102.png and /dev/null differ diff --git a/public/img/labels/10R2.png b/public/img/labels/10R2.png deleted file mode 100644 index 2b57f7d4..00000000 Binary files a/public/img/labels/10R2.png and /dev/null differ diff --git a/public/img/labels/220.png b/public/img/labels/220.png deleted file mode 100644 index 28ede43d..00000000 Binary files a/public/img/labels/220.png and /dev/null differ diff --git a/public/img/labels/221K.png b/public/img/labels/221K.png deleted file mode 100644 index 1dbb0c61..00000000 Binary files a/public/img/labels/221K.png and /dev/null differ diff --git a/public/img/labels/246-20.png b/public/img/labels/246-20.png deleted file mode 100644 index 590f7c5d..00000000 Binary files a/public/img/labels/246-20.png and /dev/null differ diff --git a/public/img/labels/3F3.png b/public/img/labels/3F3.png deleted file mode 100644 index ce85ae97..00000000 Binary files a/public/img/labels/3F3.png and /dev/null differ diff --git a/public/img/labels/R10.png b/public/img/labels/R10.png deleted file mode 100644 index 60a90182..00000000 Binary files a/public/img/labels/R10.png and /dev/null differ diff --git a/public/img/labels/template-c-elko-alu.png b/public/img/labels/template-c-elko-alu.png deleted file mode 100644 index 24d68d91..00000000 Binary files a/public/img/labels/template-c-elko-alu.png and /dev/null differ diff --git a/public/img/labels/template-c-elko.png b/public/img/labels/template-c-elko.png deleted file mode 100644 index 97e3c1ef..00000000 Binary files a/public/img/labels/template-c-elko.png and /dev/null differ diff --git a/public/img/labels/template-c-tantal.png b/public/img/labels/template-c-tantal.png deleted file mode 100644 index 3e49efee..00000000 Binary files a/public/img/labels/template-c-tantal.png and /dev/null differ diff --git a/public/img/labels/template-l.png b/public/img/labels/template-l.png deleted file mode 100644 index 7e5afd92..00000000 Binary files a/public/img/labels/template-l.png and /dev/null differ diff --git a/public/img/labels/template-r.png b/public/img/labels/template-r.png deleted file mode 100644 index 554d2a08..00000000 Binary files a/public/img/labels/template-r.png and /dev/null differ diff --git a/public/img/partdb/alldatasheet.png b/public/img/partdb/alldatasheet.png deleted file mode 100644 index d7c1d40f..00000000 Binary files a/public/img/partdb/alldatasheet.png and /dev/null differ diff --git a/public/img/partdb/dc.png b/public/img/partdb/dc.png deleted file mode 100644 index 4a9403af..00000000 Binary files a/public/img/partdb/dc.png and /dev/null differ diff --git a/public/img/partdb/dummytn.png b/public/img/partdb/dummytn.png deleted file mode 100644 index e63c9248..00000000 Binary files a/public/img/partdb/dummytn.png and /dev/null differ diff --git a/public/img/partdb/favicon.ico b/public/img/partdb/favicon.ico deleted file mode 100644 index 1d838794..00000000 Binary files a/public/img/partdb/favicon.ico and /dev/null differ diff --git a/public/img/partdb/file_all.svg b/public/img/partdb/file_all.svg deleted file mode 100644 index bb4b4248..00000000 --- a/public/img/partdb/file_all.svg +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/img/partdb/file_dc.svg b/public/img/partdb/file_dc.svg deleted file mode 100644 index f0039881..00000000 --- a/public/img/partdb/file_dc.svg +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - DC - diff --git a/public/img/partdb/file_google.svg b/public/img/partdb/file_google.svg deleted file mode 100644 index 20ea96bf..00000000 --- a/public/img/partdb/file_google.svg +++ /dev/null @@ -1,5 +0,0 @@ - - -google - - diff --git a/public/img/partdb/file_octo.svg b/public/img/partdb/file_octo.svg deleted file mode 100644 index 307439a5..00000000 --- a/public/img/partdb/file_octo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - -cog - - diff --git a/public/img/partdb/file_reichelt.svg b/public/img/partdb/file_reichelt.svg deleted file mode 100644 index 488dafaa..00000000 --- a/public/img/partdb/file_reichelt.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - diff --git a/public/img/partdb/help.png b/public/img/partdb/help.png deleted file mode 100644 index 7cb04978..00000000 Binary files a/public/img/partdb/help.png and /dev/null differ diff --git a/public/img/partdb/partdb.png b/public/img/partdb/partdb.png deleted file mode 100644 index 53f51afb..00000000 Binary files a/public/img/partdb/partdb.png and /dev/null differ diff --git a/public/img/partdb/reichelt.png b/public/img/partdb/reichelt.png deleted file mode 100644 index fcfcfd49..00000000 Binary files a/public/img/partdb/reichelt.png and /dev/null differ diff --git a/public/img/partdb/template-pdf.png b/public/img/partdb/template-pdf.png deleted file mode 100644 index 211bf5a4..00000000 Binary files a/public/img/partdb/template-pdf.png and /dev/null differ diff --git a/rector.php b/rector.php index 40eee9f7..936b447e 100644 --- a/rector.php +++ b/rector.php @@ -7,6 +7,7 @@ use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\Config\RectorConfig; use Rector\Doctrine\Set\DoctrineSetList; use Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitThisCallRector; +use Rector\PHPUnit\CodeQuality\Rector\MethodCall\AssertEmptyNullableObjectToAssertInstanceofRector; use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; @@ -16,6 +17,61 @@ use Rector\Symfony\CodeQuality\Rector\MethodCall\LiteralGetToRequestClassConstan use Rector\Symfony\Set\SymfonySetList; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; +return RectorConfig::configure() + ->withComposerBased(phpunit: true) + + ->withSymfonyContainerPhp(__DIR__ . '/tests/symfony-container.php') + ->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml') + + ->withImportNames(importShortClasses: false) + ->withPaths([ + __DIR__ . '/config', + __DIR__ . '/public', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + + ->withSets([ + PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, + PHPUnitSetList::PHPUNIT_90, + PHPUnitSetList::PHPUNIT_110, + PHPUnitSetList::PHPUNIT_CODE_QUALITY, + + + ]) + + ->withRules([ + DeclareStrictTypesRector::class + ]) + + ->withSkip([ + //Leave our AssertNull tests alone + AssertEmptyNullableObjectToAssertInstanceofRector::class, + + + CountArrayToEmptyArrayComparisonRector::class, + //Leave our !== null checks alone + FlipTypeControlToUseExclusiveTypeRector::class, + //Leave our PartList TableAction alone + ActionSuffixRemoverRector::class, + //We declare event listeners via attributes, therefore no need to migrate them to subscribers + EventListenerToEventSubscriberRector::class, + PreferPHPUnitThisCallRector::class, + //Do not replace 'GET' with class constant, + LiteralGetToRequestClassConstantRector::class, + ]) + + //Do not apply rules to Symfony own files + ->withSkip([ + __DIR__ . '/public/index.php', + __DIR__ . '/src/Kernel.php', + __DIR__ . '/config/preload.php', + __DIR__ . '/config/bundles.php', + ]) + + ; + +/* return static function (RectorConfig $rectorConfig): void { $rectorConfig->symfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml'); $rectorConfig->symfonyContainerPhp(__DIR__ . '/tests/symfony-container.php'); @@ -79,3 +135,4 @@ return static function (RectorConfig $rectorConfig): void { __DIR__ . '/config/bundles.php', ]); }; +*/ diff --git a/src/ApiPlatform/DocumentedAPIProperties/PropertyMetadataFactory.php b/src/ApiPlatform/DocumentedAPIProperties/PropertyMetadataFactory.php index 49e9a031..2ffb9179 100644 --- a/src/ApiPlatform/DocumentedAPIProperties/PropertyMetadataFactory.php +++ b/src/ApiPlatform/DocumentedAPIProperties/PropertyMetadataFactory.php @@ -70,4 +70,4 @@ class PropertyMetadataFactory implements PropertyMetadataFactoryInterface return $metadata; } -} \ No newline at end of file +} diff --git a/src/Command/Attachments/SanitizeSVGAttachmentsCommand.php b/src/Command/Attachments/SanitizeSVGAttachmentsCommand.php new file mode 100644 index 00000000..7f6550f0 --- /dev/null +++ b/src/Command/Attachments/SanitizeSVGAttachmentsCommand.php @@ -0,0 +1,90 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Command\Attachments; + +use App\Entity\Attachments\Attachment; +use App\Services\Attachments\AttachmentSubmitHandler; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand('partdb:attachments:sanitize-svg', "Sanitize uploaded SVG files.")] +class SanitizeSVGAttachmentsCommand extends Command +{ + public function __construct(private readonly EntityManagerInterface $entityManager, private readonly AttachmentSubmitHandler $attachmentSubmitHandler, ?string $name = null) + { + parent::__construct($name); + } + + public function configure(): void + { + $this->setHelp('This command allows to sanitize SVG files uploaded via attachments. This happens automatically since version 1.17.1, this command is intended to be used for older files.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->info('This command will sanitize all uploaded SVG files. This is only required if you have uploaded (untrusted) SVG files before version 1.17.1. If you are running a newer version, you don\'t need to run this command (again).'); + if (!$io->confirm('Do you want to continue?', false)) { + $io->success('Command aborted.'); + return Command::FAILURE; + } + + $io->info('Sanitizing SVG files...'); + + //Finding all attachments with svg files + $qb = $this->entityManager->createQueryBuilder(); + $qb->select('a') + ->from(Attachment::class, 'a') + ->where('a.internal_path LIKE :pattern ESCAPE \'#\'') + ->orWhere('a.original_filename LIKE :pattern ESCAPE \'#\'') + ->setParameter('pattern', '%.svg'); + + $attachments = $qb->getQuery()->getResult(); + $io->note('Found '.count($attachments).' attachments with SVG files.'); + + if (count($attachments) === 0) { + $io->success('No SVG files found.'); + return Command::FAILURE; + } + + $io->info('Sanitizing SVG files...'); + $io->progressStart(count($attachments)); + foreach ($attachments as $attachment) { + /** @var Attachment $attachment */ + $io->note('Sanitizing attachment '.$attachment->getId().' ('.($attachment->getFilename() ?? '???').')'); + $this->attachmentSubmitHandler->sanitizeSVGAttachment($attachment); + $io->progressAdvance(); + + } + $io->progressFinish(); + + $io->success('Sanitization finished. All SVG files have been sanitized.'); + return Command::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Command/CheckRequirementsCommand.php b/src/Command/CheckRequirementsCommand.php index 5e15e8e2..f9080c42 100644 --- a/src/Command/CheckRequirementsCommand.php +++ b/src/Command/CheckRequirementsCommand.php @@ -69,8 +69,8 @@ class CheckRequirementsCommand extends Command if ($io->isVerbose()) { $io->comment('Checking PHP version...'); } - //We recommend PHP 8.2, but 8.1 is the minimum - if (PHP_VERSION_ID < 80200) { + //We recommend PHP 8.2, but 8.2 is the minimum + if (PHP_VERSION_ID < 80400) { $io->warning('You are using PHP '. PHP_VERSION .'. This will work, but a newer version is recommended.'); } elseif (!$only_issues) { $io->success('PHP version is sufficient.'); @@ -84,7 +84,7 @@ class CheckRequirementsCommand extends Command $io->success('You are using a 64-bit system.'); } } else { - $io->warning('You are using a system with an unknown bit size. That is interesting xD'); + $io->warning(' areYou using a system with an unknown bit size. That is interesting xD'); } //Check if opcache is enabled diff --git a/src/Command/Currencies/UpdateExchangeRatesCommand.php b/src/Command/Currencies/UpdateExchangeRatesCommand.php index 0f3eb11f..2c1f5f92 100644 --- a/src/Command/Currencies/UpdateExchangeRatesCommand.php +++ b/src/Command/Currencies/UpdateExchangeRatesCommand.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Command\Currencies; +use App\Settings\SystemSettings\LocalizationSettings; use Symfony\Component\Console\Attribute\AsCommand; use App\Entity\PriceInformations\Currency; use App\Services\Tools\ExchangeRateUpdater; @@ -39,7 +40,7 @@ use function strlen; #[AsCommand('partdb:currencies:update-exchange-rates|partdb:update-exchange-rates|app:update-exchange-rates', 'Updates the currency exchange rates.')] class UpdateExchangeRatesCommand extends Command { - public function __construct(protected string $base_current, protected EntityManagerInterface $em, protected ExchangeRateUpdater $exchangeRateUpdater) + public function __construct(protected EntityManagerInterface $em, protected ExchangeRateUpdater $exchangeRateUpdater, private readonly LocalizationSettings $localizationSettings) { parent::__construct(); } @@ -54,13 +55,13 @@ class UpdateExchangeRatesCommand extends Command $io = new SymfonyStyle($input, $output); //Check for valid base current - if (3 !== strlen($this->base_current)) { + if (3 !== strlen($this->localizationSettings->baseCurrency)) { $io->error('Chosen Base current is not valid. Check your settings!'); return Command::FAILURE; } - $io->note('Update currency exchange rates with base currency: '.$this->base_current); + $io->note('Update currency exchange rates with base currency: '.$this->localizationSettings->baseCurrency); //Check what currencies we need to update: $iso_code = $input->getArgument('iso_code'); diff --git a/src/Command/Migrations/DBPlatformConvertCommand.php b/src/Command/Migrations/DBPlatformConvertCommand.php new file mode 100644 index 00000000..86052bf7 --- /dev/null +++ b/src/Command/Migrations/DBPlatformConvertCommand.php @@ -0,0 +1,253 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Command\Migrations; + +use App\Entity\UserSystem\User; +use App\Services\ImportExportSystem\PartKeeprImporter\PKImportHelper; +use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; +use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager; +use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Migrations\DependencyFactory; +use Doctrine\ORM\Id\AssignedGenerator; +use Doctrine\ORM\Mapping\ClassMetadata; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +#[AsCommand('partdb:migrations:convert-db-platform', 'Convert the database to a different platform')] +class DBPlatformConvertCommand extends Command +{ + + public function __construct( + private readonly EntityManagerInterface $targetEM, + private readonly PKImportHelper $importHelper, + private readonly DependencyFactory $dependencyFactory, + #[Autowire('%kernel.project_dir%')] + private readonly string $kernelProjectDir, + ) + { + parent::__construct(); + } + + public function configure(): void + { + $this + ->setHelp('This command allows you to migrate the database from one database platform to another (e.g. from MySQL to PostgreSQL).') + ->addArgument('url', InputArgument::REQUIRED, 'The database connection URL of the source database to migrate from'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $sourceEM = $this->getSourceEm($input->getArgument('url')); + + //Check that both databases are not using the same driver + if ($sourceEM->getConnection()->getDatabasePlatform()::class === $this->targetEM->getConnection()->getDatabasePlatform()::class) { + $io->warning('Source and target database are using the same database platform / driver. This command is only intended to migrate between different database platforms (e.g. from MySQL to PostgreSQL).'); + if (!$io->confirm('Do you want to continue anyway?', false)) { + $io->info('Aborting migration process.'); + return Command::SUCCESS; + } + } + + + $this->ensureVersionUpToDate($sourceEM); + + $io->note('This command is still in development. If you encounter any problems, please report them to the issue tracker on GitHub.'); + $io->warning(sprintf('This command will delete all existing data in the target database "%s". Make sure that you have no important data in the database before you continue!', + $this->targetEM->getConnection()->getDatabase() ?? 'unknown' + )); + + //$users = $sourceEM->getRepository(User::class)->findAll(); + //dump($users); + + $io->ask('Please type "DELETE ALL DATA" to continue.', '', function ($answer) { + if (strtoupper($answer) !== 'DELETE ALL DATA') { + throw new \RuntimeException('You did not type "DELETE ALL DATA"!'); + } + return $answer; + }); + + + // Example migration logic (to be replaced with actual migration code) + $io->info('Starting database migration...'); + + //Disable all event listeners on target EM to avoid unwanted side effects + $eventManager = $this->targetEM->getEventManager(); + foreach ($eventManager->getAllListeners() as $event => $listeners) { + foreach ($listeners as $listener) { + $eventManager->removeEventListener($event, $listener); + } + } + + $io->info('Clear target database...'); + $this->importHelper->purgeDatabaseForImport($this->targetEM, ['internal', 'migration_versions']); + + $metadata = $this->targetEM->getMetadataFactory()->getAllMetadata(); + + $io->info('Modifying entity metadata for migration...'); + //First we modify each entity metadata to have an persist cascade on all relations + foreach ($metadata as $metadatum) { + $entityClass = $metadatum->getName(); + $io->writeln('Modifying cascade and ID settings for entity: ' . $entityClass, OutputInterface::VERBOSITY_VERBOSE); + + foreach ($metadatum->getAssociationNames() as $fieldName) { + $mapping = $metadatum->getAssociationMapping($fieldName); + $mapping->cascade = array_unique(array_merge($mapping->cascade, ['persist'])); + $mapping->fetch = ClassMetadata::FETCH_EAGER; //Avoid lazy loading issues during migration + + $metadatum->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE); + $metadatum->setIdGenerator(new AssignedGenerator()); + } + } + + + $io->progressStart(count($metadata)); + + //First we migrate users to avoid foreign key constraint issues + $io->info('Migrating users first to avoid foreign key constraint issues...'); + $this->fixUsers($sourceEM); + + //Afterward we migrate all entities + foreach ($metadata as $metadatum) { + //skip all superclasses + if ($metadatum->isMappedSuperclass) { + continue; + } + + $entityClass = $metadatum->getName(); + + $io->note('Migrating entity: ' . $entityClass); + + $repo = $sourceEM->getRepository($entityClass); + $items = $repo->findAll(); + foreach ($items as $index => $item) { + $this->targetEM->persist($item); + } + $this->targetEM->flush(); + } + + $io->progressFinish(); + + + //Fix sequences / auto increment values on target database + $io->info('Fixing sequences / auto increment values on target database...'); + $this->fixAutoIncrements($this->targetEM); + + $io->success('Database migration completed successfully.'); + + if ($io->isVerbose()) { + $io->info('Process took peak memory: ' . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ' MB'); + } + + return Command::SUCCESS; + } + + /** + * Construct a source EntityManager based on the given connection URL + * @param string $url + * @return EntityManagerInterface + */ + private function getSourceEm(string $url): EntityManagerInterface + { + //Replace any %kernel.project_dir% placeholders + $url = str_replace('%kernel.project_dir%', $this->kernelProjectDir, $url); + + $connectionFactory = new ConnectionFactory(); + $connection = $connectionFactory->createConnection(['url' => $url]); + return new EntityManager($connection, $this->targetEM->getConfiguration()); + } + + private function ensureVersionUpToDate(EntityManagerInterface $sourceEM): void + { + //Ensure that target database is up to date + $migrationStatusCalculator = $this->dependencyFactory->getMigrationStatusCalculator(); + $newMigrations = $migrationStatusCalculator->getNewMigrations(); + if (count($newMigrations->getItems()) > 0) { + throw new \RuntimeException("Target database is not up to date. Please run all migrations (with doctrine:migrations:migrate) before starting the migration process."); + } + + $sourceDependencyLoader = DependencyFactory::fromEntityManager(new ExistingConfiguration($this->dependencyFactory->getConfiguration()), new ExistingEntityManager($sourceEM)); + $sourceMigrationStatusCalculator = $sourceDependencyLoader->getMigrationStatusCalculator(); + $sourceNewMigrations = $sourceMigrationStatusCalculator->getNewMigrations(); + if (count($sourceNewMigrations->getItems()) > 0) { + throw new \RuntimeException("Source database is not up to date. Please run all migrations (with doctrine:migrations:migrate) on the source database before starting the migration process."); + } + } + + private function fixUsers(EntityManagerInterface $sourceEM): void + { + //To avoid a problem with (Column 'settings' cannot be null) in MySQL we need to migrate the user entities first + //and fix the settings and backupCodes fields + + $reflClass = new \ReflectionClass(User::class); + foreach ($sourceEM->getRepository(User::class)->findAll() as $user) { + foreach (['settings', 'backupCodes'] as $field) { + $property = $reflClass->getProperty($field); + if (!$property->isInitialized($user) || $property->getValue($user) === null) { + $property->setValue($user, []); + } + } + $this->targetEM->persist($user); + } + } + + private function fixAutoIncrements(EntityManagerInterface $em): void + { + $connection = $em->getConnection(); + $platform = $connection->getDatabasePlatform(); + + if ($platform instanceof PostgreSQLPlatform) { + $connection->executeStatement( + //From: https://wiki.postgresql.org/wiki/Fixing_Sequences + <<datastructureImporter->importPartUnits($data); $io->success('Imported '.$count.' measurement units.'); + //Import the custom states + $io->info('Importing custom states...'); + $count = $this->datastructureImporter->importPartCustomStates($data); + $io->success('Imported '.$count.' custom states.'); + //Import manufacturers $io->info('Importing manufacturers...'); $count = $this->datastructureImporter->importManufacturers($data); diff --git a/src/Command/User/UpgradePermissionsSchemaCommand.php b/src/Command/User/UpgradePermissionsSchemaCommand.php index 4947fd5c..a53e21a0 100644 --- a/src/Command/User/UpgradePermissionsSchemaCommand.php +++ b/src/Command/User/UpgradePermissionsSchemaCommand.php @@ -39,14 +39,7 @@ final class UpgradePermissionsSchemaCommand extends Command { public function __construct(private readonly PermissionSchemaUpdater $permissionSchemaUpdater, private readonly EntityManagerInterface $em, private readonly EventCommentHelper $eventCommentHelper) { - parent::__construct(self::$defaultName); - } - - protected function configure(): void - { - $this - ->setDescription(self::$defaultDescription) - ; + parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/User/UsersPermissionsCommand.php b/src/Command/User/UsersPermissionsCommand.php index 6408e9c9..27382371 100644 --- a/src/Command/User/UsersPermissionsCommand.php +++ b/src/Command/User/UsersPermissionsCommand.php @@ -46,7 +46,7 @@ class UsersPermissionsCommand extends Command { $this->userRepository = $entityManager->getRepository(User::class); - parent::__construct(self::$defaultName); + parent::__construct(); } protected function configure(): void diff --git a/src/Controller/AdminPages/BaseAdminController.php b/src/Controller/AdminPages/BaseAdminController.php index edc5917a..7c109751 100644 --- a/src/Controller/AdminPages/BaseAdminController.php +++ b/src/Controller/AdminPages/BaseAdminController.php @@ -232,6 +232,7 @@ abstract class BaseAdminController extends AbstractController 'timeTravel' => $timeTravel_timestamp, 'repo' => $repo, 'partsContainingElement' => $repo instanceof PartsContainingRepositoryInterface, + 'showParameters' => !($this instanceof PartCustomStateController), ]); } @@ -365,6 +366,14 @@ abstract class BaseAdminController extends AbstractController } } + //Count how many actual new entities were created (id is null until persisted) + $created_count = 0; + foreach ($results as $result) { + if (null === $result->getID()) { + $created_count++; + } + } + //Persist valid entities to DB foreach ($results as $result) { $em->persist($result); @@ -372,8 +381,14 @@ abstract class BaseAdminController extends AbstractController $em->flush(); if (count($results) > 0) { - $this->addFlash('success', t('entity.mass_creation_flash', ['%COUNT%' => count($results)])); + $this->addFlash('success', t('entity.mass_creation_flash', ['%COUNT%' => $created_count])); } + + if (count($errors)) { + //Recreate mass creation form, so we get the updated parent list and empty lines + $mass_creation_form = $this->createForm(MassCreationForm::class, ['entity_class' => $this->entity_class]); + } + } return $this->render($this->twig_template, [ @@ -382,6 +397,7 @@ abstract class BaseAdminController extends AbstractController 'import_form' => $import_form, 'mass_creation_form' => $mass_creation_form, 'route_base' => $this->route_base, + 'showParameters' => !($this instanceof PartCustomStateController), ]); } diff --git a/src/Controller/AdminPages/PartCustomStateController.php b/src/Controller/AdminPages/PartCustomStateController.php new file mode 100644 index 00000000..60f63abf --- /dev/null +++ b/src/Controller/AdminPages/PartCustomStateController.php @@ -0,0 +1,83 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller\AdminPages; + +use App\Entity\Attachments\PartCustomStateAttachment; +use App\Entity\Parameters\PartCustomStateParameter; +use App\Entity\Parts\PartCustomState; +use App\Form\AdminPages\PartCustomStateAdminForm; +use App\Services\ImportExportSystem\EntityExporter; +use App\Services\ImportExportSystem\EntityImporter; +use App\Services\Trees\StructuralElementRecursionHelper; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * @see \App\Tests\Controller\AdminPages\PartCustomStateControllerTest + */ +#[Route(path: '/part_custom_state')] +class PartCustomStateController extends BaseAdminController +{ + protected string $entity_class = PartCustomState::class; + protected string $twig_template = 'admin/part_custom_state_admin.html.twig'; + protected string $form_class = PartCustomStateAdminForm::class; + protected string $route_base = 'part_custom_state'; + protected string $attachment_class = PartCustomStateAttachment::class; + protected ?string $parameter_class = PartCustomStateParameter::class; + + #[Route(path: '/{id}', name: 'part_custom_state_delete', methods: ['DELETE'])] + public function delete(Request $request, PartCustomState $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse + { + return $this->_delete($request, $entity, $recursionHelper); + } + + #[Route(path: '/{id}/edit/{timestamp}', name: 'part_custom_state_edit', requirements: ['id' => '\d+'])] + #[Route(path: '/{id}', requirements: ['id' => '\d+'])] + public function edit(PartCustomState $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response + { + return $this->_edit($entity, $request, $em, $timestamp); + } + + #[Route(path: '/new', name: 'part_custom_state_new')] + #[Route(path: '/{id}/clone', name: 'part_custom_state_clone')] + #[Route(path: '/')] + public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?PartCustomState $entity = null): Response + { + return $this->_new($request, $em, $importer, $entity); + } + + #[Route(path: '/export', name: 'part_custom_state_export_all')] + public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response + { + return $this->_exportAll($em, $exporter, $request); + } + + #[Route(path: '/{id}/export', name: 'part_custom_state_export')] + public function exportEntity(PartCustomState $entity, EntityExporter $exporter, Request $request): Response + { + return $this->_exportEntity($entity, $exporter, $request); + } +} diff --git a/src/Controller/AttachmentFileController.php b/src/Controller/AttachmentFileController.php index d8bd8d87..81369e12 100644 --- a/src/Controller/AttachmentFileController.php +++ b/src/Controller/AttachmentFileController.php @@ -24,10 +24,12 @@ 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; use App\Services\Trees\NodesListBuilder; +use App\Settings\BehaviorSettings\TableSettings; use Omines\DataTablesBundle\DataTableFactory; use RuntimeException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -98,7 +100,8 @@ class AttachmentFileController extends AbstractController } #[Route(path: '/attachment/list', name: 'attachment_list')] - public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder): Response + public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder, + TableSettings $tableSettings): Response { $this->denyAccessUnlessGranted('@attachments.list_attachments'); @@ -110,7 +113,7 @@ class AttachmentFileController extends AbstractController $filterForm->handleRequest($formRequest); - $table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter]) + $table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU]) ->handleRequest($request); if ($table->isCallback()) { diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php new file mode 100644 index 00000000..2d3dd7f6 --- /dev/null +++ b/src/Controller/BulkInfoProviderImportController.php @@ -0,0 +1,588 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Entity\InfoProviderSystem\BulkImportJobStatus; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use App\Entity\Parts\Part; +use App\Entity\Parts\Supplier; +use App\Entity\UserSystem\User; +use App\Form\InfoProviderSystem\GlobalFieldMappingType; +use App\Services\InfoProviderSystem\BulkInfoProviderService; +use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; +use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/tools/bulk_info_provider_import')] +class BulkInfoProviderImportController extends AbstractController +{ + public function __construct( + private readonly BulkInfoProviderService $bulkService, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, + #[Autowire(param: 'partdb.bulk_import.batch_size')] + private readonly int $bulkImportBatchSize, + #[Autowire(param: 'partdb.bulk_import.max_parts_per_operation')] + private readonly int $bulkImportMaxParts + ) { + } + + /** + * Convert field mappings from array format to FieldMappingDTO[]. + * + * @param array $fieldMappings Array of field mapping arrays + * @return BulkSearchFieldMappingDTO[] Array of FieldMappingDTO objects + */ + private function convertFieldMappingsToDto(array $fieldMappings): array + { + $dtos = []; + foreach ($fieldMappings as $mapping) { + $dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1); + } + return $dtos; + } + + private function createErrorResponse(string $message, int $statusCode = 400, array $context = []): JsonResponse + { + $this->logger->warning('Bulk import operation failed', array_merge([ + 'error' => $message, + 'user' => $this->getUser()?->getUserIdentifier(), + ], $context)); + + return $this->json([ + 'success' => false, + 'error' => $message + ], $statusCode); + } + + private function validateJobAccess(int $jobId): ?BulkInfoProviderImportJob + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job) { + return null; + } + + if ($job->getCreatedBy() !== $this->getUser()) { + return null; + } + + return $job; + } + + private function updatePartSearchResults(BulkInfoProviderImportJob $job, ?BulkSearchPartResultsDTO $newResults): void + { + if ($newResults === null) { + return; + } + + // Only deserialize and update if we have new results + $allResults = $job->getSearchResults($this->entityManager); + + // Find and update the results for this specific part + $allResults = $allResults->replaceResultsForPart($newResults); + + // Save updated results back to job + $job->setSearchResults($allResults); + } + + #[Route('/step1', name: 'bulk_info_provider_step1')] + public function step1(Request $request): Response + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + set_time_limit(600); + + $ids = $request->query->get('ids'); + if (!$ids) { + $this->addFlash('error', 'No parts selected for bulk import'); + return $this->redirectToRoute('homepage'); + } + + $partIds = explode(',', $ids); + $partRepository = $this->entityManager->getRepository(Part::class); + $parts = $partRepository->getElementsFromIDArray($partIds); + + if (empty($parts)) { + $this->addFlash('error', 'No valid parts found for bulk import'); + return $this->redirectToRoute('homepage'); + } + + // Validate against configured maximum + if (count($parts) > $this->bulkImportMaxParts) { + $this->addFlash('error', sprintf( + 'Too many parts selected (%d). Maximum allowed is %d parts per operation.', + count($parts), + $this->bulkImportMaxParts + )); + return $this->redirectToRoute('homepage'); + } + + if (count($parts) > ($this->bulkImportMaxParts / 2)) { + $this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.'); + } + + // Generate field choices + $fieldChoices = [ + 'info_providers.bulk_search.field.mpn' => 'mpn', + 'info_providers.bulk_search.field.name' => 'name', + ]; + + // Add dynamic supplier fields + $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); + foreach ($suppliers as $supplier) { + $supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); + $fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn'; + } + + // Initialize form with useful default mappings + $initialData = [ + 'field_mappings' => [ + ['field' => 'mpn', 'providers' => [], 'priority' => 1] + ], + 'prefetch_details' => false + ]; + + $form = $this->createForm(GlobalFieldMappingType::class, $initialData, [ + 'field_choices' => $fieldChoices + ]); + $form->handleRequest($request); + + $searchResults = null; + + if ($form->isSubmitted() && $form->isValid()) { + $formData = $form->getData(); + $fieldMappingDtos = $this->convertFieldMappingsToDto($formData['field_mappings']); + $prefetchDetails = $formData['prefetch_details'] ?? false; + + $user = $this->getUser(); + if (!$user instanceof User) { + throw new \RuntimeException('User must be authenticated and of type User'); + } + + // Validate part count against configuration limit + if (count($parts) > $this->bulkImportMaxParts) { + $this->addFlash('error', "Too many parts selected. Maximum allowed: {$this->bulkImportMaxParts}"); + $partIds = array_map(fn($part) => $part->getId(), $parts); + return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]); + } + + // Create and save the job + $job = new BulkInfoProviderImportJob(); + $job->setFieldMappings($fieldMappingDtos); + $job->setPrefetchDetails($prefetchDetails); + $job->setCreatedBy($user); + + foreach ($parts as $part) { + $jobPart = new BulkInfoProviderImportJobPart($job, $part); + $job->addJobPart($jobPart); + } + + $this->entityManager->persist($job); + $this->entityManager->flush(); + + try { + $searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails); + + // Save search results to job + $job->setSearchResults($searchResultsDto); + $job->markAsInProgress(); + $this->entityManager->flush(); + + // Prefetch details if requested + if ($prefetchDetails) { + $this->bulkService->prefetchDetailsForResults($searchResultsDto); + } + + return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]); + + } catch (\Exception $e) { + $this->logger->error('Critical error during bulk import search', [ + 'job_id' => $job->getId(), + 'error' => $e->getMessage(), + 'exception' => $e + ]); + + $this->entityManager->remove($job); + $this->entityManager->flush(); + + $this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage()); + $partIds = array_map(fn($part) => $part->getId(), $parts); + return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]); + } + } + + // Get existing in-progress jobs for current user + $existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10); + + return $this->render('info_providers/bulk_import/step1.html.twig', [ + 'form' => $form, + 'parts' => $parts, + 'search_results' => $searchResults, + 'existing_jobs' => $existingJobs, + 'fieldChoices' => $fieldChoices + ]); + } + + #[Route('/manage', name: 'bulk_info_provider_manage')] + public function manageBulkJobs(): Response + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + // Get all jobs for current user + $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy([], ['createdAt' => 'DESC']); + + // Check and auto-complete jobs that should be completed + // Also clean up jobs with no results (failed searches) + $updatedJobs = false; + $jobsToDelete = []; + + foreach ($allJobs as $job) { + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + $updatedJobs = true; + } + + // Mark jobs with no results for deletion (failed searches) + if ($job->getResultCount() === 0 && $job->isInProgress()) { + $jobsToDelete[] = $job; + } + } + + // Delete failed jobs + foreach ($jobsToDelete as $job) { + $this->entityManager->remove($job); + $updatedJobs = true; + } + + // Flush changes if any jobs were updated + if ($updatedJobs) { + $this->entityManager->flush(); + + if (!empty($jobsToDelete)) { + $this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.'); + } + } + + return $this->render('info_providers/bulk_import/manage.html.twig', [ + 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup + ]); + } + + #[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])] + public function deleteJob(int $jobId): Response + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + // Only allow deletion of completed, failed, or stopped jobs + if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) { + return $this->json(['error' => 'Cannot delete active job'], 400); + } + + $this->entityManager->remove($job); + $this->entityManager->flush(); + + return $this->json(['success' => true]); + } + + #[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])] + public function stopJob(int $jobId): Response + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + // Only allow stopping of pending or in-progress jobs + if (!$job->canBeStopped()) { + return $this->json(['error' => 'Cannot stop job in current status'], 400); + } + + $job->markAsStopped(); + $this->entityManager->flush(); + + return $this->json(['success' => true]); + } + + + #[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')] + public function step2(int $jobId): Response + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job) { + $this->addFlash('error', 'Bulk import job not found'); + return $this->redirectToRoute('bulk_info_provider_step1'); + } + + // Check if user owns this job + if ($job->getCreatedBy() !== $this->getUser()) { + $this->addFlash('error', 'Access denied to this bulk import job'); + return $this->redirectToRoute('bulk_info_provider_step1'); + } + + // Get the parts and deserialize search results + $parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray(); + $searchResults = $job->getSearchResults($this->entityManager); + + return $this->render('info_providers/bulk_import/step2.html.twig', [ + 'job' => $job, + 'parts' => $parts, + 'search_results' => $searchResults, + ]); + } + + + #[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])] + public function markPartCompleted(int $jobId, int $partId): Response + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $job->markPartAsCompleted($partId); + + // Auto-complete job if all parts are done + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted() + ]); + } + + #[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])] + public function markPartSkipped(int $jobId, int $partId, Request $request): Response + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $reason = $request->request->get('reason', ''); + $job->markPartAsSkipped($partId, $reason); + + // Auto-complete job if all parts are done + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'skipped_count' => $job->getSkippedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted() + ]); + } + + #[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])] + public function markPartPending(int $jobId, int $partId): Response + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $job->markPartAsPending($partId); + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'skipped_count' => $job->getSkippedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted() + ]); + } + + #[Route('/job/{jobId}/part/{partId}/research', name: 'bulk_info_provider_research_part', methods: ['POST'])] + public function researchPart(int $jobId, int $partId): JsonResponse + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $part = $this->entityManager->getRepository(Part::class)->find($partId); + if (!$part) { + return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]); + } + + // Only refresh if the entity might be stale (optional optimization) + if ($this->entityManager->getUnitOfWork()->isScheduledForUpdate($part)) { + $this->entityManager->refresh($part); + } + + try { + // Use the job's field mappings to perform the search + $fieldMappingDtos = $job->getFieldMappings(); + $prefetchDetails = $job->isPrefetchDetails(); + + try { + $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails); + } catch (\Exception $searchException) { + // Handle "no search results found" as a normal case, not an error + if (str_contains($searchException->getMessage(), 'No search results found')) { + $searchResultsDto = null; + } else { + throw $searchException; + } + } + + // Update the job's search results for this specific part efficiently + $this->updatePartSearchResults($job, $searchResultsDto[0] ?? null); + + // Prefetch details if requested + if ($prefetchDetails && $searchResultsDto !== null) { + $this->bulkService->prefetchDetailsForResults($searchResultsDto); + } + + $this->entityManager->flush(); + + // Return the new results for this part + $newResults = $searchResultsDto[0] ?? null; + + return $this->json([ + 'success' => true, + 'part_id' => $partId, + 'results_count' => $newResults ? $newResults->getResultCount() : 0, + 'errors_count' => $newResults ? $newResults->getErrorCount() : 0, + 'message' => 'Part research completed successfully' + ]); + + } catch (\Exception $e) { + return $this->createErrorResponse( + 'Research failed: ' . $e->getMessage(), + 500, + [ + 'job_id' => $jobId, + 'part_id' => $partId, + 'exception' => $e->getMessage() + ] + ); + } + } + + #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])] + public function researchAllParts(int $jobId): JsonResponse + { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + // Get all parts that are not completed or skipped + $parts = []; + foreach ($job->getJobParts() as $jobPart) { + if (!$jobPart->isCompleted() && !$jobPart->isSkipped()) { + $parts[] = $jobPart->getPart(); + } + } + + if (empty($parts)) { + return $this->json([ + 'success' => true, + 'message' => 'No parts to research', + 'researched_count' => 0 + ]); + } + + try { + $fieldMappingDtos = $job->getFieldMappings(); + $prefetchDetails = $job->isPrefetchDetails(); + + // Process in batches to reduce memory usage for large operations + $allResults = new BulkSearchResponseDTO(partResults: []); + $batches = array_chunk($parts, $this->bulkImportBatchSize); + + foreach ($batches as $batch) { + $batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails); + $allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto); + + // Properly manage entity manager memory without losing state + $jobId = $job->getId(); + //$this->entityManager->clear(); //TODO: This seems to cause problems with the user relation, when trying to flush later + $job = $this->entityManager->find(BulkInfoProviderImportJob::class, $jobId); + } + + // Update the job's search results + $job->setSearchResults($allResults); + + // Prefetch details if requested + if ($prefetchDetails) { + $this->bulkService->prefetchDetailsForResults($allResults); + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'researched_count' => count($parts), + 'message' => sprintf('Successfully researched %d parts', count($parts)) + ]); + + } catch (\Exception $e) { + return $this->createErrorResponse( + 'Bulk research failed: ' . $e->getMessage(), + 500, + [ + 'job_id' => $jobId, + 'part_count' => count($parts), + 'exception' => $e->getMessage() + ] + ); + } + } +} diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index a6ce3f1b..b79c307c 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -25,14 +25,20 @@ 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; use Psr\Log\LoggerInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\HttpClient\Exception\ClientException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -46,7 +52,9 @@ class InfoProviderController extends AbstractController public function __construct(private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever, - private readonly ExistingPartFinder $existingPartFinder + private readonly ExistingPartFinder $existingPartFinder, + private readonly SettingsManagerInterface $settingsManager, + private readonly SettingsFormFactoryInterface $settingsFormFactory ) { @@ -63,9 +71,51 @@ class InfoProviderController extends AbstractController ]); } + #[Route('/provider/{provider}/settings', name: 'info_providers_provider_settings')] + public function providerSettings(string $provider, Request $request): Response + { + $this->denyAccessUnlessGranted('@config.change_system_settings'); + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + $providerInstance = $this->providerRegistry->getProviderByKey($provider); + $settingsClass = $providerInstance->getProviderInfo()['settings_class'] ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined'); + + //Create a clone of the settings object + $settings = $this->settingsManager->createTemporaryCopy($settingsClass); + + //Create a form builder for the settings object + $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings); + + //Add a submit button to the form + $builder->add('submit', SubmitType::class, ['label' => 'save']); + + //Create the form + $form = $builder->getForm(); + $form->handleRequest($request); + + //If the form was submitted and is valid, save the settings + if ($form->isSubmitted() && $form->isValid()) { + $this->settingsManager->mergeTemporaryCopy($settings); + $this->settingsManager->save($settings); + + $this->addFlash('success', t('settings.flash.saved')); + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', t('settings.flash.invalid')); + } + + //Render the form + return $this->render('info_providers/settings/provider_settings.html.twig', [ + 'form' => $form, + 'info_provider_key' => $provider, + 'info_provider_info' => $providerInstance->getProviderInfo(), + ]); + } + #[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'); @@ -96,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(); @@ -109,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 @@ -128,4 +198,4 @@ class InfoProviderController extends AbstractController 'update_target' => $update_target ]); } -} \ No newline at end of file +} diff --git a/src/Controller/LabelController.php b/src/Controller/LabelController.php index 4950628b..90a6715b 100644 --- a/src/Controller/LabelController.php +++ b/src/Controller/LabelController.php @@ -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() diff --git a/src/Controller/LogController.php b/src/Controller/LogController.php index a849539d..8aed44e8 100644 --- a/src/Controller/LogController.php +++ b/src/Controller/LogController.php @@ -38,6 +38,7 @@ use App\Services\LogSystem\LogEntryExtraFormatter; use App\Services\LogSystem\LogLevelHelper; use App\Services\LogSystem\LogTargetHelper; use App\Services\LogSystem\TimeTravel; +use App\Settings\BehaviorSettings\TableSettings; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Omines\DataTablesBundle\DataTableFactory; @@ -58,7 +59,7 @@ class LogController extends AbstractController } #[Route(path: '/', name: 'log_view')] - public function showLogs(Request $request, DataTableFactory $dataTable): Response + public function showLogs(Request $request, DataTableFactory $dataTable, TableSettings $tableSettings): Response { $this->denyAccessUnlessGranted('@system.show_logs'); @@ -72,7 +73,7 @@ class LogController extends AbstractController $table = $dataTable->createFromType(LogDataTable::class, [ 'filter' => $filter, - ]) + ], ['pageLength' => $tableSettings->fullDefaultPageSize]) ->handleRequest($request); if ($table->isCallback()) { diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b11a5c90..3a121ad2 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -46,6 +46,8 @@ 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 App\Settings\MiscSettings\IpnSuggestSettings; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; @@ -63,14 +65,18 @@ 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, + private readonly IpnSuggestSettings $ipnSuggestSettings, + ) { } /** @@ -79,9 +85,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 +132,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 +144,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 +188,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 +207,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 +310,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 +331,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 +381,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 +422,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,33 +446,39 @@ class PartController extends AbstractController $template = 'parts/edit/update_from_ip.html.twig'; } - return $this->render($template, + $partRepository = $this->em->getRepository(Part::class); + + return $this->render( + $template, [ 'part' => $new_part, + 'ipnSuggestions' => $partRepository->autoCompleteIpn($data, $data->getDescription(), $this->ipnSuggestSettings->suggestPartDigits), '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') + ] + ); } - #[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])] public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response { 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 +492,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 +541,7 @@ class PartController extends AbstractController err: //If a redirect was passed, then redirect there - if($request->request->get('_redirect')) { + if ($request->request->get('_redirect')) { return $this->redirect($request->request->get('_redirect')); } //Otherwise just redirect to the part page diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 48995228..2210fc18 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -36,6 +36,8 @@ 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; use Omines\DataTablesBundle\DataTableFactory; @@ -51,10 +53,25 @@ use function Symfony\Component\Translation\t; class PartListsController extends AbstractController { - public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator) + public function __construct(private readonly EntityManagerInterface $entityManager, + private readonly NodesListBuilder $nodesListBuilder, + private readonly DataTableFactory $dataTableFactory, + private readonly TranslatorInterface $translator, + 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 { @@ -148,18 +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), - ['lengthMenu' => PartsDataTable::LENGTH_MENU] - ) + $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()) { @@ -182,7 +202,7 @@ class PartListsController extends AbstractController return $this->render($template, array_merge([ 'datatable' => $table, - 'filterForm' => $filterForm->createView(), + 'filterForm' => $filterForm?->createView(), ], $additonal_template_vars)); } @@ -194,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')); }, [ @@ -212,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')); }, [ @@ -230,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')); }, [ @@ -248,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')); }, [ @@ -266,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')); }, [ @@ -299,6 +319,7 @@ class PartListsController extends AbstractController //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! $filter->setName($request->query->getBoolean('name')); + $filter->setDbId($request->query->getBoolean('dbid')); $filter->setCategory($request->query->getBoolean('category')); $filter->setDescription($request->query->getBoolean('description')); $filter->setMpn($request->query->getBoolean('mpn')); diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 761e498c..2a6d19ee 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -31,10 +31,12 @@ use App\Form\ProjectSystem\ProjectBuildType; use App\Helpers\Projects\ProjectBuildRequest; use App\Services\ImportExportSystem\BOMImporter; use App\Services\ProjectSystem\ProjectBuildHelper; +use App\Settings\BehaviorSettings\TableSettings; 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; @@ -55,11 +57,12 @@ class ProjectController extends AbstractController } #[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])] - public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper): Response + public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper, TableSettings $tableSettings): Response { $this->denyAccessUnlessGranted('read', $project); - $table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project]) + $table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project], + ['pageLength' => $tableSettings->fullDefaultPageSize]) ->handleRequest($request); if ($table->isCallback()) { @@ -100,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'); @@ -118,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(); @@ -136,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, [ @@ -159,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()])); } } @@ -189,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'); @@ -240,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); } diff --git a/src/Controller/RedirectController.php b/src/Controller/RedirectController.php index 65bd78f5..a4cac3aa 100644 --- a/src/Controller/RedirectController.php +++ b/src/Controller/RedirectController.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\UserSystem\User; +use App\Settings\SystemSettings\LocalizationSettings; use function function_exists; use function in_array; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -35,7 +36,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; */ class RedirectController extends AbstractController { - public function __construct(protected string $default_locale, protected TranslatorInterface $translator, protected bool $enforce_index_php) + public function __construct(private readonly LocalizationSettings $localizationSettings, protected TranslatorInterface $translator, protected bool $enforce_index_php) { } @@ -46,7 +47,7 @@ class RedirectController extends AbstractController public function addLocalePart(Request $request): RedirectResponse { //By default, we use the global default locale - $locale = $this->default_locale; + $locale = $this->localizationSettings->locale; //Check if a user has set a preferred language setting: $user = $this->getUser(); diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php new file mode 100644 index 00000000..15c945f6 --- /dev/null +++ b/src/Controller/SettingsController.php @@ -0,0 +1,81 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use App\Settings\AppSettings; +use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface; +use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Cache\TagAwareCacheInterface; + +use function Symfony\Component\Translation\t; + +class SettingsController extends AbstractController +{ + public function __construct(private readonly SettingsManagerInterface $settingsManager, private readonly SettingsFormFactoryInterface $settingsFormFactory) + {} + + #[Route("/settings", name: "system_settings")] + public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response + { + $this->denyAccessUnlessGranted('@config.change_system_settings'); + + //Create a clone of the settings object + $settings = $this->settingsManager->createTemporaryCopy(AppSettings::class); + + //Create a form builder for the settings object + $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings); + + //Add a submit button to the form + $builder->add('submit', SubmitType::class, ['label' => 'save']); + + //Create the form + $form = $builder->getForm(); + $form->handleRequest($request); + + //If the form was submitted and is valid, save the settings + if ($form->isSubmitted() && $form->isValid()) { + $this->settingsManager->mergeTemporaryCopy($settings); + $this->settingsManager->save($settings); + + //It might be possible, that the tree settings have changed, so clear the cache + $cache->invalidateTags(['tree_tools', 'tree_treeview', 'sidebar_tree_update', 'synonyms']); + + $this->addFlash('success', t('settings.flash.saved')); + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', t('settings.flash.invalid')); + } + + //Render the form + return $this->render('settings/settings.html.twig', [ + 'form' => $form + ]); + } +} diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index dbcb91a1..d78aff62 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -29,6 +29,7 @@ use App\Services\Doctrine\DBInfoHelper; use App\Services\Doctrine\NatsortDebugHelper; use App\Services\Misc\GitVersionInfo; use App\Services\System\UpdateAvailableManager; +use App\Settings\AppSettings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -47,7 +48,8 @@ class ToolsController extends AbstractController #[Route(path: '/server_infos', name: 'tools_server_infos')] public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, - AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager): Response + AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager, + AppSettings $settings): Response { $this->denyAccessUnlessGranted('@system.server_infos'); @@ -55,23 +57,23 @@ class ToolsController extends AbstractController //Part-DB section 'git_branch' => $versionInfo->getGitBranchName(), 'git_commit' => $versionInfo->getGitCommitHash(), - 'default_locale' => $this->getParameter('partdb.locale'), - 'default_timezone' => $this->getParameter('partdb.timezone'), - 'default_currency' => $this->getParameter('partdb.default_currency'), - 'default_theme' => $this->getParameter('partdb.global_theme'), + 'default_locale' => $settings->system->localization->locale, + 'default_timezone' => $settings->system->localization->timezone, + 'default_currency' => $settings->system->localization->baseCurrency, + 'default_theme' => $settings->system->customization->theme, 'enabled_locales' => $this->getParameter('partdb.locale_menu'), 'demo_mode' => $this->getParameter('partdb.demo_mode'), + 'use_gravatar' => $settings->system->privacy->useGravatar, 'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'), - 'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'), 'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'), 'environment' => $this->getParameter('kernel.environment'), 'is_debug' => $this->getParameter('kernel.debug'), 'email_sender' => $this->getParameter('partdb.mail.sender_email'), 'email_sender_name' => $this->getParameter('partdb.mail.sender_name'), - 'allow_attachments_downloads' => $this->getParameter('partdb.attachments.allow_downloads'), + 'allow_attachments_downloads' => $settings->system->attachments->allowDownloads, 'detailed_error_pages' => $this->getParameter('partdb.error_pages.show_help'), 'error_page_admin_email' => $this->getParameter('partdb.error_pages.admin_email'), - 'configured_max_file_size' => $this->getParameter('partdb.attachments.max_file_size'), + 'configured_max_file_size' => $settings->system->attachments->maxFileSize, 'effective_max_file_size' => $attachmentSubmitHandler->getMaximumAllowedUploadSize(), 'saml_enabled' => $this->getParameter('partdb.saml.enabled'), diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 89eac7ff..39821f59 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Parameters\AbstractParameter; +use App\Settings\MiscSettings\IpnSuggestSettings; use Symfony\Component\HttpFoundation\Response; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Category; @@ -60,8 +61,11 @@ use Symfony\Component\Serializer\Serializer; #[Route(path: '/typeahead')] class TypeaheadController extends AbstractController { - public function __construct(protected AttachmentURLGenerator $urlGenerator, protected Packages $assets) - { + public function __construct( + protected AttachmentURLGenerator $urlGenerator, + protected Packages $assets, + protected IpnSuggestSettings $ipnSuggestSettings, + ) { } #[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')] @@ -183,4 +187,30 @@ class TypeaheadController extends AbstractController return new JsonResponse($data, Response::HTTP_OK, [], true); } + + #[Route(path: '/parts/ipn-suggestions', name: 'ipn_suggestions', methods: ['GET'])] + public function ipnSuggestions( + Request $request, + EntityManagerInterface $entityManager + ): JsonResponse { + $partId = $request->query->get('partId'); + if ($partId === '0' || $partId === 'undefined' || $partId === 'null') { + $partId = null; + } + $categoryId = $request->query->getInt('categoryId'); + $description = base64_decode($request->query->getString('description'), true); + + /** @var Part $part */ + $part = $partId !== null ? $entityManager->getRepository(Part::class)->find($partId) : new Part(); + /** @var Category|null $category */ + $category = $entityManager->getRepository(Category::class)->find($categoryId); + + $clonedPart = clone $part; + $clonedPart->setCategory($category); + + $partRepository = $entityManager->getRepository(Part::class); + $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits); + + return new JsonResponse($ipnSuggestions); + } } diff --git a/src/DataFixtures/CurrencyFixtures.php b/src/DataFixtures/CurrencyFixtures.php new file mode 100644 index 00000000..2de5b277 --- /dev/null +++ b/src/DataFixtures/CurrencyFixtures.php @@ -0,0 +1,64 @@ +. + */ + +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(); + } +} diff --git a/src/DataFixtures/DataStructureFixtures.php b/src/DataFixtures/DataStructureFixtures.php index fc713d4d..9c685338 100644 --- a/src/DataFixtures/DataStructureFixtures.php +++ b/src/DataFixtures/DataStructureFixtures.php @@ -24,6 +24,7 @@ namespace App\DataFixtures; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -50,7 +51,7 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface { //Reset autoincrement $types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class, - MeasurementUnit::class, StorageLocation::class, Supplier::class,]; + MeasurementUnit::class, StorageLocation::class, Supplier::class, PartCustomState::class]; foreach ($types as $type) { $this->createNodesForClass($type, $manager); diff --git a/src/DataTables/Filters/AttachmentFilter.php b/src/DataTables/Filters/AttachmentFilter.php index d41bbe39..69d2aeac 100644 --- a/src/DataTables/Filters/AttachmentFilter.php +++ b/src/DataTables/Filters/AttachmentFilter.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\DataTables\Filters; +use App\DataTables\Filters\Constraints\AbstractConstraint; use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; @@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint; use App\Entity\Attachments\AttachmentType; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\QueryBuilder; +use Omines\DataTablesBundle\Filter\AbstractFilter; class AttachmentFilter implements FilterInterface { @@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface public function __construct(NodesListBuilder $nodesListBuilder) { + //Must be done for every new set of attachment filters, to ensure deterministic parameter names. + AbstractConstraint::resetParameterCounter(); + $this->dbId = new IntConstraint('attachment.id'); $this->name = new TextConstraint('attachment.name'); $this->targetType = new InstanceOfConstraint('attachment'); diff --git a/src/DataTables/Filters/Constraints/AbstractConstraint.php b/src/DataTables/Filters/Constraints/AbstractConstraint.php index 7f16511e..c632b2a4 100644 --- a/src/DataTables/Filters/Constraints/AbstractConstraint.php +++ b/src/DataTables/Filters/Constraints/AbstractConstraint.php @@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface { use FilterTrait; - /** - * @var string - */ - protected string $identifier; + protected ?string $identifier; /** diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php index 3260e4e3..2932914a 100644 --- a/src/DataTables/Filters/Constraints/FilterTrait.php +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -28,6 +28,7 @@ trait FilterTrait { protected bool $useHaving = false; + protected static int $parameterCounter = 0; public function useHaving($value = true): static { @@ -50,8 +51,18 @@ trait FilterTrait { //Replace all special characters with underscores $property = preg_replace('/\W/', '_', $property); - //Add a random number to the end of the property name for uniqueness - return $property . '_' . uniqid("", false); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + + /** + * Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again. + * This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter + * identifiers are deterministic so that they are cacheable. + * @return void + */ + public static function resetParameterCounter(): void + { + self::$parameterCounter = 0; } /** diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php new file mode 100644 index 00000000..9d21dd58 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php @@ -0,0 +1,59 @@ +. + */ + +namespace App\DataTables\Filters\Constraints\Part; + +use App\DataTables\Filters\Constraints\BooleanConstraint; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use Doctrine\ORM\QueryBuilder; + +class BulkImportJobExistsConstraint extends BooleanConstraint +{ + + public function __construct() + { + parent::__construct('bulk_import_job_exists'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + // Do not apply a filter if value is null (filter is set to ignore) + if (!$this->isEnabled()) { + return; + } + + // Use EXISTS subquery to avoid join conflicts + $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder(); + $existsSubquery->select('1') + ->from(BulkInfoProviderImportJobPart::class, 'bip_exists') + ->where('bip_exists.part = part.id'); + + if ($this->value === true) { + // Filter for parts that ARE in bulk import jobs + $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')'); + } else { + // Filter for parts that are NOT in bulk import jobs + $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')'); + } + } +} diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php new file mode 100644 index 00000000..d9451577 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php @@ -0,0 +1,64 @@ +. + */ + +namespace App\DataTables\Filters\Constraints\Part; + +use App\DataTables\Filters\Constraints\AbstractConstraint; +use App\DataTables\Filters\Constraints\ChoiceConstraint; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use Doctrine\ORM\QueryBuilder; + +class BulkImportJobStatusConstraint extends ChoiceConstraint +{ + + public function __construct() + { + parent::__construct('bulk_import_job_status'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + // Do not apply a filter if values are empty or operator is null + if (!$this->isEnabled()) { + return; + } + + // Use EXISTS subquery to check if part has a job with the specified status(es) + $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder(); + $existsSubquery->select('1') + ->from(BulkInfoProviderImportJobPart::class, 'bip_status') + ->join('bip_status.job', 'job_status') + ->where('bip_status.part = part.id'); + + // Add status conditions based on operator + if ($this->operator === 'ANY') { + $existsSubquery->andWhere('job_status.status IN (:job_status_values)'); + $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')'); + $queryBuilder->setParameter('job_status_values', $this->value); + } elseif ($this->operator === 'NONE') { + $existsSubquery->andWhere('job_status.status IN (:job_status_values)'); + $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')'); + $queryBuilder->setParameter('job_status_values', $this->value); + } + } +} diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php new file mode 100644 index 00000000..7656a290 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php @@ -0,0 +1,61 @@ +. + */ + +namespace App\DataTables\Filters\Constraints\Part; + +use App\DataTables\Filters\Constraints\ChoiceConstraint; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use Doctrine\ORM\QueryBuilder; + +class BulkImportPartStatusConstraint extends ChoiceConstraint +{ + public function __construct() + { + parent::__construct('bulk_import_part_status'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + // Do not apply a filter if values are empty or operator is null + if (!$this->isEnabled()) { + return; + } + + // Use EXISTS subquery to check if part has the specified status(es) + $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder(); + $existsSubquery->select('1') + ->from(BulkInfoProviderImportJobPart::class, 'bip_part_status') + ->where('bip_part_status.part = part.id'); + + // Add status conditions based on operator + if ($this->operator === 'ANY') { + $existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)'); + $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')'); + $queryBuilder->setParameter('part_status_values', $this->value); + } elseif ($this->operator === 'NONE') { + $existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)'); + $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')'); + $queryBuilder->setParameter('part_status_values', $this->value); + } + } +} diff --git a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php index 02eab7a1..2b28e6b4 100644 --- a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php +++ b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php @@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint //Escape any %, _ or \ in the tag $tag = addcslashes($tag, '%_\\'); - $tag_identifier_prefix = uniqid($this->identifier . '_', false); + $tag_identifier_prefix = $this->generateParameterIdentifier('tag'); $expr = $queryBuilder->expr(); diff --git a/src/DataTables/Filters/Constraints/TextConstraint.php b/src/DataTables/Filters/Constraints/TextConstraint.php index 31b12a5e..c6a6fe19 100644 --- a/src/DataTables/Filters/Constraints/TextConstraint.php +++ b/src/DataTables/Filters/Constraints/TextConstraint.php @@ -96,14 +96,15 @@ class TextConstraint extends AbstractConstraint //The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator, but we have to build the value string differently $like_value = null; + $escaped_value = str_replace(['%', '_'], ['\%', '\_'], $this->value); if ($this->operator === 'LIKE') { - $like_value = $this->value; + $like_value = $this->value; //Here we do not escape anything, as the user may provide % and _ wildcards } elseif ($this->operator === 'STARTS') { - $like_value = $this->value . '%'; + $like_value = $escaped_value . '%'; } elseif ($this->operator === 'ENDS') { - $like_value = '%' . $this->value; + $like_value = '%' . $escaped_value; } elseif ($this->operator === 'CONTAINS') { - $like_value = '%' . $this->value . '%'; + $like_value = '%' . $escaped_value . '%'; } if ($like_value !== null) { diff --git a/src/DataTables/Filters/LogFilter.php b/src/DataTables/Filters/LogFilter.php index 35d32e74..38dc2191 100644 --- a/src/DataTables/Filters/LogFilter.php +++ b/src/DataTables/Filters/LogFilter.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\DataTables\Filters; +use App\DataTables\Filters\Constraints\AbstractConstraint; use App\DataTables\Filters\Constraints\ChoiceConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; @@ -44,6 +45,9 @@ class LogFilter implements FilterInterface public function __construct() { + //Must be done for every new set of attachment filters, to ensure deterministic parameter names. + AbstractConstraint::resetParameterCounter(); + $this->timestamp = new DateTimeConstraint('log.timestamp'); $this->dbId = new IntConstraint('log.id'); $this->level = new ChoiceConstraint('log.level'); diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index ff98c76f..cf185dfd 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -22,12 +22,16 @@ declare(strict_types=1); */ namespace App\DataTables\Filters; +use App\DataTables\Filters\Constraints\AbstractConstraint; use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\ChoiceConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\IntConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; +use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint; +use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint; +use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint; use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint; use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\Constraints\Part\TagsConstraint; @@ -37,6 +41,7 @@ use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; @@ -82,6 +87,7 @@ class PartFilter implements FilterInterface public readonly EntityConstraint $lotOwner; public readonly EntityConstraint $measurementUnit; + public readonly EntityConstraint $partCustomState; public readonly TextConstraint $manufacturer_product_url; public readonly TextConstraint $manufacturer_product_number; public readonly IntConstraint $attachmentsCount; @@ -101,8 +107,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'); @@ -113,6 +130,7 @@ class PartFilter implements FilterInterface $this->favorite = new BooleanConstraint('part.favorite'); $this->needsReview = new BooleanConstraint('part.needs_review'); $this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit'); + $this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState'); $this->mass = new NumberConstraint('part.mass'); $this->dbId = new IntConstraint('part.id'); $this->ipn = new TextConstraint('part.ipn'); @@ -126,7 +144,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 +180,11 @@ class PartFilter implements FilterInterface $this->bomName = new TextConstraint('_projectBomEntries.name'); $this->bomComment = new TextConstraint('_projectBomEntries.comment'); + // Bulk Import Job filters + $this->inBulkImportJob = new BulkImportJobExistsConstraint(); + $this->bulkImportJobStatus = new BulkImportJobStatusConstraint(); + $this->bulkImportPartStatus = new BulkImportPartStatusConstraint(); + } public function apply(QueryBuilder $queryBuilder): void diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 6e2e5894..c0951d3a 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -21,7 +21,9 @@ declare(strict_types=1); * along with this program. If not, see . */ namespace App\DataTables\Filters; +use App\DataTables\Filters\Constraints\AbstractConstraint; use Doctrine\ORM\QueryBuilder; +use Doctrine\DBAL\ParameterType; class PartSearchFilter implements FilterInterface { @@ -32,6 +34,9 @@ class PartSearchFilter implements FilterInterface /** @var bool Use name field for searching */ protected bool $name = true; + /** @var bool Use id field for searching */ + protected bool $dbId = false; + /** @var bool Use category name for searching */ protected bool $category = true; @@ -119,31 +124,51 @@ class PartSearchFilter implements FilterInterface public function apply(QueryBuilder $queryBuilder): void { $fields_to_search = $this->getFieldsToSearch(); + $is_numeric = preg_match('/^\d+$/', $this->keyword) === 1; + + // Add exact ID match only when the keyword is numeric + $search_dbId = $is_numeric && (bool)$this->dbId; //If we have nothing to search for, do nothing - if ($fields_to_search === [] || $this->keyword === '') { + if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') { return; } - //Convert the fields to search to a list of expressions - $expressions = array_map(function (string $field): string { + $expressions = []; + + if($fields_to_search !== []) { + //Convert the fields to search to a list of expressions + $expressions = array_map(function (string $field): string { + if ($this->regex) { + return sprintf("REGEXP(%s, :search_query) = TRUE", $field); + } + + return sprintf("ILIKE(%s, :search_query) = TRUE", $field); + }, $fields_to_search); + + //For regex, we pass the query as is, for like we add % to the start and end as wildcards if ($this->regex) { - return sprintf("REGEXP(%s, :search_query) = TRUE", $field); + $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 . '%'); } + } - return sprintf("ILIKE(%s, :search_query) = TRUE", $field); - }, $fields_to_search); + //Use equal expression to just search for exact numeric matches + if ($search_dbId) { + $expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact'); + $queryBuilder->setParameter('id_exact', (int) $this->keyword, + \Doctrine\DBAL\ParameterType::INTEGER); + } - //Add Or concatenation of the expressions to our query - $queryBuilder->andWhere( - $queryBuilder->expr()->orX(...$expressions) - ); - - //For regex, we pass the query as is, for like we add % to the start and end as wildcards - if ($this->regex) { - $queryBuilder->setParameter('search_query', $this->keyword); - } else { - $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%'); + //Guard condition + if (!empty($expressions)) { + //Add Or concatenation of the expressions to our query + $queryBuilder->andWhere( + $queryBuilder->expr()->orX(...$expressions) + ); } } @@ -180,6 +205,17 @@ class PartSearchFilter implements FilterInterface return $this; } + public function isDbId(): bool + { + return $this->dbId; + } + + public function setDbId(bool $dbId): PartSearchFilter + { + $this->dbId = $dbId; + return $this; + } + public function isCategory(): bool { return $this->category; diff --git a/src/DataTables/Helpers/ColumnSortHelper.php b/src/DataTables/Helpers/ColumnSortHelper.php index 05bd8182..b7b5b567 100644 --- a/src/DataTables/Helpers/ColumnSortHelper.php +++ b/src/DataTables/Helpers/ColumnSortHelper.php @@ -72,7 +72,8 @@ class ColumnSortHelper * Apply the visibility configuration to the given DataTable and configure the columns. * @param DataTable $dataTable * @param string|array $visible_columns Either a list or a comma separated string of column names, which should - * be visible by default. If a column is not listed here, it will be hidden by default. + * be visible by default. If a column is not listed here, it will be hidden by default. If an array of enum values are passed, + * their value will be used as the column name. * @return void */ public function applyVisibilityAndConfigureColumns(DataTable $dataTable, string|array $visible_columns, @@ -83,6 +84,14 @@ class ColumnSortHelper $visible_columns = array_map(trim(...), explode(",", $visible_columns)); } + //If $visible_columns is a list of enum values, convert them to the column names + foreach ($visible_columns as &$value) { + if ($value instanceof \BackedEnum) { + $value = $value->value; + } + } + unset ($value); + $processed_columns = []; //First add all columns which visibility is not configurable diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 3163a38b..0baee630 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -45,6 +45,7 @@ use App\Entity\Parts\PartLot; use App\Entity\ProjectSystem\Project; use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; +use App\Settings\BehaviorSettings\TableSettings; use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\QueryBuilder; use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; @@ -65,8 +66,8 @@ final class PartsDataTable implements DataTableTypeInterface private readonly AmountFormatter $amountFormatter, private readonly PartDataTableHelper $partDataTableHelper, private readonly Security $security, - private readonly string $visible_columns, private readonly ColumnSortHelper $csh, + private readonly TableSettings $tableSettings, ) { } @@ -141,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 ''; @@ -166,11 +169,24 @@ final class PartsDataTable implements DataTableTypeInterface $tmp = htmlspecialchars($partUnit->getName()); if ($partUnit->getUnit()) { - $tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')'; + $tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')'; } return $tmp; } ]) + ->add('partCustomState', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.partCustomState'), + 'orderField' => 'NATSORT(_partCustomState.name)', + 'render' => function($value, Part $context): string { + $partCustomState = $context->getPartCustomState(); + + if ($partCustomState === null) { + return ''; + } + + return htmlspecialchars($partCustomState->getName()); + } + ]) ->add('addedDate', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('part.table.addedDate'), ]) @@ -229,7 +245,7 @@ final class PartsDataTable implements DataTableTypeInterface } if (count($projects) > $max) { - $tmp .= ", + ".(count($projects) - $max); + $tmp .= ", + " . (count($projects) - $max); } return $tmp; @@ -246,7 +262,7 @@ final class PartsDataTable implements DataTableTypeInterface ]); //Apply the user configured order and visibility and add the columns to the table - $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns, + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->partsDefaultColumns, "TABLE_PARTS_DEFAULT_COLUMNS"); $dataTable->addOrderBy('name') @@ -306,6 +322,7 @@ final class PartsDataTable implements DataTableTypeInterface ->addSelect('footprint') ->addSelect('manufacturer') ->addSelect('partUnit') + ->addSelect('partCustomState') ->addSelect('master_picture_attachment') ->addSelect('footprint_attachment') ->addSelect('partLots') @@ -324,6 +341,7 @@ final class PartsDataTable implements DataTableTypeInterface ->leftJoin('orderdetails.supplier', 'suppliers') ->leftJoin('part.attachments', 'attachments') ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.partCustomState', 'partCustomState') ->leftJoin('part.parameters', 'parameters') ->where('part.id IN (:ids)') ->setParameter('ids', $ids) @@ -341,6 +359,7 @@ final class PartsDataTable implements DataTableTypeInterface ->addGroupBy('suppliers') ->addGroupBy('attachments') ->addGroupBy('partUnit') + ->addGroupBy('partCustomState') ->addGroupBy('parameters'); //Get the results in the same order as the IDs were passed @@ -365,7 +384,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()) @@ -412,6 +431,10 @@ final class PartsDataTable implements DataTableTypeInterface $builder->leftJoin('part.partUnit', '_partUnit'); $builder->addGroupBy('_partUnit'); } + if (str_contains($dql, '_partCustomState')) { + $builder->leftJoin('part.partCustomState', '_partCustomState'); + $builder->addGroupBy('_partCustomState'); + } if (str_contains($dql, '_parameters')) { $builder->leftJoin('part.parameters', '_parameters'); //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 @@ -422,6 +445,13 @@ final class PartsDataTable implements DataTableTypeInterface //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_projectBomEntries'); } + if (str_contains($dql, '_jobPart')) { + $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); + $builder->leftJoin('_jobPart.job', '_bulkImportJob'); + //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 + //$builder->addGroupBy('_jobPart'); + //$builder->addGroupBy('_bulkImportJob'); + } return $builder; } diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php index fcb06984..433f6f78 100644 --- a/src/DataTables/ProjectBomEntriesDataTable.php +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -29,6 +29,7 @@ use App\DataTables\Helpers\PartDataTableHelper; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Services\ElementTypeNameGenerator; use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; use Doctrine\ORM\QueryBuilder; @@ -41,7 +42,8 @@ use Symfony\Contracts\Translation\TranslatorInterface; class ProjectBomEntriesDataTable implements DataTableTypeInterface { - public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter) + public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, + protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter) { } @@ -79,7 +81,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit())); }, ]) - + ->add('partId', TextColumn::class, [ + 'label' => $this->translator->trans('project.bom.part_id'), + 'visible' => true, + 'orderField' => 'part.id', + 'render' => function ($value, ProjectBOMEntry $context) { + return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : ''; + }, + ]) ->add('name', TextColumn::class, [ 'label' => $this->translator->trans('part.table.name'), 'orderField' => 'NATSORT(part.name)', diff --git a/src/Doctrine/Functions/ILike.php b/src/Doctrine/Functions/ILike.php index 5246220a..ff2d2163 100644 --- a/src/Doctrine/Functions/ILike.php +++ b/src/Doctrine/Functions/ILike.php @@ -56,7 +56,6 @@ class ILike extends FunctionNode { $platform = $sqlWalker->getConnection()->getDatabasePlatform(); - // if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) { $operator = 'LIKE'; } elseif ($platform instanceof PostgreSQLPlatform) { @@ -66,6 +65,12 @@ class ILike extends FunctionNode throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.'); } - return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')'; + $escape = ""; + if ($platform instanceof SQLitePlatform) { + //SQLite needs ESCAPE explicitly defined backslash as escape character + $escape = " ESCAPE '\\'"; + } + + return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . $escape . ')'; } -} \ No newline at end of file +} diff --git a/src/Doctrine/Helpers/FieldHelper.php b/src/Doctrine/Helpers/FieldHelper.php index 11300db3..6b6583f7 100644 --- a/src/Doctrine/Helpers/FieldHelper.php +++ b/src/Doctrine/Helpers/FieldHelper.php @@ -104,7 +104,7 @@ final class FieldHelper { $db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform(); - $key = 'field2_' . md5($field_expr); + $key = 'field2_' . hash('xxh3', $field_expr); //If we are on MySQL, we can just use the FIELD function if ($db_platform instanceof AbstractMySQLPlatform) { @@ -121,4 +121,4 @@ final class FieldHelper return $qb; } -} \ No newline at end of file +} diff --git a/src/Doctrine/Migration/ContainerAwareMigrationFactory.php b/src/Doctrine/Migration/ContainerAwareMigrationFactory.php new file mode 100644 index 00000000..81565c0e --- /dev/null +++ b/src/Doctrine/Migration/ContainerAwareMigrationFactory.php @@ -0,0 +1,55 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Doctrine\Migration; + +use App\Services\UserSystem\PermissionPresetsHelper; +use Doctrine\Migrations\AbstractMigration; +use Doctrine\Migrations\Version\MigrationFactory; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Psr\Container\ContainerInterface; + +#[AsDecorator("doctrine.migrations.migrations_factory")] +class ContainerAwareMigrationFactory implements MigrationFactory +{ + public function __construct(private readonly MigrationFactory $decorated, + //List all services that should be available in migrations here + #[AutowireLocator([ + PermissionPresetsHelper::class + ])] + private readonly ContainerInterface $container) + { + } + + public function createVersion(string $migrationClassName): AbstractMigration + { + $migration = $this->decorated->createVersion($migrationClassName); + + if ($migration instanceof ContainerAwareMigrationInterface) { + $migration->setContainer($this->container); + } + + return $migration; + } +} \ No newline at end of file diff --git a/src/Doctrine/Migration/ContainerAwareMigrationInterface.php b/src/Doctrine/Migration/ContainerAwareMigrationInterface.php new file mode 100644 index 00000000..bd92116a --- /dev/null +++ b/src/Doctrine/Migration/ContainerAwareMigrationInterface.php @@ -0,0 +1,31 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Doctrine\Migration; + +use Psr\Container\ContainerInterface; + +interface ContainerAwareMigrationInterface +{ + public function setContainer(?ContainerInterface $container = null): void; +} \ No newline at end of file diff --git a/src/Doctrine/Types/ArrayType.php b/src/Doctrine/Types/ArrayType.php deleted file mode 100644 index daab9b75..00000000 --- a/src/Doctrine/Types/ArrayType.php +++ /dev/null @@ -1,116 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Doctrine\Types; - -use Doctrine\DBAL\Platforms\AbstractPlatform; -use Doctrine\DBAL\Types\Exception\SerializationFailed; -use Doctrine\DBAL\Types\Type; -use Doctrine\Deprecations\Deprecation; - -use function is_resource; -use function restore_error_handler; -use function serialize; -use function set_error_handler; -use function stream_get_contents; -use function unserialize; - -use const E_DEPRECATED; -use const E_USER_DEPRECATED; - -/** - * This class is taken from doctrine ORM 3.8. https://github.com/doctrine/dbal/blob/3.8.x/src/Types/ArrayType.php - * - * It was removed in doctrine ORM 4.0. However, we require it for backward compatibility with WebauthnKey. - * Therefore, we manually added it here as a custom type as a forward compatibility layer. - */ -class ArrayType extends Type -{ - /** - * {@inheritDoc} - */ - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string - { - return $platform->getClobTypeDeclarationSQL($column); - } - - /** - * {@inheritDoc} - */ - public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string - { - return serialize($value); - } - - /** - * {@inheritDoc} - */ - public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed - { - if ($value === null) { - return null; - } - - $value = is_resource($value) ? stream_get_contents($value) : $value; - - set_error_handler(function (int $code, string $message): bool { - if ($code === E_DEPRECATED || $code === E_USER_DEPRECATED) { - return false; - } - - //Change to original code. Use SerializationFailed instead of ConversionException. - throw new SerializationFailed("Serialization failed (Code $code): " . $message); - }); - - try { - //Change to original code. Use false for allowed_classes, to avoid unsafe unserialization of objects. - return unserialize($value, ['allowed_classes' => false]); - } finally { - restore_error_handler(); - } - } - - /** - * {@inheritDoc} - */ - public function getName(): string - { - return "array"; - } - - /** - * {@inheritDoc} - * - * @deprecated - */ - public function requiresSQLCommentHint(AbstractPlatform $platform): bool - { - Deprecation::triggerIfCalledFromOutside( - 'doctrine/dbal', - 'https://github.com/doctrine/dbal/pull/5509', - '%s is deprecated.', - __METHOD__, - ); - - return true; - } -} \ No newline at end of file diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index 00cf581a..259785cb 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -97,7 +97,7 @@ use function in_array; #[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)] abstract class Attachment extends AbstractNamedDBElement { - private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class, + private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class, 'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class, 'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class, @@ -107,7 +107,8 @@ abstract class Attachment extends AbstractNamedDBElement /* * The discriminator map used for API platform. The key should be the same as the api platform short type (the @type JSONLD field). */ - private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "Project" => ProjectAttachment::class, "AttachmentType" => AttachmentTypeAttachment::class, + private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "PartCustomState" => PartCustomStateAttachment::class, "Project" => ProjectAttachment::class, + "AttachmentType" => AttachmentTypeAttachment::class, "Category" => CategoryAttachment::class, "Footprint" => FootprintAttachment::class, "Manufacturer" => ManufacturerAttachment::class, "Currency" => CurrencyAttachment::class, "Group" => GroupAttachment::class, "MeasurementUnit" => MeasurementUnitAttachment::class, "StorageLocation" => StorageLocationAttachment::class, "Supplier" => SupplierAttachment::class, "User" => UserAttachment::class, "LabelProfile" => LabelAttachment::class]; @@ -165,9 +166,10 @@ abstract class Attachment extends AbstractNamedDBElement * @var string|null The path to the external source if the file is stored externally or was downloaded from an * external source. Null if there is no external source. */ - #[ORM\Column(type: Types::STRING, nullable: true)] + #[ORM\Column(type: Types::STRING, length: 2048, nullable: true)] #[Groups(['attachment:read'])] #[ApiProperty(example: 'http://example.com/image.jpg')] + #[Assert\Length(max: 2048)] protected ?string $external_path = null; /** @@ -550,8 +552,8 @@ abstract class Attachment extends AbstractNamedDBElement */ #[Groups(['attachment:write'])] #[SerializedName('url')] - #[ApiProperty(description: 'Set the path of the attachment here. - Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty + #[ApiProperty(description: 'Set the path of the attachment here. + Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty string if the attachment has an internal file associated and you\'d like to reset the external source. If you set a new (nonempty) file path any associated internal file will be removed!')] public function setURL(?string $url): self diff --git a/src/Entity/Attachments/PartCustomStateAttachment.php b/src/Entity/Attachments/PartCustomStateAttachment.php new file mode 100644 index 00000000..3a561b13 --- /dev/null +++ b/src/Entity/Attachments/PartCustomStateAttachment.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\Attachments; + +use App\Entity\Parts\PartCustomState; +use App\Serializer\APIPlatform\OverrideClassDenormalizer; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Attribute\Context; + +/** + * An attachment attached to a part custom state element. + * @extends Attachment + */ +#[UniqueEntity(['name', 'attachment_type', 'element'])] +#[ORM\Entity] +class PartCustomStateAttachment extends Attachment +{ + final public const ALLOWED_ELEMENT_CLASS = PartCustomState::class; + + #[ORM\ManyToOne(targetEntity: PartCustomState::class, inversedBy: 'attachments')] + #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')] + #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])] + protected ?AttachmentContainingDBElement $element = null; +} diff --git a/src/Entity/Base/AbstractCompany.php b/src/Entity/Base/AbstractCompany.php index 947d1339..7d05c93f 100644 --- a/src/Entity/Base/AbstractCompany.php +++ b/src/Entity/Base/AbstractCompany.php @@ -81,10 +81,10 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement /** * @var string The website of the company */ - #[Assert\Url] + #[Assert\Url(requireTld: false)] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])] - #[ORM\Column(type: Types::STRING)] - #[Assert\Length(max: 255)] + #[ORM\Column(type: Types::STRING, length: 2048)] + #[Assert\Length(max: 2048)] protected string $website = ''; #[Groups(['company:read', 'company:write', 'import', 'full', 'extended'])] @@ -93,8 +93,8 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement /** * @var string The link to the website of an article. Use %PARTNUMBER% as placeholder for the part number. */ - #[ORM\Column(type: Types::STRING)] - #[Assert\Length(max: 255)] + #[ORM\Column(type: Types::STRING, length: 2048)] + #[Assert\Length(max: 2048)] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])] protected string $auto_product_url = ''; diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php index 871a22d0..a088b3df 100644 --- a/src/Entity/Base/AbstractDBElement.php +++ b/src/Entity/Base/AbstractDBElement.php @@ -33,12 +33,15 @@ use App\Entity\Attachments\LabelAttachment; use App\Entity\Attachments\ManufacturerAttachment; use App\Entity\Attachments\MeasurementUnitAttachment; use App\Entity\Attachments\PartAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\StorageLocationAttachment; use App\Entity\Attachments\SupplierAttachment; use App\Entity\Attachments\UserAttachment; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; +use App\Entity\PriceInformations\Pricedetail; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\Parts\Footprint; @@ -67,7 +70,41 @@ use Symfony\Component\Serializer\Annotation\Groups; * Every database table which are managed with this class (or a subclass of it) * must have the table row "id"!! The ID is the unique key to identify the elements. */ -#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => 'App\Entity\PriceInformation\Pricedetail', 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])] +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'attachment_type' => AttachmentType::class, + 'attachment' => Attachment::class, + 'attachment_type_attachment' => AttachmentTypeAttachment::class, + 'category_attachment' => CategoryAttachment::class, + 'currency_attachment' => CurrencyAttachment::class, + 'footprint_attachment' => FootprintAttachment::class, + 'group_attachment' => GroupAttachment::class, + 'label_attachment' => LabelAttachment::class, + 'manufacturer_attachment' => ManufacturerAttachment::class, + 'measurement_unit_attachment' => MeasurementUnitAttachment::class, + 'part_attachment' => PartAttachment::class, + 'part_custom_state_attachment' => PartCustomStateAttachment::class, + 'project_attachment' => ProjectAttachment::class, + 'storelocation_attachment' => StorageLocationAttachment::class, + 'supplier_attachment' => SupplierAttachment::class, + 'user_attachment' => UserAttachment::class, + 'category' => Category::class, + 'project' => Project::class, + 'project_bom_entry' => ProjectBOMEntry::class, + 'footprint' => Footprint::class, + 'group' => Group::class, + 'manufacturer' => Manufacturer::class, + 'orderdetail' => Orderdetail::class, + 'part' => Part::class, + 'part_custom_state' => PartCustomState::class, + 'pricedetail' => Pricedetail::class, + 'storelocation' => StorageLocation::class, + 'part_lot' => PartLot::class, + 'currency' => Currency::class, + 'measurement_unit' => MeasurementUnit::class, + 'parameter' => AbstractParameter::class, + 'supplier' => Supplier::class, + 'user' => User::class] +)] #[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)] abstract class AbstractDBElement implements JsonSerializable { diff --git a/src/Entity/Base/AbstractStructuralDBElement.php b/src/Entity/Base/AbstractStructuralDBElement.php index f1cab493..660710db 100644 --- a/src/Entity/Base/AbstractStructuralDBElement.php +++ b/src/Entity/Base/AbstractStructuralDBElement.php @@ -318,6 +318,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement return new ArrayCollection(); } + //@phpstan-ignore-next-line return $this->children ?? new ArrayCollection(); } diff --git a/src/Entity/InfoProviderSystem/BulkImportJobStatus.php b/src/Entity/InfoProviderSystem/BulkImportJobStatus.php new file mode 100644 index 00000000..7a88802f --- /dev/null +++ b/src/Entity/InfoProviderSystem/BulkImportJobStatus.php @@ -0,0 +1,35 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\InfoProviderSystem; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum BulkImportJobStatus: string +{ + case PENDING = 'pending'; + case IN_PROGRESS = 'in_progress'; + case COMPLETED = 'completed'; + case STOPPED = 'stopped'; + case FAILED = 'failed'; +} diff --git a/src/Entity/InfoProviderSystem/BulkImportPartStatus.php b/src/Entity/InfoProviderSystem/BulkImportPartStatus.php new file mode 100644 index 00000000..0eedc553 --- /dev/null +++ b/src/Entity/InfoProviderSystem/BulkImportPartStatus.php @@ -0,0 +1,32 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\InfoProviderSystem; + + +enum BulkImportPartStatus: string +{ + case PENDING = 'pending'; + case COMPLETED = 'completed'; + case SKIPPED = 'skipped'; + case FAILED = 'failed'; +} diff --git a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php new file mode 100644 index 00000000..bc842a26 --- /dev/null +++ b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php @@ -0,0 +1,449 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\InfoProviderSystem; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Parts\Part; +use App\Entity\UserSystem\User; +use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ORM\Table(name: 'bulk_info_provider_import_jobs')] +class BulkInfoProviderImportJob extends AbstractDBElement +{ + #[ORM\Column(type: Types::TEXT)] + private string $name = ''; + + #[ORM\Column(type: Types::JSON)] + private array $fieldMappings = []; + + /** + * @var BulkSearchFieldMappingDTO[] The deserialized field mappings DTOs, cached for performance + */ + private ?array $fieldMappingsDTO = null; + + #[ORM\Column(type: Types::JSON)] + private array $searchResults = []; + + /** + * @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance + */ + private ?BulkSearchResponseDTO $searchResultsDTO = null; + + #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)] + private BulkImportJobStatus $status = BulkImportJobStatus::PENDING; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $completedAt = null; + + #[ORM\Column(type: Types::BOOLEAN)] + private bool $prefetchDetails = false; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + private ?User $createdBy = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $jobParts; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + $this->jobParts = new ArrayCollection(); + } + + public function getName(): string + { + return $this->name; + } + + public function getDisplayNameKey(): string + { + return 'info_providers.bulk_import.job_name_template'; + } + + public function getDisplayNameParams(): array + { + return ['%count%' => $this->getPartCount()]; + } + + public function getFormattedTimestamp(): string + { + return $this->createdAt->format('Y-m-d H:i:s'); + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getJobParts(): Collection + { + return $this->jobParts; + } + + public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self + { + if (!$this->jobParts->contains($jobPart)) { + $this->jobParts->add($jobPart); + $jobPart->setJob($this); + } + return $this; + } + + public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self + { + if ($this->jobParts->removeElement($jobPart)) { + if ($jobPart->getJob() === $this) { + $jobPart->setJob(null); + } + } + return $this; + } + + public function getPartIds(): array + { + return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray(); + } + + public function setPartIds(array $partIds): self + { + // This method is kept for backward compatibility but should be replaced with addJobPart + // Clear existing job parts + $this->jobParts->clear(); + + // Add new job parts (this would need the actual Part entities, not just IDs) + // This is a simplified implementation - in practice, you'd want to pass Part entities + return $this; + } + + public function addPart(Part $part): self + { + $jobPart = new BulkInfoProviderImportJobPart($this, $part); + $this->addJobPart($jobPart); + return $this; + } + + /** + * @return BulkSearchFieldMappingDTO[] The deserialized field mappings + */ + public function getFieldMappings(): array + { + if ($this->fieldMappingsDTO === null) { + // Lazy load the DTOs from the raw JSON data + $this->fieldMappingsDTO = array_map( + static fn($data) => BulkSearchFieldMappingDTO::fromSerializableArray($data), + $this->fieldMappings + ); + } + + return $this->fieldMappingsDTO; + } + + /** + * @param BulkSearchFieldMappingDTO[] $fieldMappings + * @return $this + */ + public function setFieldMappings(array $fieldMappings): self + { + //Ensure that we are dealing with the objects here + if (count($fieldMappings) > 0 && !$fieldMappings[0] instanceof BulkSearchFieldMappingDTO) { + throw new \InvalidArgumentException('Expected an array of FieldMappingDTO objects'); + } + + $this->fieldMappingsDTO = $fieldMappings; + + $this->fieldMappings = array_map( + static fn(BulkSearchFieldMappingDTO $dto) => $dto->toSerializableArray(), + $fieldMappings + ); + return $this; + } + + public function getSearchResultsRaw(): array + { + return $this->searchResults; + } + + public function setSearchResultsRaw(array $searchResults): self + { + $this->searchResults = $searchResults; + return $this; + } + + public function setSearchResults(BulkSearchResponseDTO $searchResponse): self + { + $this->searchResultsDTO = $searchResponse; + $this->searchResults = $searchResponse->toSerializableRepresentation(); + return $this; + } + + public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO + { + if ($this->searchResultsDTO === null) { + // Lazy load the DTO from the raw JSON data + $this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager); + } + return $this->searchResultsDTO; + } + + public function hasSearchResults(): bool + { + return !empty($this->searchResults); + } + + public function getStatus(): BulkImportJobStatus + { + return $this->status; + } + + public function setStatus(BulkImportJobStatus $status): self + { + $this->status = $status; + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } + + public function setCompletedAt(?\DateTimeImmutable $completedAt): self + { + $this->completedAt = $completedAt; + return $this; + } + + public function isPrefetchDetails(): bool + { + return $this->prefetchDetails; + } + + public function setPrefetchDetails(bool $prefetchDetails): self + { + $this->prefetchDetails = $prefetchDetails; + return $this; + } + + public function getCreatedBy(): User + { + return $this->createdBy; + } + + public function setCreatedBy(User $createdBy): self + { + $this->createdBy = $createdBy; + return $this; + } + + public function getProgress(): array + { + $progress = []; + foreach ($this->jobParts as $jobPart) { + $progressData = [ + 'status' => $jobPart->getStatus()->value + ]; + + // Only include completed_at if it's not null + if ($jobPart->getCompletedAt() !== null) { + $progressData['completed_at'] = $jobPart->getCompletedAt()->format('c'); + } + + // Only include reason if it's not null + if ($jobPart->getReason() !== null) { + $progressData['reason'] = $jobPart->getReason(); + } + + $progress[$jobPart->getPart()->getId()] = $progressData; + } + return $progress; + } + + public function markAsCompleted(): self + { + $this->status = BulkImportJobStatus::COMPLETED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsFailed(): self + { + $this->status = BulkImportJobStatus::FAILED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsStopped(): self + { + $this->status = BulkImportJobStatus::STOPPED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsInProgress(): self + { + $this->status = BulkImportJobStatus::IN_PROGRESS; + return $this; + } + + public function isPending(): bool + { + return $this->status === BulkImportJobStatus::PENDING; + } + + public function isInProgress(): bool + { + return $this->status === BulkImportJobStatus::IN_PROGRESS; + } + + public function isCompleted(): bool + { + return $this->status === BulkImportJobStatus::COMPLETED; + } + + public function isFailed(): bool + { + return $this->status === BulkImportJobStatus::FAILED; + } + + public function isStopped(): bool + { + return $this->status === BulkImportJobStatus::STOPPED; + } + + public function canBeStopped(): bool + { + return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS; + } + + public function getPartCount(): int + { + return $this->jobParts->count(); + } + + public function getResultCount(): int + { + $count = 0; + foreach ($this->searchResults as $partResult) { + $count += count($partResult['search_results'] ?? []); + } + return $count; + } + + public function markPartAsCompleted(int $partId): self + { + $jobPart = $this->findJobPartByPartId($partId); + if ($jobPart) { + $jobPart->markAsCompleted(); + } + return $this; + } + + public function markPartAsSkipped(int $partId, string $reason = ''): self + { + $jobPart = $this->findJobPartByPartId($partId); + if ($jobPart) { + $jobPart->markAsSkipped($reason); + } + return $this; + } + + public function markPartAsPending(int $partId): self + { + $jobPart = $this->findJobPartByPartId($partId); + if ($jobPart) { + $jobPart->markAsPending(); + } + return $this; + } + + public function isPartCompleted(int $partId): bool + { + $jobPart = $this->findJobPartByPartId($partId); + return $jobPart ? $jobPart->isCompleted() : false; + } + + public function isPartSkipped(int $partId): bool + { + $jobPart = $this->findJobPartByPartId($partId); + return $jobPart ? $jobPart->isSkipped() : false; + } + + public function getCompletedPartsCount(): int + { + return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count(); + } + + public function getSkippedPartsCount(): int + { + return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count(); + } + + private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart + { + foreach ($this->jobParts as $jobPart) { + if ($jobPart->getPart()->getId() === $partId) { + return $jobPart; + } + } + return null; + } + + public function getProgressPercentage(): float + { + $total = $this->getPartCount(); + if ($total === 0) { + return 100.0; + } + + $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); + return round(($completed / $total) * 100, 1); + } + + public function isAllPartsCompleted(): bool + { + $total = $this->getPartCount(); + if ($total === 0) { + return true; + } + + $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); + return $completed >= $total; + } +} diff --git a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php new file mode 100644 index 00000000..90519561 --- /dev/null +++ b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php @@ -0,0 +1,182 @@ +. + */ + +declare(strict_types=1); + +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2023 Jan Bรถhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace App\Entity\InfoProviderSystem; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Parts\Part; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ORM\Table(name: 'bulk_info_provider_import_job_parts')] +#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])] +class BulkInfoProviderImportJobPart extends AbstractDBElement +{ + #[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')] + #[ORM\JoinColumn(nullable: false)] + private BulkInfoProviderImportJob $job; + + #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')] + #[ORM\JoinColumn(nullable: false)] + private Part $part; + + #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)] + private BulkImportPartStatus $status = BulkImportPartStatus::PENDING; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $reason = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $completedAt = null; + + public function __construct(BulkInfoProviderImportJob $job, Part $part) + { + $this->job = $job; + $this->part = $part; + } + + public function getJob(): BulkInfoProviderImportJob + { + return $this->job; + } + + public function setJob(?BulkInfoProviderImportJob $job): self + { + $this->job = $job; + return $this; + } + + public function getPart(): Part + { + return $this->part; + } + + public function setPart(?Part $part): self + { + $this->part = $part; + return $this; + } + + public function getStatus(): BulkImportPartStatus + { + return $this->status; + } + + public function setStatus(BulkImportPartStatus $status): self + { + $this->status = $status; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } + + public function setCompletedAt(?\DateTimeImmutable $completedAt): self + { + $this->completedAt = $completedAt; + return $this; + } + + public function markAsCompleted(): self + { + $this->status = BulkImportPartStatus::COMPLETED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsSkipped(string $reason = ''): self + { + $this->status = BulkImportPartStatus::SKIPPED; + $this->reason = $reason; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsFailed(string $reason = ''): self + { + $this->status = BulkImportPartStatus::FAILED; + $this->reason = $reason; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsPending(): self + { + $this->status = BulkImportPartStatus::PENDING; + $this->reason = null; + $this->completedAt = null; + return $this; + } + + public function isPending(): bool + { + return $this->status === BulkImportPartStatus::PENDING; + } + + public function isCompleted(): bool + { + return $this->status === BulkImportPartStatus::COMPLETED; + } + + public function isSkipped(): bool + { + return $this->status === BulkImportPartStatus::SKIPPED; + } + + public function isFailed(): bool + { + return $this->status === BulkImportPartStatus::FAILED; + } +} diff --git a/src/Entity/LogSystem/CollectionElementDeleted.php b/src/Entity/LogSystem/CollectionElementDeleted.php index 16bf33f5..34ab8fba 100644 --- a/src/Entity/LogSystem/CollectionElementDeleted.php +++ b/src/Entity/LogSystem/CollectionElementDeleted.php @@ -46,6 +46,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentTypeAttachment; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; @@ -58,6 +59,8 @@ use App\Entity\Attachments\UserAttachment; use App\Entity\Base\AbstractDBElement; use App\Entity\Contracts\LogWithEventUndoInterface; use App\Entity\Contracts\NamedElementInterface; +use App\Entity\Parameters\PartCustomStateParameter; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AttachmentTypeParameter; @@ -158,6 +161,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU Part::class => PartParameter::class, StorageLocation::class => StorageLocationParameter::class, Supplier::class => SupplierParameter::class, + PartCustomState::class => PartCustomStateParameter::class, default => throw new \RuntimeException('Unknown target class for parameter: '.$this->getTargetClass()), }; } @@ -173,6 +177,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU Manufacturer::class => ManufacturerAttachment::class, MeasurementUnit::class => MeasurementUnitAttachment::class, Part::class => PartAttachment::class, + PartCustomState::class => PartCustomStateAttachment::class, StorageLocation::class => StorageLocationAttachment::class, Supplier::class => SupplierAttachment::class, User::class => UserAttachment::class, diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index 1c6e4f8c..3b2d8682 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -24,6 +24,8 @@ namespace App\Entity\LogSystem; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -32,6 +34,7 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartAssociation; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; @@ -67,6 +70,9 @@ enum LogTargetType: int case LABEL_PROFILE = 19; case PART_ASSOCIATION = 20; + case BULK_INFO_PROVIDER_IMPORT_JOB = 21; + case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22; + case PART_CUSTOM_STATE = 23; /** * Returns the class name of the target type or null if the target type is NONE. @@ -96,6 +102,9 @@ enum LogTargetType: int self::PARAMETER => AbstractParameter::class, self::LABEL_PROFILE => LabelProfile::class, self::PART_ASSOCIATION => PartAssociation::class, + self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class, + self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class, + self::PART_CUSTOM_STATE => PartCustomState::class }; } diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index edcedc3e..d84e68ad 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -73,7 +73,8 @@ use function sprintf; #[ORM\DiscriminatorMap([0 => CategoryParameter::class, 1 => CurrencyParameter::class, 2 => ProjectParameter::class, 3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class, 6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class, - 9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class])] + 9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class, + 12 => PartCustomStateParameter::class])] #[ORM\Table('parameters')] #[ORM\Index(columns: ['name'], name: 'parameter_name_idx')] #[ORM\Index(columns: ['param_group'], name: 'parameter_group_idx')] @@ -105,7 +106,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu "AttachmentType" => AttachmentTypeParameter::class, "Category" => CategoryParameter::class, "Currency" => CurrencyParameter::class, "Project" => ProjectParameter::class, "Footprint" => FootprintParameter::class, "Group" => GroupParameter::class, "Manufacturer" => ManufacturerParameter::class, "MeasurementUnit" => MeasurementUnitParameter::class, - "StorageLocation" => StorageLocationParameter::class, "Supplier" => SupplierParameter::class]; + "StorageLocation" => StorageLocationParameter::class, "Supplier" => SupplierParameter::class, "PartCustomState" => PartCustomStateParameter::class]; /** * @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses. @@ -123,7 +124,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu /** * @var float|null the guaranteed minimum value of this property */ - #[Assert\Type(['float', null])] + #[Assert\Type(['float', 'null'])] #[Assert\LessThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.min_lesser_typical')] #[Assert\LessThan(propertyPath: 'value_max', message: 'parameters.validator.min_lesser_max')] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])] @@ -133,7 +134,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu /** * @var float|null the typical value of this property */ - #[Assert\Type([null, 'float'])] + #[Assert\Type(['null', 'float'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])] #[ORM\Column(type: Types::FLOAT, nullable: true)] protected ?float $value_typical = null; @@ -141,7 +142,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu /** * @var float|null the maximum value of this property */ - #[Assert\Type(['float', null])] + #[Assert\Type(['float', 'null'])] #[Assert\GreaterThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.max_greater_typical')] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])] #[ORM\Column(type: Types::FLOAT, nullable: true)] @@ -217,7 +218,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu $str = ''; $bracket_opened = false; - if ($this->value_typical) { + if ($this->value_typical !== null) { $str .= $this->getValueTypicalWithUnit($latex_formatted); if ($this->value_min || $this->value_max) { $bracket_opened = true; @@ -225,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu } } - if ($this->value_max && $this->value_min) { + if ($this->value_max !== null && $this->value_min !== null) { $str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted); - } elseif ($this->value_max) { + } elseif ($this->value_max !== null) { $str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted); - } elseif ($this->value_min) { + } elseif ($this->value_min !== null) { $str .= 'min. '.$this->getValueMinWithUnit($latex_formatted); } @@ -449,7 +450,10 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu if (!$with_latex) { $unit = $this->unit; } else { - $unit = '$\mathrm{'.$this->unit.'}$'; + //Escape the percentage sign for convenience (as latex uses it as comment and it is often used in units) + $escaped = preg_replace('/\\\\?%/', "\\\\%", $this->unit); + + $unit = '$\mathrm{'.$escaped.'}$'; } return $str.' '.$unit; diff --git a/src/Entity/Parameters/PartCustomStateParameter.php b/src/Entity/Parameters/PartCustomStateParameter.php new file mode 100644 index 00000000..ceedf7b4 --- /dev/null +++ b/src/Entity/Parameters/PartCustomStateParameter.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + +/** + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2022 Jan Bรถhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace App\Entity\Parameters; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Parts\PartCustomState; +use App\Repository\ParameterRepository; +use App\Serializer\APIPlatform\OverrideClassDenormalizer; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Attribute\Context; + +#[UniqueEntity(fields: ['name', 'group', 'element'])] +#[ORM\Entity(repositoryClass: ParameterRepository::class)] +class PartCustomStateParameter extends AbstractParameter +{ + final public const ALLOWED_ELEMENT_CLASS = PartCustomState::class; + + /** + * @var PartCustomState the element this para is associated with + */ + #[ORM\ManyToOne(targetEntity: PartCustomState::class, inversedBy: 'parameters')] + #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')] + #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])] + protected ?AbstractDBElement $element = null; +} diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index 99ed3c6d..7fca81bc 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -118,6 +118,13 @@ class Category extends AbstractPartsContainingDBElement #[ORM\Column(type: Types::TEXT)] protected string $partname_regex = ''; + /** + * @var string The prefix for ipn generation for created parts in this category. + */ + #[Groups(['full', 'import', 'category:read', 'category:write'])] + #[ORM\Column(type: Types::STRING, length: 255, nullable: false, options: ['default' => ''])] + protected string $part_ipn_prefix = ''; + /** * @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet). */ @@ -225,6 +232,16 @@ class Category extends AbstractPartsContainingDBElement return $this; } + public function getPartIpnPrefix(): string + { + return $this->part_ipn_prefix; + } + + public function setPartIpnPrefix(string $part_ipn_prefix): void + { + $this->part_ipn_prefix = $part_ipn_prefix; + } + public function isDisableFootprints(): bool { return $this->disable_footprints; diff --git a/src/Entity/Parts/InfoProviderReference.php b/src/Entity/Parts/InfoProviderReference.php index bfa62f32..810aef0c 100644 --- a/src/Entity/Parts/InfoProviderReference.php +++ b/src/Entity/Parts/InfoProviderReference.php @@ -50,7 +50,7 @@ class InfoProviderReference /** * @var string|null The url of this part inside the provider system or null if this info is not existing */ - #[Column(type: Types::STRING, nullable: true)] + #[Column(type: Types::STRING, length: 2048, nullable: true)] #[Groups(['provider_reference:read', 'full'])] private ?string $provider_url = null; @@ -157,4 +157,4 @@ class InfoProviderReference $ref->last_updated = new \DateTimeImmutable(); return $ref; } -} \ No newline at end of file +} diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 14a7903f..d0a279e3 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -22,8 +22,6 @@ declare(strict_types=1); namespace App\Entity\Parts; -use App\ApiPlatform\Filter\TagFilter; -use Doctrine\Common\Collections\Criteria; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; @@ -40,10 +38,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\PartStoragelocationFilter; +use App\ApiPlatform\Filter\TagFilter; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\PartAttachment; use App\Entity\EDA\EDAPartInfo; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; use App\Entity\Parameters\ParametersTrait; use App\Entity\Parameters\PartParameter; use App\Entity\Parts\PartTraits\AdvancedPropertyTrait; @@ -59,8 +59,8 @@ use App\Repository\PartRepository; use App\Validator\Constraints\UniqueObjectCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; -use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -74,7 +74,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * @extends AttachmentContainingDBElement * @template-use ParametersTrait */ -#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')] #[ORM\Entity(repositoryClass: PartRepository::class)] #[ORM\EntityListeners([TreeCacheInvalidationListener::class])] #[ORM\Table('`parts`')] @@ -83,8 +82,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')] #[ApiResource( operations: [ - new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', - 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'], + new Get(normalizationContext: [ + 'groups' => [ + 'part:read', + 'provider_reference:read', + 'api:basic:read', + 'part_lot:read', + 'orderdetail:read', + 'pricedetail:read', + 'parameter:read', + 'attachment:read', + 'eda_info:read' + ], 'openapi_definition_name' => 'Read', ], security: 'is_granted("read", object)'), new GetCollection(security: 'is_granted("@parts.read")'), @@ -92,15 +101,15 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; new Patch(security: 'is_granted("edit", object)'), new Delete(security: 'is_granted("delete", object)'), ], - normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], + normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], )] #[ApiFilter(PropertyFilter::class)] -#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])] +#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])] #[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])] #[ApiFilter(TagFilter::class, properties: ["tags"])] -#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])] +#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])] #[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] @@ -160,6 +169,12 @@ class Part extends AttachmentContainingDBElement #[Groups(['part:read'])] protected ?\DateTimeImmutable $lastModified = null; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)] + protected Collection $bulkImportJobParts; + public function __construct() { @@ -172,6 +187,7 @@ class Part extends AttachmentContainingDBElement $this->associated_parts_as_owner = new ArrayCollection(); $this->associated_parts_as_other = new ArrayCollection(); + $this->bulkImportJobParts = new ArrayCollection(); //By default, the part has no provider $this->providerReference = InfoProviderReference::noProvider(); @@ -230,4 +246,38 @@ class Part extends AttachmentContainingDBElement } } } + + /** + * Get all bulk import job parts for this part + * @return Collection + */ + public function getBulkImportJobParts(): Collection + { + return $this->bulkImportJobParts; + } + + /** + * Add a bulk import job part to this part + */ + public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self + { + if (!$this->bulkImportJobParts->contains($jobPart)) { + $this->bulkImportJobParts->add($jobPart); + $jobPart->setPart($this); + } + return $this; + } + + /** + * Remove a bulk import job part from this part + */ + public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self + { + if ($this->bulkImportJobParts->removeElement($jobPart)) { + if ($jobPart->getPart() === $this) { + $jobPart->setPart(null); + } + } + return $this; + } } diff --git a/src/Entity/Parts/PartCustomState.php b/src/Entity/Parts/PartCustomState.php new file mode 100644 index 00000000..136ff984 --- /dev/null +++ b/src/Entity/Parts/PartCustomState.php @@ -0,0 +1,127 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\Parts; + +use ApiPlatform\Metadata\ApiProperty; +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\PartCustomStateAttachment; +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Serializer\Filter\PropertyFilter; +use App\ApiPlatform\Filter\LikeFilter; +use App\Entity\Base\AbstractPartsContainingDBElement; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parameters\PartCustomStateParameter; +use App\Repository\Parts\PartCustomStateRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * This entity represents a custom part state. + * If an organisation uses Part-DB and has its custom part states, this is useful. + * + * @extends AbstractPartsContainingDBElement + */ +#[ORM\Entity(repositoryClass: PartCustomStateRepository::class)] +#[ORM\Table('`part_custom_states`')] +#[ORM\Index(columns: ['name'], name: 'part_custom_state_name')] +#[ApiResource( + operations: [ + new Get(security: 'is_granted("read", object)'), + new GetCollection(security: 'is_granted("@part_custom_states.read")'), + new Post(securityPostDenormalize: 'is_granted("create", object)'), + new Patch(security: 'is_granted("edit", object)'), + new Delete(security: 'is_granted("delete", object)'), + ], + normalizationContext: ['groups' => ['part_custom_state:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], + denormalizationContext: ['groups' => ['part_custom_state:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'], +)] +#[ApiFilter(PropertyFilter::class)] +#[ApiFilter(LikeFilter::class, properties: ["name"])] +#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] +#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] +class PartCustomState extends AbstractPartsContainingDBElement +{ + /** + * @var string The comment info for this element as markdown + */ + #[Groups(['part_custom_state:read', 'part_custom_state:write', 'full', 'import'])] + protected string $comment = ''; + + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class, cascade: ['persist'])] + #[ORM\OrderBy(['name' => Criteria::ASC])] + protected Collection $children; + + #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_id')] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + #[ApiProperty(readableLink: false, writableLink: false)] + protected ?AbstractStructuralDBElement $parent = null; + + /** + * @var Collection + */ + #[Assert\Valid] + #[ORM\OneToMany(targetEntity: PartCustomStateAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['name' => Criteria::ASC])] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + protected Collection $attachments; + + #[ORM\ManyToOne(targetEntity: PartCustomStateAttachment::class)] + #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + protected ?Attachment $master_picture_attachment = null; + + /** @var Collection + */ + #[Assert\Valid] + #[ORM\OneToMany(mappedBy: 'element', targetEntity: PartCustomStateParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['name' => 'ASC'])] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + protected Collection $parameters; + + #[Groups(['part_custom_state:read'])] + protected ?\DateTimeImmutable $addedDate = null; + #[Groups(['part_custom_state:read'])] + protected ?\DateTimeImmutable $lastModified = null; + + public function __construct() + { + parent::__construct(); + $this->children = new ArrayCollection(); + $this->attachments = new ArrayCollection(); + $this->parameters = new ArrayCollection(); + } +} diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index 230ba7b7..2cee7f1a 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -23,12 +23,14 @@ declare(strict_types=1); namespace App\Entity\Parts\PartTraits; use App\Entity\Parts\InfoProviderReference; +use App\Entity\Parts\PartCustomState; use Doctrine\DBAL\Types\Types; use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\Length; +use App\Validator\Constraints\UniquePartIpnConstraint; /** * Advanced properties of a part, not related to a more specific group. @@ -64,6 +66,7 @@ trait AdvancedPropertyTrait #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] #[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)] #[Length(max: 100)] + #[UniquePartIpnConstraint] protected ?string $ipn = null; /** @@ -73,6 +76,14 @@ trait AdvancedPropertyTrait #[Groups(['full', 'part:read'])] protected InfoProviderReference $providerReference; + /** + * @var ?PartCustomState the custom state for the part + */ + #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] + #[ORM\ManyToOne(targetEntity: PartCustomState::class)] + #[ORM\JoinColumn(name: 'id_part_custom_state')] + protected ?PartCustomState $partCustomState = null; + /** * Checks if this part is marked, for that it needs further review. */ @@ -180,7 +191,24 @@ trait AdvancedPropertyTrait return $this; } + /** + * Gets the custom part state for the part + * Returns null if no specific part state is set. + */ + public function getPartCustomState(): ?PartCustomState + { + return $this->partCustomState; + } + /** + * Sets the custom part state. + * + * @return $this + */ + public function setPartCustomState(?PartCustomState $partCustomState): self + { + $this->partCustomState = $partCustomState; - + return $this; + } } diff --git a/src/Entity/Parts/PartTraits/ManufacturerTrait.php b/src/Entity/Parts/PartTraits/ManufacturerTrait.php index 5d7f8749..911a0806 100644 --- a/src/Entity/Parts/PartTraits/ManufacturerTrait.php +++ b/src/Entity/Parts/PartTraits/ManufacturerTrait.php @@ -49,7 +49,7 @@ trait ManufacturerTrait /** * @var string The url to the part on the manufacturer's homepage */ - #[Assert\Url] + #[Assert\Url(requireTld: false)] #[Groups(['full', 'import', 'part:read', 'part:write'])] #[ORM\Column(type: Types::TEXT)] protected string $manufacturer_product_url = ''; diff --git a/src/Entity/Parts/PartTraits/ProjectTrait.php b/src/Entity/Parts/PartTraits/ProjectTrait.php index 45719377..7e1962d3 100644 --- a/src/Entity/Parts/PartTraits/ProjectTrait.php +++ b/src/Entity/Parts/PartTraits/ProjectTrait.php @@ -15,7 +15,7 @@ trait ProjectTrait /** * @var Collection $project_bom_entries */ - #[ORM\OneToMany(mappedBy: 'part', targetEntity: ProjectBOMEntry::class, cascade: ['remove'], orphanRemoval: true)] + #[ORM\OneToMany(targetEntity: ProjectBOMEntry::class, mappedBy: 'part')] protected Collection $project_bom_entries; /** diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index 3709b37d..8ed76a46 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -124,7 +124,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N /** * @var string The URL to the product on the supplier's website */ - #[Assert\Url] + #[Assert\Url(requireTld: false)] #[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])] #[ORM\Column(type: Types::TEXT)] protected string $supplier_product_url = ''; diff --git a/src/Entity/SettingsEntry.php b/src/Entity/SettingsEntry.php new file mode 100644 index 00000000..488de1d1 --- /dev/null +++ b/src/Entity/SettingsEntry.php @@ -0,0 +1,35 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Jbtronics\SettingsBundle\Entity\AbstractSettingsORMEntry; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class SettingsEntry extends AbstractSettingsORMEntry +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: Types::INTEGER)] + protected int $id; +} \ No newline at end of file diff --git a/src/Entity/UserSystem/User.php b/src/Entity/UserSystem/User.php index b39bea4f..78f89347 100644 --- a/src/Entity/UserSystem/User.php +++ b/src/Entity/UserSystem/User.php @@ -197,7 +197,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe /** * @var string|null The language/locale the user prefers */ - #[Assert\Language] + #[Assert\Locale] #[Groups(['full', 'import', 'user:read'])] #[ORM\Column(name: 'config_language', type: Types::STRING, nullable: true)] protected ?string $language = ''; diff --git a/src/Entity/UserSystem/WebauthnKey.php b/src/Entity/UserSystem/WebauthnKey.php index b2716e07..7d3cb7b3 100644 --- a/src/Entity/UserSystem/WebauthnKey.php +++ b/src/Entity/UserSystem/WebauthnKey.php @@ -100,16 +100,19 @@ class WebauthnKey extends BasePublicKeyCredentialSource implements TimeStampable public static function fromRegistration(BasePublicKeyCredentialSource $registration): self { return new self( - $registration->getPublicKeyCredentialId(), - $registration->getType(), - $registration->getTransports(), - $registration->getAttestationType(), - $registration->getTrustPath(), - $registration->getAaguid(), - $registration->getCredentialPublicKey(), - $registration->getUserHandle(), - $registration->getCounter(), - $registration->getOtherUI() + publicKeyCredentialId: $registration->publicKeyCredentialId, + type: $registration->type, + transports: $registration->transports, + attestationType: $registration->attestationType, + trustPath: $registration->trustPath, + aaguid: $registration->aaguid, + credentialPublicKey: $registration->credentialPublicKey, + userHandle: $registration->userHandle, + counter: $registration->counter, + otherUI: $registration->otherUI, + backupEligible: $registration->backupEligible, + backupStatus: $registration->backupStatus, + uvInitialized: $registration->uvInitialized, ); } } diff --git a/src/EntityListeners/PartProjectBOMEntryUnlinkListener.php b/src/EntityListeners/PartProjectBOMEntryUnlinkListener.php new file mode 100644 index 00000000..08a93f76 --- /dev/null +++ b/src/EntityListeners/PartProjectBOMEntryUnlinkListener.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + + +namespace App\EntityListeners; + +use App\Entity\Parts\Part; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; +use Doctrine\ORM\Event\PreRemoveEventArgs; + +/** + * If an part is deleted, this listener makes sure that all ProjectBOMEntries that reference this part, are updated + * to not reference the part anymore, but instead store the part name in the name field. + */ +#[AsEntityListener(event: "preRemove", entity: Part::class)] +class PartProjectBOMEntryUnlinkListener +{ + public function preRemove(Part $part, PreRemoveEventArgs $event): void + { + // Iterate over all ProjectBOMEntries that use this part and put the part name into the name field + foreach ($part->getProjectBomEntries() as $bom_entry) { + $old_name = $bom_entry->getName(); + if ($old_name === null || trim($old_name) === '') { + $bom_entry->setName($part->getName()); + } else { + $bom_entry->setName($old_name . ' (' . $part->getName() . ')'); + } + + $old_comment = $bom_entry->getComment(); + if ($old_comment === null || trim($old_comment) === '') { + $bom_entry->setComment('Part was deleted: ' . $part->getName()); + } else { + $bom_entry->setComment($old_comment . "\n\n Part was deleted: " . $part->getName()); + } + + //Remove the part reference + $bom_entry->setPart(null); + } + } +} diff --git a/src/EnvVarProcessors/AddSlashEnvVarProcessor.php b/src/EnvVarProcessors/AddSlashEnvVarProcessor.php new file mode 100644 index 00000000..aaf0abc9 --- /dev/null +++ b/src/EnvVarProcessors/AddSlashEnvVarProcessor.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\EnvVarProcessors; + +use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; + +/** + * Env var processor that adds a trailing slash to a string if not already present. + */ +final class AddSlashEnvVarProcessor implements EnvVarProcessorInterface +{ + + public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed + { + $env = $getEnv($name); + if (!is_string($env)) { + throw new \InvalidArgumentException(sprintf('The "addSlash" env var processor only works with strings, got %s.', gettype($env))); + } + return rtrim($env, '/') . '/'; + } + + public static function getProvidedTypes(): array + { + return [ + 'addSlash' => 'string', + ]; + } +} diff --git a/src/Services/CustomEnvVarProcessor.php b/src/EnvVarProcessors/CustomEnvVarProcessor.php similarity index 98% rename from src/Services/CustomEnvVarProcessor.php rename to src/EnvVarProcessors/CustomEnvVarProcessor.php index f269cc7d..55a6b94d 100644 --- a/src/Services/CustomEnvVarProcessor.php +++ b/src/EnvVarProcessors/CustomEnvVarProcessor.php @@ -20,7 +20,7 @@ declare(strict_types=1); -namespace App\Services; +namespace App\EnvVarProcessors; use Closure; use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; diff --git a/src/EventListener/LogSystem/EventLoggerListener.php b/src/EventListener/LogSystem/EventLoggerListener.php index 6fe3d8dc..f5029c28 100644 --- a/src/EventListener/LogSystem/EventLoggerListener.php +++ b/src/EventListener/LogSystem/EventLoggerListener.php @@ -39,6 +39,8 @@ use App\Services\LogSystem\EventCommentHelper; use App\Services\LogSystem\EventLogger; use App\Services\LogSystem\EventUndoHelper; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use App\Settings\SystemSettings\HistorySettings; +use Doctrine\Common\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\PostFlushEventArgs; @@ -74,14 +76,15 @@ class EventLoggerListener ]; protected const MAX_STRING_LENGTH = 2000; - protected bool $save_new_data; - public function __construct(protected EventLogger $logger, protected SerializerInterface $serializer, protected EventCommentHelper $eventCommentHelper, - protected bool $save_changed_fields, protected bool $save_changed_data, protected bool $save_removed_data, bool $save_new_data, - protected PropertyAccessorInterface $propertyAccessor, protected EventUndoHelper $eventUndoHelper) + public function __construct( + protected EventLogger $logger, + protected SerializerInterface $serializer, + protected EventCommentHelper $eventCommentHelper, + private readonly HistorySettings $settings, + protected PropertyAccessorInterface $propertyAccessor, + protected EventUndoHelper $eventUndoHelper) { - //This option only makes sense if save_changed_data is true - $this->save_new_data = $save_new_data && $save_changed_data; } public function onFlush(OnFlushEventArgs $eventArgs): void @@ -167,6 +170,7 @@ class EventLoggerListener public function hasFieldRestrictions(AbstractDBElement $element): bool { foreach (array_keys(static::FIELD_BLACKLIST) as $class) { + /** @var string $class */ if ($element instanceof $class) { return true; } @@ -181,6 +185,7 @@ class EventLoggerListener public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool { foreach (static::FIELD_BLACKLIST as $class => $blacklist) { + /** @var string $class */ if ($element instanceof $class && in_array($field_name, $blacklist, true)) { return false; } @@ -200,18 +205,19 @@ class EventLoggerListener if ($this->eventUndoHelper->isUndo()) { $log->setUndoneEvent($this->eventUndoHelper->getUndoneEvent(), $this->eventUndoHelper->getMode()); } - if ($this->save_removed_data) { + if ($this->settings->saveRemovedData) { //The 4th param is important here, as we delete the element... $this->saveChangeSet($entity, $log, $em, true); } $this->logger->logFromOnFlush($log); //Check if we have to log CollectionElementDeleted entries - if ($this->save_changed_data) { + if ($this->settings->saveOldData) { $metadata = $em->getClassMetadata($entity::class); $mappings = $metadata->getAssociationMappings(); //Check if class is whitelisted for CollectionElementDeleted entry foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) { + /** @var string $class */ if ($entity instanceof $class) { //Check names foreach ($mappings as $field => $mapping) { @@ -243,9 +249,9 @@ class EventLoggerListener } $log = new ElementEditedLogEntry($entity); - if ($this->save_changed_data) { + if ($this->settings->saveOldData) { $this->saveChangeSet($entity, $log, $em); - } elseif ($this->save_changed_fields) { + } elseif ($this->settings->saveChangedFields) { $changed_fields = array_keys($uow->getEntityChangeSet($entity)); //Remove lastModified field, as this is always changed (gives us no additional info) $changed_fields = array_diff($changed_fields, ['lastModified']); @@ -313,7 +319,7 @@ class EventLoggerListener $changeSet = $uow->getEntityChangeSet($entity); $old_data = array_combine(array_keys($changeSet), array_column($changeSet, 0)); //If save_new_data is enabled, we extract it from the change set - if ($this->save_new_data) { + if ($this->settings->saveNewData && $this->settings->saveOldData) { //Only useful if we save old data too $new_data = array_combine(array_keys($changeSet), array_column($changeSet, 1)); } } diff --git a/src/EventListener/RegisterSynonymsAsTranslationParametersListener.php b/src/EventListener/RegisterSynonymsAsTranslationParametersListener.php new file mode 100644 index 00000000..5862fa33 --- /dev/null +++ b/src/EventListener/RegisterSynonymsAsTranslationParametersListener.php @@ -0,0 +1,93 @@ +. + */ + +declare(strict_types=1); + + +namespace App\EventListener; + +use App\Services\ElementTypeNameGenerator; +use App\Services\ElementTypes; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Cache\TagAwareCacheInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +#[AsEventListener] +readonly class RegisterSynonymsAsTranslationParametersListener +{ + private Translator $translator; + + public function __construct( + #[Autowire(service: 'translator.default')] TranslatorInterface $translator, + private TagAwareCacheInterface $cache, + private ElementTypeNameGenerator $typeNameGenerator) + { + if (!$translator instanceof Translator) { + throw new \RuntimeException('Translator must be an instance of Symfony\Component\Translation\Translator or this listener cannot be used.'); + } + $this->translator = $translator; + } + + public function getSynonymPlaceholders(string $locale): array + { + return $this->cache->get('partdb_synonym_placeholders' . '_' . $locale, function (ItemInterface $item) use ($locale) { + $item->tag('synonyms'); + + + $placeholders = []; + + //Generate a placeholder for each element type + foreach (ElementTypes::cases() as $elementType) { + //Versions with capitalized first letter + $capitalized = ucfirst($elementType->value); //We have only ASCII element type values, so this is sufficient + $placeholders['[' . $capitalized . ']'] = $this->typeNameGenerator->typeLabel($elementType, $locale); + $placeholders['[[' . $capitalized . ']]'] = $this->typeNameGenerator->typeLabelPlural($elementType, $locale); + + //And we have lowercase versions for both + $placeholders['[' . $elementType->value . ']'] = mb_strtolower($this->typeNameGenerator->typeLabel($elementType, $locale)); + $placeholders['[[' . $elementType->value . ']]'] = mb_strtolower($this->typeNameGenerator->typeLabelPlural($elementType, $locale)); + } + + return $placeholders; + }); + } + + public function __invoke(RequestEvent $event): void + { + //If we already added the parameters, skip adding them again + if (isset($this->translator->getGlobalParameters()['@@partdb_synonyms_registered@@'])) { + return; + } + + //Register all placeholders for synonyms + $placeholders = $this->getSynonymPlaceholders($event->getRequest()->getLocale()); + foreach ($placeholders as $key => $value) { + $this->translator->addGlobalParameter($key, $value); + } + + //Register the marker parameter to avoid double registration + $this->translator->addGlobalParameter('@@partdb_synonyms_registered@@', 'registered'); + } +} diff --git a/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php b/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php deleted file mode 100644 index 6f17e399..00000000 --- a/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php +++ /dev/null @@ -1,69 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\EventSubscriber; - -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\HttpKernel\Event\ResponseEvent; - -/** - * This subscriber sets a Header in Debug mode that signals the Symfony Profiler to also update on Ajax requests. - */ -final class SymfonyDebugToolbarSubscriber implements EventSubscriberInterface -{ - public function __construct(private readonly bool $kernel_debug_enabled) - { - } - - /** - * Returns an array of event names this subscriber wants to listen to. - * - * The array keys are event names and the value can be: - * - * * The method name to call (priority defaults to 0) - * * An array composed of the method name to call and the priority - * * An array of arrays composed of the method names to call and respective - * priorities, or 0 if unset - * - * For instance: - * - * * ['eventName' => 'methodName'] - * * ['eventName' => ['methodName', $priority]] - * * ['eventName' => [['methodName1', $priority], ['methodName2']]] - * - * @return array The event names to listen to - */ - public static function getSubscribedEvents(): array - { - return ['kernel.response' => 'onKernelResponse']; - } - - public function onKernelResponse(ResponseEvent $event): void - { - if (!$this->kernel_debug_enabled) { - return; - } - - $response = $event->getResponse(); - $response->headers->set('Symfony-Debug-Toolbar-Replace', '1'); - } -} diff --git a/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php new file mode 100644 index 00000000..ecc25b4f --- /dev/null +++ b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php @@ -0,0 +1,97 @@ +ipnSuggestSettings->autoAppendSuffix) { + return; + } + + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + $meta = $em->getClassMetadata(Part::class); + + // Collect all IPNs already reserved in the current flush (so new entities do not collide with each other) + $reservedIpns = []; + + // Helper to assign a collision-free IPN for a Part entity + $ensureUnique = function (Part $part) use ($em, $uow, $meta, &$reservedIpns) { + $ipn = $part->getIpn(); + if ($ipn === null || $ipn === '') { + return; + } + + // Check against IPNs already reserved in the current flush (except itself) + $originalIpn = $ipn; + $candidate = $originalIpn; + $increment = 1; + + $conflicts = function (string $candidate) use ($em, $part, $reservedIpns) { + // Collision within the current flush session? + if (isset($reservedIpns[$candidate]) && $reservedIpns[$candidate] !== $part) { + return true; + } + // Collision with an existing DB row? + $existing = $em->getRepository(Part::class)->findOneBy(['ipn' => $candidate]); + return $existing !== null && $existing->getId() !== $part->getId(); + }; + + while ($conflicts($candidate)) { + $candidate = $originalIpn . '_' . $increment; + $increment++; + } + + if ($candidate !== $ipn) { + $before = $part->getIpn(); + $part->setIpn($candidate); + + // Recompute the change set so Doctrine writes the change + $uow->recomputeSingleEntityChangeSet($meta, $part); + $reservedIpns[$candidate] = $part; + + // If the old IPN was reserved already, clean it up + if ($before !== null && isset($reservedIpns[$before]) && $reservedIpns[$before] === $part) { + unset($reservedIpns[$before]); + } + } else { + // Candidate unchanged, but reserve it so subsequent entities see it + $reservedIpns[$candidate] = $part; + } + }; + + // 1) Iterate over new entities + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof Part) { + $ensureUnique($entity); + } + } + + // 2) Iterate over updates (if IPN changed, ensure uniqueness again) + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof Part) { + $ensureUnique($entity); + } + } + } +} diff --git a/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php b/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php index 10ecaddf..9964c618 100644 --- a/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php +++ b/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\EventSubscriber\UserSystem; use App\Entity\UserSystem\User; +use App\Settings\SystemSettings\LocalizationSettings; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; @@ -33,7 +34,7 @@ use Symfony\Component\HttpKernel\KernelEvents; */ final class SetUserTimezoneSubscriber implements EventSubscriberInterface { - public function __construct(private readonly string $default_timezone, private readonly Security $security) + public function __construct(private readonly LocalizationSettings $localizationSettings, private readonly Security $security) { } @@ -48,8 +49,8 @@ final class SetUserTimezoneSubscriber implements EventSubscriberInterface } //Fill with default value if needed - if (null === $timezone && $this->default_timezone !== '') { - $timezone = $this->default_timezone; + if (null === $timezone && $this->localizationSettings->timezone !== '') { + $timezone = $this->localizationSettings->timezone; } //If timezone was configured anywhere set it, otherwise just use the one from php.ini diff --git a/src/Exceptions/OAuthReconnectRequiredException.php b/src/Exceptions/OAuthReconnectRequiredException.php new file mode 100644 index 00000000..97abb19f --- /dev/null +++ b/src/Exceptions/OAuthReconnectRequiredException.php @@ -0,0 +1,48 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Exceptions; + +use Throwable; + +class OAuthReconnectRequiredException extends \RuntimeException +{ + private string $providerName = "unknown"; + + public function __construct(string $message = "You need to reconnect the OAuth connection for this provider!", int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + public static function forProvider(string $providerName): self + { + $exception = new self("You need to reconnect the OAuth connection for the provider '$providerName'!"); + $exception->providerName = $providerName; + return $exception; + } + + public function getProviderName(): string + { + return $this->providerName; + } +} diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index d1a0ffd0..5a4ef5bc 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -25,6 +25,7 @@ namespace App\Form\AdminPages; use App\Entity\PriceInformations\Currency; use App\Entity\ProjectSystem\Project; use App\Entity\UserSystem\Group; +use App\Services\LogSystem\EventCommentType; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\Base\AbstractStructuralDBElement; @@ -152,7 +153,7 @@ class BaseEntityAdminForm extends AbstractType $builder->add('log_comment', TextType::class, [ 'label' => 'edit.log_comment', 'mapped' => false, - 'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? 'datastructure_create': 'datastructure_edit'), + 'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? EventCommentType::DATASTRUCTURE_CREATE: EventCommentType::DATASTRUCTURE_EDIT), 'empty_data' => null, ]); diff --git a/src/Form/AdminPages/CategoryAdminForm.php b/src/Form/AdminPages/CategoryAdminForm.php index 44c1dede..489649ed 100644 --- a/src/Form/AdminPages/CategoryAdminForm.php +++ b/src/Form/AdminPages/CategoryAdminForm.php @@ -84,6 +84,17 @@ class CategoryAdminForm extends BaseEntityAdminForm 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); + $builder->add('part_ipn_prefix', TextType::class, [ + 'required' => false, + 'empty_data' => '', + 'label' => 'category.edit.part_ipn_prefix', + 'help' => 'category.edit.part_ipn_prefix.help', + 'attr' => [ + 'placeholder' => 'category.edit.part_ipn_prefix.placeholder', + ], + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), + ]); + $builder->add('default_description', RichTextEditorType::class, [ 'required' => false, 'empty_data' => '', diff --git a/src/Form/AdminPages/CurrencyAdminForm.php b/src/Form/AdminPages/CurrencyAdminForm.php index 0fab055d..afcf3c1f 100644 --- a/src/Form/AdminPages/CurrencyAdminForm.php +++ b/src/Form/AdminPages/CurrencyAdminForm.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form\AdminPages; +use App\Settings\SystemSettings\LocalizationSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Form\Type\BigDecimalMoneyType; @@ -32,7 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface; class CurrencyAdminForm extends BaseEntityAdminForm { - public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly string $base_currency) + public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly LocalizationSettings $localizationSettings) { parent::__construct($security, $eventCommentNeededHelper); } @@ -51,7 +52,7 @@ class CurrencyAdminForm extends BaseEntityAdminForm $builder->add('exchange_rate', BigDecimalMoneyType::class, [ 'required' => false, 'label' => 'currency.edit.exchange_rate', - 'currency' => $this->base_currency, + 'currency' => $this->localizationSettings->baseCurrency, 'scale' => 6, 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php index 3e87812c..0bd3cea1 100644 --- a/src/Form/AdminPages/ImportType.php +++ b/src/Form/AdminPages/ImportType.php @@ -59,6 +59,8 @@ class ImportType extends AbstractType 'XML' => 'xml', 'CSV' => 'csv', 'YAML' => 'yaml', + 'XLSX' => 'xlsx', + 'XLS' => 'xls', ], 'label' => 'export.format', 'disabled' => $disabled, diff --git a/src/Form/AdminPages/PartCustomStateAdminForm.php b/src/Form/AdminPages/PartCustomStateAdminForm.php new file mode 100644 index 00000000..b8bb2815 --- /dev/null +++ b/src/Form/AdminPages/PartCustomStateAdminForm.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\AdminPages; + +class PartCustomStateAdminForm extends BaseEntityAdminForm +{ +} diff --git a/src/Form/AdminPages/SupplierForm.php b/src/Form/AdminPages/SupplierForm.php index 34b3b27a..43ac0616 100644 --- a/src/Form/AdminPages/SupplierForm.php +++ b/src/Form/AdminPages/SupplierForm.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form\AdminPages; +use App\Settings\SystemSettings\LocalizationSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\PriceInformations\Currency; @@ -32,7 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface; class SupplierForm extends CompanyForm { - public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, protected string $base_currency) + public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly LocalizationSettings $localizationSettings) { parent::__construct($security, $eventCommentNeededHelper); } @@ -53,7 +54,7 @@ class SupplierForm extends CompanyForm $builder->add('shipping_costs', BigDecimalMoneyType::class, [ 'required' => false, - 'currency' => $this->base_currency, + 'currency' => $this->localizationSettings->baseCurrency, 'scale' => 3, 'label' => 'supplier.shipping_costs.label', 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), diff --git a/src/Form/AttachmentFormType.php b/src/Form/AttachmentFormType.php index 957d692b..eb484a58 100644 --- a/src/Form/AttachmentFormType.php +++ b/src/Form/AttachmentFormType.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form; +use App\Settings\SystemSettings\AttachmentsSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; @@ -54,9 +55,7 @@ class AttachmentFormType extends AbstractType protected Security $security, protected AttachmentSubmitHandler $submitHandler, protected TranslatorInterface $translator, - protected bool $allow_attachments_download, - protected bool $download_by_default, - protected string $max_file_size + protected AttachmentsSettings $settings, ) { } @@ -108,7 +107,7 @@ class AttachmentFormType extends AbstractType 'required' => false, 'label' => 'attachment.edit.download_url', 'mapped' => false, - 'disabled' => !$this->allow_attachments_download, + 'disabled' => !$this->settings->allowDownloads, ]); $builder->add('file', FileType::class, [ @@ -177,7 +176,7 @@ class AttachmentFormType extends AbstractType //If the attachment should be downloaded by default (and is download allowed at all), register a listener, // which sets the downloadURL checkbox to true for new attachments - if ($this->download_by_default && $this->allow_attachments_download) { + if ($this->settings->downloadByDefault && $this->settings->allowDownloads) { $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { $form = $event->getForm(); $attachment = $form->getData(); @@ -204,7 +203,7 @@ class AttachmentFormType extends AbstractType { $resolver->setDefaults([ 'data_class' => Attachment::class, - 'max_file_size' => $this->max_file_size, + 'max_file_size' => $this->settings->maxFileSize, 'allow_builtins' => true, ]); } diff --git a/src/Form/CollectionTypeExtension.php b/src/Form/Extension/CollectionTypeExtension.php similarity index 99% rename from src/Form/CollectionTypeExtension.php rename to src/Form/Extension/CollectionTypeExtension.php index 4fa93852..52cd4186 100644 --- a/src/Form/CollectionTypeExtension.php +++ b/src/Form/Extension/CollectionTypeExtension.php @@ -39,7 +39,7 @@ declare(strict_types=1); * along with this program. If not, see . */ -namespace App\Form; +namespace App\Form\Extension; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; diff --git a/src/Form/PasswordTypeExtension.php b/src/Form/Extension/PasswordTypeExtension.php similarity index 67% rename from src/Form/PasswordTypeExtension.php rename to src/Form/Extension/PasswordTypeExtension.php index 64711c53..cc0486b0 100644 --- a/src/Form/PasswordTypeExtension.php +++ b/src/Form/Extension/PasswordTypeExtension.php @@ -1,4 +1,22 @@ . + */ declare(strict_types=1); @@ -20,7 +38,7 @@ declare(strict_types=1); * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -namespace App\Form; +namespace App\Form\Extension; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\PasswordType; diff --git a/src/Form/Extension/SelectTypeOrderExtension.php b/src/Form/Extension/SelectTypeOrderExtension.php new file mode 100644 index 00000000..e8e9a93f --- /dev/null +++ b/src/Form/Extension/SelectTypeOrderExtension.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Extension; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class SelectTypeOrderExtension extends AbstractTypeExtension +{ + public static function getExtendedTypes(): iterable + { + return [ + ChoiceType::class, + EnumType::class + ]; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('ordered', false); + $resolver->setDefault('by_reference', function (Options $options) { + //Disable by_reference if the field is ordered (otherwise the order will be lost) + return !$options['ordered']; + }); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + //Pass the data in ordered form to the frontend controller, so it can make the items appear in the correct order. + if ($options['ordered']) { + $view->vars['attr']['data-ordered-value'] = json_encode($form->getViewData(), JSON_THROW_ON_ERROR); + } + } +} diff --git a/src/Form/Extension/TogglePasswordTypeExtension.php b/src/Form/Extension/TogglePasswordTypeExtension.php new file mode 100644 index 00000000..fec4c0b3 --- /dev/null +++ b/src/Form/Extension/TogglePasswordTypeExtension.php @@ -0,0 +1,122 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Extension; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Translation\TranslatableMessage; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class TogglePasswordTypeExtension extends AbstractTypeExtension +{ + public function __construct(private readonly ?TranslatorInterface $translator) + { + } + + public static function getExtendedTypes(): iterable + { + return [PasswordType::class]; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'toggle' => false, + 'hidden_label' => new TranslatableMessage('password_toggle.hide'), + 'visible_label' => new TranslatableMessage('password_toggle.show'), + 'hidden_icon' => 'Default', + 'visible_icon' => 'Default', + 'button_classes' => ['toggle-password-button'], + 'toggle_container_classes' => ['toggle-password-container'], + 'toggle_translation_domain' => null, + 'use_toggle_form_theme' => true, + ]); + + $resolver->setNormalizer( + 'toggle_translation_domain', + static fn (Options $options, $labelTranslationDomain) => $labelTranslationDomain ?? $options['translation_domain'], + ); + + $resolver->setAllowedTypes('toggle', ['bool']); + $resolver->setAllowedTypes('hidden_label', ['string', TranslatableMessage::class, 'null']); + $resolver->setAllowedTypes('visible_label', ['string', TranslatableMessage::class, 'null']); + $resolver->setAllowedTypes('hidden_icon', ['string', 'null']); + $resolver->setAllowedTypes('visible_icon', ['string', 'null']); + $resolver->setAllowedTypes('button_classes', ['string[]']); + $resolver->setAllowedTypes('toggle_container_classes', ['string[]']); + $resolver->setAllowedTypes('toggle_translation_domain', ['string', 'bool', 'null']); + $resolver->setAllowedTypes('use_toggle_form_theme', ['bool']); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['toggle'] = $options['toggle']; + + if (!$options['toggle']) { + return; + } + + if ($options['use_toggle_form_theme']) { + array_splice($view->vars['block_prefixes'], -1, 0, 'toggle_password'); + } + + $controllerName = 'toggle-password'; + $controllerValues = []; + $view->vars['attr']['data-controller'] = trim(\sprintf('%s %s', $view->vars['attr']['data-controller'] ?? '', $controllerName)); + + if (false !== $options['toggle_translation_domain']) { + $controllerValues['hidden-label'] = $this->translateLabel($options['hidden_label'], $options['toggle_translation_domain']); + $controllerValues['visible-label'] = $this->translateLabel($options['visible_label'], $options['toggle_translation_domain']); + } else { + $controllerValues['hidden-label'] = $options['hidden_label']; + $controllerValues['visible-label'] = $options['visible_label']; + } + + $controllerValues['hidden-icon'] = $options['hidden_icon']; + $controllerValues['visible-icon'] = $options['visible_icon']; + $controllerValues['button-classes'] = json_encode($options['button_classes'], \JSON_THROW_ON_ERROR); + + foreach ($controllerValues as $name => $value) { + $view->vars['attr'][\sprintf('data-%s-%s-value', $controllerName, $name)] = $value; + } + + $view->vars['toggle_container_classes'] = $options['toggle_container_classes']; + } + + private function translateLabel(string|TranslatableMessage|null $label, ?string $translationDomain): ?string + { + if (null === $this->translator || null === $label) { + return $label; + } + + if ($label instanceof TranslatableMessage) { + return $label->trans($this->translator); + } + + return $this->translator->trans($label, domain: $translationDomain); + } +} diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php index 42b367b7..30abf723 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -100,7 +100,7 @@ class LogFilterType extends AbstractType ]); $builder->add('user', UserEntityConstraintType::class, [ - 'label' => 'log.user', + 'label' => 'log.user', ]); $builder->add('targetType', EnumConstraintType::class, [ @@ -128,11 +128,14 @@ class LogFilterType extends AbstractType LogTargetType::PARAMETER => 'parameter.label', LogTargetType::LABEL_PROFILE => 'label_profile.label', LogTargetType::PART_ASSOCIATION => 'part_association.label', + LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', + LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label', + LogTargetType::PART_CUSTOM_STATE => 'part_custom_state.label', }, ]); $builder->add('targetId', NumberConstraintType::class, [ - 'label' => 'log.target_id', + 'label' => 'log.target_id', 'min' => 1, 'step' => 1, ]); diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index dfe449d1..e101c635 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -22,19 +22,27 @@ declare(strict_types=1); */ namespace App\Form\Filters; +use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint; use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\PartFilter; use App\Entity\Attachments\AttachmentType; +use App\Entity\InfoProviderSystem\BulkImportJobStatus; +use App\Entity\InfoProviderSystem\BulkImportPartStatus; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; use App\Form\Filters\Constraints\BooleanConstraintType; +use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType; +use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType; +use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType; use App\Form\Filters\Constraints\ChoiceConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; +use App\Form\Filters\Constraints\EnumConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; use App\Form\Filters\Constraints\ParameterConstraintType; use App\Form\Filters\Constraints\StructuralEntityConstraintType; @@ -50,6 +58,8 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use function Symfony\Component\Translation\t; + class PartFilterType extends AbstractType { public function __construct(private readonly Security $security) @@ -130,6 +140,11 @@ class PartFilterType extends AbstractType 'entity_class' => MeasurementUnit::class ]); + $builder->add('partCustomState', StructuralEntityConstraintType::class, [ + 'label' => 'part.edit.partCustomState', + 'entity_class' => PartCustomState::class + ]); + $builder->add('lastModified', DateTimeConstraintType::class, [ 'label' => 'lastModified' ]); @@ -298,6 +313,31 @@ class PartFilterType extends AbstractType } + /************************************************************************** + * Bulk Import Job tab + **************************************************************************/ + if ($this->security->isGranted('@info_providers.create_parts')) { + $builder + ->add('inBulkImportJob', BooleanConstraintType::class, [ + 'label' => 'part.filter.in_bulk_import_job', + ]) + ->add('bulkImportJobStatus', EnumConstraintType::class, [ + 'enum_class' => BulkImportJobStatus::class, + 'label' => 'part.filter.bulk_import_job_status', + 'choice_label' => function (BulkImportJobStatus $value) { + return t('bulk_import.status.' . $value->value); + }, + ]) + ->add('bulkImportPartStatus', EnumConstraintType::class, [ + 'enum_class' => BulkImportPartStatus::class, + 'label' => 'part.filter.bulk_import_part_status', + 'choice_label' => function (BulkImportPartStatus $value) { + return t('bulk_import.part_status.' . $value->value); + }, + ]) + ; + } + $builder->add('submit', SubmitType::class, [ 'label' => 'filter.submit', diff --git a/src/Form/History/EnforceEventCommentTypesType.php b/src/Form/History/EnforceEventCommentTypesType.php new file mode 100644 index 00000000..85e43e6e --- /dev/null +++ b/src/Form/History/EnforceEventCommentTypesType.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\History; + +use App\Services\LogSystem\EventCommentType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * The type for the "enforceComments" setting in the HistorySettings. + */ +class EnforceEventCommentTypesType extends AbstractType +{ + public function getParent(): string + { + return EnumType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'multiple' => true, + 'class' => EventCommentType::class, + 'empty_data' => [], + ]); + } +} diff --git a/src/Form/InfoProviderSystem/BulkProviderSearchType.php b/src/Form/InfoProviderSystem/BulkProviderSearchType.php new file mode 100644 index 00000000..24a3cfb4 --- /dev/null +++ b/src/Form/InfoProviderSystem/BulkProviderSearchType.php @@ -0,0 +1,62 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use App\Entity\Parts\Part; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class BulkProviderSearchType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $parts = $options['parts']; + + $builder->add('part_configurations', CollectionType::class, [ + 'entry_type' => PartProviderConfigurationType::class, + 'entry_options' => [ + 'label' => false, + ], + 'allow_add' => false, + 'allow_delete' => false, + 'label' => false, + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'info_providers.bulk_search.submit' + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'parts' => [], + ]); + $resolver->setRequired('parts'); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/FieldToProviderMappingType.php b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php new file mode 100644 index 00000000..13e9581e --- /dev/null +++ b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php @@ -0,0 +1,75 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FieldToProviderMappingType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $fieldChoices = $options['field_choices'] ?? []; + + $builder->add('field', ChoiceType::class, [ + 'label' => 'info_providers.bulk_search.search_field', + 'choices' => $fieldChoices, + 'expanded' => false, + 'multiple' => false, + 'required' => false, + 'placeholder' => 'info_providers.bulk_search.field.select', + ]); + + $builder->add('providers', ProviderSelectType::class, [ + 'label' => 'info_providers.bulk_search.providers', + 'help' => 'info_providers.bulk_search.providers.help', + 'required' => false, + ]); + + $builder->add('priority', IntegerType::class, [ + 'label' => 'info_providers.bulk_search.priority', + 'help' => 'info_providers.bulk_search.priority.help', + 'required' => false, + 'data' => 1, // Default priority + 'attr' => [ + 'min' => 1, + 'max' => 10, + 'class' => 'form-control-sm', + 'style' => 'width: 80px;' + ], + 'constraints' => [ + new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'field_choices' => [], + ]); + } +} diff --git a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php new file mode 100644 index 00000000..ea70284f --- /dev/null +++ b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class GlobalFieldMappingType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $fieldChoices = $options['field_choices'] ?? []; + + $builder->add('field_mappings', CollectionType::class, [ + 'entry_type' => FieldToProviderMappingType::class, + 'entry_options' => [ + 'label' => false, + 'field_choices' => $fieldChoices, + ], + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + 'label' => false, + ]); + + $builder->add('prefetch_details', CheckboxType::class, [ + 'label' => 'info_providers.bulk_import.prefetch_details', + 'required' => false, + 'help' => 'info_providers.bulk_import.prefetch_details_help', + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'info_providers.bulk_import.search.submit' + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'field_choices' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/PartProviderConfigurationType.php b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php new file mode 100644 index 00000000..cecf62a3 --- /dev/null +++ b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php @@ -0,0 +1,55 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; + +class PartProviderConfigurationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('part_id', HiddenType::class); + + $builder->add('search_field', ChoiceType::class, [ + 'label' => 'info_providers.bulk_search.search_field', + 'choices' => [ + 'info_providers.bulk_search.field.mpn' => 'mpn', + 'info_providers.bulk_search.field.name' => 'name', + 'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn', + 'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn', + 'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn', + 'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn', + ], + 'expanded' => false, + 'multiple' => false, + ]); + + $builder->add('providers', ProviderSelectType::class, [ + 'label' => 'info_providers.bulk_search.providers', + 'help' => 'info_providers.bulk_search.providers.help', + ]); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/ProviderSelectType.php b/src/Form/InfoProviderSystem/ProviderSelectType.php index a9373390..bad3edaa 100644 --- a/src/Form/InfoProviderSystem/ProviderSelectType.php +++ b/src/Form/InfoProviderSystem/ProviderSelectType.php @@ -28,7 +28,9 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\ChoiceList\ChoiceList; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Translation\StaticMessage; class ProviderSelectType extends AbstractType { @@ -44,13 +46,43 @@ class ProviderSelectType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { - $resolver->setDefaults([ - 'choices' => $this->providerRegistry->getActiveProviders(), - 'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']), - 'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()), + $providers = $this->providerRegistry->getActiveProviders(); - 'multiple' => true, - ]); + $resolver->setDefault('input', 'object'); + $resolver->setAllowedTypes('input', 'string'); + //Either the form returns the provider objects or their keys + $resolver->setAllowedValues('input', ['object', 'string']); + $resolver->setDefault('multiple', true); + + $resolver->setDefault('choices', function (Options $options) use ($providers) { + if ('object' === $options['input']) { + return $this->providerRegistry->getActiveProviders(); + } + + $tmp = []; + foreach ($providers as $provider) { + $name = $provider->getProviderInfo()['name']; + $tmp[$name] = $provider->getProviderKey(); + } + + return $tmp; + }); + + //The choice_label and choice_value only needs to be set if we want the objects + $resolver->setDefault('choice_label', function (Options $options){ + if ('object' === $options['input']) { + return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => new StaticMessage($choice?->getProviderInfo()['name'])); + } + + return static fn ($choice, $key, $value) => new StaticMessage($key); + }); + $resolver->setDefault('choice_value', function (Options $options) { + if ('object' === $options['input']) { + return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()); + } + + return null; + }); } -} \ No newline at end of file +} diff --git a/src/Form/LabelSystem/LabelDialogType.php b/src/Form/LabelSystem/LabelDialogType.php index f2710b19..d79d01f6 100644 --- a/src/Form/LabelSystem/LabelDialogType.php +++ b/src/Form/LabelSystem/LabelDialogType.php @@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType ] ]); + if ($options['profile'] !== null) { + $builder->add('update_profile', SubmitType::class, [ + 'label' => 'label_generator.update_profile', + 'disabled' => !$this->security->isGranted('edit', $options['profile']), + 'attr' => [ + 'class' => 'btn btn-outline-success' + ] + ]); + } + $builder->add('update', SubmitType::class, [ 'label' => 'label_generator.update', ]); @@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType parent::configureOptions($resolver); $resolver->setDefault('mapped', false); $resolver->setDefault('disable_options', false); + $resolver->setDefault('profile', null); } } diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index b1d2ebea..b8276589 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -30,6 +30,7 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; +use App\Entity\Parts\PartCustomState; use App\Entity\PriceInformations\Orderdetail; use App\Form\AttachmentFormType; use App\Form\ParameterType; @@ -40,6 +41,8 @@ use App\Form\Type\SIUnitType; use App\Form\Type\StructuralEntityType; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\LogSystem\EventCommentNeededHelper; +use App\Services\LogSystem\EventCommentType; +use App\Settings\MiscSettings\IpnSuggestSettings; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -55,8 +58,12 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class PartBaseType extends AbstractType { - public function __construct(protected Security $security, protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper) - { + public function __construct( + protected Security $security, + protected UrlGeneratorInterface $urlGenerator, + protected EventCommentNeededHelper $event_comment_needed_helper, + protected IpnSuggestSettings $ipnSuggestSettings, + ) { } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -68,6 +75,39 @@ class PartBaseType extends AbstractType /** @var PartDetailDTO|null $dto */ $dto = $options['info_provider_dto']; + $descriptionAttr = [ + 'placeholder' => 'part.edit.description.placeholder', + 'rows' => 2, + ]; + + if ($this->ipnSuggestSettings->useDuplicateDescription) { + // Only add attribute when duplicate description feature is enabled + $descriptionAttr['data-ipn-suggestion'] = 'descriptionField'; + } + + $ipnAttr = [ + 'class' => 'ipn-suggestion-field', + 'data-elements--ipn-suggestion-target' => 'input', + 'autocomplete' => 'off', + ]; + + if ($this->ipnSuggestSettings->regex !== null && $this->ipnSuggestSettings->regex !== '') { + $ipnAttr['pattern'] = $this->ipnSuggestSettings->regex; + $ipnAttr['placeholder'] = $this->ipnSuggestSettings->regex; + $ipnAttr['title'] = $this->ipnSuggestSettings->regexHelp; + } + + $ipnOptions = [ + 'required' => false, + 'empty_data' => null, + 'label' => 'part.edit.ipn', + 'attr' => $ipnAttr, + ]; + + if (isset($ipnAttr['pattern']) && $this->ipnSuggestSettings->regexHelp !== null && $this->ipnSuggestSettings->regexHelp !== '') { + $ipnOptions['help'] = $this->ipnSuggestSettings->regexHelp; + } + //Common section $builder ->add('name', TextType::class, [ @@ -82,10 +122,7 @@ class PartBaseType extends AbstractType 'empty_data' => '', 'label' => 'part.edit.description', 'mode' => 'markdown-single_line', - 'attr' => [ - 'placeholder' => 'part.edit.description.placeholder', - 'rows' => 2, - ], + 'attr' => $descriptionAttr, ]) ->add('minAmount', SIUnitType::class, [ 'attr' => [ @@ -103,6 +140,9 @@ class PartBaseType extends AbstractType 'disable_not_selectable' => true, //Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity) 'required' => !$new_part, + 'attr' => [ + 'data-ipn-suggestion' => 'categoryField', + ] ]) ->add('footprint', StructuralEntityType::class, [ 'class' => Footprint::class, @@ -170,11 +210,13 @@ class PartBaseType extends AbstractType 'disable_not_selectable' => true, 'label' => 'part.edit.partUnit', ]) - ->add('ipn', TextType::class, [ + ->add('partCustomState', StructuralEntityType::class, [ + 'class' => PartCustomState::class, 'required' => false, - 'empty_data' => null, - 'label' => 'part.edit.ipn', - ]); + 'disable_not_selectable' => true, + 'label' => 'part.edit.partCustomState', + ]) + ->add('ipn', TextType::class, $ipnOptions); //Comment section $builder->add('comment', RichTextEditorType::class, [ @@ -265,7 +307,7 @@ class PartBaseType extends AbstractType $builder->add('log_comment', TextType::class, [ 'label' => 'edit.log_comment', 'mapped' => false, - 'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? 'part_create' : 'part_edit'), + 'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? EventCommentType::PART_CREATE : EventCommentType::PART_EDIT), 'empty_data' => null, ]); diff --git a/src/Form/Settings/LanguageMenuEntriesType.php b/src/Form/Settings/LanguageMenuEntriesType.php new file mode 100644 index 00000000..9bc2e850 --- /dev/null +++ b/src/Form/Settings/LanguageMenuEntriesType.php @@ -0,0 +1,56 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Settings; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Intl\Languages; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class LanguageMenuEntriesType extends AbstractType +{ + public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages) + { + + } + + public function getParent(): string + { + return LanguageType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $choices = []; + foreach ($this->preferred_languages as $lang_code) { + $choices[Languages::getName($lang_code)] = $lang_code; + } + + $resolver->setDefaults([ + 'choice_loader' => null, + 'choices' => $choices, + ]); + } +} diff --git a/src/Form/Settings/TypeSynonymRowType.php b/src/Form/Settings/TypeSynonymRowType.php new file mode 100644 index 00000000..f3b8f0b6 --- /dev/null +++ b/src/Form/Settings/TypeSynonymRowType.php @@ -0,0 +1,150 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Settings; + +use App\Services\ElementTypes; +use App\Settings\SystemSettings\LocalizationSettings; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\Extension\Core\Type\LocaleType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Intl\Locales; +use Symfony\Component\Translation\StaticMessage; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * A single translation row: data source + language + translations (singular/plural). + */ +class TypeSynonymRowType extends AbstractType +{ + + private const PREFERRED_TYPES = [ + ElementTypes::CATEGORY, + ElementTypes::STORAGE_LOCATION, + ElementTypes::FOOTPRINT, + ElementTypes::MANUFACTURER, + ElementTypes::SUPPLIER, + ElementTypes::PROJECT, + ]; + + public function __construct( + private readonly LocalizationSettings $localizationSettings, + private readonly TranslatorInterface $translator, + #[Autowire(param: 'partdb.locale_menu')] private readonly array $preferredLanguagesParam, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('dataSource', EnumType::class, [ + 'class' => ElementTypes::class, + 'label' => false, + 'required' => true, + 'constraints' => [ + new Assert\NotBlank(), + ], + 'choice_label' => function (ElementTypes $choice) { + return new StaticMessage( + $this->translator->trans($choice->getDefaultLabelKey()) . ' (' . $this->translator->trans($choice->getDefaultPluralLabelKey()) . ')' + ); + }, + 'row_attr' => ['class' => 'mb-0'], + 'attr' => ['class' => 'form-select-sm'], + 'preferred_choices' => self::PREFERRED_TYPES + ]) + ->add('locale', LocaleType::class, [ + 'label' => false, + 'required' => true, + // Restrict to languages configured in the language menu: disable ChoiceLoader and provide explicit choices + 'choice_loader' => null, + 'choices' => $this->buildLocaleChoices(true), + 'preferred_choices' => $this->getPreferredLocales(), + 'constraints' => [ + new Assert\NotBlank(), + ], + 'row_attr' => ['class' => 'mb-0'], + 'attr' => ['class' => 'form-select-sm'] + ]) + ->add('translation_singular', TextType::class, [ + 'label' => false, + 'required' => true, + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + ], + 'row_attr' => ['class' => 'mb-0'], + 'attr' => ['class' => 'form-select-sm'] + ]) + ->add('translation_plural', TextType::class, [ + 'label' => false, + 'required' => true, + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + ], + 'row_attr' => ['class' => 'mb-0'], + 'attr' => ['class' => 'form-select-sm'] + ]); + } + + + /** + * Returns only locales configured in the language menu (settings) or falls back to the parameter. + * Format: ['German (DE)' => 'de', ...] + */ + private function buildLocaleChoices(bool $returnPossible = false): array + { + $locales = $this->getPreferredLocales(); + + if ($returnPossible) { + $locales = $this->getPossibleLocales(); + } + + $choices = []; + foreach ($locales as $code) { + $label = Locales::getName($code); + $choices[$label . ' (' . strtoupper($code) . ')'] = $code; + } + return $choices; + } + + /** + * Source of allowed locales: + * 1) LocalizationSettings->languageMenuEntries (if set) + * 2) Fallback: parameter partdb.locale_menu + */ + private function getPreferredLocales(): array + { + $fromSettings = $this->localizationSettings->languageMenuEntries ?? []; + return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam); + } + + private function getPossibleLocales(): array + { + return array_values($this->preferredLanguagesParam); + } +} diff --git a/src/Form/Settings/TypeSynonymsCollectionType.php b/src/Form/Settings/TypeSynonymsCollectionType.php new file mode 100644 index 00000000..4756930a --- /dev/null +++ b/src/Form/Settings/TypeSynonymsCollectionType.php @@ -0,0 +1,223 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Settings; + +use App\Services\ElementTypes; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\CallbackTransformer; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Intl\Locales; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Flat collection of translation rows. + * View data: list [{dataSource, locale, translation_singular, translation_plural}, ...] + * Model data: same structure (list). Optionally expands a nested map to a list. + */ +class TypeSynonymsCollectionType extends AbstractType +{ + public function __construct(private readonly TranslatorInterface $translator) + { + } + + private function flattenStructure(array $modelValue): array + { + //If the model is already flattened, return as is + if (array_is_list($modelValue)) { + return $modelValue; + } + + $out = []; + foreach ($modelValue as $dataSource => $locales) { + if (!is_array($locales)) { + continue; + } + foreach ($locales as $locale => $translations) { + if (!is_array($translations)) { + continue; + } + $out[] = [ + //Convert string to enum value + 'dataSource' => ElementTypes::from($dataSource), + 'locale' => $locale, + 'translation_singular' => $translations['singular'] ?? '', + 'translation_plural' => $translations['plural'] ?? '', + ]; + } + } + return $out; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { + //Flatten the structure + $data = $event->getData(); + $event->setData($this->flattenStructure($data)); + }); + + $builder->addModelTransformer(new CallbackTransformer( + // Model -> View + $this->flattenStructure(...), + // View -> Model (keep list; let existing behavior unchanged) + function (array $viewValue) { + //Turn our flat list back into the structured array + + $out = []; + + foreach ($viewValue as $row) { + if (!is_array($row)) { + continue; + } + $dataSource = $row['dataSource'] ?? null; + $locale = $row['locale'] ?? null; + $translation_singular = $row['translation_singular'] ?? null; + $translation_plural = $row['translation_plural'] ?? null; + + if ($dataSource === null || + !is_string($locale) || $locale === '' + ) { + continue; + } + + $out[$dataSource->value][$locale] = [ + 'singular' => is_string($translation_singular) ? $translation_singular : '', + 'plural' => is_string($translation_plural) ? $translation_plural : '', + ]; + } + + return $out; + } + )); + + // Validation and normalization (duplicates + sorting) during SUBMIT + $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + $form = $event->getForm(); + $rows = $event->getData(); + + if (!is_array($rows)) { + return; + } + + // Duplicate check: (dataSource, locale) must be unique + $seen = []; + $hasDuplicate = false; + + foreach ($rows as $idx => $row) { + if (!is_array($row)) { + continue; + } + $ds = $row['dataSource'] ?? null; + $loc = $row['locale'] ?? null; + + if ($ds !== null && is_string($loc) && $loc !== '') { + $key = $ds->value . '|' . $loc; + if (isset($seen[$key])) { + $hasDuplicate = true; + + if ($form->has((string)$idx)) { + $child = $form->get((string)$idx); + + if ($child->has('dataSource')) { + $child->get('dataSource')->addError( + new FormError($this->translator->trans( + 'settings.synonyms.type_synonyms.collection_type.duplicate', + [], 'validators' + )) + ); + } + if ($child->has('locale')) { + $child->get('locale')->addError( + new FormError($this->translator->trans( + 'settings.synonyms.type_synonyms.collection_type.duplicate', + [], 'validators' + )) + ); + } + } + } else { + $seen[$key] = true; + } + } + } + + if ($hasDuplicate) { + return; + } + + // Overall sort: first by dataSource key, then by localized language name + $sortable = $rows; + + usort($sortable, static function ($a, $b) { + $aDs = $a['dataSource']->value ?? ''; + $bDs = $b['dataSource']->value ?? ''; + + $cmpDs = strcasecmp($aDs, $bDs); + if ($cmpDs !== 0) { + return $cmpDs; + } + + $aLoc = (string)($a['locale'] ?? ''); + $bLoc = (string)($b['locale'] ?? ''); + + $aName = Locales::getName($aLoc); + $bName = Locales::getName($bLoc); + + return strcasecmp($aName, $bName); + }); + + $event->setData($sortable); + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + + // Defaults for the collection and entry type + $resolver->setDefaults([ + 'entry_type' => TypeSynonymRowType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'required' => false, + 'prototype' => true, + 'empty_data' => [], + 'entry_options' => ['label' => false], + ]); + } + + public function getParent(): ?string + { + return CollectionType::class; + } + + public function getBlockPrefix(): string + { + return 'type_synonyms_collection'; + } +} diff --git a/src/Form/Type/APIKeyType.php b/src/Form/Type/APIKeyType.php new file mode 100644 index 00000000..57eaea96 --- /dev/null +++ b/src/Form/Type/APIKeyType.php @@ -0,0 +1,81 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +class APIKeyType extends AbstractType +{ + public function __construct(private readonly TranslatorInterface $translator) + { + } + + public function getParent(): string + { + return PasswordType::class; + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $viewData = $form->getViewData(); + + //If the field is disabled, show the redacted API key + if ($options['disabled'] ?? false) { + if ($viewData === null || $viewData === '') { + $view->vars['value'] = $viewData; + } else { + + $view->vars['value'] = self::redact((string)$viewData) . ' (' . $this ->translator->trans("form.apikey.redacted") . ')'; + } + } else { //Otherwise, show the actual value + $view->vars['value'] = $viewData; + } + } + + public static function redact(string $apiKey): string + { + //Show only the last 2 characters of the API key if it is long enough (more than 16 characters) + //Replace all other characters with dots + if (strlen($apiKey) > 16) { + return str_repeat('*', strlen($apiKey) - 2) . substr($apiKey, -2); + } + + return str_repeat('*', strlen($apiKey)); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'always_empty' => false, + 'toggle' => true, + 'empty_data' => null, + 'attr' => ['autocomplete' => 'off'], + ]); + } +} diff --git a/src/Form/Type/CurrencyEntityType.php b/src/Form/Type/CurrencyEntityType.php index 07f0a9f8..875ca35f 100644 --- a/src/Form/Type/CurrencyEntityType.php +++ b/src/Form/Type/CurrencyEntityType.php @@ -25,6 +25,7 @@ namespace App\Form\Type; use App\Entity\PriceInformations\Currency; use App\Form\Type\Helper\StructuralEntityChoiceHelper; use App\Services\Trees\NodesListBuilder; +use App\Settings\SystemSettings\LocalizationSettings; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Intl\Currencies; use Symfony\Component\OptionsResolver\Options; @@ -36,7 +37,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; */ class CurrencyEntityType extends StructuralEntityType { - public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, protected ?string $base_currency) + public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, private readonly LocalizationSettings $localizationSettings) { parent::__construct($em, $builder, $translator, $choiceHelper); } @@ -57,7 +58,7 @@ class CurrencyEntityType extends StructuralEntityType $resolver->setDefault('empty_message', function (Options $options) { //By default, we use the global base currency: - $iso_code = $this->base_currency; + $iso_code = $this->localizationSettings->baseCurrency; if ($options['base_currency']) { //Allow to override it $iso_code = $options['base_currency']; diff --git a/src/Form/Type/LocaleSelectType.php b/src/Form/Type/LocaleSelectType.php new file mode 100644 index 00000000..6dc6f9fc --- /dev/null +++ b/src/Form/Type/LocaleSelectType.php @@ -0,0 +1,52 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Type; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\LocaleType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A locale select field that uses the preferred languages from the configuration. + */ +class LocaleSelectType extends AbstractType +{ + + public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages) + { + + } + public function getParent(): string + { + return LocaleType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'preferred_choices' => $this->preferred_languages, + ]); + } +} diff --git a/src/Form/Type/StructuralEntityType.php b/src/Form/Type/StructuralEntityType.php index 1018eeeb..51eb21a1 100644 --- a/src/Form/Type/StructuralEntityType.php +++ b/src/Form/Type/StructuralEntityType.php @@ -110,8 +110,10 @@ class StructuralEntityType extends AbstractType //If no help text is explicitly set, we use the dto value as help text and show it as html $resolver->setDefault('help', fn(Options $options) => $this->dtoText($options['dto_value'])); $resolver->setDefault('help_html', fn(Options $options) => $options['dto_value'] !== null); + - $resolver->setDefault('attr', function (Options $options) { + //Normalize the attr to merge custom attributes + $resolver->setNormalizer('attr', function (Options $options, $value) { $tmp = [ 'data-controller' => $options['controller'], 'data-allow-add' => $options['allow_add'] ? 'true' : 'false', @@ -121,7 +123,7 @@ class StructuralEntityType extends AbstractType $tmp['data-empty-message'] = $options['empty_message']; } - return $tmp; + return array_merge($tmp, $value); }); } diff --git a/src/Form/Type/TriStateCheckboxType.php b/src/Form/Type/TriStateCheckboxType.php index 4523a839..b2a85ad3 100644 --- a/src/Form/Type/TriStateCheckboxType.php +++ b/src/Form/Type/TriStateCheckboxType.php @@ -100,7 +100,7 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer * @return mixed The value in the transformed representation * */ - public function transform(mixed $value) + public function transform(mixed $value): mixed { if (true === $value) { return 'true'; @@ -142,7 +142,7 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer * * @return mixed The value in the original representation */ - public function reverseTransform(mixed $value) + public function reverseTransform(mixed $value): mixed { return match ($value) { 'true' => true, diff --git a/src/Form/UserAdminForm.php b/src/Form/UserAdminForm.php index 864bcf6b..69be181f 100644 --- a/src/Form/UserAdminForm.php +++ b/src/Form/UserAdminForm.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form; +use App\Form\Type\LocaleSelectType; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\UserSystem\Group; @@ -35,7 +36,6 @@ use App\Form\Type\ThemeChoiceType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\LanguageType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\ResetType; @@ -140,11 +140,10 @@ class UserAdminForm extends AbstractType ]) //Config section - ->add('language', LanguageType::class, [ + ->add('language', LocaleSelectType::class, [ 'required' => false, 'placeholder' => 'user_settings.language.placeholder', 'label' => 'user.language_select', - 'preferred_choices' => ['en', 'de'], 'disabled' => !$this->security->isGranted('change_user_settings', $entity), ]) ->add('timezone', TimezoneType::class, [ diff --git a/src/Form/UserSettingsType.php b/src/Form/UserSettingsType.php index 05f63df4..0c7cb169 100644 --- a/src/Form/UserSettingsType.php +++ b/src/Form/UserSettingsType.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Form; +use App\Form\Type\LocaleSelectType; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\UserSystem\User; use App\Form\Type\CurrencyEntityType; @@ -33,7 +34,6 @@ use Symfony\Component\Form\Event\PreSetDataEvent; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\FileType; -use Symfony\Component\Form\Extension\Core\Type\LanguageType; use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -47,7 +47,7 @@ class UserSettingsType extends AbstractType { public function __construct(protected Security $security, protected bool $demo_mode, - #[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages) + ) { } @@ -107,12 +107,11 @@ class UserSettingsType extends AbstractType 'mode' => 'markdown-full', 'disabled' => !$this->security->isGranted('edit_infos', $options['data']) || $this->demo_mode, ]) - ->add('language', LanguageType::class, [ + ->add('language', LocaleSelectType::class, [ 'disabled' => $this->demo_mode, 'required' => false, 'placeholder' => 'user_settings.language.placeholder', 'label' => 'user.language_select', - 'preferred_choices' => $this->preferred_languages, ]) ->add('timezone', TimezoneType::class, [ 'disabled' => $this->demo_mode, diff --git a/src/Migration/WithPermPresetsTrait.php b/src/Migration/WithPermPresetsTrait.php index 44bc4510..203ef68a 100644 --- a/src/Migration/WithPermPresetsTrait.php +++ b/src/Migration/WithPermPresetsTrait.php @@ -26,7 +26,7 @@ namespace App\Migration; use App\Entity\UserSystem\PermissionData; use App\Security\Interfaces\HasPermissionsInterface; use App\Services\UserSystem\PermissionPresetsHelper; -use Symfony\Component\DependencyInjection\ContainerInterface; +use Psr\Container\ContainerInterface; trait WithPermPresetsTrait { diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index edccd74b..9d5fee5e 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -22,17 +22,35 @@ declare(strict_types=1); namespace App\Repository; +use App\Entity\Parts\Category; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; +use App\Settings\MiscSettings\IpnSuggestSettings; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use Doctrine\ORM\QueryBuilder; +use Symfony\Contracts\Translation\TranslatorInterface; +use Doctrine\ORM\EntityManagerInterface; /** * @extends NamedDBElementRepository */ class PartRepository extends NamedDBElementRepository { + private TranslatorInterface $translator; + private IpnSuggestSettings $ipnSuggestSettings; + + public function __construct( + EntityManagerInterface $em, + TranslatorInterface $translator, + IpnSuggestSettings $ipnSuggestSettings, + ) { + parent::__construct($em, $em->getClassMetadata(Part::class)); + + $this->translator = $translator; + $this->ipnSuggestSettings = $ipnSuggestSettings; + } + /** * Gets the summed up instock of all parts (only parts without a measurement unit). * @@ -84,8 +102,7 @@ class PartRepository extends NamedDBElementRepository ->where('ILIKE(part.name, :query) = TRUE') ->orWhere('ILIKE(part.description, :query) = TRUE') ->orWhere('ILIKE(category.name, :query) = TRUE') - ->orWhere('ILIKE(footprint.name, :query) = TRUE') - ; + ->orWhere('ILIKE(footprint.name, :query) = TRUE'); $qb->setParameter('query', '%'.$query.'%'); @@ -94,4 +111,282 @@ class PartRepository extends NamedDBElementRepository return $qb->getQuery()->getResult(); } + + /** + * Provides IPN (Internal Part Number) suggestions for a given part based on its category, description, + * and configured autocomplete digit length. + * + * This function generates suggestions for common prefixes and incremented prefixes based on + * the part's current category and its hierarchy. If the part is unsaved, a default "n.a." prefix is returned. + * + * @param Part $part The part for which autocomplete suggestions are generated. + * @param string $description description to assist in generating suggestions. + * @param int $suggestPartDigits The number of digits used in autocomplete increments. + * + * @return array An associative array containing the following keys: + * - 'commonPrefixes': List of common prefixes found for the part. + * - 'prefixesPartIncrement': Increments for the generated prefixes, including hierarchical prefixes. + */ + public function autoCompleteIpn(Part $part, string $description, int $suggestPartDigits): array + { + $category = $part->getCategory(); + $ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []]; + + //Show global prefix first if configured + if ($this->ipnSuggestSettings->globalPrefix !== null && $this->ipnSuggestSettings->globalPrefix !== '') { + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $this->ipnSuggestSettings->globalPrefix, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.global_prefix') + ]; + + $increment = $this->generateNextPossibleGlobalIncrement(); + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $this->ipnSuggestSettings->globalPrefix . $increment, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.global_prefix') + ]; + } + + if (strlen($description) > 150) { + $description = substr($description, 0, 150); + } + + if ($description !== '' && $this->ipnSuggestSettings->useDuplicateDescription) { + // Check if the description is already used in another part, + + $suggestionByDescription = $this->getIpnSuggestByDescription($description); + + if ($suggestionByDescription !== null && $suggestionByDescription !== $part->getIpn() && $part->getIpn() !== null && $part->getIpn() !== '') { + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $part->getIpn(), + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.current-increment') + ]; + } + + if ($suggestionByDescription !== null) { + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $suggestionByDescription, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.increment') + ]; + } + } + + // Validate the category and ensure it's an instance of Category + if ($category instanceof Category) { + $currentPath = $category->getPartIpnPrefix(); + $directIpnPrefixEmpty = $category->getPartIpnPrefix() === ''; + $currentPath = $currentPath === '' ? $this->ipnSuggestSettings->fallbackPrefix : $currentPath; + + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); + + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator, + 'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category') + ]; + + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator . $increment, + 'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category.increment') + ]; + + // Process parent categories + $parentCategory = $category->getParent(); + + while ($parentCategory instanceof Category) { + // Prepend the parent category's prefix to the current path + $effectiveIPNPrefix = $parentCategory->getPartIpnPrefix() === '' ? $this->ipnSuggestSettings->fallbackPrefix : $parentCategory->getPartIpnPrefix(); + + $currentPath = $effectiveIPNPrefix . $this->ipnSuggestSettings->categorySeparator . $currentPath; + + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment') + ]; + + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); + + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $currentPath . $this->ipnSuggestSettings->numberSeparator . $increment, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.increment') + ]; + + // Move to the next parent category + $parentCategory = $parentCategory->getParent(); + } + } elseif ($part->getID() === null) { + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $this->ipnSuggestSettings->fallbackPrefix, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.not_saved') + ]; + } + + return $ipnSuggestions; + } + + /** + * Suggests the next IPN (Internal Part Number) based on the provided part description. + * + * Searches for parts with similar descriptions and retrieves their existing IPNs to calculate the next suggestion. + * Returns null if the description is empty or no suggestion can be generated. + * + * @param string $description The part description to search for. + * + * @return string|null The suggested IPN, or null if no suggestion is possible. + * + * @throws NonUniqueResultException + */ + public function getIpnSuggestByDescription(string $description): ?string + { + if ($description === '') { + return null; + } + + $qb = $this->createQueryBuilder('part'); + + $qb->select('part') + ->where('part.description LIKE :descriptionPattern') + ->setParameter('descriptionPattern', $description.'%') + ->orderBy('part.id', 'ASC'); + + $partsBySameDescription = $qb->getQuery()->getResult(); + $givenIpnsWithSameDescription = []; + + foreach ($partsBySameDescription as $part) { + if ($part->getIpn() === null || $part->getIpn() === '') { + continue; + } + + $givenIpnsWithSameDescription[] = $part->getIpn(); + } + + return $this->getNextIpnSuggestion($givenIpnsWithSameDescription); + } + + private function generateNextPossibleGlobalIncrement(): string + { + $qb = $this->createQueryBuilder('part'); + + + $qb->select('part.ipn') + ->where('REGEXP(part.ipn, :ipnPattern) = TRUE') + ->setParameter('ipnPattern', '^' . preg_quote($this->ipnSuggestSettings->globalPrefix, '/') . '\d+$') + ->orderBy('NATSORT(part.ipn)', 'DESC') + ->setMaxResults(1) + ; + + $highestIPN = $qb->getQuery()->getOneOrNullResult(); + if ($highestIPN !== null) { + //Remove the prefix and extract the increment part + $incrementPart = substr($highestIPN['ipn'], strlen($this->ipnSuggestSettings->globalPrefix)); + //Extract a number using regex + preg_match('/(\d+)$/', $incrementPart, $matches); + $incrementInt = isset($matches[1]) ? (int) $matches[1] + 1 : 0; + } else { + $incrementInt = 1; + } + + + return str_pad((string) $incrementInt, $this->ipnSuggestSettings->suggestPartDigits, '0', STR_PAD_LEFT); + } + + /** + * Generates the next possible increment for a part within a given category, while ensuring uniqueness. + * + * This method calculates the next available increment for a part's identifier (`ipn`) based on the current path + * and the number of digits specified for the autocomplete feature. It ensures that the generated identifier + * aligns with the expected length and does not conflict with already existing identifiers in the same category. + * + * @param string $currentPath The base path or prefix for the part's identifier. + * @param Part $currentPart The part entity for which the increment is being generated. + * @param int $suggestPartDigits The number of digits reserved for the increment. + * + * @return string The next possible increment as a zero-padded string. + * + * @throws NonUniqueResultException If the query returns non-unique results. + * @throws NoResultException If the query fails to return a result. + */ + private function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $suggestPartDigits): string + { + $qb = $this->createQueryBuilder('part'); + + $expectedLength = strlen($currentPath) + strlen($this->ipnSuggestSettings->categorySeparator) + $suggestPartDigits; // Path + '-' + $suggestPartDigits digits + + // Fetch all parts in the given category, sorted by their ID in ascending order + $qb->select('part') + ->where('part.ipn LIKE :ipnPattern') + ->andWhere('LENGTH(part.ipn) = :expectedLength') + ->setParameter('ipnPattern', $currentPath . '%') + ->setParameter('expectedLength', $expectedLength) + ->orderBy('part.id', 'ASC'); + + $parts = $qb->getQuery()->getResult(); + + // Collect all used increments in the category + $usedIncrements = []; + foreach ($parts as $part) { + if ($part->getIpn() === null || $part->getIpn() === '') { + continue; + } + + if ($part->getId() === $currentPart->getId() && $currentPart->getID() !== null) { + // Extract and return the current part's increment directly + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); + if (is_numeric($incrementPart)) { + return str_pad((string) $incrementPart, $suggestPartDigits, '0', STR_PAD_LEFT); + } + } + + // Extract last $autocompletePartDigits digits for possible available part increment + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); + if (is_numeric($incrementPart)) { + $usedIncrements[] = (int) $incrementPart; + } + + } + + // Generate the next free $autocompletePartDigits-digit increment + $nextIncrement = 1; // Start at the beginning + + while (in_array($nextIncrement, $usedIncrements, true)) { + $nextIncrement++; + } + + return str_pad((string) $nextIncrement, $suggestPartDigits, '0', STR_PAD_LEFT); + } + + /** + * Generates the next IPN suggestion based on the maximum numeric suffix found in the given IPNs. + * + * The new IPN is constructed using the base format of the first provided IPN, + * incremented by the next free numeric suffix. If no base IPNs are found, + * returns null. + * + * @param array $givenIpns List of IPNs to analyze. + * + * @return string|null The next suggested IPN, or null if no base IPNs can be derived. + */ + private function getNextIpnSuggestion(array $givenIpns): ?string { + $maxSuffix = 0; + + foreach ($givenIpns as $ipn) { + // Check whether the IPN contains a suffix "_ " + if (preg_match('/_(\d+)$/', $ipn, $matches)) { + $suffix = (int)$matches[1]; + if ($suffix > $maxSuffix) { + $maxSuffix = $suffix; // Hรถchste Nummer speichern + } + } + } + + // Find the basic format (the IPN without suffix) from the first IPN + $baseIpn = $givenIpns[0] ?? ''; + $baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Remove existing "_ " + + if ($baseIpn === '') { + return null; + } + + // Generate next free possible IPN + return $baseIpn . '_' . ($maxSuffix + 1); + } + } diff --git a/src/Repository/Parts/PartCustomStateRepository.php b/src/Repository/Parts/PartCustomStateRepository.php new file mode 100644 index 00000000..d66221a2 --- /dev/null +++ b/src/Repository/Parts/PartCustomStateRepository.php @@ -0,0 +1,48 @@ +. + */ +namespace App\Repository\Parts; + +use App\Entity\Parts\PartCustomState; +use App\Repository\AbstractPartsContainingRepository; +use InvalidArgumentException; + +class PartCustomStateRepository extends AbstractPartsContainingRepository +{ + public function getParts(object $element, string $nameOrderDirection = "ASC"): array + { + if (!$element instanceof PartCustomState) { + throw new InvalidArgumentException('$element must be an PartCustomState!'); + } + + return $this->getPartsByField($element, $nameOrderDirection, 'partUnit'); + } + + public function getPartsCount(object $element): int + { + if (!$element instanceof PartCustomState) { + throw new InvalidArgumentException('$element must be an PartCustomState!'); + } + + return $this->getPartsCountByField($element, 'partUnit'); + } +} diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php index 781c7622..7fc38671 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -243,6 +243,14 @@ class StructuralDBElementRepository extends AttachmentContainingDBElementReposit return $result[0]; } + //If the name contains category delimiters like ->, try to find the element by its full path + if (str_contains($name, '->')) { + $tmp = $this->getEntityByPath($name, '->'); + if (count($tmp) > 0) { + return $tmp[count($tmp) - 1]; + } + } + //If we find nothing, return null return null; } diff --git a/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php b/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php index 9bfa691d..4d1269d6 100644 --- a/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php +++ b/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php @@ -33,6 +33,7 @@ use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; +use Webauthn\PublicKeyCredential; /** * This class decorates the Webauthn TwoFactorProvider and adds additional logic which allows us to set a last used date @@ -88,10 +89,12 @@ class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface private function getWebauthnKeyFromCode(string $authenticationCode): ?WebauthnKey { - $publicKeyCredentialLoader = $this->webauthnProvider->getPublicKeyCredentialLoader(); + $serializer = $this->webauthnProvider->getWebauthnSerializer(); //Try to load the public key credential from the code - $publicKeyCredential = $publicKeyCredentialLoader->load($authenticationCode); + $publicKeyCredential = $serializer->deserialize($authenticationCode, PublicKeyCredential::class, 'json', [ + 'json_decode_options' => JSON_THROW_ON_ERROR + ]); //Find the credential source for the given credential id $publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId); @@ -103,4 +106,4 @@ class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface return $publicKeyCredentialSource; } -} \ No newline at end of file +} diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php index 16afb37e..239a6096 100644 --- a/src/Security/UserChecker.php +++ b/src/Security/UserChecker.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Security; use App\Entity\UserSystem\User; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AccountStatusException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException; use Symfony\Component\Security\Core\User\UserCheckerInterface; @@ -51,7 +52,7 @@ final class UserChecker implements UserCheckerInterface * * @throws AccountStatusException */ - public function checkPostAuth(UserInterface $user): void + public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void { if (!$user instanceof User) { return; diff --git a/src/Security/Voter/AttachmentVoter.php b/src/Security/Voter/AttachmentVoter.php index c2b17053..df3d73a7 100644 --- a/src/Security/Voter/AttachmentVoter.php +++ b/src/Security/Voter/AttachmentVoter.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Security\Voter; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Services\UserSystem\VoterHelper; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\AttachmentContainingDBElement; @@ -41,6 +42,7 @@ use App\Entity\Attachments\UserAttachment; use RuntimeException; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use function in_array; @@ -56,7 +58,7 @@ final class AttachmentVoter extends Voter { } - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { //This voter only works for attachments @@ -65,7 +67,8 @@ final class AttachmentVoter extends Voter } if ($attribute === 'show_private') { - return $this->helper->isGranted($token, 'attachments', 'show_private'); + $vote?->addReason('User is not allowed to view private attachments.'); + return $this->helper->isGranted($token, 'attachments', 'show_private', $vote); } @@ -97,6 +100,8 @@ final class AttachmentVoter extends Voter $param = 'measurement_units'; } elseif (is_a($subject, PartAttachment::class, true)) { $param = 'parts'; + } elseif (is_a($subject, PartCustomStateAttachment::class, true)) { + $param = 'part_custom_states'; } elseif (is_a($subject, StorageLocationAttachment::class, true)) { $param = 'storelocations'; } elseif (is_a($subject, SupplierAttachment::class, true)) { @@ -111,7 +116,8 @@ final class AttachmentVoter extends Voter throw new RuntimeException('Encountered unknown Parameter type: ' . $subject); } - return $this->helper->isGranted($token, $param, $this->mapOperation($attribute)); + $vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.'); + return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote); } return false; diff --git a/src/Security/Voter/BOMEntryVoter.php b/src/Security/Voter/BOMEntryVoter.php index 121c8172..4ce40d47 100644 --- a/src/Security/Voter/BOMEntryVoter.php +++ b/src/Security/Voter/BOMEntryVoter.php @@ -27,6 +27,7 @@ use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -46,7 +47,7 @@ class BOMEntryVoter extends Voter return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class, true); } - protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool { if (!is_a($subject, ProjectBOMEntry::class, true)) { return false; @@ -87,4 +88,4 @@ class BOMEntryVoter extends Voter { return $subjectType === 'string' || is_a($subjectType, ProjectBOMEntry::class, true); } -} \ No newline at end of file +} diff --git a/src/Security/Voter/GroupVoter.php b/src/Security/Voter/GroupVoter.php index 34839d38..f2ce6953 100644 --- a/src/Security/Voter/GroupVoter.php +++ b/src/Security/Voter/GroupVoter.php @@ -25,6 +25,7 @@ namespace App\Security\Voter; use App\Entity\UserSystem\Group; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -43,9 +44,9 @@ final class GroupVoter extends Voter * * @param string $attribute */ - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { - return $this->helper->isGranted($token, 'groups', $attribute); + return $this->helper->isGranted($token, 'groups', $attribute, $vote); } /** diff --git a/src/Security/Voter/HasAccessPermissionsVoter.php b/src/Security/Voter/HasAccessPermissionsVoter.php index bd466d07..9adef977 100644 --- a/src/Security/Voter/HasAccessPermissionsVoter.php +++ b/src/Security/Voter/HasAccessPermissionsVoter.php @@ -26,6 +26,7 @@ namespace App\Security\Voter; use App\Services\UserSystem\PermissionManager; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -41,7 +42,7 @@ final class HasAccessPermissionsVoter extends Voter { } - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $user = $this->helper->resolveUser($token); return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user); @@ -56,4 +57,4 @@ final class HasAccessPermissionsVoter extends Voter { return $attribute === self::ROLE; } -} \ No newline at end of file +} diff --git a/src/Security/Voter/ImpersonateUserVoter.php b/src/Security/Voter/ImpersonateUserVoter.php index edf55c62..1f8a70c6 100644 --- a/src/Security/Voter/ImpersonateUserVoter.php +++ b/src/Security/Voter/ImpersonateUserVoter.php @@ -26,6 +26,7 @@ namespace App\Security\Voter; use App\Entity\UserSystem\User; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\User\UserInterface; @@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter && $subject instanceof UserInterface; } - protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool { - return $this->helper->isGranted($token, 'users', 'impersonate'); + $result = $this->helper->isGranted($token, 'users', 'impersonate'); + + if ($result === false) { + $vote?->addReason('User is not allowed to impersonate other users.'); + $this->helper->addReason($vote, 'users', 'impersonate'); + } + + return $result; } public function supportsAttribute(string $attribute): bool @@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter { return is_a($subjectType, User::class, true); } -} \ No newline at end of file +} diff --git a/src/Security/Voter/LabelProfileVoter.php b/src/Security/Voter/LabelProfileVoter.php index 47505bf9..1687bf45 100644 --- a/src/Security/Voter/LabelProfileVoter.php +++ b/src/Security/Voter/LabelProfileVoter.php @@ -44,6 +44,7 @@ namespace App\Security\Voter; use App\Entity\LabelSystem\LabelProfile; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter 'delete' => 'delete_profiles', 'show_history' => 'show_history', 'revert_element' => 'revert_element', + 'import' => 'import', ]; public function __construct(private readonly VoterHelper $helper) {} - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { - return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]); + return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote); } protected function supports($attribute, $subject): bool diff --git a/src/Security/Voter/LogEntryVoter.php b/src/Security/Voter/LogEntryVoter.php index 08bc3b70..dcb75a7a 100644 --- a/src/Security/Voter/LogEntryVoter.php +++ b/src/Security/Voter/LogEntryVoter.php @@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\LogSystem\AbstractLogEntry; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter { } - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $user = $this->helper->resolveUser($token); @@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter } if ('delete' === $attribute) { - return $this->helper->isGranted($token, 'system', 'delete_logs'); + return $this->helper->isGranted($token, 'system', 'delete_logs', $vote); } if ('read' === $attribute) { //Allow read of the users own log entries if ( $subject->getUser() === $user - && $this->helper->isGranted($token, 'self', 'show_logs') + && $this->helper->isGranted($token, 'self', 'show_logs', $vote) ) { return true; } - return $this->helper->isGranted($token, 'system', 'show_logs'); + return $this->helper->isGranted($token, 'system', 'show_logs', $vote); } if ('show_details' === $attribute) { diff --git a/src/Security/Voter/OrderdetailVoter.php b/src/Security/Voter/OrderdetailVoter.php index 20843b9a..3bb2a3a3 100644 --- a/src/Security/Voter/OrderdetailVoter.php +++ b/src/Security/Voter/OrderdetailVoter.php @@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\Part; use App\Entity\PriceInformations\Orderdetail; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { if (! is_a($subject, Orderdetail::class, true)) { throw new \RuntimeException('This voter can only handle Orderdetail objects!'); @@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter //If we have no part associated use the generic part permission if (is_string($subject) || !$subject->getPart() instanceof Part) { - return $this->helper->isGranted($token, 'parts', $operation); + return $this->helper->isGranted($token, 'parts', $operation, $vote); } //Otherwise vote on the part diff --git a/src/Security/Voter/ParameterVoter.php b/src/Security/Voter/ParameterVoter.php index 8ee2b9f5..5dc30ea2 100644 --- a/src/Security/Voter/ParameterVoter.php +++ b/src/Security/Voter/ParameterVoter.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\Security\Voter; +use App\Entity\Parameters\PartCustomStateParameter; use App\Services\UserSystem\VoterHelper; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractDBElement; @@ -39,6 +40,7 @@ use App\Entity\Parameters\StorageLocationParameter; use App\Entity\Parameters\SupplierParameter; use RuntimeException; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -53,7 +55,7 @@ final class ParameterVoter extends Voter { } - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { //return $this->resolver->inherit($user, 'attachments', $attribute) ?? false; @@ -96,6 +98,8 @@ final class ParameterVoter extends Voter $param = 'measurement_units'; } elseif (is_a($subject, PartParameter::class, true)) { $param = 'parts'; + } elseif (is_a($subject, PartCustomStateParameter::class, true)) { + $param = 'part_custom_states'; } elseif (is_a($subject, StorageLocationParameter::class, true)) { $param = 'storelocations'; } elseif (is_a($subject, SupplierParameter::class, true)) { @@ -108,7 +112,7 @@ final class ParameterVoter extends Voter throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject)); } - return $this->helper->isGranted($token, $param, $attribute); + return $this->helper->isGranted($token, $param, $attribute, $vote); } protected function supports(string $attribute, $subject): bool diff --git a/src/Security/Voter/PartAssociationVoter.php b/src/Security/Voter/PartAssociationVoter.php index 7678b67a..f1eb83c7 100644 --- a/src/Security/Voter/PartAssociationVoter.php +++ b/src/Security/Voter/PartAssociationVoter.php @@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\Part; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { if (!is_string($subject) && !$subject instanceof PartAssociation) { throw new \RuntimeException('Invalid subject type!'); @@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter //If we have no part associated use the generic part permission if (is_string($subject) || !$subject->getOwner() instanceof Part) { - return $this->helper->isGranted($token, 'parts', $operation); + return $this->helper->isGranted($token, 'parts', $operation, $vote); } //Otherwise vote on the part diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php index a64473c8..87c3d135 100644 --- a/src/Security/Voter/PartLotVoter.php +++ b/src/Security/Voter/PartLotVoter.php @@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -59,13 +60,13 @@ final class PartLotVoter extends Voter protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move']; - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $user = $this->helper->resolveUser($token); if (in_array($attribute, ['withdraw', 'add', 'move'], true)) { - $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute); + $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote); $lot_permission = true; //If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it. @@ -73,6 +74,10 @@ final class PartLotVoter extends Voter $lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID(); } + if (!$lot_permission) { + $vote->addReason('User is not the owner of the lot.'); + } + return $base_permission && $lot_permission; } @@ -86,7 +91,7 @@ final class PartLotVoter extends Voter //If we have no part associated use the generic part permission if (is_string($subject) || !$subject->getPart() instanceof Part) { - return $this->helper->isGranted($token, 'parts', $operation); + return $this->helper->isGranted($token, 'parts', $operation, $vote); } //Otherwise vote on the part diff --git a/src/Security/Voter/PartVoter.php b/src/Security/Voter/PartVoter.php index ef70b6ce..159e6893 100644 --- a/src/Security/Voter/PartVoter.php +++ b/src/Security/Voter/PartVoter.php @@ -25,6 +25,7 @@ namespace App\Security\Voter; use App\Entity\Parts\Part; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -52,10 +53,9 @@ final class PartVoter extends Voter return false; } - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { - //Null concealing operator means, that no - return $this->helper->isGranted($token, 'parts', $attribute); + return $this->helper->isGranted($token, 'parts', $attribute, $vote); } public function supportsAttribute(string $attribute): bool diff --git a/src/Security/Voter/PermissionVoter.php b/src/Security/Voter/PermissionVoter.php index c6ec1b3d..8c304d86 100644 --- a/src/Security/Voter/PermissionVoter.php +++ b/src/Security/Voter/PermissionVoter.php @@ -24,6 +24,7 @@ namespace App\Security\Voter; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -39,12 +40,17 @@ final class PermissionVoter extends Voter } - protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool { $attribute = ltrim($attribute, '@'); [$perm, $op] = explode('.', $attribute); - return $this->helper->isGranted($token, $perm, $op); + $result = $this->helper->isGranted($token, $perm, $op); + if ($result === false) { + $this->helper->addReason($vote, $perm, $op); + } + + return $result; } public function supportsAttribute(string $attribute): bool diff --git a/src/Security/Voter/PricedetailVoter.php b/src/Security/Voter/PricedetailVoter.php index 681b73b7..ca86f1ce 100644 --- a/src/Security/Voter/PricedetailVoter.php +++ b/src/Security/Voter/PricedetailVoter.php @@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail; use App\Entity\Parts\Part; use App\Entity\PriceInformations\Pricedetail; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** @@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element']; - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $operation = match ($attribute) { 'read' => 'read', @@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter //If we have no part associated use the generic part permission if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) { - return $this->helper->isGranted($token, 'parts', $operation); + return $this->helper->isGranted($token, 'parts', $operation, $vote); } //Otherwise vote on the part diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php index 2417b796..16d38e05 100644 --- a/src/Security/Voter/StructureVoter.php +++ b/src/Security/Voter/StructureVoter.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Security\Voter; use App\Entity\Attachments\AttachmentType; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -33,6 +34,7 @@ use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use function is_object; @@ -52,6 +54,7 @@ final class StructureVoter extends Voter Supplier::class => 'suppliers', Currency::class => 'currencies', MeasurementUnit::class => 'measurement_units', + PartCustomState::class => 'part_custom_states', ]; public function __construct(private readonly VoterHelper $helper) @@ -113,10 +116,10 @@ final class StructureVoter extends Voter * * @param string $attribute */ - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $permission_name = $this->instanceToPermissionName($subject); //Just resolve the permission - return $this->helper->isGranted($token, $permission_name, $attribute); + return $this->helper->isGranted($token, $permission_name, $attribute, $vote); } } diff --git a/src/Security/Voter/UserVoter.php b/src/Security/Voter/UserVoter.php index b41c1a40..97f8e4fb 100644 --- a/src/Security/Voter/UserVoter.php +++ b/src/Security/Voter/UserVoter.php @@ -26,6 +26,7 @@ use App\Entity\UserSystem\User; use App\Services\UserSystem\PermissionManager; use App\Services\UserSystem\VoterHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use function in_array; @@ -79,7 +80,7 @@ final class UserVoter extends Voter * * @param string $attribute */ - protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $user = $this->helper->resolveUser($token); @@ -97,7 +98,7 @@ final class UserVoter extends Voter if (($subject instanceof User) && $subject->getID() === $user->getID() && $this->helper->isValidOperation('self', $attribute)) { //Then we also need to check the self permission - $tmp = $this->helper->isGranted($token, 'self', $attribute); + $tmp = $this->helper->isGranted($token, 'self', $attribute, $vote); //But if the self value is not allowed then use just the user value: if ($tmp) { return $tmp; @@ -106,7 +107,7 @@ final class UserVoter extends Voter //Else just check user permission: if ($this->helper->isValidOperation('users', $attribute)) { - return $this->helper->isGranted($token, 'users', $attribute); + return $this->helper->isGranted($token, 'users', $attribute, $vote); } return false; diff --git a/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php b/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php index 8283dbbe..78679214 100644 --- a/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php +++ b/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace App\Serializer\APIPlatform; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; -use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -121,4 +121,4 @@ class DetermineTypeFromElementIRIDenormalizer implements DenormalizerInterface, return $tmp; } -} \ No newline at end of file +} diff --git a/src/Serializer/PartNormalizer.php b/src/Serializer/PartNormalizer.php index 9050abfc..775df77f 100644 --- a/src/Serializer/PartNormalizer.php +++ b/src/Serializer/PartNormalizer.php @@ -92,7 +92,7 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm return $data; } - public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool + public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool { //Only denormalize if we are doing a file import operation if (!($context['partdb_import'] ?? false)) { diff --git a/src/Serializer/StructuralElementDenormalizer.php b/src/Serializer/StructuralElementDenormalizer.php index d9b03ae7..9f4256f9 100644 --- a/src/Serializer/StructuralElementDenormalizer.php +++ b/src/Serializer/StructuralElementDenormalizer.php @@ -122,7 +122,7 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz return $deserialized_entity; } - public function getSupportedTypes(): array + public function getSupportedTypes(?string $format): array { //Must be false, because we use in_array in supportsDenormalization return [ diff --git a/src/Serializer/StructuralElementNormalizer.php b/src/Serializer/StructuralElementNormalizer.php index e73f69be..bf3e1097 100644 --- a/src/Serializer/StructuralElementNormalizer.php +++ b/src/Serializer/StructuralElementNormalizer.php @@ -23,20 +23,21 @@ declare(strict_types=1); namespace App\Serializer; use App\Entity\Base\AbstractStructuralDBElement; +use App\Serializer\APIPlatform\SkippableItemNormalizer; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; /** * @see \App\Tests\Serializer\StructuralElementNormalizerTest */ -class StructuralElementNormalizer implements NormalizerInterface +class StructuralElementNormalizer implements NormalizerInterface, NormalizerAwareInterface { - public function __construct( - #[Autowire(service: ObjectNormalizer::class)]private readonly NormalizerInterface $normalizer - ) - { - } + use NormalizerAwareTrait; + + public const ALREADY_CALLED = 'STRUCTURAL_ELEMENT_NORMALIZER_ALREADY_CALLED'; public function supportsNormalization($data, ?string $format = null, array $context = []): bool { @@ -45,15 +46,25 @@ class StructuralElementNormalizer implements NormalizerInterface return false; } + if (isset($context[self::ALREADY_CALLED]) && in_array($data, $context[self::ALREADY_CALLED], true)) { + //If we already handled this object, skip it + return false; + } + return $data instanceof AbstractStructuralDBElement; } - public function normalize($object, ?string $format = null, array $context = []): mixed + public function normalize($object, ?string $format = null, array $context = []): \ArrayObject|bool|float|int|string|array { if (!$object instanceof AbstractStructuralDBElement) { throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!'); } + //Avoid infinite recursion by checking if we already handled this object + $context[self::ALREADY_CALLED] = $context[self::ALREADY_CALLED] ?? []; + $context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true; + $context[self::ALREADY_CALLED][] = $object; + $data = $this->normalizer->normalize($object, $format, $context); //If the data is not an array, we can't do anything with it @@ -77,7 +88,8 @@ class StructuralElementNormalizer implements NormalizerInterface public function getSupportedTypes(?string $format): array { return [ - AbstractStructuralDBElement::class => true, + //We cannot cache the result, as it depends on the context + AbstractStructuralDBElement::class => false, ]; } } diff --git a/src/Services/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php index 60849aa0..c7e69257 100644 --- a/src/Services/Attachments/AttachmentSubmitHandler.php +++ b/src/Services/Attachments/AttachmentSubmitHandler.php @@ -30,6 +30,7 @@ use App\Entity\Attachments\AttachmentUpload; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; use App\Entity\Attachments\LabelAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; @@ -40,6 +41,7 @@ use App\Entity\Attachments\StorageLocationAttachment; use App\Entity\Attachments\SupplierAttachment; use App\Entity\Attachments\UserAttachment; use App\Exceptions\AttachmentDownloadException; +use App\Settings\SystemSettings\AttachmentsSettings; use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile; use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile; use const DIRECTORY_SEPARATOR; @@ -56,6 +58,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ class AttachmentSubmitHandler { + /** + * @var array The mapping used to determine which folder will be used for an attachment type + */ protected array $folder_mapping; private ?int $max_upload_size_bytes = null; @@ -64,16 +69,19 @@ class AttachmentSubmitHandler 'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess', 'htpasswd', '']; - public function __construct(protected AttachmentPathResolver $pathResolver, protected bool $allow_attachments_downloads, - protected HttpClientInterface $httpClient, protected MimeTypesInterface $mimeTypes, - protected FileTypeFilterTools $filterTools, /** - * @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to - */ - protected string $max_upload_size) + public function __construct( + protected AttachmentPathResolver $pathResolver, + protected HttpClientInterface $httpClient, + protected MimeTypesInterface $mimeTypes, + protected FileTypeFilterTools $filterTools, + protected AttachmentsSettings $settings, + protected readonly SVGSanitizer $SVGSanitizer, + ) { //The mapping used to determine which folder will be used for an attachment type $this->folder_mapping = [ PartAttachment::class => 'part', + PartCustomStateAttachment::class => 'part_custom_state', AttachmentTypeAttachment::class => 'attachment_type', CategoryAttachment::class => 'category', CurrencyAttachment::class => 'currency', @@ -157,6 +165,7 @@ class AttachmentSubmitHandler } else { //If not, check for instance of: foreach ($this->folder_mapping as $class => $folder) { + /** @var string $class */ if ($attachment instanceof $class) { $prefix = $folder; break; @@ -214,6 +223,9 @@ class AttachmentSubmitHandler //Move the attachment files to secure location (and back) if needed $this->moveFile($attachment, $secure_attachment); + //Sanitize the SVG if needed + $this->sanitizeSVGAttachment($attachment); + //Rename blacklisted (unsecure) files to a better extension $this->renameBlacklistedExtensions($attachment); @@ -334,7 +346,7 @@ class AttachmentSubmitHandler protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment { //Check if we are allowed to download files - if (!$this->allow_attachments_downloads) { + if (!$this->settings->allowDownloads) { throw new RuntimeException('Download of attachments is not allowed!'); } @@ -493,9 +505,37 @@ class AttachmentSubmitHandler $this->max_upload_size_bytes = min( $this->parseFileSizeString(ini_get('post_max_size')), $this->parseFileSizeString(ini_get('upload_max_filesize')), - $this->parseFileSizeString($this->max_upload_size), + $this->parseFileSizeString($this->settings->maxFileSize) ); return $this->max_upload_size_bytes; } + + /** + * Sanitizes the given SVG file, if the attachment is an internal SVG file. + * @param Attachment $attachment + * @return Attachment + */ + public function sanitizeSVGAttachment(Attachment $attachment): Attachment + { + //We can not do anything on builtins or external ressources + if ($attachment->isBuiltIn() || !$attachment->hasInternal()) { + return $attachment; + } + + //Resolve the path to the file + $path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath()); + + //Check if the file exists + if (!file_exists($path)) { + return $attachment; + } + + //Check if the file is an SVG + if ($attachment->getExtension() === "svg") { + $this->SVGSanitizer->sanitizeFile($path); + } + + return $attachment; + } } diff --git a/src/Services/Attachments/AttachmentURLGenerator.php b/src/Services/Attachments/AttachmentURLGenerator.php index c22cefe4..e505408f 100644 --- a/src/Services/Attachments/AttachmentURLGenerator.php +++ b/src/Services/Attachments/AttachmentURLGenerator.php @@ -112,12 +112,12 @@ class AttachmentURLGenerator /** * Returns a URL to a thumbnail of the attachment file. * For external files the original URL is returned. - * @return string|null The URL or null if the attachment file is not existing + * @return string|null The URL or null if the attachment file is not existing or is invalid */ public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string { if (!$attachment->isPicture()) { - throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!'); + return null; } if (!$attachment->hasInternal()){ diff --git a/src/Services/Attachments/FileTypeFilterTools.php b/src/Services/Attachments/FileTypeFilterTools.php index d689fda3..198d4f79 100644 --- a/src/Services/Attachments/FileTypeFilterTools.php +++ b/src/Services/Attachments/FileTypeFilterTools.php @@ -139,7 +139,7 @@ class FileTypeFilterTools { $filter = trim($filter); - return $this->cache->get('filter_exts_'.md5($filter), function (ItemInterface $item) use ($filter) { + return $this->cache->get('filter_exts_'.hash('xxh3', $filter), function (ItemInterface $item) use ($filter) { $elements = explode(',', $filter); $extensions = []; diff --git a/src/Services/Attachments/PartPreviewGenerator.php b/src/Services/Attachments/PartPreviewGenerator.php index ba6e5db0..9aedba74 100644 --- a/src/Services/Attachments/PartPreviewGenerator.php +++ b/src/Services/Attachments/PartPreviewGenerator.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\Attachments; use App\Entity\Parts\Footprint; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\StorageLocation; diff --git a/src/Services/Attachments/SVGSanitizer.php b/src/Services/Attachments/SVGSanitizer.php new file mode 100644 index 00000000..9ac5956b --- /dev/null +++ b/src/Services/Attachments/SVGSanitizer.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\Attachments; + +use Rhukster\DomSanitizer\DOMSanitizer; + +class SVGSanitizer +{ + + /** + * Sanitizes the given SVG string by removing any potentially harmful content (like inline scripts). + * @param string $input + * @return string + */ + public function sanitizeString(string $input): string + { + return (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($input); + } + + /** + * Sanitizes the given SVG file by removing any potentially harmful content (like inline scripts). + * The sanitized content is written back to the file. + * @param string $filepath + */ + public function sanitizeFile(string $filepath): void + { + //Open the file and read the content + $content = file_get_contents($filepath); + if ($content === false) { + throw new \RuntimeException('Could not read file: ' . $filepath); + } + //Sanitize the content + $sanitizedContent = $this->sanitizeString($content); + //Write the sanitized content back to the file + file_put_contents($filepath, $sanitizedContent); + } +} \ No newline at end of file diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index d4cbab34..4b7c5e5a 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -29,6 +29,7 @@ use App\Entity\Parts\Part; use App\Services\Cache\ElementCacheTagGenerator; use App\Services\EntityURLGenerator; use App\Services\Trees\NodesListBuilder; +use App\Settings\MiscSettings\KiCadEDASettings; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -39,6 +40,9 @@ use Symfony\Contracts\Translation\TranslatorInterface; class KiCadHelper { + /** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */ + private readonly int $category_depth; + public function __construct( private readonly NodesListBuilder $nodesListBuilder, private readonly TagAwareCacheInterface $kicadCache, @@ -47,9 +51,9 @@ class KiCadHelper private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityURLGenerator $entityURLGenerator, private readonly TranslatorInterface $translator, - /** The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */ - private readonly int $category_depth, + KiCadEDASettings $kiCadEDASettings, ) { + $this->category_depth = $kiCadEDASettings->categoryDepth; } /** @@ -229,6 +233,10 @@ class KiCadHelper } $result["fields"]["Part-DB Unit"] = $this->createField($unit); } + if ($part->getPartCustomState() !== null) { + $customState = $part->getPartCustomState()->getName(); + $result["fields"]["Part-DB Custom state"] = $this->createField($customState); + } if ($part->getMass()) { $result["fields"]["Mass"] = $this->createField($part->getMass() . ' g'); } @@ -237,6 +245,49 @@ class KiCadHelper $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn()); } + // Add supplier information from orderdetails (include obsolete orderdetails) + if ($part->getOrderdetails(false)->count() > 0) { + $supplierCounts = []; + + foreach ($part->getOrderdetails(false) as $orderdetail) { + if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { + $supplierName = $orderdetail->getSupplier()->getName(); + + $supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number + + if (!isset($supplierCounts[$supplierName])) { + $supplierCounts[$supplierName] = 0; + } + $supplierCounts[$supplierName]++; + + // Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.) + $fieldName = $supplierCounts[$supplierName] > 1 + ? $supplierName . ' ' . $supplierCounts[$supplierName] + : $supplierName; + + $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); + } + } + } + + //Add fields for KiCost: + if ($part->getManufacturer() !== null) { + $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); + } + if ($part->getManufacturerProductNumber() !== "") { + $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); + } + + //For each supplier, add a field with the supplier name and the supplier part number for KiCost + if ($part->getOrderdetails(false)->count() > 0) { + foreach ($part->getOrderdetails(false) as $orderdetail) { + if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { + $fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; + + $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); + } + } + } return $result; } diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 14247145..19bb19f5 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -22,64 +22,33 @@ declare(strict_types=1); namespace App\Services; -use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\Attachment; -use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Base\AbstractDBElement; use App\Entity\Contracts\NamedElementInterface; -use App\Entity\Parts\PartAssociation; -use App\Entity\ProjectSystem\Project; -use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; -use App\Entity\Parts\Category; -use App\Entity\Parts\Footprint; -use App\Entity\Parts\Manufacturer; -use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; -use App\Entity\Parts\StorageLocation; -use App\Entity\Parts\Supplier; -use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; +use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; -use App\Entity\UserSystem\Group; -use App\Entity\UserSystem\User; use App\Exceptions\EntityNotSupportedException; +use App\Settings\SynonymSettings; use Symfony\Contracts\Translation\TranslatorInterface; /** * @see \App\Tests\Services\ElementTypeNameGeneratorTest */ -class ElementTypeNameGenerator +final readonly class ElementTypeNameGenerator { - protected array $mapping; - public function __construct(protected TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator) + public function __construct( + private TranslatorInterface $translator, + private EntityURLGenerator $entityURLGenerator, + private SynonymSettings $synonymsSettings, + ) { - //Child classes has to become before parent classes - $this->mapping = [ - Attachment::class => $this->translator->trans('attachment.label'), - Category::class => $this->translator->trans('category.label'), - AttachmentType::class => $this->translator->trans('attachment_type.label'), - Project::class => $this->translator->trans('project.label'), - ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'), - Footprint::class => $this->translator->trans('footprint.label'), - Manufacturer::class => $this->translator->trans('manufacturer.label'), - MeasurementUnit::class => $this->translator->trans('measurement_unit.label'), - Part::class => $this->translator->trans('part.label'), - PartLot::class => $this->translator->trans('part_lot.label'), - StorageLocation::class => $this->translator->trans('storelocation.label'), - Supplier::class => $this->translator->trans('supplier.label'), - Currency::class => $this->translator->trans('currency.label'), - Orderdetail::class => $this->translator->trans('orderdetail.label'), - Pricedetail::class => $this->translator->trans('pricedetail.label'), - Group::class => $this->translator->trans('group.label'), - User::class => $this->translator->trans('user.label'), - AbstractParameter::class => $this->translator->trans('parameter.label'), - LabelProfile::class => $this->translator->trans('label_profile.label'), - PartAssociation::class => $this->translator->trans('part_association.label'), - ]; } /** @@ -93,27 +62,69 @@ class ElementTypeNameGenerator * @return string the localized label for the entity type * * @throws EntityNotSupportedException when the passed entity is not supported + * @deprecated Use label() instead */ public function getLocalizedTypeLabel(object|string $entity): string { - $class = is_string($entity) ? $entity : $entity::class; - - //Check if we have a direct array entry for our entity class, then we can use it - if (isset($this->mapping[$class])) { - return $this->mapping[$class]; - } - - //Otherwise iterate over array and check for inheritance (needed when the proxy element from doctrine are passed) - foreach ($this->mapping as $class_to_check => $translation) { - if (is_a($entity, $class_to_check, true)) { - return $translation; - } - } - - //When nothing was found throw an exception - throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', is_object($entity) ? $entity::class : (string) $entity)); + return $this->typeLabel($entity); } + private function resolveSynonymLabel(ElementTypes $type, ?string $locale, bool $plural): ?string + { + $locale ??= $this->translator->getLocale(); + + if ($this->synonymsSettings->isSynonymDefinedForType($type)) { + if ($plural) { + $syn = $this->synonymsSettings->getPluralSynonymForType($type, $locale); + } else { + $syn = $this->synonymsSettings->getSingularSynonymForType($type, $locale); + } + + if ($syn === null) { + //Try to fall back to english + if ($plural) { + $syn = $this->synonymsSettings->getPluralSynonymForType($type, 'en'); + } else { + $syn = $this->synonymsSettings->getSingularSynonymForType($type, 'en'); + } + } + + return $syn; + } + + return null; + } + + /** + * Gets a localized label for the type of the entity. If user defined synonyms are defined, + * these are used instead of the default labels. + * @param object|string $entity + * @param string|null $locale + * @return string + */ + public function typeLabel(object|string $entity, ?string $locale = null): string + { + $type = ElementTypes::fromValue($entity); + + return $this->resolveSynonymLabel($type, $locale, false) + ?? $this->translator->trans($type->getDefaultLabelKey(), locale: $locale); + } + + /** + * Similar to label(), but returns the plural version of the label. + * @param object|string $entity + * @param string|null $locale + * @return string + */ + public function typeLabelPlural(object|string $entity, ?string $locale = null): string + { + $type = ElementTypes::fromValue($entity); + + return $this->resolveSynonymLabel($type, $locale, true) + ?? $this->translator->trans($type->getDefaultPluralLabelKey(), locale: $locale); + } + + /** * Returns a string like in the format ElementType: ElementName. * For example this could be something like: "Part: BC547". @@ -128,17 +139,17 @@ class ElementTypeNameGenerator */ public function getTypeNameCombination(NamedElementInterface $entity, bool $use_html = false): string { - $type = $this->getLocalizedTypeLabel($entity); + $type = $this->typeLabel($entity); if ($use_html) { - return ''.$type.': '.htmlspecialchars($entity->getName()); + return '' . $type . ': ' . htmlspecialchars($entity->getName()); } - return $type.': '.$entity->getName(); + return $type . ': ' . $entity->getName(); } /** - * Returns a HTML formatted label for the given enitity in the format "Type: Name" (on elements with a name) and + * Returns a HTML formatted label for the given entity in the format "Type: Name" (on elements with a name) and * "Type: ID" (on elements without a name). If possible the value is given as a link to the element. * @param AbstractDBElement $entity The entity for which the label should be generated * @param bool $include_associated If set to true, the associated entity (like the part belonging to a part lot) is included in the label to give further information @@ -159,7 +170,7 @@ class ElementTypeNameGenerator } else { //Target does not have a name $tmp = sprintf( '%s: %s', - $this->getLocalizedTypeLabel($entity), + $this->typeLabel($entity), $entity->getID() ); } @@ -203,7 +214,7 @@ class ElementTypeNameGenerator { return sprintf( '%s: %s [%s]', - $this->getLocalizedTypeLabel($class), + $this->typeLabel($class), $id, $this->translator->trans('log.target_deleted') ); diff --git a/src/Services/ElementTypes.php b/src/Services/ElementTypes.php new file mode 100644 index 00000000..6ce8f851 --- /dev/null +++ b/src/Services/ElementTypes.php @@ -0,0 +1,229 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services; + +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\AttachmentType; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use App\Entity\LabelSystem\LabelProfile; +use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartAssociation; +use App\Entity\Parts\PartCustomState; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Currency; +use App\Entity\PriceInformations\Orderdetail; +use App\Entity\PriceInformations\Pricedetail; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Entity\UserSystem\Group; +use App\Entity\UserSystem\User; +use App\Exceptions\EntityNotSupportedException; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum ElementTypes: string implements TranslatableInterface +{ + case ATTACHMENT = "attachment"; + case CATEGORY = "category"; + case ATTACHMENT_TYPE = "attachment_type"; + case PROJECT = "project"; + case PROJECT_BOM_ENTRY = "project_bom_entry"; + case FOOTPRINT = "footprint"; + case MANUFACTURER = "manufacturer"; + case MEASUREMENT_UNIT = "measurement_unit"; + case PART = "part"; + case PART_LOT = "part_lot"; + case STORAGE_LOCATION = "storage_location"; + case SUPPLIER = "supplier"; + case CURRENCY = "currency"; + case ORDERDETAIL = "orderdetail"; + case PRICEDETAIL = "pricedetail"; + case GROUP = "group"; + case USER = "user"; + case PARAMETER = "parameter"; + case LABEL_PROFILE = "label_profile"; + case PART_ASSOCIATION = "part_association"; + case BULK_INFO_PROVIDER_IMPORT_JOB = "bulk_info_provider_import_job"; + case BULK_INFO_PROVIDER_IMPORT_JOB_PART = "bulk_info_provider_import_job_part"; + case PART_CUSTOM_STATE = "part_custom_state"; + + //Child classes has to become before parent classes + private const CLASS_MAPPING = [ + Attachment::class => self::ATTACHMENT, + Category::class => self::CATEGORY, + AttachmentType::class => self::ATTACHMENT_TYPE, + Project::class => self::PROJECT, + ProjectBOMEntry::class => self::PROJECT_BOM_ENTRY, + Footprint::class => self::FOOTPRINT, + Manufacturer::class => self::MANUFACTURER, + MeasurementUnit::class => self::MEASUREMENT_UNIT, + Part::class => self::PART, + PartLot::class => self::PART_LOT, + StorageLocation::class => self::STORAGE_LOCATION, + Supplier::class => self::SUPPLIER, + Currency::class => self::CURRENCY, + Orderdetail::class => self::ORDERDETAIL, + Pricedetail::class => self::PRICEDETAIL, + Group::class => self::GROUP, + User::class => self::USER, + AbstractParameter::class => self::PARAMETER, + LabelProfile::class => self::LABEL_PROFILE, + PartAssociation::class => self::PART_ASSOCIATION, + BulkInfoProviderImportJob::class => self::BULK_INFO_PROVIDER_IMPORT_JOB, + BulkInfoProviderImportJobPart::class => self::BULK_INFO_PROVIDER_IMPORT_JOB_PART, + PartCustomState::class => self::PART_CUSTOM_STATE, + ]; + + /** + * Gets the default translation key for the label of the element type (singular form). + */ + public function getDefaultLabelKey(): string + { + return match ($this) { + self::ATTACHMENT => 'attachment.label', + self::CATEGORY => 'category.label', + self::ATTACHMENT_TYPE => 'attachment_type.label', + self::PROJECT => 'project.label', + self::PROJECT_BOM_ENTRY => 'project_bom_entry.label', + self::FOOTPRINT => 'footprint.label', + self::MANUFACTURER => 'manufacturer.label', + self::MEASUREMENT_UNIT => 'measurement_unit.label', + self::PART => 'part.label', + self::PART_LOT => 'part_lot.label', + self::STORAGE_LOCATION => 'storelocation.label', + self::SUPPLIER => 'supplier.label', + self::CURRENCY => 'currency.label', + self::ORDERDETAIL => 'orderdetail.label', + self::PRICEDETAIL => 'pricedetail.label', + self::GROUP => 'group.label', + self::USER => 'user.label', + self::PARAMETER => 'parameter.label', + self::LABEL_PROFILE => 'label_profile.label', + self::PART_ASSOCIATION => 'part_association.label', + self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', + self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label', + self::PART_CUSTOM_STATE => 'part_custom_state.label', + }; + } + + public function getDefaultPluralLabelKey(): string + { + return match ($this) { + self::ATTACHMENT => 'attachment.labelp', + self::CATEGORY => 'category.labelp', + self::ATTACHMENT_TYPE => 'attachment_type.labelp', + self::PROJECT => 'project.labelp', + self::PROJECT_BOM_ENTRY => 'project_bom_entry.labelp', + self::FOOTPRINT => 'footprint.labelp', + self::MANUFACTURER => 'manufacturer.labelp', + self::MEASUREMENT_UNIT => 'measurement_unit.labelp', + self::PART => 'part.labelp', + self::PART_LOT => 'part_lot.labelp', + self::STORAGE_LOCATION => 'storelocation.labelp', + self::SUPPLIER => 'supplier.labelp', + self::CURRENCY => 'currency.labelp', + self::ORDERDETAIL => 'orderdetail.labelp', + self::PRICEDETAIL => 'pricedetail.labelp', + self::GROUP => 'group.labelp', + self::USER => 'user.labelp', + self::PARAMETER => 'parameter.labelp', + self::LABEL_PROFILE => 'label_profile.labelp', + self::PART_ASSOCIATION => 'part_association.labelp', + self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.labelp', + self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.labelp', + self::PART_CUSTOM_STATE => 'part_custom_state.labelp', + }; + } + + /** + * Used to get a user-friendly representation of the object that can be translated. + * For this the singular default label key is used. + * @param TranslatorInterface $translator + * @param string|null $locale + * @return string + */ + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return $translator->trans($this->getDefaultLabelKey(), locale: $locale); + } + + /** + * Determines the ElementType from a value, which can either be an enum value, an ElementTypes instance, a class name or an object instance. + * @param string|object $value + * @return self + */ + public static function fromValue(string|object $value): self + { + if ($value instanceof self) { + return $value; + } + if (is_object($value)) { + return self::fromClass($value); + } + + + //Otherwise try to parse it as enum value first + $enumValue = self::tryFrom($value); + + //Otherwise try to get it from class name + return $enumValue ?? self::fromClass($value); + } + + /** + * Determines the ElementType from a class name or object instance. + * @param string|object $class + * @throws EntityNotSupportedException if the class is not supported + * @return self + */ + public static function fromClass(string|object $class): self + { + if (is_object($class)) { + $className = get_class($class); + } else { + $className = $class; + } + + if (array_key_exists($className, self::CLASS_MAPPING)) { + return self::CLASS_MAPPING[$className]; + } + + //Otherwise we need to check for inheritance + foreach (self::CLASS_MAPPING as $entityClass => $elementType) { + if (is_a($className, $entityClass, true)) { + return $elementType; + } + } + + throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', $className)); + } + +} diff --git a/src/Services/EntityMergers/EntityMerger.php b/src/Services/EntityMergers/EntityMerger.php index c0be84ee..f8cf8a11 100644 --- a/src/Services/EntityMergers/EntityMerger.php +++ b/src/Services/EntityMergers/EntityMerger.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace App\Services\EntityMergers; use App\Services\EntityMergers\Mergers\EntityMergerInterface; -use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; +use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; /** * This service is used to merge two entities together. @@ -32,7 +32,7 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; */ class EntityMerger { - public function __construct(#[TaggedIterator('app.entity_merger')] protected iterable $mergers) + public function __construct(#[AutowireIterator('app.entity_merger')] protected iterable $mergers) { } @@ -73,4 +73,4 @@ class EntityMerger } return $merger->merge($target, $other, $context); } -} \ No newline at end of file +} diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php index 4ce779e8..d1f5c137 100644 --- a/src/Services/EntityMergers/Mergers/PartMerger.php +++ b/src/Services/EntityMergers/Mergers/PartMerger.php @@ -65,6 +65,7 @@ class PartMerger implements EntityMergerInterface $this->useOtherValueIfNotNull($target, $other, 'footprint'); $this->useOtherValueIfNotNull($target, $other, 'category'); $this->useOtherValueIfNotNull($target, $other, 'partUnit'); + $this->useOtherValueIfNotNull($target, $other, 'partCustomState'); //We assume that the higher value is the correct one for minimum instock $this->useLargerValue($target, $other, 'minamount'); @@ -100,7 +101,8 @@ class PartMerger implements EntityMergerInterface return $target; } - private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool { + private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool + { //We compare the translation keys, as it contains info about the type and other type info return $t->getOther() === $o->getOther() && $t->getTypeTranslationKey() === $o->getTypeTranslationKey(); @@ -141,40 +143,39 @@ class PartMerger implements EntityMergerInterface $owner->addAssociatedPartsAsOwner($clone); } + // Merge orderdetails, considering same supplier+part number as duplicates $this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) { - //First check that the orderdetails infos are equal - $tmp = $t->getSupplier() === $o->getSupplier() - && $t->getSupplierPartNr() === $o->getSupplierPartNr() - && $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false); - - if (!$tmp) { - return false; - } - - //Check if the pricedetails are equal - $t_pricedetails = $t->getPricedetails(); - $o_pricedetails = $o->getPricedetails(); - //Ensure that both pricedetails have the same length - if (count($t_pricedetails) !== count($o_pricedetails)) { - return false; - } - - //Check if all pricedetails are equal - for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) { - $t_price = $t_pricedetails->get($n); - $o_price = $o_pricedetails->get($n); - - if (!$t_price->getPrice()->isEqualTo($o_price->getPrice()) - || $t_price->getCurrency() !== $o_price->getCurrency() - || $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity() - || $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity() - ) { - return false; + // If supplier and part number match, merge the orderdetails + if ($t->getSupplier() === $o->getSupplier() && $t->getSupplierPartNr() === $o->getSupplierPartNr()) { + // Update URL if target doesn't have one + if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) { + $t->setSupplierProductUrl($o->getSupplierProductUrl(false)); } + // Merge price details: add new ones, update empty ones, keep existing non-empty ones + foreach ($o->getPricedetails() as $otherPrice) { + $found = false; + foreach ($t->getPricedetails() as $targetPrice) { + if ($targetPrice->getMinDiscountQuantity() === $otherPrice->getMinDiscountQuantity() + && $targetPrice->getCurrency() === $otherPrice->getCurrency()) { + // Only update price if the existing one is zero/empty (most logical) + if ($targetPrice->getPrice()->isZero()) { + $targetPrice->setPrice($otherPrice->getPrice()); + $targetPrice->setPriceRelatedQuantity($otherPrice->getPriceRelatedQuantity()); + } + $found = true; + break; + } + } + // Add completely new price tiers + if (!$found) { + $clonedPrice = clone $otherPrice; + $clonedPrice->setOrderdetail($t); + $t->addPricedetail($clonedPrice); + } + } + return true; // Consider them equal so the other one gets skipped } - - //If all pricedetails are equal, the orderdetails are equal - return true; + return false; // Different supplier/part number, add as new }); //The pricedetails are not correctly assigned to the new orderdetails, so fix that foreach ($target->getOrderdetails() as $orderdetail) { diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php index 78db06f0..91e271cc 100644 --- a/src/Services/EntityURLGenerator.php +++ b/src/Services/EntityURLGenerator.php @@ -27,6 +27,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\PartAttachment; use App\Entity\Base\AbstractDBElement; use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; @@ -107,6 +108,7 @@ class EntityURLGenerator MeasurementUnit::class => 'measurement_unit_edit', Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', + PartCustomState::class => 'part_custom_state_edit', ]; try { @@ -213,6 +215,7 @@ class EntityURLGenerator MeasurementUnit::class => 'measurement_unit_edit', Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', + PartCustomState::class => 'part_custom_state_edit', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -243,6 +246,7 @@ class EntityURLGenerator MeasurementUnit::class => 'measurement_unit_edit', Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', + PartCustomState::class => 'part_custom_state_edit', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -274,6 +278,7 @@ class EntityURLGenerator MeasurementUnit::class => 'measurement_unit_new', Group::class => 'group_new', LabelProfile::class => 'label_profile_new', + PartCustomState::class => 'part_custom_state_new', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity)); @@ -305,6 +310,7 @@ class EntityURLGenerator MeasurementUnit::class => 'measurement_unit_clone', Group::class => 'group_clone', LabelProfile::class => 'label_profile_clone', + PartCustomState::class => 'part_custom_state_clone', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -350,6 +356,7 @@ class EntityURLGenerator MeasurementUnit::class => 'measurement_unit_delete', Group::class => 'group_delete', LabelProfile::class => 'label_profile_delete', + PartCustomState::class => 'part_custom_state_delete', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); diff --git a/src/Services/Formatters/MoneyFormatter.php b/src/Services/Formatters/MoneyFormatter.php index 44a49cb5..505752c3 100644 --- a/src/Services/Formatters/MoneyFormatter.php +++ b/src/Services/Formatters/MoneyFormatter.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\Formatters; use App\Entity\PriceInformations\Currency; +use App\Settings\SystemSettings\LocalizationSettings; use Locale; use NumberFormatter; @@ -30,7 +31,7 @@ class MoneyFormatter { protected string $locale; - public function __construct(protected string $base_currency) + public function __construct(private readonly LocalizationSettings $localizationSettings) { $this->locale = Locale::getDefault(); } @@ -45,7 +46,7 @@ class MoneyFormatter */ public function format(string|float $value, ?Currency $currency = null, int $decimals = 5, bool $show_all_digits = false): string { - $iso_code = $this->base_currency; + $iso_code = $this->localizationSettings->baseCurrency; if ($currency instanceof Currency && ($currency->getIsoCode() !== '')) { $iso_code = $currency->getIsoCode(); } diff --git a/src/Services/Formatters/SIFormatter.php b/src/Services/Formatters/SIFormatter.php index a6325987..b83501fa 100644 --- a/src/Services/Formatters/SIFormatter.php +++ b/src/Services/Formatters/SIFormatter.php @@ -38,6 +38,11 @@ class SIFormatter */ public function getMagnitude(float $value): int { + //Check for zero, as log10(0) is undefined/gives -infinity, which leads to casting issues in PHP8.5+ + if (0.0 === $value) { + return 0; + } + return (int) floor(log10(abs($value))); } diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index d4876445..e511c04d 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -22,10 +22,13 @@ declare(strict_types=1); */ namespace App\Services\ImportExportSystem; +use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; +use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use League\Csv\Reader; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -44,14 +47,25 @@ class BOMImporter 5 => 'Supplier and ref', ]; - public function __construct() - { + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, + private readonly BOMValidationService $validationService + ) { } protected function configureOptions(OptionsResolver $resolver): OptionsResolver { $resolver->setRequired('type'); - $resolver->setAllowedValues('type', ['kicad_pcbnew']); + $resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']); + + // For flexible schematic import with field mapping + $resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']); + $resolver->setDefault('delimiter', ','); + $resolver->setDefault('field_priorities', []); + $resolver->setAllowedTypes('field_mapping', 'array'); + $resolver->setAllowedTypes('field_priorities', 'array'); + $resolver->setAllowedTypes('delimiter', 'string'); return $resolver; } @@ -82,6 +96,23 @@ class BOMImporter return $this->stringToBOMEntries($file->getContent(), $options); } + /** + * Validate BOM data before importing + * @return array Validation result with errors, warnings, and info + */ + public function validateBOMData(string $data, array $options): array + { + $resolver = new OptionsResolver(); + $resolver = $this->configureOptions($resolver); + $options = $resolver->resolve($options); + + return match ($options['type']) { + 'kicad_pcbnew' => $this->validateKiCADPCB($data), + 'kicad_schematic' => $this->validateKiCADSchematicData($data, $options), + default => throw new InvalidArgumentException('Invalid import type!'), + }; + } + /** * Import string data into an array of BOM entries, which are not yet assigned to a project. * @param string $data The data to import @@ -95,14 +126,15 @@ class BOMImporter $options = $resolver->resolve($options); return match ($options['type']) { - 'kicad_pcbnew' => $this->parseKiCADPCB($data, $options), + 'kicad_pcbnew' => $this->parseKiCADPCB($data), + 'kicad_schematic' => $this->parseKiCADSchematic($data, $options), default => throw new InvalidArgumentException('Invalid import type!'), }; } - private function parseKiCADPCB(string $data, array $options = []): array + private function parseKiCADPCB(string $data): array { - $csv = Reader::createFromString($data); + $csv = Reader::fromString($data); $csv->setDelimiter(';'); $csv->setHeaderOffset(0); @@ -113,17 +145,17 @@ class BOMImporter $entry = $this->normalizeColumnNames($entry); //Ensure that the entry has all required fields - if (!isset ($entry['Designator'])) { - throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!'); + if (!isset($entry['Designator'])) { + throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!'); } - if (!isset ($entry['Package'])) { - throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!'); + if (!isset($entry['Package'])) { + throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!'); } - if (!isset ($entry['Designation'])) { - throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!'); + if (!isset($entry['Designation'])) { + throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!'); } - if (!isset ($entry['Quantity'])) { - throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!'); + if (!isset($entry['Quantity'])) { + throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!'); } $bom_entry = new ProjectBOMEntry(); @@ -138,6 +170,63 @@ class BOMImporter return $bom_entries; } + /** + * Validate KiCad PCB data + */ + private function validateKiCADPCB(string $data): array + { + $csv = Reader::fromString($data); + $csv->setDelimiter(';'); + $csv->setHeaderOffset(0); + + $mapped_entries = []; + + foreach ($csv->getRecords() as $offset => $entry) { + // Translate the german field names to english + $entry = $this->normalizeColumnNames($entry); + $mapped_entries[] = $entry; + } + + return $this->validationService->validateBOMEntries($mapped_entries); + } + + /** + * Validate KiCad schematic data + */ + private function validateKiCADSchematicData(string $data, array $options): array + { + $delimiter = $options['delimiter'] ?? ','; + $field_mapping = $options['field_mapping'] ?? []; + $field_priorities = $options['field_priorities'] ?? []; + + // Handle potential BOM (Byte Order Mark) at the beginning + $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); + + $csv = Reader::fromString($data); + $csv->setDelimiter($delimiter); + $csv->setHeaderOffset(0); + + // Handle quoted fields properly + $csv->setEscape('\\'); + $csv->setEnclosure('"'); + + $mapped_entries = []; + + foreach ($csv->getRecords() as $offset => $entry) { + // Apply field mapping to translate column names + $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities); + + // Extract footprint package name if it contains library prefix + if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) { + $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1]; + } + + $mapped_entries[] = $mapped_entry; + } + + return $this->validationService->validateBOMEntries($mapped_entries, $options); + } + /** * This function uses the order of the fields in the CSV files to make them locale independent. * @param array $entry @@ -160,4 +249,482 @@ class BOMImporter return $out; } + + /** + * Parse KiCad schematic BOM with flexible field mapping + */ + private function parseKiCADSchematic(string $data, array $options = []): array + { + $delimiter = $options['delimiter'] ?? ','; + $field_mapping = $options['field_mapping'] ?? []; + $field_priorities = $options['field_priorities'] ?? []; + + // Handle potential BOM (Byte Order Mark) at the beginning + $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); + + $csv = Reader::fromString($data); + $csv->setDelimiter($delimiter); + $csv->setHeaderOffset(0); + + // Handle quoted fields properly + $csv->setEscape('\\'); + $csv->setEnclosure('"'); + + $bom_entries = []; + $entries_by_key = []; // Track entries by name+part combination + $mapped_entries = []; // Collect all mapped entries for validation + + foreach ($csv->getRecords() as $offset => $entry) { + // Apply field mapping to translate column names + $mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities); + + // Extract footprint package name if it contains library prefix + if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) { + $mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1]; + } + + $mapped_entries[] = $mapped_entry; + } + + // Validate all entries before processing + $validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options); + + // Log validation results + $this->logger->info('BOM import validation completed', [ + 'total_entries' => $validation_result['total_entries'], + 'valid_entries' => $validation_result['valid_entries'], + 'invalid_entries' => $validation_result['invalid_entries'], + 'error_count' => count($validation_result['errors']), + 'warning_count' => count($validation_result['warnings']), + ]); + + // If there are validation errors, throw an exception with detailed messages + if (!empty($validation_result['errors'])) { + $error_message = $this->validationService->getErrorMessage($validation_result); + throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message); + } + + // Process validated entries + foreach ($mapped_entries as $offset => $mapped_entry) { + + // Set name - prefer MPN, fall back to Value, then default format + $mpn = trim($mapped_entry['MPN'] ?? ''); + $designation = trim($mapped_entry['Designation'] ?? ''); + $value = trim($mapped_entry['Value'] ?? ''); + + // Use the first non-empty value, or 'Unknown Component' if all are empty + $name = ''; + if (!empty($mpn)) { + $name = $mpn; + } elseif (!empty($designation)) { + $name = $designation; + } elseif (!empty($value)) { + $name = $value; + } else { + $name = 'Unknown Component'; + } + + if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) { + $name .= ' (' . trim($mapped_entry['Package']) . ')'; + } + + // Set mountnames and quantity + // The Designator field contains comma-separated mount names for all instances + $designator = trim($mapped_entry['Designator']); + $quantity = (float) $mapped_entry['Quantity']; + + // Get mountnames array (validation already ensured they match quantity) + $mountnames_array = array_map('trim', explode(',', $designator)); + + // Try to link existing Part-DB part if ID is provided + $part = null; + if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) { + $partDbId = (int) $mapped_entry['Part-DB ID']; + $existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId); + + if ($existingPart) { + $part = $existingPart; + // Update name with actual part name + $name = $existingPart->getName(); + } + } + + // Create unique key for this entry (name + part ID) + $entry_key = $name . '|' . ($part ? $part->getID() : 'null'); + + // Check if we already have an entry with the same name and part + if (isset($entries_by_key[$entry_key])) { + // Merge with existing entry + $existing_entry = $entries_by_key[$entry_key]; + + // Combine mountnames + $existing_mountnames = $existing_entry->getMountnames(); + $combined_mountnames = $existing_mountnames . ',' . $designator; + $existing_entry->setMountnames($combined_mountnames); + + // Add quantities + $existing_quantity = $existing_entry->getQuantity(); + $existing_entry->setQuantity($existing_quantity + $quantity); + + $this->logger->info('Merged duplicate BOM entry', [ + 'name' => $name, + 'part_id' => $part ? $part->getID() : null, + 'original_quantity' => $existing_quantity, + 'added_quantity' => $quantity, + 'new_quantity' => $existing_quantity + $quantity, + 'original_mountnames' => $existing_mountnames, + 'added_mountnames' => $designator, + ]); + + continue; // Skip creating new entry + } + + // Create new BOM entry + $bom_entry = new ProjectBOMEntry(); + $bom_entry->setName($name); + $bom_entry->setMountnames($designator); + $bom_entry->setQuantity($quantity); + + if ($part) { + $bom_entry->setPart($part); + } + + // Set comment with additional info + $comment_parts = []; + if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) { + $comment_parts[] = 'Value: ' . $mapped_entry['Value']; + } + if (isset($mapped_entry['MPN'])) { + $comment_parts[] = 'MPN: ' . $mapped_entry['MPN']; + } + if (isset($mapped_entry['Manufacturer'])) { + $comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer']; + } + if (isset($mapped_entry['LCSC'])) { + $comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC']; + } + if (isset($mapped_entry['Supplier and ref'])) { + $comment_parts[] = $mapped_entry['Supplier and ref']; + } + + if ($part) { + $comment_parts[] = "Part-DB ID: " . $part->getID(); + } elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) { + $comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)"; + } + + $bom_entry->setComment(implode(', ', $comment_parts)); + + $bom_entries[] = $bom_entry; + $entries_by_key[$entry_key] = $bom_entry; + } + + return $bom_entries; + } + + /** + * Get all available field mapping targets with descriptions + */ + public function getAvailableFieldTargets(): array + { + $targets = [ + 'Designator' => [ + 'label' => 'Designator', + 'description' => 'Component reference designators (e.g., R1, C2, U3)', + 'required' => true, + 'multiple' => false, + ], + 'Quantity' => [ + 'label' => 'Quantity', + 'description' => 'Number of components', + 'required' => true, + 'multiple' => false, + ], + 'Designation' => [ + 'label' => 'Designation', + 'description' => 'Component designation/part number', + 'required' => false, + 'multiple' => true, + ], + 'Value' => [ + 'label' => 'Value', + 'description' => 'Component value (e.g., 10k, 100nF)', + 'required' => false, + 'multiple' => true, + ], + 'Package' => [ + 'label' => 'Package', + 'description' => 'Component package/footprint', + 'required' => false, + 'multiple' => true, + ], + 'MPN' => [ + 'label' => 'MPN', + 'description' => 'Manufacturer Part Number', + 'required' => false, + 'multiple' => true, + ], + 'Manufacturer' => [ + 'label' => 'Manufacturer', + 'description' => 'Component manufacturer name', + 'required' => false, + 'multiple' => true, + ], + 'Part-DB ID' => [ + 'label' => 'Part-DB ID', + 'description' => 'Existing Part-DB part ID for linking', + 'required' => false, + 'multiple' => false, + ], + 'Comment' => [ + 'label' => 'Comment', + 'description' => 'Additional component information', + 'required' => false, + 'multiple' => true, + ], + ]; + + // Add dynamic supplier fields based on available suppliers in the database + $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); + foreach ($suppliers as $supplier) { + $supplierName = $supplier->getName(); + $targets[$supplierName . ' SPN'] = [ + 'label' => $supplierName . ' SPN', + 'description' => "Supplier part number for {$supplierName}", + 'required' => false, + 'multiple' => true, + 'supplier_id' => $supplier->getID(), + ]; + } + + return $targets; + } + + /** + * Get suggested field mappings based on common field names + */ + public function getSuggestedFieldMapping(array $detected_fields): array + { + $suggestions = []; + + $field_patterns = [ + 'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'], + 'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'], + 'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'], + 'Value' => ['value', 'val', 'component_value'], + 'Designation' => ['designation', 'part_number', 'partnumber', 'part'], + 'Package' => ['footprint', 'package', 'housing', 'fp'], + 'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'], + 'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'], + 'Comment' => ['comment', 'comments', 'note', 'notes', 'description'], + ]; + + // Add supplier-specific patterns + $suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll(); + foreach ($suppliers as $supplier) { + $supplierName = $supplier->getName(); + $supplierLower = strtolower($supplierName); + + // Create patterns for each supplier + $field_patterns[$supplierName . ' SPN'] = [ + $supplierLower, + $supplierLower . '#', + $supplierLower . '_part', + $supplierLower . '_number', + $supplierLower . 'pn', + $supplierLower . '_spn', + $supplierLower . ' spn', + // Common abbreviations + $supplierLower === 'mouser' ? 'mouser' : null, + $supplierLower === 'digikey' ? 'dk' : null, + $supplierLower === 'farnell' ? 'farnell' : null, + $supplierLower === 'rs' ? 'rs' : null, + $supplierLower === 'lcsc' ? 'lcsc' : null, + ]; + + // Remove null values + $field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null); + } + + foreach ($detected_fields as $field) { + $field_lower = strtolower(trim($field)); + + foreach ($field_patterns as $target => $patterns) { + foreach ($patterns as $pattern) { + if (str_contains($field_lower, $pattern)) { + $suggestions[$field] = $target; + break 2; // Break both loops + } + } + } + } + + return $suggestions; + } + + /** + * Validate field mapping configuration + */ + public function validateFieldMapping(array $field_mapping, array $detected_fields): array + { + $errors = []; + $warnings = []; + $available_targets = $this->getAvailableFieldTargets(); + + // Check for required fields + $mapped_targets = array_values($field_mapping); + $required_fields = ['Designator', 'Quantity']; + + foreach ($required_fields as $required) { + if (!in_array($required, $mapped_targets, true)) { + $errors[] = "Required field '{$required}' is not mapped from any CSV column."; + } + } + + // Check for invalid target fields + foreach ($field_mapping as $csv_field => $target) { + if (!empty($target) && !isset($available_targets[$target])) { + $errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'."; + } + } + + // Check for unmapped fields (warnings) + $unmapped_fields = array_diff($detected_fields, array_keys($field_mapping)); + if (!empty($unmapped_fields)) { + $warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields); + } + + return [ + 'errors' => $errors, + 'warnings' => $warnings, + 'is_valid' => empty($errors), + ]; + } + + /** + * Apply field mapping with support for multiple fields and priority + */ + private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array + { + $mapped = []; + $field_groups = []; + + // Group fields by target with priority information + foreach ($field_mapping as $csv_field => $target) { + if (!empty($target)) { + if (!isset($field_groups[$target])) { + $field_groups[$target] = []; + } + $priority = $field_priorities[$csv_field] ?? 10; + $field_groups[$target][] = [ + 'field' => $csv_field, + 'priority' => $priority, + 'value' => $entry[$csv_field] ?? '' + ]; + } + } + + // Process each target field + foreach ($field_groups as $target => $field_data) { + // Sort by priority (lower number = higher priority) + usort($field_data, function ($a, $b) { + return $a['priority'] <=> $b['priority']; + }); + + $values = []; + $non_empty_values = []; + + // Collect all non-empty values for this target + foreach ($field_data as $data) { + $value = trim($data['value']); + if (!empty($value)) { + $non_empty_values[] = $value; + } + $values[] = $value; + } + + // Use the first non-empty value (highest priority) + if (!empty($non_empty_values)) { + $mapped[$target] = $non_empty_values[0]; + + // If multiple non-empty values exist, add alternatives to comment + if (count($non_empty_values) > 1) { + $mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1); + } + } + } + + return $mapped; + } + + /** + * Detect available fields in CSV data for field mapping UI + */ + public function detectFields(string $data, ?string $delimiter = null): array + { + if ($delimiter === null) { + // Detect delimiter by counting occurrences in the first row (header) + $delimiters = [',', ';', "\t"]; + $lines = explode("\n", $data, 2); + $header_line = $lines[0] ?? ''; + $delimiter_counts = []; + foreach ($delimiters as $delim) { + $delimiter_counts[$delim] = substr_count($header_line, $delim); + } + // Choose the delimiter with the highest count, default to comma if all are zero + $max_count = max($delimiter_counts); + $delimiter = array_search($max_count, $delimiter_counts, true); + if ($max_count === 0 || $delimiter === false) { + $delimiter = ','; + } + } + // Handle potential BOM (Byte Order Mark) at the beginning + $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); + + // Get first line only for header detection + $lines = explode("\n", $data); + $header_line = trim($lines[0] ?? ''); + + + // Simple manual parsing for header detection + // This handles quoted CSV fields better than the library for detection + $fields = []; + $current_field = ''; + $in_quotes = false; + $quote_char = '"'; + + for ($i = 0; $i < strlen($header_line); $i++) { + $char = $header_line[$i]; + + if ($char === $quote_char && !$in_quotes) { + $in_quotes = true; + } elseif ($char === $quote_char && $in_quotes) { + // Check for escaped quote (double quote) + if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) { + $current_field .= $quote_char; + $i++; // Skip next quote + } else { + $in_quotes = false; + } + } elseif ($char === $delimiter && !$in_quotes) { + $fields[] = trim($current_field); + $current_field = ''; + } else { + $current_field .= $char; + } + } + + // Add the last field + if ($current_field !== '') { + $fields[] = trim($current_field); + } + + // Clean up headers - remove quotes and trim whitespace + $headers = array_map(function ($header) { + return trim($header, '"\''); + }, $fields); + + + return array_values($headers); + } } diff --git a/src/Services/ImportExportSystem/BOMValidationService.php b/src/Services/ImportExportSystem/BOMValidationService.php new file mode 100644 index 00000000..74f81fe3 --- /dev/null +++ b/src/Services/ImportExportSystem/BOMValidationService.php @@ -0,0 +1,476 @@ +. + */ +namespace App\Services\ImportExportSystem; + +use App\Entity\Parts\Part; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Service for validating BOM import data with comprehensive validation rules + * and user-friendly error messages. + */ +class BOMValidationService +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly TranslatorInterface $translator + ) { + } + + /** + * Validation result structure + */ + public static function createValidationResult(): array + { + return [ + 'errors' => [], + 'warnings' => [], + 'info' => [], + 'is_valid' => true, + 'total_entries' => 0, + 'valid_entries' => 0, + 'invalid_entries' => 0, + ]; + } + + /** + * Validate a single BOM entry with comprehensive checks + */ + public function validateBOMEntry(array $mapped_entry, int $line_number, array $options = []): array + { + $result = [ + 'line_number' => $line_number, + 'errors' => [], + 'warnings' => [], + 'info' => [], + 'is_valid' => true, + ]; + + // Run all validation rules + $this->validateRequiredFields($mapped_entry, $result); + $this->validateDesignatorFormat($mapped_entry, $result); + $this->validateQuantityFormat($mapped_entry, $result); + $this->validateDesignatorQuantityMatch($mapped_entry, $result); + $this->validatePartDBLink($mapped_entry, $result); + $this->validateComponentName($mapped_entry, $result); + $this->validatePackageFormat($mapped_entry, $result); + $this->validateNumericFields($mapped_entry, $result); + + $result['is_valid'] = empty($result['errors']); + + return $result; + } + + /** + * Validate multiple BOM entries and provide summary + */ + public function validateBOMEntries(array $mapped_entries, array $options = []): array + { + $result = self::createValidationResult(); + $result['total_entries'] = count($mapped_entries); + + $line_results = []; + $all_errors = []; + $all_warnings = []; + $all_info = []; + + foreach ($mapped_entries as $index => $entry) { + $line_number = $index + 1; + $line_result = $this->validateBOMEntry($entry, $line_number, $options); + + $line_results[] = $line_result; + + if ($line_result['is_valid']) { + $result['valid_entries']++; + } else { + $result['invalid_entries']++; + } + + // Collect all messages + $all_errors = array_merge($all_errors, $line_result['errors']); + $all_warnings = array_merge($all_warnings, $line_result['warnings']); + $all_info = array_merge($all_info, $line_result['info']); + } + + // Add summary messages + $this->addSummaryMessages($result, $all_errors, $all_warnings, $all_info); + + $result['errors'] = $all_errors; + $result['warnings'] = $all_warnings; + $result['info'] = $all_info; + $result['line_results'] = $line_results; + $result['is_valid'] = empty($all_errors); + + return $result; + } + + /** + * Validate required fields are present + */ + private function validateRequiredFields(array $entry, array &$result): void + { + $required_fields = ['Designator', 'Quantity']; + + foreach ($required_fields as $field) { + if (!isset($entry[$field]) || trim($entry[$field]) === '') { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.required_field_missing', [ + '%line%' => $result['line_number'], + '%field%' => $field + ]); + } + } + } + + /** + * Validate designator format and content + */ + private function validateDesignatorFormat(array $entry, array &$result): void + { + if (!isset($entry['Designator']) || trim($entry['Designator']) === '') { + return; // Already handled by required fields validation + } + + $designator = trim($entry['Designator']); + $mountnames = array_map('trim', explode(',', $designator)); + + // Remove empty entries + $mountnames = array_filter($mountnames, fn($name) => !empty($name)); + + if (empty($mountnames)) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.no_valid_designators', [ + '%line%' => $result['line_number'] + ]); + return; + } + + // Validate each mountname format (allow 1-2 uppercase letters, followed by 1+ digits) + $invalid_mountnames = []; + foreach ($mountnames as $mountname) { + if (!preg_match('/^[A-Z]{1,2}[0-9]+$/', $mountname)) { + $invalid_mountnames[] = $mountname; + } + } + + if (!empty($invalid_mountnames)) { + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.unusual_designator_format', [ + '%line%' => $result['line_number'], + '%designators%' => implode(', ', $invalid_mountnames) + ]); + } + + // Check for duplicate mountnames within the same line + $duplicates = array_diff_assoc($mountnames, array_unique($mountnames)); + if (!empty($duplicates)) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.duplicate_designators', [ + '%line%' => $result['line_number'], + '%designators%' => implode(', ', array_unique($duplicates)) + ]); + } + } + + /** + * Validate quantity format and value + */ + private function validateQuantityFormat(array $entry, array &$result): void + { + if (!isset($entry['Quantity']) || trim($entry['Quantity']) === '') { + return; // Already handled by required fields validation + } + + $quantity_str = trim($entry['Quantity']); + + // Check if it's a valid number + if (!is_numeric($quantity_str)) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_quantity', [ + '%line%' => $result['line_number'], + '%quantity%' => $quantity_str + ]); + return; + } + + $quantity = (float) $quantity_str; + + // Check for reasonable quantity values + if ($quantity <= 0) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_zero_or_negative', [ + '%line%' => $result['line_number'], + '%quantity%' => $quantity_str + ]); + } elseif ($quantity > 10000) { + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_unusually_high', [ + '%line%' => $result['line_number'], + '%quantity%' => $quantity_str + ]); + } + + // Check if quantity is a whole number when it should be + if (isset($entry['Designator'])) { + $designator = trim($entry['Designator']); + $mountnames = array_map('trim', explode(',', $designator)); + $mountnames = array_filter($mountnames, fn($name) => !empty($name)); + + if (count($mountnames) > 0 && $quantity != (int) $quantity) { + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_not_whole_number', [ + '%line%' => $result['line_number'], + '%quantity%' => $quantity_str, + '%count%' => count($mountnames) + ]); + } + } + } + + /** + * Validate that designator count matches quantity + */ + private function validateDesignatorQuantityMatch(array $entry, array &$result): void + { + if (!isset($entry['Designator']) || !isset($entry['Quantity'])) { + return; // Already handled by required fields validation + } + + $designator = trim($entry['Designator']); + $quantity_str = trim($entry['Quantity']); + + if (!is_numeric($quantity_str)) { + return; // Already handled by quantity validation + } + + $mountnames = array_map('trim', explode(',', $designator)); + $mountnames = array_filter($mountnames, fn($name) => !empty($name)); + $mountnames_count = count($mountnames); + $quantity = (float) $quantity_str; + + if ($mountnames_count !== (int) $quantity) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_designator_mismatch', [ + '%line%' => $result['line_number'], + '%quantity%' => $quantity_str, + '%count%' => $mountnames_count, + '%designators%' => $designator + ]); + } + } + + /** + * Validate Part-DB ID link + */ + private function validatePartDBLink(array $entry, array &$result): void + { + if (!isset($entry['Part-DB ID']) || trim($entry['Part-DB ID']) === '') { + return; + } + + $part_db_id = trim($entry['Part-DB ID']); + + if (!is_numeric($part_db_id)) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_partdb_id', [ + '%line%' => $result['line_number'], + '%id%' => $part_db_id + ]); + return; + } + + $part_id = (int) $part_db_id; + + if ($part_id <= 0) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.partdb_id_zero_or_negative', [ + '%line%' => $result['line_number'], + '%id%' => $part_id + ]); + return; + } + + // Check if part exists in database + $existing_part = $this->entityManager->getRepository(Part::class)->find($part_id); + if (!$existing_part) { + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.partdb_id_not_found', [ + '%line%' => $result['line_number'], + '%id%' => $part_id + ]); + } else { + $result['info'][] = $this->translator->trans('project.bom_import.validation.info.partdb_link_success', [ + '%line%' => $result['line_number'], + '%name%' => $existing_part->getName(), + '%id%' => $part_id + ]); + } + } + + /** + * Validate component name/designation + */ + private function validateComponentName(array $entry, array &$result): void + { + $name_fields = ['MPN', 'Designation', 'Value']; + $has_name = false; + + foreach ($name_fields as $field) { + if (isset($entry[$field]) && trim($entry[$field]) !== '') { + $has_name = true; + break; + } + } + + if (!$has_name) { + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.no_component_name', [ + '%line%' => $result['line_number'] + ]); + } + } + + /** + * Validate package format + */ + private function validatePackageFormat(array $entry, array &$result): void + { + if (!isset($entry['Package']) || trim($entry['Package']) === '') { + return; + } + + $package = trim($entry['Package']); + + // Check for common package format issues + if (strlen($package) > 100) { + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.package_name_too_long', [ + '%line%' => $result['line_number'], + '%package%' => $package + ]); + } + + // Check for library prefixes (KiCad format) + if (str_contains($package, ':')) { + $result['info'][] = $this->translator->trans('project.bom_import.validation.info.library_prefix_detected', [ + '%line%' => $result['line_number'], + '%package%' => $package + ]); + } + } + + /** + * Validate numeric fields + */ + private function validateNumericFields(array $entry, array &$result): void + { + $numeric_fields = ['Quantity', 'Part-DB ID']; + + foreach ($numeric_fields as $field) { + if (isset($entry[$field]) && trim($entry[$field]) !== '') { + $value = trim($entry[$field]); + if (!is_numeric($value)) { + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.non_numeric_field', [ + '%line%' => $result['line_number'], + '%field%' => $field, + '%value%' => $value + ]); + } + } + } + } + + /** + * Add summary messages to validation result + */ + private function addSummaryMessages(array &$result, array $errors, array $warnings, array $info): void + { + $total_entries = $result['total_entries']; + $valid_entries = $result['valid_entries']; + $invalid_entries = $result['invalid_entries']; + + // Add summary info + if ($total_entries > 0) { + $result['info'][] = $this->translator->trans('project.bom_import.validation.info.import_summary', [ + '%total%' => $total_entries, + '%valid%' => $valid_entries, + '%invalid%' => $invalid_entries + ]); + } + + // Add error summary + if (!empty($errors)) { + $error_count = count($errors); + $result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.summary', [ + '%count%' => $error_count + ]); + } + + // Add warning summary + if (!empty($warnings)) { + $warning_count = count($warnings); + $result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.summary', [ + '%count%' => $warning_count + ]); + } + + // Add success message if all entries are valid + if ($total_entries > 0 && $invalid_entries === 0) { + $result['info'][] = $this->translator->trans('project.bom_import.validation.info.all_valid'); + } + } + + /** + * Get user-friendly error message for a validation result + */ + public function getErrorMessage(array $validation_result): string + { + if ($validation_result['is_valid']) { + return ''; + } + + $messages = []; + + if (!empty($validation_result['errors'])) { + $messages[] = 'Errors:'; + foreach ($validation_result['errors'] as $error) { + $messages[] = 'โ€ข ' . $error; + } + } + + if (!empty($validation_result['warnings'])) { + $messages[] = 'Warnings:'; + foreach ($validation_result['warnings'] as $warning) { + $messages[] = 'โ€ข ' . $warning; + } + } + + return implode("\n", $messages); + } + + /** + * Get validation statistics + */ + public function getValidationStats(array $validation_result): array + { + return [ + 'total_entries' => $validation_result['total_entries'] ?? 0, + 'valid_entries' => $validation_result['valid_entries'] ?? 0, + 'invalid_entries' => $validation_result['invalid_entries'] ?? 0, + 'error_count' => count($validation_result['errors'] ?? []), + 'warning_count' => count($validation_result['warnings'] ?? []), + 'info_count' => count($validation_result['info'] ?? []), + 'success_rate' => $validation_result['total_entries'] > 0 + ? round(($validation_result['valid_entries'] / $validation_result['total_entries']) * 100, 1) + : 0, + ]; + } +} \ No newline at end of file diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index c37db50c..70feb8e6 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Serializer\SerializerInterface; use function Symfony\Component\String\u; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use PhpOffice\PhpSpreadsheet\Writer\Xls; /** * Use this class to export an entity to multiple file formats. @@ -52,7 +55,7 @@ class EntityExporter protected function configureOptions(OptionsResolver $resolver): void { $resolver->setDefault('format', 'csv'); - $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']); $resolver->setDefault('csv_delimiter', ';'); $resolver->setAllowedTypes('csv_delimiter', 'string'); @@ -88,28 +91,35 @@ class EntityExporter $options = $resolver->resolve($options); + //Handle Excel formats by converting from CSV + if (in_array($options['format'], ['xlsx', 'xls'], true)) { + return $this->exportToExcel($entities, $options); + } + //If include children is set, then we need to add the include_children group $groups = [$options['level']]; if ($options['include_children']) { $groups[] = 'include_children'; } - return $this->serializer->serialize($entities, $options['format'], + return $this->serializer->serialize( + $entities, + $options['format'], [ 'groups' => $groups, 'as_collection' => true, 'csv_delimiter' => $options['csv_delimiter'], 'xml_root_node_name' => 'PartDBExport', 'partdb_export' => true, - //Skip the item normalizer, so that we dont get IRIs in the output + //Skip the item normalizer, so that we dont get IRIs in the output SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, - //Handle circular references + //Handle circular references AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...), ] ); } - private function handleCircularReference(object $object, string $format, array $context): string + private function handleCircularReference(object $object): string { if ($object instanceof AbstractStructuralDBElement) { return $object->getFullPath("->"); @@ -119,7 +129,75 @@ class EntityExporter return $object->__toString(); } - throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object)); + throw new CircularReferenceException('Circular reference detected for object of type ' . get_class($object)); + } + + /** + * Exports entities to Excel format (xlsx or xls). + * + * @param AbstractNamedDBElement[] $entities The entities to export + * @param array $options The export options + * + * @return string The Excel file content as binary string + */ + protected function exportToExcel(array $entities, array $options): string + { + //First get CSV data using existing serializer + $groups = [$options['level']]; + if ($options['include_children']) { + $groups[] = 'include_children'; + } + + $csvData = $this->serializer->serialize( + $entities, + 'csv', + [ + 'groups' => $groups, + 'as_collection' => true, + 'csv_delimiter' => $options['csv_delimiter'], + 'partdb_export' => true, + SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, + AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...), + ] + ); + + //Convert CSV to Excel + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + + $rows = explode("\n", $csvData); + $rowIndex = 1; + + foreach ($rows as $row) { + if (trim($row) === '') { + continue; + } + + $columns = str_getcsv($row, $options['csv_delimiter'], '"', '\\'); + $colIndex = 1; + + foreach ($columns as $column) { + $cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex; + $worksheet->setCellValue($cellCoordinate, $column); + $colIndex++; + } + $rowIndex++; + } + + //Save to memory stream + $writer = $options['format'] === 'xlsx' ? new Xlsx($spreadsheet) : new Xls($spreadsheet); + + $memFile = fopen("php://temp", 'r+b'); + $writer->save($memFile); + rewind($memFile); + $content = stream_get_contents($memFile); + fclose($memFile); + + if ($content === false) { + throw new \RuntimeException('Failed to read Excel content from memory stream.'); + } + + return $content; } /** @@ -137,7 +215,7 @@ class EntityExporter $options = [ 'format' => $request->get('format') ?? 'json', 'level' => $request->get('level') ?? 'extended', - 'include_children' => $request->request->getBoolean('include_children') ?? false, + 'include_children' => $request->request->getBoolean('include_children'), ]; if (!is_array($entities)) { @@ -156,19 +234,15 @@ class EntityExporter //Determine the content type for the response - //Plain text should work for all types - $content_type = 'text/plain'; - //Try to use better content types based on the format $format = $options['format']; - switch ($format) { - case 'xml': - $content_type = 'application/xml'; - break; - case 'json': - $content_type = 'application/json'; - break; - } + $content_type = match ($format) { + 'xml' => 'application/xml', + 'json' => 'application/json', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xls' => 'application/vnd.ms-excel', + default => 'text/plain', + }; $response->headers->set('Content-Type', $content_type); //If view option is not specified, then download the file. @@ -186,7 +260,7 @@ class EntityExporter $level = $options['level']; - $filename = 'export_'.$entity_name.'_'.$level.'.'.$format; + $filename = "export_{$entity_name}_{$level}.{$format}"; //Sanitize the filename $filename = FilenameSanatizer::sanitizeFilename($filename); diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php index cecab12d..a89be9dc 100644 --- a/src/Services/ImportExportSystem/EntityImporter.php +++ b/src/Services/ImportExportSystem/EntityImporter.php @@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; +use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use Psr\Log\LoggerInterface; /** * @see \App\Tests\Services\ImportExportSystem\EntityImporterTest @@ -50,13 +53,14 @@ 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) { } /** * Creates many entries at once, based on a (text) list of name. * The created entities are not persisted to database yet, so you have to do it yourself. + * It returns all entities in the hierachy chain (even if they are already persisted). * * @template T of AbstractNamedDBElement * @param string $lines The list of names seperated by \n @@ -101,7 +105,7 @@ class EntityImporter foreach ($names as $name) { //Count indentation level (whitespace characters at the beginning of the line) - $identSize = strlen($name)-strlen(ltrim($name)); + $identSize = strlen($name) - strlen(ltrim($name)); //If the line is intended more than the last line, we have a new parent element if ($identSize > end($indentations)) { @@ -132,32 +136,38 @@ class EntityImporter //We can only use the getNewEntityFromPath function, if the repository is a StructuralDBElementRepository if ($repo instanceof StructuralDBElementRepository) { $entities = $repo->getNewEntityFromPath($new_path); - $entity = end($entities); - if ($entity === false) { + if ($entities === []) { throw new InvalidArgumentException('getNewEntityFromPath returned an empty array!'); } } else { //Otherwise just create a new entity $entity = new $class_name; $entity->setName($name); + $entities = [$entity]; } //Validate entity - $tmp = $this->validator->validate($entity); - //If no error occured, write entry to DB: - if (0 === count($tmp)) { - $valid_entities[] = $entity; - } else { //Otherwise log error - $errors[] = [ - 'entity' => $entity, - 'violations' => $tmp, - ]; + foreach ($entities as $entity) { + $tmp = $this->validator->validate($entity); + //If no error occured, write entry to DB: + if (0 === count($tmp)) { + $valid_entities[] = $entity; + } else { //Otherwise log error + $errors[] = [ + 'entity' => $entity, + 'violations' => $tmp, + ]; + } } - $last_element = $entity; + $last_element = end($entities); + if ($last_element === false) { + $last_element = null; + } } - return $valid_entities; + //Only return objects once + return array_values(array_unique($valid_entities, SORT_REGULAR)); } /** @@ -188,16 +198,20 @@ class EntityImporter } //The [] behind class_name denotes that we expect an array. - $entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'], + $entities = $this->serializer->deserialize( + $data, + $options['class'] . '[]', + $options['format'], [ 'groups' => $groups, 'csv_delimiter' => $options['csv_delimiter'], 'create_unknown_datastructures' => $options['create_unknown_datastructures'], 'path_delimiter' => $options['path_delimiter'], 'partdb_import' => true, - //Disable API Platform normalizer, as we don't want to use it here + //Disable API Platform normalizer, as we don't want to use it here SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, - ]); + ] + ); //Ensure we have an array of entity elements. if (!is_array($entities)) { @@ -272,7 +286,7 @@ class EntityImporter 'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element ]); - $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']); $resolver->setAllowedTypes('csv_delimiter', 'string'); $resolver->setAllowedTypes('preserve_children', 'bool'); $resolver->setAllowedTypes('class', 'string'); @@ -328,6 +342,33 @@ class EntityImporter */ public function importFile(File $file, array $options = [], array &$errors = []): array { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); + + if (in_array($options['format'], ['xlsx', 'xls'], true)) { + $this->logger->info('Converting Excel file to CSV', [ + 'filename' => $file->getFilename(), + 'format' => $options['format'], + 'delimiter' => $options['csv_delimiter'] + ]); + + $csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']); + $options['format'] = 'csv'; + + $this->logger->debug('Excel to CSV conversion completed', [ + 'csv_length' => strlen($csvData), + 'csv_lines' => substr_count($csvData, "\n") + 1 + ]); + + // Log the converted CSV for debugging (first 1000 characters) + $this->logger->debug('Converted CSV preview', [ + 'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '') + ]); + + return $this->importString($csvData, $options, $errors); + } + return $this->importString($file->getContent(), $options, $errors); } @@ -347,10 +388,103 @@ class EntityImporter 'xml' => 'xml', 'csv', 'tsv' => 'csv', 'yaml', 'yml' => 'yaml', + 'xlsx' => 'xlsx', + 'xls' => 'xls', default => null, }; } + /** + * Converts Excel file to CSV format using PhpSpreadsheet. + * + * @param File $file The Excel file to convert + * @param string $delimiter The CSV delimiter to use + * + * @return string The CSV data as string + */ + protected function convertExcelToCsv(File $file, string $delimiter = ';'): string + { + try { + $this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]); + $spreadsheet = IOFactory::load($file->getPathname()); + $worksheet = $spreadsheet->getActiveSheet(); + + $csvData = []; + $highestRow = $worksheet->getHighestRow(); + $highestColumn = $worksheet->getHighestColumn(); + + $this->logger->debug('Excel file dimensions', [ + 'rows' => $highestRow, + 'columns_detected' => $highestColumn, + 'worksheet_title' => $worksheet->getTitle() + ]); + + $highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn); + + for ($row = 1; $row <= $highestRow; $row++) { + $rowData = []; + + // Read all columns using numeric index + for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) { + $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex); + try { + $cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue(); + $rowData[] = $cellValue ?? ''; + + } catch (\Exception $e) { + $this->logger->warning('Error reading cell value', [ + 'cell' => "{$col}{$row}", + 'error' => $e->getMessage() + ]); + $rowData[] = ''; + } + } + + $csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) { + $value = (string) $value; + if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) { + return '"' . str_replace('"', '""', $value) . '"'; + } + return $value; + }, $rowData)); + + $csvData[] = $csvRow; + + // Log first few rows for debugging + if ($row <= 3) { + $this->logger->debug("Row {$row} converted", [ + 'original_data' => $rowData, + 'csv_row' => $csvRow, + 'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(), + 'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue() + ]); + } + } + + $result = implode("\n", $csvData); + + $this->logger->info('Excel to CSV conversion successful', [ + 'total_rows' => count($csvData), + 'total_characters' => strlen($result) + ]); + + $this->logger->debug('Full CSV data', [ + 'csv_data' => $result + ]); + + return $result; + + } catch (\Exception $e) { + $this->logger->error('Failed to convert Excel to CSV', [ + 'file' => $file->getFilename(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + } + + /** * This functions corrects the parent setting based on the children value of the parent. * diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php index 1f842c23..ec23d34b 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php @@ -29,6 +29,7 @@ use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use Doctrine\ORM\EntityManagerInterface; @@ -148,6 +149,26 @@ class PKDatastructureImporter return is_countable($partunit_data) ? count($partunit_data) : 0; } + public function importPartCustomStates(array $data): int + { + if (!isset($data['partcustomstate'])) { + return 0; //Not all PartKeepr installations have custom states + } + + $partCustomStateData = $data['partcustomstate']; + foreach ($partCustomStateData as $partCustomState) { + $customState = new PartCustomState(); + $customState->setName($partCustomState['name']); + + $this->setIDOfEntity($customState, $partCustomState['id']); + $this->em->persist($customState); + } + + $this->em->flush(); + + return is_countable($partCustomStateData) ? count($partCustomStateData) : 0; + } + public function importCategories(array $data): int { if (!isset($data['partcategory'])) { diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php index f36e48ce..880d77be 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php @@ -39,10 +39,10 @@ class PKImportHelper * Existing users and groups are not purged. * This is needed to avoid ID collisions. */ - public function purgeDatabaseForImport(): void + public function purgeDatabaseForImport(?EntityManagerInterface $entityManager = null, array $excluded_tables = ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']): void { //We use the ResetAutoIncrementORMPurger to reset the auto increment values of the tables. Also it normalizes table names before checking for exclusion. - $purger = new ResetAutoIncrementORMPurger($this->em, ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']); + $purger = new ResetAutoIncrementORMPurger($entityManager ?? $this->em, $excluded_tables); $purger->purge(); } diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php index 1e4cd3ba..64127341 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php @@ -150,6 +150,11 @@ trait PKImportHelperTrait $target->addAttachment($attachment); $this->em->persist($attachment); + + //If the attachment is an image, and the target has no master picture yet, set it + if ($attachment->isPicture() && $target->getMasterPictureAttachment() === null) { + $target->setMasterPictureAttachment($attachment); + } } $this->em->flush(); diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php index 9dd67233..cab5a49b 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php @@ -35,6 +35,7 @@ use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; +use App\Settings\SystemSettings\LocalizationSettings; use Brick\Math\BigDecimal; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Intl\Currencies; @@ -47,7 +48,7 @@ class PKPartImporter { use PKImportHelperTrait; - public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly string $base_currency) + public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly LocalizationSettings $localizationSettings) { $this->em = $em; $this->propertyAccessor = $propertyAccessor; @@ -90,6 +91,11 @@ class PKPartImporter $this->setAssociationField($entity, 'partUnit', MeasurementUnit::class, $part['partUnit_id']); } + if (isset($part['partCustomState_id'])) { + $this->setAssociationField($entity, 'partCustomState', MeasurementUnit::class, + $part['partCustomState_id']); + } + //Create a part lot to store the stock level and location $lot = new PartLot(); $lot->setAmount((float) ($part['stockLevel'] ?? 0)); @@ -210,7 +216,7 @@ class PKPartImporter $currency_iso_code = strtoupper($currency_iso_code); //We do not have a currency for the base currency to be consistent with prices without currencies - if ($currency_iso_code === $this->base_currency) { + if ($currency_iso_code === $this->localizationSettings->baseCurrency) { return null; } diff --git a/src/Services/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php new file mode 100644 index 00000000..586fb873 --- /dev/null +++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php @@ -0,0 +1,380 @@ + Cache for normalized supplier names */ + private array $supplierCache = []; + + public function __construct( + private readonly PartInfoRetriever $infoRetriever, + private readonly ExistingPartFinder $existingPartFinder, + private readonly ProviderRegistry $providerRegistry, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger + ) {} + + /** + * Perform bulk search across multiple parts and providers. + * + * @param Part[] $parts Array of parts to search for + * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mappings defining search strategy + * @param bool $prefetchDetails Whether to prefetch detailed information for results + * @return BulkSearchResponseDTO Structured response containing all search results + * @throws \InvalidArgumentException If no valid parts provided + * @throws \RuntimeException If no search results found for any parts + */ + public function performBulkSearch(array $parts, array $fieldMappings, bool $prefetchDetails = false): BulkSearchResponseDTO + { + if (empty($parts)) { + throw new \InvalidArgumentException('No valid parts found for bulk import'); + } + + $partResults = []; + $hasAnyResults = false; + + // Group providers by batch capability + $batchProviders = []; + $regularProviders = []; + + foreach ($fieldMappings as $mapping) { + foreach ($mapping->providers as $providerKey) { + if (!is_string($providerKey)) { + $this->logger->error('Invalid provider key type', [ + 'providerKey' => $providerKey, + 'type' => gettype($providerKey) + ]); + continue; + } + + $provider = $this->providerRegistry->getProviderByKey($providerKey); + if ($provider instanceof BatchInfoProviderInterface) { + $batchProviders[$providerKey] = $provider; + } else { + $regularProviders[$providerKey] = $provider; + } + } + } + + // Process batch providers first (more efficient) + $batchResults = $this->processBatchProviders($parts, $fieldMappings, $batchProviders); + + // Process regular providers + $regularResults = $this->processRegularProviders($parts, $fieldMappings, $regularProviders, $batchResults); + + // Combine and format results for each part + foreach ($parts as $part) { + $searchResults = []; + + // Get results from batch and regular processing + $allResults = array_merge( + $batchResults[$part->getId()] ?? [], + $regularResults[$part->getId()] ?? [] + ); + + if (!empty($allResults)) { + $hasAnyResults = true; + $searchResults = $this->formatSearchResults($allResults); + } + + $partResults[] = new BulkSearchPartResultsDTO( + part: $part, + searchResults: $searchResults, + errors: [] + ); + } + + if (!$hasAnyResults) { + throw new \RuntimeException('No search results found for any of the selected parts'); + } + + $response = new BulkSearchResponseDTO($partResults); + + // Prefetch details if requested + if ($prefetchDetails) { + $this->prefetchDetailsForResults($response); + } + + return $response; + } + + /** + * Process parts using batch-capable info providers. + * + * @param Part[] $parts Array of parts to search for + * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations + * @param array $batchProviders Batch providers indexed by key + * @return array Results indexed by part ID + */ + private function processBatchProviders(array $parts, array $fieldMappings, array $batchProviders): array + { + $batchResults = []; + + foreach ($batchProviders as $providerKey => $provider) { + $keywords = $this->collectKeywordsForProvider($parts, $fieldMappings, $providerKey); + + if (empty($keywords)) { + continue; + } + + try { + $providerResults = $provider->searchByKeywordsBatch($keywords); + + // Map results back to parts + foreach ($parts as $part) { + foreach ($fieldMappings as $mapping) { + if (!in_array($providerKey, $mapping->providers, true)) { + continue; + } + + $keyword = $this->getKeywordFromField($part, $mapping->field); + if ($keyword && isset($providerResults[$keyword])) { + foreach ($providerResults[$keyword] as $dto) { + $batchResults[$part->getId()][] = new BulkSearchPartResultDTO( + searchResult: $dto, + sourceField: $mapping->field, + sourceKeyword: $keyword, + localPart: $this->existingPartFinder->findFirstExisting($dto), + priority: $mapping->priority + ); + } + } + } + } + } catch (\Exception $e) { + $this->logger->error('Batch search failed for provider ' . $providerKey, [ + 'error' => $e->getMessage(), + 'provider' => $providerKey + ]); + } + } + + return $batchResults; + } + + /** + * Process parts using regular (non-batch) info providers. + * + * @param Part[] $parts Array of parts to search for + * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations + * @param array $regularProviders Regular providers indexed by key + * @param array $excludeResults Results to exclude (from batch processing) + * @return array Results indexed by part ID + */ + private function processRegularProviders(array $parts, array $fieldMappings, array $regularProviders, array $excludeResults): array + { + $regularResults = []; + + foreach ($parts as $part) { + $regularResults[$part->getId()] = []; + + // Skip if we already have batch results for this part + if (!empty($excludeResults[$part->getId()] ?? [])) { + continue; + } + + foreach ($fieldMappings as $mapping) { + $providers = array_intersect($mapping->providers, array_keys($regularProviders)); + + if (empty($providers)) { + continue; + } + + $keyword = $this->getKeywordFromField($part, $mapping->field); + if (!$keyword) { + continue; + } + + try { + $dtos = $this->infoRetriever->searchByKeyword($keyword, $providers); + + foreach ($dtos as $dto) { + $regularResults[$part->getId()][] = new BulkSearchPartResultDTO( + searchResult: $dto, + sourceField: $mapping->field, + sourceKeyword: $keyword, + localPart: $this->existingPartFinder->findFirstExisting($dto), + priority: $mapping->priority + ); + } + } catch (ClientException $e) { + $this->logger->error('Regular search failed', [ + 'part_id' => $part->getId(), + 'field' => $mapping->field, + 'error' => $e->getMessage() + ]); + } + } + } + + return $regularResults; + } + + /** + * Collect unique keywords for a specific provider from all parts and field mappings. + * + * @param Part[] $parts Array of parts to collect keywords from + * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations + * @param string $providerKey The provider key to collect keywords for + * @return string[] Array of unique keywords + */ + private function collectKeywordsForProvider(array $parts, array $fieldMappings, string $providerKey): array + { + $keywords = []; + + foreach ($parts as $part) { + foreach ($fieldMappings as $mapping) { + if (!in_array($providerKey, $mapping->providers, true)) { + continue; + } + + $keyword = $this->getKeywordFromField($part, $mapping->field); + if ($keyword && !in_array($keyword, $keywords, true)) { + $keywords[] = $keyword; + } + } + } + + return $keywords; + } + + private function getKeywordFromField(Part $part, string $field): ?string + { + return match ($field) { + 'mpn' => $part->getManufacturerProductNumber(), + 'name' => $part->getName(), + default => $this->getSupplierPartNumber($part, $field) + }; + } + + private function getSupplierPartNumber(Part $part, string $field): ?string + { + if (!str_ends_with($field, '_spn')) { + return null; + } + + $supplierKey = substr($field, 0, -4); + $supplier = $this->getSupplierByNormalizedName($supplierKey); + + if (!$supplier) { + return null; + } + + $orderDetail = $part->getOrderdetails()->filter( + fn($od) => $od->getSupplier()?->getId() === $supplier->getId() + )->first(); + + return $orderDetail !== false ? $orderDetail->getSupplierpartnr() : null; + } + + /** + * Get supplier by normalized name with caching to prevent N+1 queries. + * + * @param string $normalizedKey The normalized supplier key to search for + * @return Supplier|null The matching supplier or null if not found + */ + private function getSupplierByNormalizedName(string $normalizedKey): ?Supplier + { + // Check cache first + if (isset($this->supplierCache[$normalizedKey])) { + return $this->supplierCache[$normalizedKey]; + } + + // Use efficient database query with PHP normalization + // Since DQL doesn't support REPLACE, we'll load all suppliers once and cache the normalization + if (empty($this->supplierCache)) { + $this->loadSuppliersIntoCache(); + } + + $supplier = $this->supplierCache[$normalizedKey] ?? null; + + // Cache the result (including null results to prevent repeated queries) + $this->supplierCache[$normalizedKey] = $supplier; + + return $supplier; + } + + /** + * Load all suppliers into cache with normalized names to avoid N+1 queries. + */ + private function loadSuppliersIntoCache(): void + { + /** @var Supplier[] $suppliers */ + $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); + + foreach ($suppliers as $supplier) { + $normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); + $this->supplierCache[$normalizedName] = $supplier; + } + } + + /** + * Format and deduplicate search results. + * + * @param BulkSearchPartResultDTO[] $bulkResults Array of bulk search results + * @return BulkSearchPartResultDTO[] Array of formatted search results with metadata + */ + private function formatSearchResults(array $bulkResults): array + { + // Sort by priority and remove duplicates + usort($bulkResults, fn($a, $b) => $a->priority <=> $b->priority); + + $uniqueResults = []; + $seenKeys = []; + + foreach ($bulkResults as $result) { + $key = "{$result->searchResult->provider_key}|{$result->searchResult->provider_id}"; + if (!in_array($key, $seenKeys, true)) { + $seenKeys[] = $key; + $uniqueResults[] = $result; + } + } + + return $uniqueResults; + } + + /** + * Prefetch detailed information for search results. + * + * @param BulkSearchResponseDTO $searchResults Search results (supports both new DTO and legacy array format) + */ + public function prefetchDetailsForResults(BulkSearchResponseDTO $searchResults): void + { + $prefetchCount = 0; + + // Handle both new DTO format and legacy array format for backwards compatibility + foreach ($searchResults->partResults as $partResult) { + foreach ($partResult->searchResults as $result) { + $dto = $result->searchResult; + + try { + $this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id); + $prefetchCount++; + } catch (\Exception $e) { + $this->logger->warning('Failed to prefetch details for provider part', [ + 'provider_key' => $dto->provider_key, + 'provider_id' => $dto->provider_id, + 'error' => $e->getMessage() + ]); + } + } + } + + $this->logger->info("Prefetched details for {$prefetchCount} search results"); + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php new file mode 100644 index 00000000..47d8ac69 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php @@ -0,0 +1,108 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; + +/** + * Represents a mapping between a part field and the info providers that should search in that field. + */ +readonly class BulkSearchFieldMappingDTO +{ + /** @var string[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell']) */ + public array $providers; + + /** + * @param string $field The field to search in (e.g., 'mpn', 'name', or supplier-specific fields like 'digikey_spn') + * @param string[]|InfoProviderInterface[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell']) + * @param int $priority Priority for this field mapping (1-10, lower numbers = higher priority) + */ + public function __construct( + public string $field, + array $providers = [], + public int $priority = 1 + ) { + if ($priority < 1 || $priority > 10) { + throw new \InvalidArgumentException('Priority must be between 1 and 10'); + } + + //Ensure that providers are provided as keys + foreach ($providers as &$provider) { + if ($provider instanceof InfoProviderInterface) { + $provider = $provider->getProviderKey(); + } + if (!is_string($provider)) { + throw new \InvalidArgumentException('Providers must be provided as strings or InfoProviderInterface instances'); + } + } + unset($provider); + $this->providers = $providers; + } + + /** + * Create a FieldMappingDTO from legacy array format. + * @param array{field: string, providers: string[], priority?: int} $data + */ + public static function fromSerializableArray(array $data): self + { + return new self( + field: $data['field'], + providers: $data['providers'] ?? [], + priority: $data['priority'] ?? 1 + ); + } + + /** + * Convert this DTO to the legacy array format for backwards compatibility. + * @return array{field: string, providers: string[], priority: int} + */ + public function toSerializableArray(): array + { + return [ + 'field' => $this->field, + 'providers' => $this->providers, + 'priority' => $this->priority, + ]; + } + + /** + * Check if this field mapping is for a supplier part number field. + */ + public function isSupplierPartNumberField(): bool + { + return str_ends_with($this->field, '_spn'); + } + + /** + * Get the supplier key from a supplier part number field. + * Returns null if this is not a supplier part number field. + */ + public function getSupplierKey(): ?string + { + if (!$this->isSupplierPartNumberField()) { + return null; + } + + return substr($this->field, 0, -4); + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php new file mode 100644 index 00000000..d46624d4 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php @@ -0,0 +1,44 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\Part; + +/** + * Represents a single search result from bulk search with additional context information, like how the part was found. + */ +readonly class BulkSearchPartResultDTO +{ + public function __construct( + /** The base search result DTO containing provider data */ + public SearchResultDTO $searchResult, + /** The field that was used to find this result */ + public ?string $sourceField = null, + /** The actual keyword that was searched for */ + public ?string $sourceKeyword = null, + /** Local part that matches this search result, if any */ + public ?Part $localPart = null, + /** Priority for this search result */ + public int $priority = 1 + ) {} +} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php new file mode 100644 index 00000000..8614f4ec --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php @@ -0,0 +1,83 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\Part; + +/** + * Represents the search results for a single part from bulk info provider search. + * It contains multiple search results, that match the part. + */ +readonly class BulkSearchPartResultsDTO +{ + /** + * @param Part $part The part that was searched for + * @param BulkSearchPartResultDTO[] $searchResults Array of search results found for this part + * @param string[] $errors Array of error messages encountered during search + */ + public function __construct( + public Part $part, + public array $searchResults = [], + public array $errors = [] + ) {} + + /** + * Check if this part has any search results. + */ + public function hasResults(): bool + { + return !empty($this->searchResults); + } + + /** + * Check if this part has any errors. + */ + public function hasErrors(): bool + { + return !empty($this->errors); + } + + /** + * Get the number of search results for this part. + */ + public function getResultCount(): int + { + return count($this->searchResults); + } + + public function getErrorCount(): int + { + return count($this->errors); + } + + /** + * Get search results sorted by priority (ascending). + * @return BulkSearchPartResultDTO[] + */ + public function getResultsSortedByPriority(): array + { + $results = $this->searchResults; + usort($results, static fn(BulkSearchPartResultDTO $a, BulkSearchPartResultDTO $b) => $a->priority <=> $b->priority); + return $results; + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php new file mode 100644 index 00000000..58e9e240 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php @@ -0,0 +1,231 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\Part; +use Doctrine\ORM\EntityManagerInterface; +use Traversable; + +/** + * Represents the complete response from a bulk info provider search operation. + * It contains a list of PartSearchResultDTOs, one for each part searched. + */ +readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate +{ + /** + * @param BulkSearchPartResultsDTO[] $partResults Array of search results for each part + */ + public function __construct( + public array $partResults + ) {} + + /** + * Replaces the search results for a specific part, and returns a new instance. + * The part to replaced, is identified by the part property of the new_results parameter. + * The original instance remains unchanged. + * @param BulkSearchPartResultsDTO $new_results + * @return BulkSearchResponseDTO + */ + public function replaceResultsForPart(BulkSearchPartResultsDTO $new_results): self + { + $array = $this->partResults; + $replaced = false; + foreach ($array as $index => $partResult) { + if ($partResult->part === $new_results->part) { + $array[$index] = $new_results; + $replaced = true; + break; + } + } + + if (!$replaced) { + throw new \InvalidArgumentException("Part not found in existing results."); + } + + return new self($array); + } + + /** + * Check if any parts have search results. + */ + public function hasAnyResults(): bool + { + foreach ($this->partResults as $partResult) { + if ($partResult->hasResults()) { + return true; + } + } + return false; + } + + /** + * Get the total number of search results across all parts. + */ + public function getTotalResultCount(): int + { + $count = 0; + foreach ($this->partResults as $partResult) { + $count += $partResult->getResultCount(); + } + return $count; + } + + /** + * Get all parts that have search results. + * @return BulkSearchPartResultsDTO[] + */ + public function getPartsWithResults(): array + { + return array_filter($this->partResults, fn($result) => $result->hasResults()); + } + + /** + * Get all parts that have errors. + * @return BulkSearchPartResultsDTO[] + */ + public function getPartsWithErrors(): array + { + return array_filter($this->partResults, fn($result) => $result->hasErrors()); + } + + /** + * Get the number of parts processed. + */ + public function getPartCount(): int + { + return count($this->partResults); + } + + /** + * Get the number of parts with successful results. + */ + public function getSuccessfulPartCount(): int + { + return count($this->getPartsWithResults()); + } + + /** + * Merge multiple BulkSearchResponseDTO instances into one. + * @param BulkSearchResponseDTO ...$responses + * @return BulkSearchResponseDTO + */ + public static function merge(BulkSearchResponseDTO ...$responses): BulkSearchResponseDTO + { + $mergedResults = []; + foreach ($responses as $response) { + foreach ($response->partResults as $partResult) { + $mergedResults[] = $partResult; + } + } + return new BulkSearchResponseDTO($mergedResults); + } + + /** + * Convert this DTO to a serializable representation suitable for storage in the database + * @return array + */ + public function toSerializableRepresentation(): array + { + $serialized = []; + + foreach ($this->partResults as $partResult) { + $partData = [ + 'part_id' => $partResult->part->getId(), + 'search_results' => [], + 'errors' => $partResult->errors ?? [] + ]; + + foreach ($partResult->searchResults as $result) { + $partData['search_results'][] = [ + 'dto' => $result->searchResult->toNormalizedSearchResultArray(), + 'source_field' => $result->sourceField ?? null, + 'source_keyword' => $result->sourceKeyword ?? null, + 'localPart' => $result->localPart?->getId(), + 'priority' => $result->priority + ]; + } + + $serialized[] = $partData; + } + + return $serialized; + } + + /** + * Creates a BulkSearchResponseDTO from a serializable representation. + * @param array $data + * @param EntityManagerInterface $entityManager + * @return BulkSearchResponseDTO + * @throws \Doctrine\ORM\Exception\ORMException + */ + public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO + { + $partResults = []; + foreach ($data as $partData) { + $partResults[] = new BulkSearchPartResultsDTO( + part: $entityManager->getReference(Part::class, $partData['part_id']), + searchResults: array_map(fn($result) => new BulkSearchPartResultDTO( + searchResult: SearchResultDTO::fromNormalizedSearchResultArray($result['dto']), + sourceField: $result['source_field'] ?? null, + sourceKeyword: $result['source_keyword'] ?? null, + localPart: isset($result['localPart']) ? $entityManager->getReference(Part::class, $result['localPart']) : null, + priority: $result['priority'] ?? null + ), $partData['search_results'] ?? []), + errors: $partData['errors'] ?? [] + ); + } + + return new BulkSearchResponseDTO($partResults); + } + + public function offsetExists(mixed $offset): bool + { + if (!is_int($offset)) { + throw new \InvalidArgumentException("Offset must be an integer."); + } + return isset($this->partResults[$offset]); + } + + public function offsetGet(mixed $offset): ?BulkSearchPartResultsDTO + { + if (!is_int($offset)) { + throw new \InvalidArgumentException("Offset must be an integer."); + } + return $this->partResults[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \LogicException("BulkSearchResponseDTO is immutable."); + } + + public function offsetUnset(mixed $offset): void + { + throw new \LogicException('BulkSearchResponseDTO is immutable.'); + } + + public function getIterator(): Traversable + { + return new \ArrayIterator($this->partResults); + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php index 0d1db76a..84eed0c9 100644 --- a/src/Services/InfoProviderSystem/DTOs/FileDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php @@ -28,12 +28,12 @@ namespace App\Services\InfoProviderSystem\DTOs; * This could be a datasheet, a 3D model, a picture or similar. * @see \App\Tests\Services\InfoProviderSystem\DTOs\FileDTOTest */ -class FileDTO +readonly class FileDTO { /** * @var string The URL where to get this file */ - public readonly string $url; + public string $url; /** * @param string $url The URL where to get this file @@ -41,7 +41,7 @@ class FileDTO */ public function __construct( string $url, - public readonly ?string $name = null, + public ?string $name = null, ) { //Find all occurrences of non URL safe characters and replace them with their URL encoded version. //We only want to replace characters which can not have a valid meaning in a URL (what would break the URL). @@ -50,4 +50,4 @@ class FileDTO } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php index 0b54d1a9..f5868039 100644 --- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -28,17 +28,17 @@ namespace App\Services\InfoProviderSystem\DTOs; * This could be a voltage, a current, a temperature or similar. * @see \App\Tests\Services\InfoProviderSystem\DTOs\ParameterDTOTest */ -class ParameterDTO +readonly class ParameterDTO { public function __construct( - public readonly string $name, - public readonly ?string $value_text = null, - public readonly ?float $value_typ = null, - public readonly ?float $value_min = null, - public readonly ?float $value_max = null, - public readonly ?string $unit = null, - public readonly ?string $symbol = null, - public readonly ?string $group = null, + public string $name, + public ?string $value_text = null, + public ?float $value_typ = null, + public ?float $value_min = null, + public ?float $value_max = null, + public ?string $unit = null, + public ?string $symbol = null, + public ?string $group = null, ) { } diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 9f365f1e..41d50510 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -70,4 +70,4 @@ class PartDetailDTO extends SearchResultDTO footprint: $footprint, ); } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php index f1eb28f7..2acf3e57 100644 --- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -28,21 +28,21 @@ use Brick\Math\BigDecimal; /** * This DTO represents a price for a single unit in a certain discount range */ -class PriceDTO +readonly class PriceDTO { - private readonly BigDecimal $price_as_big_decimal; + private BigDecimal $price_as_big_decimal; public function __construct( /** @var float The minimum amount that needs to get ordered for this price to be valid */ - public readonly float $minimum_discount_amount, + public float $minimum_discount_amount, /** @var string The price as string (with .) */ - public readonly string $price, + public string $price, /** @var string The currency of the used ISO code of this price detail */ - public readonly ?string $currency_iso_code, + public ?string $currency_iso_code, /** @var bool If the price includes tax */ - public readonly ?bool $includes_tax = true, + public ?bool $includes_tax = true, /** @var float the price related quantity */ - public readonly ?float $price_related_quantity = 1.0, + public ?float $price_related_quantity = 1.0, ) { $this->price_as_big_decimal = BigDecimal::of($this->price); diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php index bcd8be43..9ac142ff 100644 --- a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -27,15 +27,15 @@ namespace App\Services\InfoProviderSystem\DTOs; * This DTO represents a purchase information for a part (supplier name, order number and prices). * @see \App\Tests\Services\InfoProviderSystem\DTOs\PurchaseInfoDTOTest */ -class PurchaseInfoDTO +readonly class PurchaseInfoDTO { public function __construct( - public readonly string $distributor_name, - public readonly string $order_number, + public string $distributor_name, + public string $order_number, /** @var PriceDTO[] */ - public readonly array $prices, + public array $prices, /** @var string|null An url to the product page of the vendor */ - public readonly ?string $product_url = null, + public ?string $product_url = null, ) { //Ensure that the prices are PriceDTO instances @@ -45,4 +45,4 @@ class PurchaseInfoDTO } } } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 28943702..a70b2486 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -59,8 +59,8 @@ class SearchResultDTO public readonly ?string $provider_url = null, /** @var string|null A footprint representation of the providers page */ public readonly ?string $footprint = null, - ) { - + ) + { if ($preview_image_url !== null) { //Utilize the escaping mechanism of FileDTO to ensure that the preview image URL is correctly encoded //See issue #521: https://github.com/Part-DB/Part-DB-server/issues/521 @@ -71,4 +71,47 @@ class SearchResultDTO $this->preview_image_url = null; } } -} \ No newline at end of file + + /** + * This method creates a normalized array representation of the DTO. + * @return array + */ + public function toNormalizedSearchResultArray(): array + { + return [ + 'provider_key' => $this->provider_key, + 'provider_id' => $this->provider_id, + 'name' => $this->name, + 'description' => $this->description, + 'category' => $this->category, + 'manufacturer' => $this->manufacturer, + 'mpn' => $this->mpn, + 'preview_image_url' => $this->preview_image_url, + 'manufacturing_status' => $this->manufacturing_status?->value, + 'provider_url' => $this->provider_url, + 'footprint' => $this->footprint, + ]; + } + + /** + * Creates a SearchResultDTO from a normalized array representation. + * @param array $data + * @return self + */ + public static function fromNormalizedSearchResultArray(array $data): self + { + return new self( + provider_key: $data['provider_key'], + provider_id: $data['provider_id'], + name: $data['name'], + description: $data['description'], + category: $data['category'] ?? null, + manufacturer: $data['manufacturer'] ?? null, + mpn: $data['mpn'] ?? null, + preview_image_url: $data['preview_image_url'] ?? null, + manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null, + provider_url: $data['provider_url'] ?? null, + footprint: $data['footprint'] ?? null, + ); + } +} diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 40f69498..a655a0df 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Settings\SystemSettings\LocalizationSettings; use Doctrine\ORM\EntityManagerInterface; /** @@ -54,8 +55,11 @@ final class DTOtoEntityConverter private const TYPE_DATASHEETS_NAME = 'Datasheet'; private const TYPE_IMAGE_NAME = 'Image'; - public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency) + private readonly string $base_currency; + + public function __construct(private readonly EntityManagerInterface $em, LocalizationSettings $localizationSettings) { + $this->base_currency = $localizationSettings->baseCurrency; } /** @@ -217,7 +221,7 @@ final class DTOtoEntityConverter $attachment = $this->convertFile($image, $image_type); $attachments_grouped[$attachment->getName()][] = $attachment; - if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) { + if (count($attachments_grouped[$attachment->getName()]) > 1) { $attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')'); } @@ -232,7 +236,7 @@ final class DTOtoEntityConverter $attachment = $this->convertFile($datasheet, $datasheet_type); $attachments_grouped[$attachment->getName()][] = $attachment; - if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) { + if (count($attachments_grouped[$attachment->getName()]) > 1) { $attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')'); } @@ -353,4 +357,4 @@ final class DTOtoEntityConverter return $tmp; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/ExistingPartFinder.php b/src/Services/InfoProviderSystem/ExistingPartFinder.php index 762c1517..614ca105 100644 --- a/src/Services/InfoProviderSystem/ExistingPartFinder.php +++ b/src/Services/InfoProviderSystem/ExistingPartFinder.php @@ -1,5 +1,7 @@ getQuery()->getResult(); } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php new file mode 100644 index 00000000..549f117a --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php @@ -0,0 +1,40 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; + +/** + * This interface marks a provider as a info provider which can provide information directly in batch operations + */ +interface BatchInfoProviderInterface extends InfoProviderInterface +{ + /** + * Search for multiple keywords in a single batch operation and return the results, ordered by the keywords. + * This allows for a more efficient search compared to running multiple single searches. + * @param string[] $keywords + * @return array An associative array where the key is the keyword and the value is the search results for that keyword + */ + public function searchByKeywordsBatch(array $keywords): array; +} diff --git a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php new file mode 100644 index 00000000..07125c73 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php @@ -0,0 +1,639 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Settings\InfoProviderSystem\BuerklinSettings; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class BuerklinProvider implements BatchInfoProviderInterface +{ + + private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin'; + + public const DISTRIBUTOR_NAME = 'Buerklin'; + + private const CACHE_TTL = 600; + /** + * Local in-request cache to avoid hitting the PSR cache repeatedly for the same product. + * @var array + */ + private array $productCache = []; + + public function __construct( + private readonly HttpClientInterface $client, + private readonly CacheItemPoolInterface $partInfoCache, + private readonly BuerklinSettings $settings, + ) { + + } + + /** + * Gets the latest OAuth token for the Buerklin API, or creates a new one if none is available + * TODO: Rework this to use the OAuth token manager system in the database... + * @return string + */ + private function getToken(): string + { + // Cache token to avoid hammering the auth server on every request + $cacheKey = 'buerklin.oauth.token'; + $item = $this->partInfoCache->getItem($cacheKey); + + if ($item->isHit()) { + $token = $item->get(); + if (is_string($token) && $token !== '') { + return $token; + } + } + + // Buerklin OAuth2 password grant (ROPC) + $resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => [ + 'grant_type' => 'password', + 'client_id' => $this->settings->clientId, + 'client_secret' => $this->settings->secret, + 'username' => $this->settings->username, + 'password' => $this->settings->password, + ], + ]); + + $data = $resp->toArray(false); + + if (!isset($data['access_token'])) { + throw new \RuntimeException( + 'Invalid token response from Buerklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false) + ); + } + + $token = (string) $data['access_token']; + + // Cache for (expires_in - 30s) if available + $ttl = 300; + if (isset($data['expires_in']) && is_numeric($data['expires_in'])) { + $ttl = max(60, (int) $data['expires_in'] - 30); + } + + $item->set($token); + $item->expiresAfter($ttl); + $this->partInfoCache->save($item); + + return $token; + } + + private function getDefaultQueryParams(): array + { + return [ + 'curr' => $this->settings->currency ?: 'EUR', + 'language' => $this->settings->language ?: 'en', + ]; + } + + private function getProduct(string $code): array + { + $code = strtoupper(trim($code)); + if ($code === '') { + throw new \InvalidArgumentException('Product code must not be empty.'); + } + + $cacheKey = sprintf( + 'buerklin.product.%s', + md5($code . '|' . $this->settings->language . '|' . $this->settings->currency) + ); + + if (isset($this->productCache[$cacheKey])) { + return $this->productCache[$cacheKey]; + } + + $item = $this->partInfoCache->getItem($cacheKey); + if ($item->isHit() && is_array($cached = $item->get())) { + return $this->productCache[$cacheKey] = $cached; + } + + $product = $this->makeAPICall('/products/' . rawurlencode($code) . '/'); + + $item->set($product); + $item->expiresAfter(self::CACHE_TTL); + $this->partInfoCache->save($item); + + return $this->productCache[$cacheKey] = $product; + } + + private function makeAPICall(string $endpoint, array $queryParams = []): array + { + try { + $response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [ + 'auth_bearer' => $this->getToken(), + 'headers' => ['Accept' => 'application/json'], + 'query' => array_merge($this->getDefaultQueryParams(), $queryParams), + ]); + + return $response->toArray(); + } catch (\Exception $e) { + throw new \RuntimeException("Buerklin API request failed: " . + "Endpoint: " . $endpoint . + "Token: [redacted] " . + "QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " . + "Exception message: " . $e->getMessage()); + } + } + + + public function getProviderInfo(): array + { + return [ + 'name' => 'Buerklin', + 'description' => 'This provider uses the Buerklin API to search for parts.', + 'url' => 'https://www.buerklin.com/', + 'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.', + 'settings_class' => BuerklinSettings::class + ]; + } + + public function getProviderKey(): string + { + return 'buerklin'; + } + + // This provider is considered active if settings are present + public function isActive(): bool + { + // The client credentials and user credentials must be set + return $this->settings->clientId !== null && $this->settings->clientId !== '' + && $this->settings->secret !== null && $this->settings->secret !== '' + && $this->settings->username !== null && $this->settings->username !== '' + && $this->settings->password !== null && $this->settings->password !== ''; + } + + /** + * Sanitizes a field by removing any HTML tags and other unwanted characters + * @param string|null $field + * @return string|null + */ + private function sanitizeField(?string $field): ?string + { + if ($field === null) { + return null; + } + + return strip_tags($field); + } + + /** + * Takes a deserialized JSON object of the product and returns a PartDetailDTO + * @param array $product + * @return PartDetailDTO + */ + private function getPartDetail(array $product): PartDetailDTO + { + // If this is a search-result object, it may not contain prices/features/images -> reload full details. + if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) { + try { + $product = $this->getProduct((string) $product['code']); + } catch (\Throwable $e) { + // If reload fails, keep the partial product data and continue. + } + } + + // Extract images from API response + $productImages = $this->getProductImages($product['images'] ?? null); + + // Set preview image + $preview = $productImages[0]->url ?? null; + + // Extract features (parameters) from classifications[0].features of Buerklin JSON response + $features = $product['classifications'][0]['features'] ?? []; + + // Feature parameters (from classifications->features) + $featureParams = $this->attributesToParameters($features, ''); // leave group empty for normal parameters + + // Compliance parameters (from top-level fields like RoHS/SVHC/โ€ฆ) + $complianceParams = $this->complianceToParameters($product, 'Compliance'); + + // Merge all parameters + $allParams = array_merge($featureParams, $complianceParams); + + // Assign footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehรคuse" (de) + $footprint = null; + if (is_array($features)) { + foreach ($features as $feature) { + $name = $feature['name'] ?? null; + if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehรคuse') { + $footprint = $feature['featureValues'][0]['value'] ?? null; + break; + } + } + } + + // Prices: prefer volumePrices, fallback to single price + $code = (string) ($product['orderNumber'] ?? $product['code'] ?? ''); + $prices = $product['volumePrices'] ?? null; + + if (!is_array($prices) || count($prices) === 0) { + $pVal = $product['price']['value'] ?? null; + $pCur = $product['price']['currencyIso'] ?? ($this->settings->currency ?: 'EUR'); + + if (is_numeric($pVal)) { + $prices = [ + [ + 'minQuantity' => 1, + 'value' => (float) $pVal, + 'currencyIso' => (string) $pCur, + ] + ]; + } else { + $prices = []; + } + } + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: (string) ($product['code'] ?? $code), + + name: (string) ($product['manufacturerProductId'] ?? $code), + description: $this->sanitizeField($product['description'] ?? null), + + category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)), + manufacturer: $this->sanitizeField($product['manufacturer'] ?? null), + mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null), + + preview_image_url: $preview, + manufacturing_status: null, + + provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)), + footprint: $footprint, + + datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets + images: $productImages, + + parameters: $allParams, + + vendor_infos: $this->pricesToVendorInfo( + sku: $code, + url: $this->getProductShortURL($code), + prices: $prices + ), + + mass: $product['weight'] ?? null, + ); + } + + /** + * Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO + * @param string $sku + * @param string $url + * @param array $prices + * @return array + */ + private function pricesToVendorInfo(string $sku, string $url, array $prices): array + { + $priceDTOs = array_map(function ($price) { + $val = $price['value'] ?? null; + $valStr = is_numeric($val) + ? number_format((float) $val, 6, '.', '') // 6 decimal places, trailing zeros are fine + : (string) $val; + + // Optional: softly trim unnecessary trailing zeros (e.g. 75.550000 -> 75.55) + $valStr = rtrim(rtrim($valStr, '0'), '.'); + + return new PriceDTO( + minimum_discount_amount: (float) ($price['minQuantity'] ?? 1), + price: $valStr, + currency_iso_code: (string) ($price['currencyIso'] ?? $this->settings->currency ?? 'EUR'), + includes_tax: false + ); + }, $prices); + + return [ + new PurchaseInfoDTO( + distributor_name: self::DISTRIBUTOR_NAME, + order_number: $sku, + prices: $priceDTOs, + product_url: $url, + ) + ]; + } + + + /** + * Returns a valid Buerklin product short URL from product code + * @param string $product_code + * @return string + */ + private function getProductShortURL(string $product_code): string + { + return 'https://www.buerklin.com/' . $this->settings->language . '/p/' . $product_code . '/'; + } + + /** + * Returns a deduplicated list of product images as FileDTOs. + * + * - takes only real image arrays (with 'url' field) + * - makes relative URLs absolute + * - deduplicates using URL + * - prefers 'zoom' format, then 'product' format, then all others + * + * @param array|null $images + * @return \App\Services\InfoProviderSystem\DTOs\FileDTO[] + */ + private function getProductImages(?array $images): array + { + if (!is_array($images)) { + return []; + } + + // 1) Only real image entries with URL + $imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url']))); + + // 2) Prefer zoom images + $zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom')); + $chosen = count($zoom) > 0 + ? $zoom + : array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product')); + + // 3) If still none, take all + if (count($chosen) === 0) { + $chosen = $imgs; + } + + // 4) Deduplicate by URL (after making absolute) + $byUrl = []; + foreach ($chosen as $img) { + $url = (string) $img['url']; + + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://www.buerklin.com' . $url; + } + if (!filter_var($url, FILTER_VALIDATE_URL)) { + continue; + } + + $byUrl[$url] = $url; + } + + return array_map( + fn($url) => new FileDTO($url), + array_values($byUrl) + ); + } + + private function attributesToParameters(array $features, ?string $group = null): array + { + $out = []; + + foreach ($features as $f) { + if (!is_array($f)) { + continue; + } + + $name = $f['name'] ?? null; + if (!is_string($name) || trim($name) === '') { + continue; + } + + $vals = []; + foreach (($f['featureValues'] ?? []) as $fv) { + if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') { + $vals[] = trim($fv['value']); + } + } + if (empty($vals)) { + continue; + } + + // Multiple values: join with comma + $value = implode(', ', array_values(array_unique($vals))); + + // Unit/symbol from Buerklin feature + $unit = $f['featureUnit']['symbol'] ?? null; + if (!is_string($unit) || trim($unit) === '') { + $unit = null; + } + + // ParameterDTO parses value field (handles value + unit) + $out[] = ParameterDTO::parseValueField( + name: $name, + value: $value, + unit: $unit, + symbol: null, + group: $group + ); + } + + // Deduplicate by name + $byName = []; + foreach ($out as $p) { + $byName[$p->name] ??= $p; + } + + return array_values($byName); + } + + /** + * @return PartDetailDTO[] + */ + public function searchByKeyword(string $keyword): array + { + $keyword = strtoupper(trim($keyword)); + if ($keyword === '') { + return []; + } + + $response = $this->makeAPICall('/products/search/', [ + 'pageSize' => 50, + 'currentPage' => 0, + 'query' => $keyword, + 'sort' => 'relevance', + ]); + + $products = $response['products'] ?? []; + + // Normal case: products found in search results + if (is_array($products) && !empty($products)) { + return array_map(fn($p) => $this->getPartDetail($p), $products); + } + + // Fallback: try direct lookup by code + try { + $product = $this->getProduct($keyword); + return [$this->getPartDetail($product)]; + } catch (\Throwable $e) { + return []; + } + } + + public function getDetails(string $id): PartDetailDTO + { + // Detail endpoint is /products/{code}/ + $response = $this->getProduct($id); + + return $this->getPartDetail($response); + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + //ProviderCapabilities::DATASHEET, // currently not implemented + ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, + ]; + } + + private function complianceToParameters(array $product, ?string $group = 'Compliance'): array + { + $params = []; + + $add = function (string $name, $value) use (&$params, $group) { + if ($value === null) { + return; + } + + if (is_bool($value)) { + $value = $value ? 'Yes' : 'No'; + } elseif (is_array($value) || is_object($value)) { + // Avoid dumping large or complex structures + return; + } else { + $value = trim((string) $value); + if ($value === '') { + return; + } + } + + $params[] = ParameterDTO::parseValueField( + name: $name, + value: (string) $value, + unit: null, + symbol: null, + group: $group + ); + }; + + $add('RoHS conform', $product['labelRoHS'] ?? null); // "yes"/"no" + + $rawRoHsDate = $product['dateRoHS'] ?? null; + // Try to parse and reformat date to Y-m-d (do not use language-dependent formats) + if (is_string($rawRoHsDate) && $rawRoHsDate !== '') { + try { + $dt = new \DateTimeImmutable($rawRoHsDate); + $formatted = $dt->format('Y-m-d'); + } catch (\Exception $e) { + $formatted = $rawRoHsDate; + } + // Always use the same parameter name (do not use language-dependent names) + $add('RoHS date', $formatted); + } + $add('SVHC free', $product['SVHC'] ?? null); // bool + $add('Hazardous good', $product['hazardousGood'] ?? null); // bool + $add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool + + $add('Country of origin', $product['countryOfOrigin'] ?? null); + // Customs tariff code must always be stored as string, otherwise "85411000" may be stored as "8.5411e+7" + if (isset($product['articleCustomsCode'])) { + // Raw value as string + $codeRaw = (string) $product['articleCustomsCode']; + + // Optionally keep only digits (in case of spaces or other characters) + $code = preg_replace('/\D/', '', $codeRaw) ?? $codeRaw; + $code = trim($code); + + if ($code !== '') { + $params[] = new ParameterDTO( + name: 'Customs code', + value_text: $code, + value_typ: null, + value_min: null, + value_max: null, + unit: null, + symbol: null, + group: $group + ); + } + } + + return $params; + } + + /** + * @param string[] $keywords + * @return array + */ + public function searchByKeywordsBatch(array $keywords): array + { + /** @var array $results */ + $results = []; + + foreach ($keywords as $keyword) { + $keyword = strtoupper(trim((string) $keyword)); + if ($keyword === '') { + continue; + } + + // Reuse existing single search -> returns PartDetailDTO[] + /** @var PartDetailDTO[] $partDetails */ + $partDetails = $this->searchByKeyword($keyword); + + // Convert to SearchResultDTO[] + $results[$keyword] = array_map( + fn(PartDetailDTO $detail) => $this->convertPartDetailToSearchResult($detail), + $partDetails + ); + } + + return $results; + } + + /** + * Converts a PartDetailDTO into a SearchResultDTO for bulk search. + */ + private function convertPartDetailToSearchResult(PartDetailDTO $detail): SearchResultDTO + { + return new SearchResultDTO( + provider_key: $detail->provider_key, + provider_id: $detail->provider_id, + name: $detail->name, + description: $detail->description ?? '', + category: $detail->category ?? null, + manufacturer: $detail->manufacturer ?? null, + mpn: $detail->mpn ?? null, + preview_image_url: $detail->preview_image_url ?? null, + manufacturing_status: $detail->manufacturing_status ?? null, + provider_url: $detail->provider_url ?? null, + footprint: $detail->footprint ?? null, + ); + } + +} diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index b20368ce..d7eb6e4f 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; +use App\Exceptions\OAuthReconnectRequiredException; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; @@ -31,6 +32,7 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\OAuth\OAuthTokenManager; +use App\Settings\InfoProviderSystem\DigikeySettings; use Symfony\Contracts\HttpClient\HttpClientInterface; class DigikeyProvider implements InfoProviderInterface @@ -55,17 +57,16 @@ class DigikeyProvider implements InfoProviderInterface ]; public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, - private readonly string $currency, private readonly string $clientId, - private readonly string $language, private readonly string $country) + private readonly DigikeySettings $settings,) { //Create the HTTP client with some default options $this->digikeyClient = $httpClient->withOptions([ "base_uri" => self::BASE_URI, "headers" => [ - "X-DIGIKEY-Client-Id" => $clientId, - "X-DIGIKEY-Locale-Site" => $this->country, - "X-DIGIKEY-Locale-Language" => $this->language, - "X-DIGIKEY-Locale-Currency" => $this->currency, + "X-DIGIKEY-Client-Id" => $this->settings->clientId, + "X-DIGIKEY-Locale-Site" => $this->settings->country, + "X-DIGIKEY-Locale-Language" => $this->settings->language, + "X-DIGIKEY-Locale-Currency" => $this->settings->currency, "X-DIGIKEY-Customer-Id" => 0, ] ]); @@ -78,7 +79,8 @@ class DigikeyProvider implements InfoProviderInterface 'description' => 'This provider uses the DigiKey API to search for parts.', 'url' => 'https://www.digikey.com/', 'oauth_app_name' => self::OAUTH_APP_NAME, - 'disabled_help' => 'Set the PROVIDER_DIGIKEY_CLIENT_ID and PROVIDER_DIGIKEY_SECRET env option and connect OAuth to enable.' + 'disabled_help' => 'Set the Client ID and Secret in provider settings and connect OAuth to enable.', + 'settings_class' => DigikeySettings::class, ]; } @@ -101,7 +103,7 @@ class DigikeyProvider implements InfoProviderInterface public function isActive(): bool { //The client ID has to be set and a token has to be available (user clicked connect) - return $this->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); + return $this->settings->clientId !== null && $this->settings->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); } public function searchByKeyword(string $keyword): array @@ -116,12 +118,22 @@ class DigikeyProvider implements InfoProviderInterface ]; //$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [ - $response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [ - 'json' => $request, - 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) - ]); + try { + $response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [ + 'json' => $request, + 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) + ]); + + $response_array = $response->toArray(); + } catch (\InvalidArgumentException $exception) { + //Check if the exception was caused by an invalid or expired token + if (str_contains($exception->getMessage(), 'access_token')) { + throw OAuthReconnectRequiredException::forProvider($this->getProviderKey()); + } + + throw $exception; + } - $response_array = $response->toArray(); $result = []; @@ -149,9 +161,18 @@ class DigikeyProvider implements InfoProviderInterface public function getDetails(string $id): PartDetailDTO { - $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [ - 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) - ]); + try { + $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [ + 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) + ]); + } catch (\InvalidArgumentException $exception) { + //Check if the exception was caused by an invalid or expired token + if (str_contains($exception->getMessage(), 'access_token')) { + throw OAuthReconnectRequiredException::forProvider($this->getProviderKey()); + } + + throw $exception; + } $response_array = $response->toArray(); $product = $response_array['Product']; @@ -268,7 +289,7 @@ class DigikeyProvider implements InfoProviderInterface $prices = []; foreach ($price_breaks as $price_break) { - $prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->currency); + $prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->settings->currency); } return [ @@ -290,6 +311,14 @@ class DigikeyProvider implements InfoProviderInterface 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME) ]); + if ($response->getStatusCode() === 404) { + //No media found + return [ + 'datasheets' => [], + 'images' => [], + ]; + } + $media_array = $response->toArray(); foreach ($media_array['MediaLinks'] as $media_link) { diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index b942b929..27dfb908 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Settings\InfoProviderSystem\Element14Settings; use Composer\CaBundle\CaBundle; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -46,7 +47,7 @@ class Element14Provider implements InfoProviderInterface private readonly HttpClientInterface $element14Client; - public function __construct(HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id) + public function __construct(HttpClientInterface $element14Client, private readonly Element14Settings $settings) { /* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems * with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866 @@ -65,7 +66,8 @@ class Element14Provider implements InfoProviderInterface 'name' => 'Farnell element14', 'description' => 'This provider uses the Farnell element14 API to search for parts.', 'url' => 'https://www.element14.com/', - 'disabled_help' => 'Configure the API key in the PROVIDER_ELEMENT14_KEY environment variable to enable.' + 'disabled_help' => 'Configure the API key in the provider settings to enable.', + 'settings_class' => Element14Settings::class, ]; } @@ -76,7 +78,7 @@ class Element14Provider implements InfoProviderInterface public function isActive(): bool { - return $this->api_key !== ''; + return $this->settings->apiKey !== null && trim($this->settings->apiKey) !== ''; } /** @@ -88,11 +90,11 @@ class Element14Provider implements InfoProviderInterface $response = $this->element14Client->request('GET', self::ENDPOINT_URL, [ 'query' => [ 'term' => $term, - 'storeInfo.id' => $this->store_id, + 'storeInfo.id' => $this->settings->storeId, 'resultsSettings.offset' => 0, 'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS, 'resultsSettings.responseGroup' => 'large', - 'callInfo.apiKey' => $this->api_key, + 'callInfo.apiKey' => $this->settings->apiKey, 'callInfo.responseDataFormat' => 'json', 'versionNumber' => self::API_VERSION_NUMBER, ], @@ -160,7 +162,7 @@ class Element14Provider implements InfoProviderInterface $locale = 'en_US'; } - return 'https://' . $this->store_id . '/productimages/standard/' . $locale . $image['baseName']; + return 'https://' . $this->settings->storeId . '/productimages/standard/' . $locale . $image['baseName']; } /** @@ -195,7 +197,7 @@ class Element14Provider implements InfoProviderInterface public function getUsedCurrency(): string { //Decide based on the shop ID - return match ($this->store_id) { + return match ($this->settings->storeId) { 'bg.farnell.com', 'at.farnell.com', 'si.farnell.com', 'sk.farnell.com', 'ro.farnell.com', 'pt.farnell.com', 'nl.farnell.com', 'be.farnell.com', 'lv.farnell.com', 'lt.farnell.com', 'it.farnell.com', 'fr.farnell.com', 'fi.farnell.com', 'ee.farnell.com', 'es.farnell.com', 'ie.farnell.com', 'cpcireland.farnell.com', 'de.farnell.com' => 'EUR', 'cz.farnell.com' => 'CZK', 'dk.farnell.com' => 'DKK', @@ -222,7 +224,7 @@ class Element14Provider implements InfoProviderInterface 'tw.element14.com' => 'TWD', 'kr.element14.com' => 'KRW', 'vn.element14.com' => 'VND', - default => throw new \RuntimeException('Unknown store ID: ' . $this->store_id) + default => throw new \RuntimeException('Unknown store ID: ' . $this->settings->storeId) }; } @@ -307,4 +309,4 @@ class Element14Provider implements InfoProviderInterface ProviderCapabilities::DATASHEET, ]; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php new file mode 100644 index 00000000..e0de9772 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php @@ -0,0 +1,76 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Symfony\Component\DependencyInjection\Attribute\When; + +/** + * This is a provider, which is used during tests. It always returns no results. + */ +#[When(env: 'test')] +class EmptyProvider implements InfoProviderInterface +{ + public function getProviderInfo(): array + { + return [ + 'name' => 'Empty Provider', + 'description' => 'This is a test provider', + //'url' => 'https://example.com', + 'disabled_help' => 'This provider is disabled for testing purposes' + ]; + } + + public function getProviderKey(): string + { + return 'empty'; + } + + public function isActive(): bool + { + return true; + } + + public function searchByKeyword(string $keyword): array + { + return [ + + ]; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ]; + } + + public function getDetails(string $id): PartDetailDTO + { + throw new \RuntimeException('No part details available'); + } +} diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index 30821bad..1f787559 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -39,8 +39,9 @@ interface InfoProviderInterface * - url?: The url of the provider (e.g. "https://www.digikey.com") * - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it * - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown + * - settings_class?: The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown * - * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string } + * @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string, settings_class?: class-string } */ public function getProviderInfo(): array; @@ -78,4 +79,4 @@ interface InfoProviderInterface * @return ProviderCapabilities[] */ public function getCapabilities(): array; -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index d903a8dd..ede34eb8 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -29,17 +29,18 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Settings\InfoProviderSystem\LCSCSettings; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Contracts\HttpClient\HttpClientInterface; -class LCSCProvider implements InfoProviderInterface +class LCSCProvider implements BatchInfoProviderInterface { private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm'; public const DISTRIBUTOR_NAME = 'LCSC'; - public function __construct(private readonly HttpClientInterface $lcscClient, private readonly string $currency, private readonly bool $enabled = true) + public function __construct(private readonly HttpClientInterface $lcscClient, private readonly LCSCSettings $settings) { } @@ -50,7 +51,8 @@ class LCSCProvider implements InfoProviderInterface 'name' => 'LCSC', 'description' => 'This provider uses the (unofficial) LCSC API to search for parts.', 'url' => 'https://www.lcsc.com/', - 'disabled_help' => 'Set PROVIDER_LCSC_ENABLED to 1 (or true) in your environment variable config.' + 'disabled_help' => 'Enable this provider in the provider settings.', + 'settings_class' => LCSCSettings::class, ]; } @@ -62,18 +64,19 @@ class LCSCProvider implements InfoProviderInterface // This provider is always active public function isActive(): bool { - return $this->enabled; + return $this->settings->enabled; } /** * @param string $id + * @param bool $lightweight If true, skip expensive operations like datasheet resolution * @return PartDetailDTO */ - private function queryDetail(string $id): PartDetailDTO + private function queryDetail(string $id, bool $lightweight = false): PartDetailDTO { $response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [ 'headers' => [ - 'Cookie' => new Cookie('currencyCode', $this->currency) + 'Cookie' => new Cookie('currencyCode', $this->settings->currency) ], 'query' => [ 'productCode' => $id, @@ -87,7 +90,7 @@ class LCSCProvider implements InfoProviderInterface throw new \RuntimeException('Could not find product code: ' . $id); } - return $this->getPartDetail($product); + return $this->getPartDetail($product, $lightweight); } /** @@ -97,35 +100,47 @@ class LCSCProvider implements InfoProviderInterface private function getRealDatasheetUrl(?string $url): string { if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) { - if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) { - $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1]; - } - $response = $this->lcscClient->request('GET', $url, [ - 'headers' => [ - 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html' - ], - ]); - if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) { - //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused - //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934 - $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}'); - $url = $jsonObj->previewPdfUrl; - } + if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) { + $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1]; + } + $response = $this->lcscClient->request('GET', $url, [ + 'headers' => [ + 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html' + ], + ]); + if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) { + //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused + //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934 + $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}'); + $url = $jsonObj->previewPdfUrl; + } } return $url; } /** * @param string $term + * @param bool $lightweight If true, skip expensive operations like datasheet resolution * @return PartDetailDTO[] */ - private function queryByTerm(string $term): array + private function queryByTerm(string $term, bool $lightweight = false): array { - $response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [ + // Optimize: If term looks like an LCSC part number (starts with C followed by digits), + // use direct detail query instead of slower search + if (preg_match('/^C\d+$/i', trim($term))) { + try { + return [$this->queryDetail(trim($term), $lightweight)]; + } catch (\Exception $e) { + // If direct lookup fails, fall back to search + // This handles cases where the C-code might not exist + } + } + + $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [ 'headers' => [ - 'Cookie' => new Cookie('currencyCode', $this->currency) + 'Cookie' => new Cookie('currencyCode', $this->settings->currency) ], - 'query' => [ + 'json' => [ 'keyword' => $term, ], ]); @@ -143,11 +158,11 @@ class LCSCProvider implements InfoProviderInterface // detailed product listing. It does so utilizing a product tip field. // If product tip exists and there are no products in the product list try a detail query if (count($products) === 0 && $tipProductCode !== null) { - $result[] = $this->queryDetail($tipProductCode); + $result[] = $this->queryDetail($tipProductCode, $lightweight); } foreach ($products as $product) { - $result[] = $this->getPartDetail($product); + $result[] = $this->getPartDetail($product, $lightweight); } return $result; @@ -163,6 +178,9 @@ class LCSCProvider implements InfoProviderInterface if ($field === null) { return null; } + // Replace "range" indicators with mathematical tilde symbols + // so they don't get rendered as strikethrough by Markdown + $field = preg_replace("/~/", "\u{223c}", $field); return strip_tags($field); } @@ -173,7 +191,7 @@ class LCSCProvider implements InfoProviderInterface * @param array $product * @return PartDetailDTO */ - private function getPartDetail(array $product): PartDetailDTO + private function getPartDetail(array $product, bool $lightweight = false): PartDetailDTO { // Get product images in advance $product_images = $this->getProductImages($product['productImages'] ?? null); @@ -195,9 +213,6 @@ class LCSCProvider implements InfoProviderInterface $category = $product['parentCatalogName'] ?? null; if (isset($product['catalogName'])) { $category = ($category ?? '') . ' -> ' . $product['catalogName']; - - // Replace the / with a -> for better readability - $category = str_replace('/', ' -> ', $category); } return new PartDetailDTO( @@ -212,10 +227,10 @@ class LCSCProvider implements InfoProviderInterface manufacturing_status: null, provider_url: $this->getProductShortURL($product['productCode']), footprint: $this->sanitizeField($footprint), - datasheets: $this->getProductDatasheets($product['pdfUrl'] ?? null), - images: $product_images, - parameters: $this->attributesToParameters($product['paramVOList'] ?? []), - vendor_infos: $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []), + datasheets: $lightweight ? [] : $this->getProductDatasheets($product['pdfUrl'] ?? null), + images: $product_images, // Always include images - users need to see them + parameters: $lightweight ? [] : $this->attributesToParameters($product['paramVOList'] ?? []), + vendor_infos: $lightweight ? [] : $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []), mass: $product['weight'] ?? null, ); } @@ -273,7 +288,7 @@ class LCSCProvider implements InfoProviderInterface 'kr.' => 'DKK', 'โ‚น' => 'INR', //Fallback to the configured currency - default => $this->currency, + default => $this->settings->currency, }; } @@ -284,7 +299,7 @@ class LCSCProvider implements InfoProviderInterface */ private function getProductShortURL(string $product_code): string { - return 'https://www.lcsc.com/product-detail/' . $product_code .'.html'; + return 'https://www.lcsc.com/product-detail/' . $product_code . '.html'; } /** @@ -325,7 +340,7 @@ class LCSCProvider implements InfoProviderInterface //Skip this attribute if it's empty if (in_array(trim((string) $attribute['paramValueEn']), ['', '-'], true)) { - continue; + continue; } $result[] = ParameterDTO::parseValueIncludingUnit(name: $attribute['paramNameEn'], value: $attribute['paramValueEn'], group: null); @@ -336,12 +351,86 @@ class LCSCProvider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { - return $this->queryByTerm($keyword); + return $this->queryByTerm($keyword, true); // Use lightweight mode for search + } + + /** + * Batch search multiple keywords asynchronously (like JavaScript Promise.all) + * @param array $keywords Array of keywords to search + * @return array Results indexed by keyword + */ + public function searchByKeywordsBatch(array $keywords): array + { + if (empty($keywords)) { + return []; + } + + $responses = []; + $results = []; + + // Start all requests immediately (like JavaScript promises without await) + foreach ($keywords as $keyword) { + if (preg_match('/^C\d+$/i', trim($keyword))) { + // Direct detail API call for C-codes + $responses[$keyword] = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [ + 'headers' => [ + 'Cookie' => new Cookie('currencyCode', $this->settings->currency) + ], + 'query' => [ + 'productCode' => trim($keyword), + ], + ]); + } else { + // Search API call for other terms + $responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [ + 'headers' => [ + 'Cookie' => new Cookie('currencyCode', $this->settings->currency) + ], + 'json' => [ + 'keyword' => $keyword, + ], + ]); + } + } + + // Now collect all results (like .then() in JavaScript) + foreach ($responses as $keyword => $response) { + try { + $arr = $response->toArray(); // This waits for the response + $results[$keyword] = $this->processSearchResponse($arr, $keyword); + } catch (\Exception $e) { + $results[$keyword] = []; // Empty results on error + } + } + + return $results; + } + + private function processSearchResponse(array $arr, string $keyword): array + { + $result = []; + + // Check if this looks like a detail response (direct C-code lookup) + if (isset($arr['result']['productCode'])) { + $product = $arr['result']; + $result[] = $this->getPartDetail($product, true); // lightweight mode + } else { + // This is a search response + $products = $arr['result']['productSearchResultVO']['productList'] ?? []; + $tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null; + + // If no products but has tip, we'd need another API call - skip for batch mode + foreach ($products as $product) { + $result[] = $this->getPartDetail($product, true); // lightweight mode + } + } + + return $result; } public function getDetails(string $id): PartDetailDTO { - $tmp = $this->queryByTerm($id); + $tmp = $this->queryByTerm($id, false); if (count($tmp) === 0) { throw new \RuntimeException('No part found with ID ' . $id); } diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php index 90bad263..3171c994 100644 --- a/src/Services/InfoProviderSystem/Providers/MouserProvider.php +++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php @@ -37,6 +37,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Settings\InfoProviderSystem\MouserSettings; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -50,10 +51,7 @@ class MouserProvider implements InfoProviderInterface public function __construct( private readonly HttpClientInterface $mouserClient, - private readonly string $api_key, - private readonly string $language, - private readonly string $options, - private readonly int $search_limit + private readonly MouserSettings $settings, ) { } @@ -63,7 +61,8 @@ class MouserProvider implements InfoProviderInterface 'name' => 'Mouser', 'description' => 'This provider uses the Mouser API to search for parts.', 'url' => 'https://www.mouser.com/', - 'disabled_help' => 'Configure the API key in the PROVIDER_MOUSER_KEY environment variable to enable.' + 'disabled_help' => 'Configure the API key in the provider settings to enable.', + 'settings_class' => MouserSettings::class ]; } @@ -74,7 +73,7 @@ class MouserProvider implements InfoProviderInterface public function isActive(): bool { - return $this->api_key !== ''; + return $this->settings->apiKey !== '' && $this->settings->apiKey !== null; } public function searchByKeyword(string $keyword): array @@ -120,19 +119,28 @@ class MouserProvider implements InfoProviderInterface $response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/keyword", [ 'query' => [ - 'apiKey' => $this->api_key, + 'apiKey' => $this->settings->apiKey ], 'json' => [ 'SearchByKeywordRequest' => [ 'keyword' => $keyword, - 'records' => $this->search_limit, //self::NUMBER_OF_RESULTS, + 'records' => $this->settings->searchLimit, //self::NUMBER_OF_RESULTS, 'startingRecord' => 0, - 'searchOptions' => $this->options, - 'searchWithYourSignUpLanguage' => $this->language, + 'searchOptions' => $this->settings->searchOption->value, + 'searchWithYourSignUpLanguage' => $this->settings->searchWithSignUpLanguage ? 'true' : 'false', ] ], ]); + // Check for API errors before processing response + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException(sprintf( + 'Mouser API returned HTTP %d: %s', + $response->getStatusCode(), + $response->getContent(false) + )); + } + return $this->responseToDTOArray($response); } @@ -161,7 +169,7 @@ class MouserProvider implements InfoProviderInterface $response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/partnumber", [ 'query' => [ - 'apiKey' => $this->api_key, + 'apiKey' => $this->settings->apiKey, ], 'json' => [ 'SearchByPartRequest' => [ @@ -170,6 +178,16 @@ class MouserProvider implements InfoProviderInterface ] ], ]); + + // Check for API errors before processing response + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException(sprintf( + 'Mouser API returned HTTP %d: %s', + $response->getStatusCode(), + $response->getContent(false) + )); + } + $tmp = $this->responseToDTOArray($response); //Ensure that we have exactly one result @@ -287,6 +305,17 @@ class MouserProvider implements InfoProviderInterface return (float)$val; } + private function mapCurrencyCode(string $currency): string + { + //Mouser uses "RMB" for Chinese Yuan, but the correct ISO code is "CNY" + if ($currency === "RMB") { + return "CNY"; + } + + //For all other currencies, we assume that the ISO code is correct + return $currency; + } + /** * Converts the pricing (StandardPricing field) from the Mouser API to an array of PurchaseInfoDTOs * @param array $price_breaks @@ -303,7 +332,7 @@ class MouserProvider implements InfoProviderInterface $prices[] = new PriceDTO( minimum_discount_amount: $price_break['Quantity'], price: (string)$number, - currency_iso_code: $price_break['Currency'] + currency_iso_code: $this->mapCurrencyCode($price_break['Currency']) ); } @@ -347,4 +376,4 @@ class MouserProvider implements InfoProviderInterface return $tmp; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php index ccf800f8..48b6a6d8 100644 --- a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php @@ -88,6 +88,8 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Settings\InfoProviderSystem\OEMSecretsSettings; +use App\Settings\InfoProviderSystem\OEMSecretsSortMode; use Symfony\Contracts\HttpClient\HttpClientInterface; use Psr\Cache\CacheItemPoolInterface; @@ -99,12 +101,7 @@ class OEMSecretsProvider implements InfoProviderInterface public function __construct( private readonly HttpClientInterface $oemsecretsClient, - private readonly string $api_key, - private readonly string $country_code, - private readonly string $currency, - private readonly string $zero_price, - private readonly string $set_param, - private readonly string $sort_criteria, + private readonly OEMSecretsSettings $settings, private readonly CacheItemPoolInterface $partInfoCache ) { @@ -249,7 +246,8 @@ class OEMSecretsProvider implements InfoProviderInterface 'name' => 'OEMSecrets', 'description' => 'This provider uses the OEMSecrets API to search for parts.', 'url' => 'https://www.oemsecrets.com/', - 'disabled_help' => 'Configure the API key in the PROVIDER_OEMSECRETS_KEY environment variable to enable.' + 'disabled_help' => 'Configure the API key in the provider settings to enable.', + 'settings_class' => OEMSecretsSettings::class ]; } /** @@ -268,7 +266,7 @@ class OEMSecretsProvider implements InfoProviderInterface */ public function isActive(): bool { - return $this->api_key !== ''; + return $this->settings->apiKey !== null && $this->settings->apiKey !== ''; } @@ -288,18 +286,18 @@ class OEMSecretsProvider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { /* - oemsecrets Part Search API 3.0.1 + oemsecrets Part Search API 3.0.1 "https://oemsecretsapi.com/partsearch? searchTerm=BC547 &apiKey=icawpb0bspoo2c6s64uv4vpdfp2vgr7e27bxw0yct2bzh87mpl027x353uelpq2x ¤cy=EUR - &countryCode=IT" - + &countryCode=IT" + partsearch description: - Use the Part Search API to find distributor data for a full or partial manufacturer + Use the Part Search API to find distributor data for a full or partial manufacturer part number including part details, pricing, compliance and inventory. - + Required Parameter Format Description searchTerm string Part number you are searching for apiKey string Your unique API key provided to you by OEMsecrets @@ -307,14 +305,14 @@ class OEMSecretsProvider implements InfoProviderInterface Additional Parameter Format Description countryCode string The country you want to output for currency string / array The currency you want the prices to be displayed as - + To display the output for GB and to view prices in USD, add [ countryCode=GB ] and [ currency=USD ] as seen below: oemsecretsapi.com/partsearch?apiKey=abcexampleapikey123&searchTerm=bd04&countryCode=GB¤cy=USD - + To view prices in both USD and GBP add [ currency[]=USD¤cy[]=GBP ] oemsecretsapi.com/partsearch?searchTerm=bd04&apiKey=abcexampleapikey123¤cy[]=USD¤cy[]=GBP - + */ @@ -324,9 +322,9 @@ class OEMSecretsProvider implements InfoProviderInterface $response = $this->oemsecretsClient->request('GET', self::ENDPOINT_URL, [ 'query' => [ 'searchTerm' => $keyword, - 'apiKey' => $this->api_key, - 'currency' => $this->currency, - 'countryCode' => $this->country_code, + 'apiKey' => $this->settings->apiKey, + 'currency' => $this->settings->currency, + 'countryCode' => $this->settings->country, ], ]); @@ -399,13 +397,13 @@ class OEMSecretsProvider implements InfoProviderInterface * Generates a cache key for storing part details based on the provided provider ID. * * This method creates a unique cache key by prefixing the provider ID with 'part_details_' - * and hashing the provider ID using MD5 to ensure a consistent and compact key format. + * and hashing the provider ID using XXH3 to ensure a consistent and compact key format. * * @param string $provider_id The unique identifier of the provider or part. * @return string The generated cache key. */ private function getCacheKey(string $provider_id): string { - return 'oemsecrets_part_' . md5($provider_id); + return 'oemsecrets_part_' . hash('xxh3', $provider_id); } @@ -533,7 +531,7 @@ class OEMSecretsProvider implements InfoProviderInterface // Extract prices $priceDTOs = $this->getPrices($product); - if (empty($priceDTOs) && (int)$this->zero_price === 0) { + if (empty($priceDTOs) && !$this->settings->keepZeroPrices) { return null; // Skip products without valid prices } @@ -557,7 +555,7 @@ class OEMSecretsProvider implements InfoProviderInterface } $imagesResults[$provider_id] = $this->getImages($product, $imagesResults[$provider_id] ?? []); - if ($this->set_param == 1) { + if ($this->settings->parseParams) { $parametersResults[$provider_id] = $this->getParameters($product, $parametersResults[$provider_id] ?? []); } else { $parametersResults[$provider_id] = []; @@ -582,7 +580,7 @@ class OEMSecretsProvider implements InfoProviderInterface $regionB = $this->countryCodeToRegionMap[$countryCodeB] ?? ''; // If the map is empty or doesn't contain the key for $this->country_code, assign a placeholder region. - $regionForEnvCountry = $this->countryCodeToRegionMap[$this->country_code] ?? ''; + $regionForEnvCountry = $this->countryCodeToRegionMap[$this->settings->country] ?? ''; // Convert to string before comparison to avoid mixed types $countryCodeA = (string) $countryCodeA; @@ -599,9 +597,9 @@ class OEMSecretsProvider implements InfoProviderInterface } // Step 1: country_code from the environment - if ($countryCodeA === $this->country_code && $countryCodeB !== $this->country_code) { + if ($countryCodeA === $this->settings->country && $countryCodeB !== $this->settings->country) { return -1; - } elseif ($countryCodeA !== $this->country_code && $countryCodeB === $this->country_code) { + } elseif ($countryCodeA !== $this->settings->country && $countryCodeB === $this->settings->country) { return 1; } @@ -681,8 +679,8 @@ class OEMSecretsProvider implements InfoProviderInterface if (is_array($prices)) { // Step 1: Check if prices exist in the preferred currency - if (isset($prices[$this->currency]) && is_array($prices[$this->currency])) { - $priceDetails = $prices[$this->currency]; + if (isset($prices[$this->settings->currency]) && is_array($prices[$this->settings->currency])) { + $priceDetails = $prices[$this->$this->settings->currency]; foreach ($priceDetails as $priceDetail) { if ( is_array($priceDetail) && @@ -694,7 +692,7 @@ class OEMSecretsProvider implements InfoProviderInterface $priceDTOs[] = new PriceDTO( minimum_discount_amount: (float)$priceDetail['unit_break'], price: (string)$priceDetail['unit_price'], - currency_iso_code: $this->currency, + currency_iso_code: $this->settings->currency, includes_tax: false, price_related_quantity: 1.0 ); @@ -1293,7 +1291,7 @@ class OEMSecretsProvider implements InfoProviderInterface private function sortResultsData(array &$resultsData, string $searchKeyword): void { // If the SORT_CRITERIA is not 'C' or 'M', do not sort - if ($this->sort_criteria !== 'C' && $this->sort_criteria !== 'M') { + if ($this->settings->sortMode !== OEMSecretsSortMode::COMPLETENESS && $this->settings->sortMode !== OEMSecretsSortMode::MANUFACTURER) { return; } usort($resultsData, function ($a, $b) use ($searchKeyword) { @@ -1332,9 +1330,9 @@ class OEMSecretsProvider implements InfoProviderInterface } // Final sorting: by completeness or manufacturer, if necessary - if ($this->sort_criteria === 'C') { + if ($this->settings->sortMode === OEMSecretsSortMode::COMPLETENESS) { return $this->compareByCompleteness($a, $b); - } elseif ($this->sort_criteria === 'M') { + } elseif ($this->settings->sortMode === OEMSecretsSortMode::MANUFACTURER) { return strcasecmp($a->manufacturer, $b->manufacturer); } @@ -1468,4 +1466,4 @@ class OEMSecretsProvider implements InfoProviderInterface return $url; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php index e28162ba..1142f4ef 100644 --- a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\OAuth\OAuthTokenManager; +use App\Settings\InfoProviderSystem\OctopartSettings; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\HttpClient\HttpOptions; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -114,9 +115,8 @@ class OctopartProvider implements InfoProviderInterface public function __construct(private readonly HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache, - private readonly string $clientId, private readonly string $secret, - private readonly string $currency, private readonly string $country, - private readonly int $search_limit, private readonly bool $onlyAuthorizedSellers) + private readonly OctopartSettings $settings, + ) { } @@ -170,7 +170,8 @@ class OctopartProvider implements InfoProviderInterface 'name' => 'Octopart', 'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.', 'url' => 'https://www.octopart.com/', - 'disabled_help' => 'Set the PROVIDER_OCTOPART_CLIENT_ID and PROVIDER_OCTOPART_SECRET env option.' + 'disabled_help' => 'Set the Client ID and Secret in provider settings.', + 'settings_class' => OctopartSettings::class ]; } @@ -183,7 +184,8 @@ class OctopartProvider implements InfoProviderInterface { //The client ID has to be set and a token has to be available (user clicked connect) //return /*!empty($this->clientId) && */ $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); - return $this->clientId !== '' && $this->secret !== ''; + return $this->settings->clientId !== null && $this->settings->clientId !== '' + && $this->settings->secret !== null && $this->settings->secret !== ''; } private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus @@ -337,7 +339,7 @@ class OctopartProvider implements InfoProviderInterface ) { hits results { - part + part %s } } @@ -347,10 +349,10 @@ class OctopartProvider implements InfoProviderInterface $result = $this->makeGraphQLCall($graphQL, [ 'keyword' => $keyword, - 'limit' => $this->search_limit, - 'currency' => $this->currency, - 'country' => $this->country, - 'authorizedOnly' => $this->onlyAuthorizedSellers, + 'limit' => $this->settings->searchLimit, + 'currency' => $this->settings->currency, + 'country' => $this->settings->country, + 'authorizedOnly' => $this->settings->onlyAuthorizedSellers, ]); $tmp = []; @@ -383,9 +385,9 @@ class OctopartProvider implements InfoProviderInterface $result = $this->makeGraphQLCall($graphql, [ 'ids' => [$id], - 'currency' => $this->currency, - 'country' => $this->country, - 'authorizedOnly' => $this->onlyAuthorizedSellers, + 'currency' => $this->settings->currency, + 'country' => $this->settings->country, + 'authorizedOnly' => $this->settings->onlyAuthorizedSellers, ]); $tmp = $this->partResultToDTO($result['data']['supParts'][0]); @@ -403,4 +405,4 @@ class OctopartProvider implements InfoProviderInterface ProviderCapabilities::PRICE, ]; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index 77366a2d..2c5d68a3 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Settings\InfoProviderSystem\PollinSettings; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -39,8 +40,7 @@ class PollinProvider implements InfoProviderInterface { public function __construct(private readonly HttpClientInterface $client, - #[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')] - private readonly bool $enabled = true, + private readonly PollinSettings $settings, ) { } @@ -49,9 +49,10 @@ class PollinProvider implements InfoProviderInterface { return [ 'name' => 'Pollin', - 'description' => 'Webscrapping from pollin.de to get part information', - 'url' => 'https://www.reichelt.de/', - 'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1' + 'description' => 'Webscraping from pollin.de to get part information', + 'url' => 'https://www.pollin.de/', + 'disabled_help' => 'Enable the provider in provider settings', + 'settings_class' => PollinSettings::class, ]; } @@ -62,7 +63,7 @@ class PollinProvider implements InfoProviderInterface public function isActive(): bool { - return $this->enabled; + return $this->settings->enabled; } public function searchByKeyword(string $keyword): array @@ -157,7 +158,8 @@ class PollinProvider implements InfoProviderInterface category: $this->parseCategory($dom), manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null, preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'), - manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')), + //TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page + //manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')), provider_url: $productPageUrl, notes: $this->parseNotes($dom), datasheets: $this->parseDatasheets($dom), diff --git a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php index fd67cd2c..bced19de 100644 --- a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php +++ b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php @@ -31,9 +31,6 @@ enum ProviderCapabilities /** Basic information about a part, like the name, description, part number, manufacturer etc */ case BASIC; - /** Information about the footprint of a part */ - case FOOTPRINT; - /** Provider can provide a picture for a part */ case PICTURE; @@ -43,6 +40,24 @@ enum ProviderCapabilities /** Provider can provide prices for a part */ case PRICE; + /** Information about the footprint of a part */ + case FOOTPRINT; + + /** + * Get the order index for displaying capabilities in a stable order. + * @return int + */ + public function getOrderIndex(): int + { + return match($this) { + self::BASIC => 1, + self::PICTURE => 2, + self::DATASHEET => 3, + self::PRICE => 4, + self::FOOTPRINT => 5, + }; + } + public function getTranslationKey(): string { return 'info_providers.capabilities.' . match($this) { diff --git a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index 98eac09b..5c8efbf1 100644 --- a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php @@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Settings\InfoProviderSystem\ReicheltSettings; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -39,16 +40,7 @@ class ReicheltProvider implements InfoProviderInterface public const DISTRIBUTOR_NAME = "Reichelt"; public function __construct(private readonly HttpClientInterface $client, - #[Autowire(env: "bool:PROVIDER_REICHELT_ENABLED")] - private readonly bool $enabled = true, - #[Autowire(env: "PROVIDER_REICHELT_LANGUAGE")] - private readonly string $language = "en", - #[Autowire(env: "PROVIDER_REICHELT_COUNTRY")] - private readonly string $country = "DE", - #[Autowire(env: "PROVIDER_REICHELT_INCLUDE_VAT")] - private readonly bool $includeVAT = false, - #[Autowire(env: "PROVIDER_REICHELT_CURRENCY")] - private readonly string $currency = "EUR", + private readonly ReicheltSettings $settings, ) { } @@ -57,9 +49,10 @@ class ReicheltProvider implements InfoProviderInterface { return [ 'name' => 'Reichelt', - 'description' => 'Webscrapping from reichelt.com to get part information', + 'description' => 'Webscraping from reichelt.com to get part information', 'url' => 'https://www.reichelt.com/', - 'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1' + 'disabled_help' => 'Enable provider in provider settings.', + 'settings_class' => ReicheltSettings::class, ]; } @@ -70,7 +63,7 @@ class ReicheltProvider implements InfoProviderInterface public function isActive(): bool { - return $this->enabled; + return $this->settings->enabled; } public function searchByKeyword(string $keyword): array @@ -121,8 +114,8 @@ class ReicheltProvider implements InfoProviderInterface sprintf( 'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s', $id, - strtoupper($this->language), - strtoupper($this->country) + strtoupper($this->settings->language), + strtoupper($this->settings->country) ) ); $json = $response->toArray(); @@ -133,8 +126,8 @@ class ReicheltProvider implements InfoProviderInterface $response = $this->client->request('GET', $productPage, [ 'query' => [ - 'CCTYPE' => $this->includeVAT ? 'private' : 'business', - 'currency' => $this->currency, + 'CCTYPE' => $this->settings->includeVAT ? 'private' : 'business', + 'currency' => $this->settings->currency, ], ]); $html = $response->getContent(); @@ -158,7 +151,7 @@ class ReicheltProvider implements InfoProviderInterface distributor_name: self::DISTRIBUTOR_NAME, order_number: $json[0]['article_artnr'], prices: array_merge( - [new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)] + [new PriceDTO(1.0, $priceString, $currency, $this->settings->includeVAT)] , $this->parseBatchPrices($dom, $currency)), product_url: $productPage ); @@ -218,7 +211,7 @@ class ReicheltProvider implements InfoProviderInterface //Strip any non-numeric characters $priceString = preg_replace('/[^0-9.]/', '', $priceString); - $prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT); + $prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->settings->includeVAT); }); return $prices; @@ -270,7 +263,7 @@ class ReicheltProvider implements InfoProviderInterface private function getBaseURL(): string { //Without the trailing slash - return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language); + return 'https://www.reichelt.com/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language); } public function getCapabilities(): array @@ -282,4 +275,4 @@ class ReicheltProvider implements InfoProviderInterface ProviderCapabilities::PRICE, ]; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php index d4df133e..ae2ab0d1 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEClient.php +++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Settings\InfoProviderSystem\TMESettings; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -30,15 +31,15 @@ class TMEClient { public const BASE_URI = 'https://api.tme.eu'; - public function __construct(private readonly HttpClientInterface $tmeClient, private readonly string $token, private readonly string $secret) + public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings) { } public function makeRequest(string $action, array $parameters): ResponseInterface { - $parameters['Token'] = $this->token; - $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->secret); + $parameters['Token'] = $this->settings->apiToken; + $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->settings->apiSecret); return $this->tmeClient->request('POST', $this->getUrlForAction($action), [ 'body' => $parameters, @@ -47,7 +48,7 @@ class TMEClient public function isUsable(): bool { - return $this->token !== '' && $this->secret !== ''; + return !($this->settings->apiToken === null || $this->settings->apiSecret === null); } /** @@ -58,7 +59,7 @@ class TMEClient public function isUsingPrivateToken(): bool { //Private tokens are longer than anonymous ones (50 instead of 45 characters) - return strlen($this->token) > 45; + return strlen($this->settings->apiToken ?? '') > 45; } /** @@ -93,4 +94,4 @@ class TMEClient return $params; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 32fc0c72..9bc73f09 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -30,24 +30,21 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Settings\InfoProviderSystem\TMESettings; class TMEProvider implements InfoProviderInterface { private const VENDOR_NAME = 'TME'; - /** @var bool If true, the prices are gross prices. If false, the prices are net prices. */ private readonly bool $get_gross_prices; - - public function __construct(private readonly TMEClient $tmeClient, private readonly string $country, - private readonly string $language, private readonly string $currency, - bool $get_gross_prices) + public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings) { //If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then if ($this->tmeClient->isUsingPrivateToken()) { $this->get_gross_prices = false; } else { - $this->get_gross_prices = $get_gross_prices; + $this->get_gross_prices = $this->settings->grossPrices; } } @@ -57,7 +54,8 @@ class TMEProvider implements InfoProviderInterface 'name' => 'TME', 'description' => 'This provider uses the API of TME (Transfer Multipart).', 'url' => 'https://tme.eu/', - 'disabled_help' => 'Configure the PROVIDER_TME_KEY and PROVIDER_TME_SECRET environment variables to use this provider.' + 'disabled_help' => 'Configure the API Token and secret in provider settings to use this provider.', + 'settings_class' => TMESettings::class ]; } @@ -74,8 +72,8 @@ class TMEProvider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { $response = $this->tmeClient->makeRequest('Products/Search', [ - 'Country' => $this->country, - 'Language' => $this->language, + 'Country' => $this->settings->country, + 'Language' => $this->settings->language, 'SearchPlain' => $keyword, ]); @@ -104,8 +102,8 @@ class TMEProvider implements InfoProviderInterface public function getDetails(string $id): PartDetailDTO { $response = $this->tmeClient->makeRequest('Products/GetProducts', [ - 'Country' => $this->country, - 'Language' => $this->language, + 'Country' => $this->settings->country, + 'Language' => $this->settings->language, 'SymbolList' => [$id], ]); @@ -149,8 +147,8 @@ class TMEProvider implements InfoProviderInterface public function getFiles(string $id): array { $response = $this->tmeClient->makeRequest('Products/GetProductsFiles', [ - 'Country' => $this->country, - 'Language' => $this->language, + 'Country' => $this->settings->country, + 'Language' => $this->settings->language, 'SymbolList' => [$id], ]); @@ -191,9 +189,9 @@ class TMEProvider implements InfoProviderInterface public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO { $response = $this->tmeClient->makeRequest('Products/GetPricesAndStocks', [ - 'Country' => $this->country, - 'Language' => $this->language, - 'Currency' => $this->currency, + 'Country' => $this->settings->country, + 'Language' => $this->settings->language, + 'Currency' => $this->settings->currency, 'GrossPrices' => $this->get_gross_prices, 'SymbolList' => [$id], ]); @@ -234,8 +232,8 @@ class TMEProvider implements InfoProviderInterface public function getParameters(string $id, string|null &$footprint_name = null): array { $response = $this->tmeClient->makeRequest('Products/GetParameters', [ - 'Country' => $this->country, - 'Language' => $this->language, + 'Country' => $this->settings->country, + 'Language' => $this->settings->language, 'SymbolList' => [$id], ]); @@ -298,4 +296,4 @@ class TMEProvider implements InfoProviderInterface ProviderCapabilities::PRICE, ]; } -} \ No newline at end of file +} diff --git a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php index 7ceb30dd..3df7d227 100644 --- a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php +++ b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php @@ -95,6 +95,11 @@ final class BarcodeContentGenerator return $prefix.$id; } + /** + * @param array $map + * @param object $target + * @return string + */ private function classToString(array $map, object $target): string { $class = $target::class; diff --git a/src/Services/LabelSystem/LabelHTMLGenerator.php b/src/Services/LabelSystem/LabelHTMLGenerator.php index 42aa1e72..8a5201ff 100644 --- a/src/Services/LabelSystem/LabelHTMLGenerator.php +++ b/src/Services/LabelSystem/LabelHTMLGenerator.php @@ -42,6 +42,7 @@ declare(strict_types=1); namespace App\Services\LabelSystem; use App\Entity\LabelSystem\LabelProcessMode; +use App\Settings\SystemSettings\CustomizationSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Contracts\NamedElementInterface; use App\Entity\LabelSystem\LabelOptions; @@ -60,7 +61,7 @@ final class LabelHTMLGenerator private readonly LabelBarcodeGenerator $barcodeGenerator, private readonly SandboxedTwigFactory $sandboxedTwigProvider, private readonly Security $security, - private readonly string $partdb_title) + private readonly CustomizationSettings $customizationSettings,) { } @@ -88,7 +89,8 @@ final class LabelHTMLGenerator 'page' => $page, 'last_page' => count($elements), 'user' => $current_user, - 'install_title' => $this->partdb_title, + 'install_title' => $this->customizationSettings->instanceName, + 'partdb_title' => $this->customizationSettings->instanceName, 'paper_width' => $options->getWidth(), 'paper_height' => $options->getHeight(), ] diff --git a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php index dd70177f..400fef35 100644 --- a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php +++ b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php @@ -63,12 +63,24 @@ final class BarcodeProvider implements PlaceholderProviderInterface return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target); } + if ('[[BARCODE_DATAMATRIX]]' === $placeholder) { + $label_options = new LabelOptions(); + $label_options->setBarcodeType(BarcodeType::DATAMATRIX); + return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target); + } + if ('[[BARCODE_C39]]' === $placeholder) { $label_options = new LabelOptions(); $label_options->setBarcodeType(BarcodeType::CODE39); return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target); } + if ('[[BARCODE_C93]]' === $placeholder) { + $label_options = new LabelOptions(); + $label_options->setBarcodeType(BarcodeType::CODE93); + return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target); + } + if ('[[BARCODE_C128]]' === $placeholder) { $label_options = new LabelOptions(); $label_options->setBarcodeType(BarcodeType::CODE128); diff --git a/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php b/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php index ddd4dbf1..f14a5863 100644 --- a/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php +++ b/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php @@ -41,6 +41,7 @@ declare(strict_types=1); namespace App\Services\LabelSystem\PlaceholderProviders; +use App\Settings\SystemSettings\CustomizationSettings; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\UserSystem\User; use DateTime; @@ -54,14 +55,18 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; */ final class GlobalProviders implements PlaceholderProviderInterface { - public function __construct(private readonly string $partdb_title, private readonly Security $security, private readonly UrlGeneratorInterface $url_generator) + public function __construct( + private readonly Security $security, + private readonly UrlGeneratorInterface $url_generator, + private CustomizationSettings $customizationSettings, + ) { } public function replace(string $placeholder, object $label_target, array $options = []): ?string { if ('[[INSTALL_NAME]]' === $placeholder) { - return $this->partdb_title; + return $this->customizationSettings->instanceName; } $user = $this->security->getUser(); diff --git a/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php b/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php index 0df4d3d7..7d9e4db5 100644 --- a/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php +++ b/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php @@ -46,7 +46,9 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Footprint; use App\Entity\Parts\Part; use App\Services\Formatters\SIFormatter; -use Parsedown; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\InlinesOnly\InlinesOnlyExtension; +use League\CommonMark\MarkdownConverter; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -54,8 +56,13 @@ use Symfony\Contracts\Translation\TranslatorInterface; */ final class PartProvider implements PlaceholderProviderInterface { + private readonly MarkdownConverter $inlineConverter; + public function __construct(private readonly SIFormatter $siFormatter, private readonly TranslatorInterface $translator) { + $environment = new Environment(); + $environment->addExtension(new InlinesOnlyExtension()); + $this->inlineConverter = new MarkdownConverter($environment); } public function replace(string $placeholder, object $part, array $options = []): ?string @@ -112,22 +119,20 @@ final class PartProvider implements PlaceholderProviderInterface return $this->translator->trans($part->getManufacturingStatus()->toTranslationKey()); } - $parsedown = new Parsedown(); - if ('[[DESCRIPTION]]' === $placeholder) { - return $parsedown->line($part->getDescription()); + return trim($this->inlineConverter->convert($part->getDescription())->getContent()); } if ('[[DESCRIPTION_T]]' === $placeholder) { - return strip_tags((string) $parsedown->line($part->getDescription())); + return trim(strip_tags($this->inlineConverter->convert($part->getDescription())->getContent())); } if ('[[COMMENT]]' === $placeholder) { - return $parsedown->line($part->getComment()); + return trim($this->inlineConverter->convert($part->getComment())->getContent()); } if ('[[COMMENT_T]]' === $placeholder) { - return strip_tags((string) $parsedown->line($part->getComment())); + return trim(strip_tags($this->inlineConverter->convert($part->getComment())->getContent())); } return null; diff --git a/src/Services/LabelSystem/SandboxedTwigFactory.php b/src/Services/LabelSystem/SandboxedTwigFactory.php index d6ea6968..d5e09fa5 100644 --- a/src/Services/LabelSystem/SandboxedTwigFactory.php +++ b/src/Services/LabelSystem/SandboxedTwigFactory.php @@ -133,7 +133,7 @@ final class SandboxedTwigFactory Supplier::class => ['getShippingCosts', 'getDefaultCurrency'], Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference', 'getDescription', 'getComment', 'isFavorite', 'getCategory', 'getFootprint', - 'getPartLots', 'getPartUnit', 'useFloatAmount', 'getMinAmount', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum', + 'getPartLots', 'getPartUnit', 'getPartCustomState', 'useFloatAmount', 'getMinAmount', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum', 'getManufacturerProductUrl', 'getCustomProductURL', 'getManufacturingStatus', 'getManufacturer', 'getManufacturerProductNumber', 'getOrderdetails', 'isObsolete', 'getParameters', 'getGroupedParameters', diff --git a/src/Services/LogSystem/EventCommentNeededHelper.php b/src/Services/LogSystem/EventCommentNeededHelper.php index 8440f199..cacf525f 100644 --- a/src/Services/LogSystem/EventCommentNeededHelper.php +++ b/src/Services/LogSystem/EventCommentNeededHelper.php @@ -22,37 +22,25 @@ declare(strict_types=1); */ namespace App\Services\LogSystem; +use App\Settings\SystemSettings\HistorySettings; + /** * This service is used to check if a log change comment is needed for a given operation type. * It is configured using the "enforce_change_comments_for" config parameter. * @see \App\Tests\Services\LogSystem\EventCommentNeededHelperTest */ -class EventCommentNeededHelper +final class EventCommentNeededHelper { - final public const VALID_OPERATION_TYPES = [ - 'part_edit', - 'part_create', - 'part_delete', - 'part_stock_operation', - 'datastructure_edit', - 'datastructure_create', - 'datastructure_delete', - ]; - - public function __construct(protected array $enforce_change_comments_for) + public function __construct(private readonly HistorySettings $settings) { + } /** * Checks if a log change comment is needed for the given operation type */ - public function isCommentNeeded(string $comment_type): bool + public function isCommentNeeded(EventCommentType $comment_type): bool { - //Check if the comment type is valid - if (! in_array($comment_type, self::VALID_OPERATION_TYPES, true)) { - throw new \InvalidArgumentException('The comment type "'.$comment_type.'" is not valid!'); - } - - return in_array($comment_type, $this->enforce_change_comments_for, true); + return in_array($comment_type, $this->settings->enforceComments, true); } } diff --git a/src/Services/LogSystem/EventCommentType.php b/src/Services/LogSystem/EventCommentType.php new file mode 100644 index 00000000..d68c03b2 --- /dev/null +++ b/src/Services/LogSystem/EventCommentType.php @@ -0,0 +1,47 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\LogSystem; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * This enum represents the different types of event comments that could be required, by the system. + * They are almost only useful when working with the EventCommentNeededHelper service. + */ +enum EventCommentType: string implements TranslatableInterface +{ + case PART_EDIT = 'part_edit'; + case PART_CREATE = 'part_create'; + case PART_DELETE = 'part_delete'; + case PART_STOCK_OPERATION = 'part_stock_operation'; + case DATASTRUCTURE_EDIT = 'datastructure_edit'; + case DATASTRUCTURE_CREATE = 'datastructure_create'; + case DATASTRUCTURE_DELETE = 'datastructure_delete'; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return $translator->trans('settings.system.history.enforceComments.type.' . $this->value, locale: $locale); + } +} diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 616df229..945cff7b 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -30,13 +30,11 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; -use App\Repository\PartRepository; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; -use Symfony\Contracts\Translation\TranslatableInterface; use function Symfony\Component\Translation\t; @@ -100,7 +98,7 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart //When action starts with "export_" we have to redirect to the export controller $matches = []; - if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) { + if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) { $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); $level = match ($target_id) { 2 => 'extended', @@ -119,6 +117,16 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart ); } + if ($action === 'bulk_info_provider_import') { + $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); + return new RedirectResponse( + $this->urlGenerator->generate('bulk_info_provider_step1', [ + 'ids' => $ids, + '_redirect' => $redirect_url + ]) + ); + } + //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { diff --git a/src/Services/Parts/PricedetailHelper.php b/src/Services/Parts/PricedetailHelper.php index 092cc278..b2e1340f 100644 --- a/src/Services/Parts/PricedetailHelper.php +++ b/src/Services/Parts/PricedetailHelper.php @@ -25,6 +25,7 @@ namespace App\Services\Parts; use App\Entity\Parts\Part; use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Pricedetail; +use App\Settings\SystemSettings\LocalizationSettings; use Brick\Math\BigDecimal; use Brick\Math\RoundingMode; use Doctrine\ORM\PersistentCollection; @@ -39,7 +40,7 @@ class PricedetailHelper { protected string $locale; - public function __construct(protected string $base_currency) + public function __construct() { $this->locale = Locale::getDefault(); } diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php index 269c7e4c..a541c29d 100644 --- a/src/Services/ProjectSystem/ProjectBuildHelper.php +++ b/src/Services/ProjectSystem/ProjectBuildHelper.php @@ -31,9 +31,9 @@ use App\Services\Parts\PartLotWithdrawAddHelper; /** * @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest */ -class ProjectBuildHelper +final readonly class ProjectBuildHelper { - public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper) + public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper) { } @@ -63,20 +63,37 @@ class ProjectBuildHelper */ public function getMaximumBuildableCount(Project $project): int { + $bom_entries = $project->getBomEntries(); + if ($bom_entries->isEmpty()) { + return 0; + } $maximum_buildable_count = PHP_INT_MAX; - foreach ($project->getBomEntries() as $bom_entry) { + foreach ($bom_entries as $bom_entry) { //Skip BOM entries without a part (as we can not determine that) if (!$bom_entry->isPartBomEntry()) { continue; } - //The maximum buildable count for the whole project is the minimum of all BOM entries $maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry)); } - return $maximum_buildable_count; } + /** + * Returns the maximum buildable amount of the given project as string, based on the stock of the used parts in the BOM. + * If the maximum buildable count is infinite, the string 'โˆž' is returned. + * @param Project $project + * @return string + */ + public function getMaximumBuildableCountAsString(Project $project): string + { + $max_count = $this->getMaximumBuildableCount($project); + if ($max_count === PHP_INT_MAX) { + return 'โˆž'; + } + return (string) $max_count; + } + /** * Checks if the given project can be built with the current stock. * This means that the maximum buildable count is greater or equal than the requested $number_of_projects diff --git a/src/Services/System/BannerHelper.php b/src/Services/System/BannerHelper.php index 3d5daef9..bb27158f 100644 --- a/src/Services/System/BannerHelper.php +++ b/src/Services/System/BannerHelper.php @@ -23,12 +23,14 @@ declare(strict_types=1); namespace App\Services\System; +use App\Settings\SystemSettings\CustomizationSettings; + /** * Helper service to retrieve the banner of this Part-DB installation */ class BannerHelper { - public function __construct(private readonly string $project_dir, private readonly string $partdb_banner) + public function __construct(private readonly CustomizationSettings $customizationSettings) { } @@ -39,18 +41,6 @@ class BannerHelper */ public function getBanner(): string { - $banner = $this->partdb_banner; - if ($banner === '') { - $banner_path = $this->project_dir - .DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md'; - - $tmp = file_get_contents($banner_path); - if (false === $tmp) { - throw new \RuntimeException('The banner file could not be read.'); - } - $banner = $tmp; - } - - return $banner; + return $this->customizationSettings->banner ?? ""; } -} \ No newline at end of file +} diff --git a/src/Services/System/UpdateAvailableManager.php b/src/Services/System/UpdateAvailableManager.php index 31cb3266..82cfb84e 100644 --- a/src/Services/System/UpdateAvailableManager.php +++ b/src/Services/System/UpdateAvailableManager.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\System; +use App\Settings\SystemSettings\PrivacySettings; use Psr\Log\LoggerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -43,7 +44,7 @@ class UpdateAvailableManager public function __construct(private readonly HttpClientInterface $httpClient, private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager, - private readonly bool $check_for_updates, private readonly LoggerInterface $logger, + private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger, #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode) { @@ -83,7 +84,7 @@ class UpdateAvailableManager public function isUpdateAvailable(): bool { //If we don't want to check for updates, we can return false - if (!$this->check_for_updates) { + if (!$this->privacySettings->checkForUpdates) { return false; } @@ -101,7 +102,7 @@ class UpdateAvailableManager private function getLatestVersionInfo(): array { //If we don't want to check for updates, we can return dummy data - if (!$this->check_for_updates) { + if (!$this->privacySettings->checkForUpdates) { return [ 'version' => '0.0.1', 'url' => 'update-checking-disabled' diff --git a/src/Services/Tools/ExchangeRateUpdater.php b/src/Services/Tools/ExchangeRateUpdater.php index eac6de16..6eb7ec13 100644 --- a/src/Services/Tools/ExchangeRateUpdater.php +++ b/src/Services/Tools/ExchangeRateUpdater.php @@ -23,13 +23,16 @@ declare(strict_types=1); namespace App\Services\Tools; use App\Entity\PriceInformations\Currency; +use App\Settings\SystemSettings\LocalizationSettings; use Brick\Math\BigDecimal; use Brick\Math\RoundingMode; +use Exchanger\Exception\UnsupportedCurrencyPairException; +use Exchanger\Exception\UnsupportedExchangeQueryException; use Swap\Swap; class ExchangeRateUpdater { - public function __construct(private readonly string $base_currency, private readonly Swap $swap) + public function __construct(private LocalizationSettings $localizationSettings, private readonly Swap $swap) { } @@ -38,15 +41,21 @@ class ExchangeRateUpdater */ public function update(Currency $currency): Currency { - //Currency pairs are always in the format "BASE/QUOTE" - $rate = $this->swap->latest($this->base_currency.'/'.$currency->getIsoCode()); - //The rate says how many quote units are worth one base unit - //So we need to invert it to get the exchange rate + try { + //Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction + $rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency); + $effective_rate = BigDecimal::of($rate->getValue()); + } catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) { + //Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE" + $rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode()); + //The rate says how many quote units are worth one base unit + //So we need to invert it to get the exchange rate - $rate_bd = BigDecimal::of($rate->getValue()); - $rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP); + $rate_bd = BigDecimal::of($rate->getValue()); + $effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP); + } - $currency->setExchangeRate($rate_inverted); + $currency->setExchangeRate($effective_rate); return $currency; } diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 18571306..37a09b09 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -29,6 +29,7 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; @@ -37,6 +38,7 @@ use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; use App\Helpers\Trees\TreeViewNode; use App\Services\Cache\UserCacheKeyGenerator; +use App\Services\ElementTypeNameGenerator; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -49,8 +51,14 @@ use Symfony\Contracts\Translation\TranslatorInterface; */ class ToolsTreeBuilder { - public function __construct(protected TranslatorInterface $translator, protected UrlGeneratorInterface $urlGenerator, protected TagAwareCacheInterface $cache, protected UserCacheKeyGenerator $keyGenerator, protected Security $security) - { + public function __construct( + protected TranslatorInterface $translator, + protected UrlGeneratorInterface $urlGenerator, + protected TagAwareCacheInterface $cache, + protected UserCacheKeyGenerator $keyGenerator, + protected Security $security, + private readonly ElementTypeNameGenerator $elementTypeNameGenerator, + ) { } /** @@ -138,6 +146,11 @@ class ToolsTreeBuilder $this->translator->trans('info_providers.search.title'), $this->urlGenerator->generate('info_providers_search') ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); + + $nodes[] = (new TreeViewNode( + $this->translator->trans('info_providers.bulk_import.manage_jobs'), + $this->urlGenerator->generate('bulk_info_provider_manage') + ))->setIcon('fa-treeview fa-fw fa-solid fa-tasks'); } return $nodes; @@ -154,64 +167,70 @@ class ToolsTreeBuilder if ($this->security->isGranted('read', new AttachmentType())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.attachment_types'), + $this->elementTypeNameGenerator->typeLabelPlural(AttachmentType::class), $this->urlGenerator->generate('attachment_type_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-file-alt'); } if ($this->security->isGranted('read', new Category())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.categories'), + $this->elementTypeNameGenerator->typeLabelPlural(Category::class), $this->urlGenerator->generate('category_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-tags'); } if ($this->security->isGranted('read', new Project())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.projects'), + $this->elementTypeNameGenerator->typeLabelPlural(Project::class), $this->urlGenerator->generate('project_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-archive'); } if ($this->security->isGranted('read', new Supplier())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.suppliers'), + $this->elementTypeNameGenerator->typeLabelPlural(Supplier::class), $this->urlGenerator->generate('supplier_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-truck'); } if ($this->security->isGranted('read', new Manufacturer())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.manufacturer'), + $this->elementTypeNameGenerator->typeLabelPlural(Manufacturer::class), $this->urlGenerator->generate('manufacturer_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-industry'); } if ($this->security->isGranted('read', new StorageLocation())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.storelocation'), + $this->elementTypeNameGenerator->typeLabelPlural(StorageLocation::class), $this->urlGenerator->generate('store_location_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-cube'); } if ($this->security->isGranted('read', new Footprint())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.footprint'), + $this->elementTypeNameGenerator->typeLabelPlural(Footprint::class), $this->urlGenerator->generate('footprint_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-microchip'); } if ($this->security->isGranted('read', new Currency())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.currency'), + $this->elementTypeNameGenerator->typeLabelPlural(Currency::class), $this->urlGenerator->generate('currency_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-coins'); } if ($this->security->isGranted('read', new MeasurementUnit())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.measurement_unit'), + $this->elementTypeNameGenerator->typeLabelPlural(MeasurementUnit::class), $this->urlGenerator->generate('measurement_unit_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-balance-scale'); } if ($this->security->isGranted('read', new LabelProfile())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.label_profile'), + $this->elementTypeNameGenerator->typeLabelPlural(LabelProfile::class), $this->urlGenerator->generate('label_profile_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-qrcode'); } + if ($this->security->isGranted('read', new PartCustomState())) { + $nodes[] = (new TreeViewNode( + $this->elementTypeNameGenerator->typeLabelPlural(PartCustomState::class), + $this->urlGenerator->generate('part_custom_state_new') + ))->setIcon('fa-fw fa-treeview fa-solid fa-tools'); + } if ($this->security->isGranted('create', new Part())) { $nodes[] = (new TreeViewNode( $this->translator->trans('tree.tools.edit.part'), @@ -289,6 +308,13 @@ class ToolsTreeBuilder ))->setIcon('fa-fw fa-treeview fa-solid fa-database'); } + if ($this->security->isGranted('@config.change_system_settings')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tree.tools.system.settings'), + $this->urlGenerator->generate('system_settings') + ))->setIcon('fa fa-fw fa-gears fa-solid'); + } + return $nodes; } } diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php index 23d6a406..d55c87b7 100644 --- a/src/Services/Trees/TreeViewGenerator.php +++ b/src/Services/Trees/TreeViewGenerator.php @@ -34,10 +34,11 @@ use App\Entity\ProjectSystem\Project; use App\Helpers\Trees\TreeViewNode; use App\Helpers\Trees\TreeViewNodeIterator; use App\Repository\NamedDBElementRepository; -use App\Repository\StructuralDBElementRepository; use App\Services\Cache\ElementCacheTagGenerator; use App\Services\Cache\UserCacheKeyGenerator; +use App\Services\ElementTypeNameGenerator; use App\Services\EntityURLGenerator; +use App\Settings\BehaviorSettings\SidebarSettings; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use RecursiveIteratorIterator; @@ -53,6 +54,10 @@ use function count; */ class TreeViewGenerator { + + private readonly bool $rootNodeExpandedByDefault; + private readonly bool $rootNodeEnabled; + public function __construct( protected EntityURLGenerator $urlGenerator, protected EntityManagerInterface $em, @@ -61,10 +66,11 @@ class TreeViewGenerator protected UserCacheKeyGenerator $keyGenerator, protected TranslatorInterface $translator, private readonly UrlGeneratorInterface $router, - protected bool $rootNodeExpandedByDefault, - protected bool $rootNodeEnabled, - + private readonly SidebarSettings $sidebarSettings, + private readonly ElementTypeNameGenerator $elementTypeNameGenerator ) { + $this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled; + $this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded; } /** @@ -174,10 +180,7 @@ class TreeViewGenerator } if (($mode === 'list_parts_root' || $mode === 'devices') && $this->rootNodeEnabled) { - //We show the root node as a link to the list of all parts - $show_all_parts_url = $this->router->generate('parts_show_all'); - - $root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $show_all_parts_url, $generic); + $root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $this->entityClassToRootNodeHref($class), $generic); $root_node->setExpanded($this->rootNodeExpandedByDefault); $root_node->setIcon($this->entityClassToRootNodeIcon($class)); @@ -187,17 +190,30 @@ class TreeViewGenerator return array_merge($head, $generic); } + protected function entityClassToRootNodeHref(string $class): ?string + { + //If the root node should redirect to the new entity page, we return the URL for the new entity. + if ($this->sidebarSettings->rootNodeRedirectsToNewEntity) { + return match ($class) { + Category::class => $this->router->generate('category_new'), + StorageLocation::class => $this->router->generate('store_location_new'), + Footprint::class => $this->router->generate('footprint_new'), + Manufacturer::class => $this->router->generate('manufacturer_new'), + Supplier::class => $this->router->generate('supplier_new'), + Project::class => $this->router->generate('project_new'), + default => null, + }; + } + + return match ($class) { + Project::class => $this->router->generate('project_new'), + default => $this->router->generate('parts_show_all') + }; + } + protected function entityClassToRootNodeString(string $class): string { - return match ($class) { - Category::class => $this->translator->trans('category.labelp'), - StorageLocation::class => $this->translator->trans('storelocation.labelp'), - Footprint::class => $this->translator->trans('footprint.labelp'), - Manufacturer::class => $this->translator->trans('manufacturer.labelp'), - Supplier::class => $this->translator->trans('supplier.labelp'), - Project::class => $this->translator->trans('project.labelp'), - default => $this->translator->trans('tree.root_node.text'), - }; + return $this->elementTypeNameGenerator->typeLabelPlural($class); } protected function entityClassToRootNodeIcon(string $class): ?string diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index eeb80f61..a3ed01b8 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -102,9 +102,13 @@ class PermissionPresetsHelper $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'attachment_types', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'currencies', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'measurement_units', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'part_custom_states', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW); + //Allow to change system settings + $this->permissionResolver->setPermission($perm_holder, 'config', 'change_system_settings', PermissionData::ALLOW); + //Allow to manage Oauth tokens $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW); //Allow to show updates @@ -128,6 +132,7 @@ class PermissionPresetsHelper $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'attachment_types', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'currencies', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'measurement_units', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'part_custom_states', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'suppliers', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'projects', PermissionData::ALLOW, ['import']); diff --git a/src/Services/UserSystem/UserAvatarHelper.php b/src/Services/UserSystem/UserAvatarHelper.php index a694fa77..9dbe9c12 100644 --- a/src/Services/UserSystem/UserAvatarHelper.php +++ b/src/Services/UserSystem/UserAvatarHelper.php @@ -30,6 +30,7 @@ use App\Entity\Attachments\UserAttachment; use App\Entity\UserSystem\User; use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\Attachments\AttachmentURLGenerator; +use App\Settings\SystemSettings\PrivacySettings; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Asset\Packages; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -42,7 +43,7 @@ class UserAvatarHelper public const IMG_DEFAULT_AVATAR_PATH = 'img/default_avatar.svg'; public function __construct( - private readonly bool $use_gravatar, + private readonly PrivacySettings $privacySettings, private readonly Packages $packages, private readonly AttachmentURLGenerator $attachmentURLGenerator, private readonly EntityManagerInterface $entityManager, @@ -65,7 +66,7 @@ class UserAvatarHelper } //If not check if gravatar is enabled (then use gravatar URL) - if ($this->use_gravatar) { + if ($this->privacySettings->useGravatar) { return $this->getGravatar($user, 200); //200px wide picture } @@ -82,7 +83,7 @@ class UserAvatarHelper } //If not check if gravatar is enabled (then use gravatar URL) - if ($this->use_gravatar) { + if ($this->privacySettings->useGravatar) { return $this->getGravatar($user, 50); //50px wide picture } @@ -99,7 +100,7 @@ class UserAvatarHelper } //If not check if gravatar is enabled (then use gravatar URL) - if ($this->use_gravatar) { + if ($this->privacySettings->useGravatar) { return $this->getGravatar($user, 150); } diff --git a/src/Services/UserSystem/VoterHelper.php b/src/Services/UserSystem/VoterHelper.php index 644351f4..d3c5368c 100644 --- a/src/Services/UserSystem/VoterHelper.php +++ b/src/Services/UserSystem/VoterHelper.php @@ -28,6 +28,9 @@ use App\Repository\UserRepository; use App\Security\ApiTokenAuthenticatedToken; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\Voter; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @see \App\Tests\Services\UserSystem\VoterHelperTest @@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; final class VoterHelper { private readonly UserRepository $userRepository; + private readonly array $permissionStructure; - public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager) + public function __construct(private readonly PermissionManager $permissionManager, + private readonly TranslatorInterface $translator, + private readonly EntityManagerInterface $entityManager) { $this->userRepository = $this->entityManager->getRepository(User::class); + $this->permissionStructure = $this->permissionManager->getPermissionStructure(); } /** @@ -47,11 +54,16 @@ final class VoterHelper * @param TokenInterface $token The token to check * @param string $permission The permission to check * @param string $operation The operation to check + * @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added. * @return bool */ - public function isGranted(TokenInterface $token, string $permission, string $operation): bool + public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool { - return $this->isGrantedTrinary($token, $permission, $operation) ?? false; + $tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false; + if ($tmp === false) { + $this->addReason($vote, $permission, $operation); + } + return $tmp; } /** @@ -124,4 +136,17 @@ final class VoterHelper { return $this->permissionManager->isValidOperation($permission, $operation); } -} \ No newline at end of file + + public function addReason(?Vote $voter, string $permission, $operation): void + { + if ($voter !== null) { + $voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).", + $this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ), + $this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission), + $this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation), + $permission, + $operation + )); + } + } +} diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php new file mode 100644 index 00000000..14d9395e --- /dev/null +++ b/src/Settings/AppSettings.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings; + +use App\Settings\BehaviorSettings\BehaviorSettings; +use App\Settings\InfoProviderSystem\InfoProviderSettings; +use App\Settings\MiscSettings\MiscSettings; +use App\Settings\SystemSettings\SystemSettings; +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; + +#[Settings] +#[SettingsIcon('folder-tree')] +class AppSettings +{ + use SettingsTrait; + + + #[EmbeddedSettings()] + public ?SystemSettings $system = null; + + #[EmbeddedSettings()] + public ?BehaviorSettings $behavior = null; + + #[EmbeddedSettings()] + public ?InfoProviderSettings $infoProviders = null; + + #[EmbeddedSettings] + public ?SynonymSettings $synonyms = null; + + #[EmbeddedSettings()] + public ?MiscSettings $miscSettings = null; + + + +} diff --git a/src/Settings/BehaviorSettings/BehaviorSettings.php b/src/Settings/BehaviorSettings/BehaviorSettings.php new file mode 100644 index 00000000..3053073f --- /dev/null +++ b/src/Settings/BehaviorSettings/BehaviorSettings.php @@ -0,0 +1,44 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.behavior"))] +class BehaviorSettings +{ + use SettingsTrait; + + #[EmbeddedSettings] + public ?SidebarSettings $sidebar = null; + + #[EmbeddedSettings] + public ?TableSettings $table = null; + + #[EmbeddedSettings] + public ?PartInfoSettings $partInfo = null; +} diff --git a/src/Settings/BehaviorSettings/PartInfoSettings.php b/src/Settings/BehaviorSettings/PartInfoSettings.php new file mode 100644 index 00000000..f017c846 --- /dev/null +++ b/src/Settings/BehaviorSettings/PartInfoSettings.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(name: "part_info", label: new TM("settings.behavior.part_info"))] +#[SettingsIcon('fa-circle-info')] +class PartInfoSettings +{ + /** + * Whether to show the part image overlays in the part info view + * @var bool + */ + #[SettingsParameter(label: new TM("settings.behavior.part_info.show_part_image_overlay"), description: new TM("settings.behavior.part_info.show_part_image_overlay.help"), + envVar: "bool:SHOW_PART_IMAGE_OVERLAY", envVarMode: EnvVarMode::OVERWRITE)] + public bool $showPartImageOverlay = true; + + #[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_description"))] + public bool $extractParamsFromDescription = true; + + #[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_notes"))] + public bool $extractParamsFromNotes = true; +} diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php new file mode 100644 index 00000000..c025c952 --- /dev/null +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -0,0 +1,67 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum PartTableColumns : string implements TranslatableInterface +{ + + case NAME = "name"; + case ID = "id"; + case IPN = "ipn"; + case DESCRIPTION = "description"; + case CATEGORY = "category"; + case FOOTPRINT = "footprint"; + case MANUFACTURER = "manufacturer"; + case LOCATION = "storage_location"; + case AMOUNT = "amount"; + case MIN_AMOUNT = "minamount"; + case PART_UNIT = "partUnit"; + case ADDED_DATE = "addedDate"; + case LAST_MODIFIED = "lastModified"; + case NEEDS_REVIEW = "needs_review"; + case FAVORITE = "favorite"; + case MANUFACTURING_STATUS = "manufacturing_status"; + case MPN = "manufacturer_product_number"; + case CUSTOM_PART_STATE = 'partCustomState'; + case MASS = "mass"; + case TAGS = "tags"; + case ATTACHMENTS = "attachments"; + case EDIT = "edit"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + self::LOCATION => 'part.table.storeLocations', + self::NEEDS_REVIEW => 'part.table.needsReview', + self::MANUFACTURING_STATUS => 'part.table.manufacturingStatus', + self::MPN => 'part.table.mpn', + default => 'part.table.' . $this->value, + }; + + return $translator->trans($key, locale: $locale); + } +} diff --git a/src/Settings/BehaviorSettings/SidebarItems.php b/src/Settings/BehaviorSettings/SidebarItems.php new file mode 100644 index 00000000..cb0e60be --- /dev/null +++ b/src/Settings/BehaviorSettings/SidebarItems.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum SidebarItems: string implements TranslatableInterface +{ + case TOOLS = "tools"; + case CATEGORIES = "categories"; + case LOCATIONS = "locations"; + case FOOTPRINTS = "footprints"; + case MANUFACTURERS = "manufacturers"; + case SUPPLIERS = "suppliers"; + case PROJECTS = "projects"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + self::TOOLS => 'tools.label', + self::CATEGORIES => 'category.labelp', + self::LOCATIONS => 'storelocation.labelp', + self::FOOTPRINTS => 'footprint.labelp', + self::MANUFACTURERS => 'manufacturer.labelp', + self::SUPPLIERS => 'supplier.labelp', + self::PROJECTS => 'project.labelp', + }; + + return $translator->trans($key, locale: $locale); + } +} \ No newline at end of file diff --git a/src/Settings/BehaviorSettings/SidebarSettings.php b/src/Settings/BehaviorSettings/SidebarSettings.php new file mode 100644 index 00000000..a1ff6985 --- /dev/null +++ b/src/Settings/BehaviorSettings/SidebarSettings.php @@ -0,0 +1,83 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\EnumType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.behavior.sidebar"))] +#[SettingsIcon('fa-border-top-left')] +class SidebarSettings +{ + use SettingsTrait; + + + /** + * @var SidebarItems[] The items to show in the sidebar. + */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.sidebar.items"), + description: new TM("settings.behavior.sidebar.items.help"), + options: ['type' => EnumType::class, 'options' => ['class' => SidebarItems::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => SidebarItems::class, 'multiple' => true, 'ordered' => true] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + public array $items = [SidebarItems::CATEGORIES, SidebarItems::PROJECTS, SidebarItems::TOOLS]; + + /** + * @var bool Whether categories, etc. should be grouped under a root node or put directly into the sidebar trees. + */ + #[SettingsParameter( + label: new TM("settings.behavior.sidebar.rootNodeEnabled"), + description: new TM("settings.behavior.sidebar.rootNodeEnabled.help") + )] + public bool $rootNodeEnabled = true; + + /** + * @var bool Whether the root node should be expanded by default, or not. + */ + #[SettingsParameter(label: new TM("settings.behavior.sidebar.rootNodeExpanded"))] + public bool $rootNodeExpanded = true; + + /** + * @var bool Whether the root node should redirect to a new entity creation page when clicked. + */ + #[SettingsParameter(label: new TM("settings.behavior.sidebar.rootNodeRedirectsToNewEntity"))] + public bool $rootNodeRedirectsToNewEntity = false; + + /** + * @var bool Whether to include child nodes in the data structure nodes table, or only show the selected node's parts. + */ + #[SettingsParameter(label: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children"), + description: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children.help"))] + public bool $dataStructureNodesTableIncludeChildren = true; +} diff --git a/src/Settings/BehaviorSettings/TableSettings.php b/src/Settings/BehaviorSettings/TableSettings.php new file mode 100644 index 00000000..b3421e41 --- /dev/null +++ b/src/Settings/BehaviorSettings/TableSettings.php @@ -0,0 +1,104 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\EnumType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.behavior.table"))] +#[SettingsIcon('fa-table')] +class TableSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.behavior.table.default_page_size"), + description: new TM("settings.behavior.table.default_page_size.help"), + envVar: "int:TABLE_DEFAULT_PAGE_SIZE", + envVarMode: EnvVarMode::OVERWRITE, + )] + #[Assert\AtLeastOneOf(constraints: + [ + new Assert\Positive(), + new Assert\EqualTo(value: -1) + ] + )] + public int $fullDefaultPageSize = 50; + + + /** @var PartTableColumns[] */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.table.parts_default_columns"), + description: new TM("settings.behavior.table.parts_default_columns.help"), + options: ['type' => EnumType::class, 'options' => ['class' => PartTableColumns::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => PartTableColumns::class, 'multiple' => true, 'ordered' => true], + envVar: "TABLE_PARTS_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapPartsDefaultColumnsEnv'] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + #[Assert\All([new Assert\Type(PartTableColumns::class)])] + public array $partsDefaultColumns = [PartTableColumns::NAME, PartTableColumns::DESCRIPTION, + PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER, + PartTableColumns::LOCATION, PartTableColumns::AMOUNT, PartTableColumns::CUSTOM_PART_STATE]; + + #[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"), + formOptions: ['attr' => ['min' => 1, 'max' => 100]], + envVar: "int:TABLE_IMAGE_PREVIEW_MIN_SIZE", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Range(min: 1, max: 100)] + public int $previewImageMinWidth = 20; + + #[SettingsParameter(label: new TM("settings.behavior.table.preview_image_max_width"), + formOptions: ['attr' => ['min' => 1, 'max' => 100]], + envVar: "int:TABLE_IMAGE_PREVIEW_MAX_SIZE", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Range(min: 1, max: 100)] + #[Assert\GreaterThanOrEqual(propertyPath: 'previewImageMinWidth')] + public int $previewImageMaxWidth = 35; + + public static function mapPartsDefaultColumnsEnv(string $columns): array + { + $exploded = explode(',', $columns); + $ret = []; + foreach ($exploded as $column) { + $enum = PartTableColumns::tryFrom($column); + if (!$enum) { + throw new \InvalidArgumentException("Invalid column '$column' in TABLE_PARTS_DEFAULT_COLUMNS"); + } + + $ret[] = $enum; + } + + return $ret; + } + +} diff --git a/src/Settings/InfoProviderSystem/BuerklinSettings.php b/src/Settings/InfoProviderSystem/BuerklinSettings.php new file mode 100644 index 00000000..c083c07a --- /dev/null +++ b/src/Settings/InfoProviderSystem/BuerklinSettings.php @@ -0,0 +1,84 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.buerklin"), description: new TM("settings.ips.buerklin.help"))] +#[SettingsIcon("fa-plug")] +class BuerklinSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.client_id"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $clientId = null; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.secret"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_SECRET", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $secret = null; + + #[SettingsParameter( + label: new TM("settings.ips.buerklin.username"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_USER", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $username = null; + + #[SettingsParameter( + label: new TM("user.edit.password"), + formType: APIKeyType::class, + envVar: "PROVIDER_BUERKLIN_PASSWORD", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $password = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, + formOptions: ["preferred_choices" => ["EUR"]], + envVar: "PROVIDER_BUERKLIN_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Currency()] + public string $currency = "EUR"; + + #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, + formOptions: ["preferred_choices" => ["en", "de"]], + envVar: "PROVIDER_BUERKLIN_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Language] + public string $language = "en"; +} diff --git a/src/Settings/InfoProviderSystem/DigikeySettings.php b/src/Settings/InfoProviderSystem/DigikeySettings.php new file mode 100644 index 00000000..f42c1c1c --- /dev/null +++ b/src/Settings/InfoProviderSystem/DigikeySettings.php @@ -0,0 +1,73 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.digikey"))] +#[SettingsIcon("fa-plug")] +class DigikeySettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.client_id"), + formType: APIKeyType::class, + envVar: "PROVIDER_DIGIKEY_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $clientId = null; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.secret"), + formType: APIKeyType::class, + envVar: "PROVIDER_DIGIKEY_SECRET", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $secret = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, + formOptions: ["preferred_choices" => ["EUR", "USD", "CHF", "GBP"]], + envVar: "PROVIDER_DIGIKEY_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Currency()] + public string $currency = "EUR"; + + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, + envVar: "PROVIDER_DIGIKEY_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Country] + public string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, + envVar: "PROVIDER_DIGIKEY_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Language] + public string $language = "en"; +} diff --git a/src/Settings/InfoProviderSystem/Element14Settings.php b/src/Settings/InfoProviderSystem/Element14Settings.php new file mode 100644 index 00000000..a4cdbf0d --- /dev/null +++ b/src/Settings/InfoProviderSystem/Element14Settings.php @@ -0,0 +1,48 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.element14"))] +#[SettingsIcon("fa-plug")] +class Element14Settings +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.element14.apiKey"), description: new TM("settings.ips.element14.apiKey.help"),# + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "PROVIDER_ELEMENT14_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + #[SettingsParameter(label: new TM("settings.ips.element14.storeId"), description: new TM("settings.ips.element14.storeId.help"), + formOptions: ["help_html" => true], envVar: "PROVIDER_ELEMENT14_STORE_ID", envVarMode: EnvVarMode::OVERWRITE)] + public string $storeId = "de.farnell.com"; +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderGeneralSettings.php b/src/Settings/InfoProviderSystem/InfoProviderGeneralSettings.php new file mode 100644 index 00000000..fac6aae9 --- /dev/null +++ b/src/Settings/InfoProviderSystem/InfoProviderGeneralSettings.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\InfoProviderSystem\ProviderSelectType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\StringType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.general"))] +#[SettingsIcon("fa-magnifying-glass")] +class InfoProviderGeneralSettings +{ + /** + * @var string[] + */ + #[SettingsParameter(type: ArrayType::class, label: new TM("settings.ips.default_providers"), + description: new TM("settings.ips.default_providers.help"), options: ['type' => StringType::class], + formType: ProviderSelectType::class, formOptions: ['input' => 'string', 'required' => false, 'empty_data' => []])] + public array $defaultSearchProviders = []; +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php new file mode 100644 index 00000000..d4679e23 --- /dev/null +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -0,0 +1,69 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips"))] +class InfoProviderSettings +{ + use SettingsTrait; + + #[EmbeddedSettings] + public ?InfoProviderGeneralSettings $general = null; + + #[EmbeddedSettings] + public ?DigikeySettings $digikey = null; + + #[EmbeddedSettings] + public ?MouserSettings $mouser = null; + + #[EmbeddedSettings] + public ?TMESettings $tme = null; + + #[EmbeddedSettings] + public ?Element14Settings $element14 = null; + + #[EmbeddedSettings] + public ?OctopartSettings $octopartSettings = null; + + #[EmbeddedSettings] + public ?LCSCSettings $lcsc = null; + + #[EmbeddedSettings] + public ?OEMSecretsSettings $oemsecrets = null; + + #[EmbeddedSettings] + public ?ReicheltSettings $reichelt = null; + + #[EmbeddedSettings] + public ?PollinSettings $pollin = null; + + #[EmbeddedSettings] + public ?BuerklinSettings $buerklin = null; +} diff --git a/src/Settings/InfoProviderSystem/LCSCSettings.php b/src/Settings/InfoProviderSystem/LCSCSettings.php new file mode 100644 index 00000000..906838e2 --- /dev/null +++ b/src/Settings/InfoProviderSystem/LCSCSettings.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.lcsc"), description: new TM("settings.ips.lcsc.help"))] +#[SettingsIcon("fa-plug")] +class LCSCSettings +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), + envVar: "bool:PROVIDER_LCSC_ENABLED", envVarMode: EnvVarMode::OVERWRITE)] + public bool $enabled = false; + + #[SettingsParameter(label: new TM("settings.ips.lcsc.currency"), formType: CurrencyType::class, + envVar: "string:PROVIDER_LCSC_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Currency()] + public string $currency = 'EUR'; +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/MouserSearchOptions.php b/src/Settings/InfoProviderSystem/MouserSearchOptions.php new file mode 100644 index 00000000..429fab56 --- /dev/null +++ b/src/Settings/InfoProviderSystem/MouserSearchOptions.php @@ -0,0 +1,47 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum MouserSearchOptions: string implements TranslatableInterface +{ + case NONE = "None"; + case ROHS = "Rohs"; + case IN_STOCK = "InStock"; + case ROHS_AND_INSTOCK = "RohsAndInStock"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + self::NONE => "settings.ips.mouser.searchOptions.none", + self::ROHS => "settings.ips.mouser.searchOptions.rohs", + self::IN_STOCK => "settings.ips.mouser.searchOptions.inStock", + self::ROHS_AND_INSTOCK => "settings.ips.mouser.searchOptions.rohsAndInStock", + }; + + return $translator->trans($key, locale: $locale); + } +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/MouserSettings.php b/src/Settings/InfoProviderSystem/MouserSettings.php new file mode 100644 index 00000000..0abaa7f2 --- /dev/null +++ b/src/Settings/InfoProviderSystem/MouserSettings.php @@ -0,0 +1,69 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.mouser"))] +#[SettingsIcon("fa-plug")] +class MouserSettings +{ + #[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"), description: new TM("settings.ips.mouser.apiKey.help"), + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "PROVIDER_MOUSER_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + /** @var int The number of results to get from Mouser while searching (please note that this value is max 50) */ + #[SettingsParameter(label: new TM("settings.ips.mouser.searchLimit"), description: new TM("settings.ips.mouser.searchLimit.help"), + envVar: "int:PROVIDER_MOUSER_SEARCH_LIMIT", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Range(min: 1, max: 50)] + public int $searchLimit = 50; + + /** @var MouserSearchOptions Filter search results by RoHS compliance and stock availability */ + #[SettingsParameter(label: new TM("settings.ips.mouser.searchOptions"), description: new TM("settings.ips.mouser.searchOptions.help"), + envVar: "PROVIDER_MOUSER_SEARCH_OPTION", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, "mapSearchOptionEnvVar"])] + public MouserSearchOptions $searchOption = MouserSearchOptions::NONE; + + /** @var bool It is recommended to leave this set to 'true'. The option is not really documented by Mouser: + * Used when searching for keywords in the language specified when you signed up for Search API. */ + //TODO: Put this into some expert mode only + //#[SettingsParameter(envVar: "bool:PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE")] + public bool $searchWithSignUpLanguage = true; + + public static function mapSearchOptionEnvVar(?string $value): MouserSearchOptions + { + if (!$value) { + return MouserSearchOptions::NONE; + } + + return MouserSearchOptions::tryFrom($value) ?? MouserSearchOptions::NONE; + } + +} diff --git a/src/Settings/InfoProviderSystem/OEMSecretsSettings.php b/src/Settings/InfoProviderSystem/OEMSecretsSettings.php new file mode 100644 index 00000000..77cf9080 --- /dev/null +++ b/src/Settings/InfoProviderSystem/OEMSecretsSettings.php @@ -0,0 +1,90 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.oemsecrets"))] +#[SettingsIcon("fa-plug")] +class OEMSecretsSettings +{ + use SettingsTrait; + + public const SUPPORTED_CURRENCIES = ["AUD", "CAD", "CHF", "CNY", "DKK", "EUR", "GBP", "HKD", "ILS", "INR", "JPY", "KRW", "NOK", + "NZD", "RUB", "SEK", "SGD", "TWD", "USD"]; + + #[SettingsParameter(label: new TM("settings.ips.element14.apiKey"), + formType: APIKeyType::class, + envVar: "PROVIDER_OEMSECRETS_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + #[Assert\Country] + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, formOptions: ["preferred_choices" => ["DE", "PL", "GB", "FR", "US"]], + envVar: "PROVIDER_OEMSECRETS_COUNTRY_CODE", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, formOptions: ["preferred_choices" => self::SUPPORTED_CURRENCIES], + envVar: "PROVIDER_OEMSECRETS_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Choice(choices: self::SUPPORTED_CURRENCIES)] + public string $currency = "EUR"; + + /** + * @var bool If this is enabled, distributors with zero prices + * will be discarded from the creation of a new part + */ + #[SettingsParameter(label: new TM("settings.ips.oemsecrets.keepZeroPrices"), description: new TM("settings.ips.oemsecrets.keepZeroPrices.help"), + envVar: "bool:PROVIDER_OEMSECRETS_ZERO_PRICE", envVarMode: EnvVarMode::OVERWRITE)] + public bool $keepZeroPrices = false; + + /** + * @var bool If set to 1 the parameters for the part are generated + * # from the description transforming unstructured descriptions into structured parameters; + * # each parameter in description should have the form: "...;name1:value1;name2:value2" + */ + #[SettingsParameter(label: new TM("settings.ips.oemsecrets.parseParams"), description: new TM("settings.ips.oemsecrets.parseParams.help"), + envVar: "bool:PROVIDER_OEMSECRETS_SET_PARAM", envVarMode: EnvVarMode::OVERWRITE)] + public bool $parseParams = true; + + #[SettingsParameter(label: new TM("settings.ips.oemsecrets.sortMode"), envVar: "PROVIDER_OEMSECRETS_SORT_CRITERIA", envVarMapper: [self::class, "mapSortModeEnvVar"])] + public OEMSecretsSortMode $sortMode = OEMSecretsSortMode::COMPLETENESS; + + + public static function mapSortModeEnvVar(?string $value): OEMSecretsSortMode + { + if (!$value) { + return OEMSecretsSortMode::NONE; + } + + return OEMSecretsSortMode::tryFrom($value) ?? OEMSecretsSortMode::NONE; + } +} diff --git a/src/Settings/InfoProviderSystem/OEMSecretsSortMode.php b/src/Settings/InfoProviderSystem/OEMSecretsSortMode.php new file mode 100644 index 00000000..e479e07e --- /dev/null +++ b/src/Settings/InfoProviderSystem/OEMSecretsSortMode.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * This environment variable determines the sorting criteria for product results. + * The sorting process first arranges items based on the provided keyword. + * Then, if set to 'C', it further sorts by completeness (prioritizing items with the most + * detailed information). If set to 'M', it further sorts by manufacturer name. + * If unset or set to any other value, no sorting is performed. + */ +enum OEMSecretsSortMode : string implements TranslatableInterface +{ + case NONE = "N"; + case COMPLETENESS = "C"; + case MANUFACTURER = "M"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return $translator->trans('settings.ips.oemsecrets.sortMode.' . $this->value, locale: $locale); + } +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/OctopartSettings.php b/src/Settings/InfoProviderSystem/OctopartSettings.php new file mode 100644 index 00000000..c28da459 --- /dev/null +++ b/src/Settings/InfoProviderSystem/OctopartSettings.php @@ -0,0 +1,81 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.octopart"))] +#[SettingsIcon("fa-plug")] +class OctopartSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.client_id"), + formType: APIKeyType::class, + envVar: "PROVIDER_OCTOPART_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $clientId = null; + + #[SettingsParameter( + label: new TM("settings.ips.digikey.secret"), + formType: APIKeyType::class, + envVar: "PROVIDER_OCTOPART_SECRET", envVarMode: EnvVarMode::OVERWRITE + )] + public ?string $secret = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, + formOptions: ["preferred_choices" => ["EUR", "USD", "CHF", "GBP"]], + envVar: "PROVIDER_OCTOPART_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Currency()] + public string $currency = "EUR"; + + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, + envVar: "PROVIDER_OCTOPART_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Country] + public string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.octopart.searchLimit"), description: new TM("settings.ips.octopart.searchLimit.help"), + formType: NumberType::class, formOptions: ["attr" => ["min" => 1, "max" => 100]], + envVar: "int:PROVIDER_OCTOPART_SEARCH_LIMIT", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Range(min: 1, max: 100)] + public int $searchLimit = 10; + + #[SettingsParameter(label: new TM("settings.ips.octopart.onlyAuthorizedSellers"), + description: new TM("settings.ips.octopart.onlyAuthorizedSellers.help"), + envVar: "bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $onlyAuthorizedSellers = true; + +} diff --git a/src/Settings/InfoProviderSystem/PollinSettings.php b/src/Settings/InfoProviderSystem/PollinSettings.php new file mode 100644 index 00000000..033d8b7e --- /dev/null +++ b/src/Settings/InfoProviderSystem/PollinSettings.php @@ -0,0 +1,39 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.pollin"), description: new TM("settings.ips.pollin.help"))] +#[SettingsIcon("fa-plug")] +class PollinSettings +{ + #[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), + envVar: "bool:PROVIDER_POLLIN_ENABLED", envVarMode: EnvVarMode::OVERWRITE)] + public bool $enabled = false; +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/ReicheltSettings.php b/src/Settings/InfoProviderSystem/ReicheltSettings.php new file mode 100644 index 00000000..588447de --- /dev/null +++ b/src/Settings/InfoProviderSystem/ReicheltSettings.php @@ -0,0 +1,68 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.reichelt"), description: new TM("settings.ips.reichelt.help"))] +#[SettingsIcon("fa-plug")] +class ReicheltSettings +{ + use SettingsTrait; + + public const SUPPORTED_LANGUAGE = ["en", "de", "fr", "nl", "pl", "it", "es"]; + + #[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), + envVar: "bool:PROVIDER_REICHELT_ENABLED", envVarMode: EnvVarMode::OVERWRITE)] + public bool $enabled = false; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, formOptions: ["preferred_choices" => ["EUR"]], + envVar: "PROVIDER_REICHELT_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + public string $currency = "EUR"; + + #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, formOptions: ["preferred_choices" => self::SUPPORTED_LANGUAGE], + envVar: "PROVIDER_REICHELT_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Language()] + #[Assert\Choice(choices: self::SUPPORTED_LANGUAGE)] + public string $language = "en"; + + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, formOptions: ["preferred_choices" => ["DE", "PL", "GB", "FR"]], + envVar: "PROVIDER_REICHELT_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Country] + public string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.reichelt.include_vat"), + envVar: "bool:PROVIDER_REICHELT_INCLUDE_VAT", envVarMode: EnvVarMode::OVERWRITE)] + public bool $includeVAT = true; + +} \ No newline at end of file diff --git a/src/Settings/InfoProviderSystem/TMESettings.php b/src/Settings/InfoProviderSystem/TMESettings.php new file mode 100644 index 00000000..d6f03d34 --- /dev/null +++ b/src/Settings/InfoProviderSystem/TMESettings.php @@ -0,0 +1,75 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ips.tme"))] +#[SettingsIcon("fa-plug")] +class TMESettings +{ + use SettingsTrait; + + private const SUPPORTED_CURRENCIES = ["EUR", "USD", "PLN", "GBP"]; + + #[SettingsParameter(label: new TM("settings.ips.tme.token"), + description: new TM("settings.ips.tme.token.help"), + formType: APIKeyType::class, formOptions: ["help_html" => true], + envVar: "PROVIDER_TME_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiToken = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.secret"), + formType: APIKeyType::class, + envVar: "PROVIDER_TME_SECRET", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiSecret = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class, formOptions: ["preferred_choices" => self::SUPPORTED_CURRENCIES], + envVar: "PROVIDER_TME_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Choice(choices: self::SUPPORTED_CURRENCIES)] + public string $currency = "EUR"; + + #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, formOptions: ["preferred_choices" => ["en", "de", "fr", "pl"]], + envVar: "PROVIDER_TME_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Language] + public string $language = "en"; + + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, formOptions: ["preferred_choices" => ["DE", "PL", "GB", "FR"]], + envVar: "PROVIDER_TME_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Country] + public string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.tme.grossPrices"), + envVar: "bool:PROVIDER_TME_GET_GROSS_PRICES", envVarMode: EnvVarMode::OVERWRITE)] + public bool $grossPrices = true; +} diff --git a/src/Settings/MiscSettings/ExchangeRateSettings.php b/src/Settings/MiscSettings/ExchangeRateSettings.php new file mode 100644 index 00000000..744523c6 --- /dev/null +++ b/src/Settings/MiscSettings/ExchangeRateSettings.php @@ -0,0 +1,43 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(name: "exchange_rate", label: new TM("settings.misc.exchange_rate"))] +#[SettingsIcon("fa-money-bill-transfer")] +class ExchangeRateSettings +{ + #[SettingsParameter(label: new TM("settings.misc.exchange_rate.fixer_api_key"), + description: new TM("settings.misc.exchange_rate.fixer_api_key.help"), + formType: APIKeyType::class, + envVar: "FIXER_API_KEY", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $fixerApiKey = null; +} diff --git a/src/Settings/MiscSettings/IpnSuggestSettings.php b/src/Settings/MiscSettings/IpnSuggestSettings.php new file mode 100644 index 00000000..2c2cb21a --- /dev/null +++ b/src/Settings/MiscSettings/IpnSuggestSettings.php @@ -0,0 +1,109 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\StringType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\StaticMessage; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.misc.ipn_suggest"))] +#[SettingsIcon("fa-arrow-up-1-9")] +class IpnSuggestSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.regex"), + description: new TM("settings.misc.ipn_suggest.regex.help"), + options: ['type' => StringType::class], + formOptions: ['attr' => ['placeholder' => new StaticMessage( '^[A-Za-z0-9]{3,4}(?:-[A-Za-z0-9]{3,4})*-\d{4}$')]], + envVar: "IPN_SUGGEST_REGEX", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $regex = null; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.regex_help"), + description: new TM("settings.misc.ipn_suggest.regex_help_description"), + options: ['type' => StringType::class], + formOptions: ['attr' => ['placeholder' => new TM('settings.misc.ipn_suggest.regex.help.placeholder')]], + envVar: "IPN_SUGGEST_REGEX_HELP", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $regexHelp = null; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.autoAppendSuffix"), + envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $autoAppendSuffix = false; + + #[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"), + description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"), + formOptions: ['attr' => ['min' => 1, 'max' => 8]], + envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Range(min: 1, max: 8)] + public int $suggestPartDigits = 4; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"), + description: new TM("settings.misc.ipn_suggest.useDuplicateDescription.help"), + envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $useDuplicateDescription = false; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.fallbackPrefix"), + description: new TM("settings.misc.ipn_suggest.fallbackPrefix.help"), + options: ['type' => StringType::class], + )] + public string $fallbackPrefix = 'N.A.'; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.numberSeparator"), + description: new TM("settings.misc.ipn_suggest.numberSeparator.help"), + options: ['type' => StringType::class], + )] + public string $numberSeparator = '-'; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.categorySeparator"), + description: new TM("settings.misc.ipn_suggest.categorySeparator.help"), + options: ['type' => StringType::class], + )] + public string $categorySeparator = '-'; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.globalPrefix"), + description: new TM("settings.misc.ipn_suggest.globalPrefix.help"), + options: ['type' => StringType::class], + )] + public ?string $globalPrefix = null; +} diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php new file mode 100644 index 00000000..d8f1026d --- /dev/null +++ b/src/Settings/MiscSettings/KiCadEDASettings.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.misc.kicad_eda"))] +#[SettingsIcon("fa-bolt-lightning")] +class KiCadEDASettings +{ + use SettingsTrait; + + + #[SettingsParameter(label: new TM("settings.misc.kicad_eda.category_depth"), + description: new TM("settings.misc.kicad_eda.category_depth.help"), + envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Range(min: -1)] + public int $categoryDepth = 0; +} \ No newline at end of file diff --git a/src/Settings/MiscSettings/MiscSettings.php b/src/Settings/MiscSettings/MiscSettings.php new file mode 100644 index 00000000..050dbcbc --- /dev/null +++ b/src/Settings/MiscSettings/MiscSettings.php @@ -0,0 +1,41 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.misc"))] +class MiscSettings +{ + #[EmbeddedSettings] + public ?KiCadEDASettings $kicadEDA = null; + + #[EmbeddedSettings] + public ?ExchangeRateSettings $exchangeRate = null; + + #[EmbeddedSettings] + public ?IpnSuggestSettings $ipnSuggestSettings = null; +} diff --git a/src/Settings/SettingsIcon.php b/src/Settings/SettingsIcon.php new file mode 100644 index 00000000..45bfc544 --- /dev/null +++ b/src/Settings/SettingsIcon.php @@ -0,0 +1,32 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings; + +#[\Attribute(\Attribute::TARGET_CLASS)] +class SettingsIcon +{ + public function __construct(public string $icon) + { + } +} \ No newline at end of file diff --git a/src/Settings/SynonymSettings.php b/src/Settings/SynonymSettings.php new file mode 100644 index 00000000..25fc87e9 --- /dev/null +++ b/src/Settings/SynonymSettings.php @@ -0,0 +1,116 @@ +. + */ + +declare(strict_types=1); + +namespace App\Settings; + +use App\Form\Settings\TypeSynonymsCollectionType; +use App\Services\ElementTypes; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\SerializeType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.synonyms"), description: "settings.synonyms.help")] +#[SettingsIcon("fa-language")] +class SynonymSettings +{ + use SettingsTrait; + + #[SettingsParameter( + ArrayType::class, + label: new TM("settings.synonyms.type_synonyms"), + description: new TM("settings.synonyms.type_synonyms.help"), + options: ['type' => SerializeType::class], + formType: TypeSynonymsCollectionType::class, + formOptions: [ + 'required' => false, + ], + )] + #[Assert\Type('array')] + #[Assert\All([new Assert\Type('array')])] + /** + * @var array> $typeSynonyms + * An array of the form: [ + * 'category' => [ + * 'en' => ['singular' => 'Category', 'plural' => 'Categories'], + * 'de' => ['singular' => 'Kategorie', 'plural' => 'Kategorien'], + * ], + * 'manufacturer' => [ + * 'en' => ['singular' => 'Manufacturer', 'plural' =>'Manufacturers'], + * ], + * ] + */ + public array $typeSynonyms = []; + + /** + * Checks if there is any synonym defined for the given type (no matter which language). + * @param ElementTypes $type + * @return bool + */ + public function isSynonymDefinedForType(ElementTypes $type): bool + { + return isset($this->typeSynonyms[$type->value]) && count($this->typeSynonyms[$type->value]) > 0; + } + + /** + * Returns the singular synonym for the given type and locale, or null if none is defined. + * @param ElementTypes $type + * @param string $locale + * @return string|null + */ + public function getSingularSynonymForType(ElementTypes $type, string $locale): ?string + { + return $this->typeSynonyms[$type->value][$locale]['singular'] ?? null; + } + + /** + * Returns the plural synonym for the given type and locale, or null if none is defined. + * @param ElementTypes $type + * @param string|null $locale + * @return string|null + */ + public function getPluralSynonymForType(ElementTypes $type, ?string $locale): ?string + { + return $this->typeSynonyms[$type->value][$locale]['plural'] + ?? $this->typeSynonyms[$type->value][$locale]['singular'] + ?? null; + } + + /** + * Sets a synonym for the given type and locale. + * @param ElementTypes $type + * @param string $locale + * @param string $singular + * @param string $plural + * @return void + */ + public function setSynonymForType(ElementTypes $type, string $locale, string $singular, string $plural): void + { + $this->typeSynonyms[$type->value][$locale] = [ + 'singular' => $singular, + 'plural' => $plural, + ]; + } +} diff --git a/src/Settings/SystemSettings/AttachmentsSettings.php b/src/Settings/SystemSettings/AttachmentsSettings.php new file mode 100644 index 00000000..6d15c639 --- /dev/null +++ b/src/Settings/SystemSettings/AttachmentsSettings.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.system.attachments"))] +#[SettingsIcon("fa-paperclip")] +class AttachmentsSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.system.attachments.maxFileSize"), + description: new TM("settings.system.attachments.maxFileSize.help"), + envVar: "MAX_ATTACHMENT_FILE_SIZE", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Regex("/^([1-9][0-9]*)([KMG])?$/", message: "validator.fileSize.invalidFormat")] + public string $maxFileSize = '100M'; + + #[SettingsParameter( + label: new TM("settings.system.attachments.allowDownloads"), + description: new TM("settings.system.attachments.allowDownloads.help"), + formOptions: ['help_html' => true], + envVar: "bool:ALLOW_ATTACHMENT_DOWNLOADS", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $allowDownloads = false; + + #[SettingsParameter( + label: new TM("settings.system.attachments.downloadByDefault"), + envVar: "bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $downloadByDefault = false; +} \ No newline at end of file diff --git a/src/Settings/SystemSettings/CustomizationSettings.php b/src/Settings/SystemSettings/CustomizationSettings.php new file mode 100644 index 00000000..623e6187 --- /dev/null +++ b/src/Settings/SystemSettings/CustomizationSettings.php @@ -0,0 +1,84 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use App\Form\Type\RichTextEditorType; +use App\Form\Type\ThemeChoiceType; +use App\Settings\SettingsIcon; +use App\Validator\Constraints\ValidTheme; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\EnumType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(name: "customization", label: new TM("settings.system.customization"))] +#[SettingsIcon("fa-paint-roller")] +class CustomizationSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.system.customization.instanceName"), + description: new TM("settings.system.customization.instanceName.help"), + envVar: "INSTANCE_NAME", envVarMode: EnvVarMode::OVERWRITE, + )] + public string $instanceName = "Part-DB"; + + #[SettingsParameter( + label: new TM("settings.system.customization.theme"), + formType: ThemeChoiceType::class, formOptions: ['placeholder' => false] + )] + #[ValidTheme] + public string $theme = 'bootstrap'; + + #[SettingsParameter( + label: new TM("settings.system.customization.banner"), + formType: RichTextEditorType::class, formOptions: ['mode' => 'markdown-full'], + envVar: "BANNER", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $banner = null; + + /** + * @var HomepageItems[] The items to show in the sidebar. + */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.hompepage.items"), + description: new TM("settings.behavior.homepage.items.help"), + options: ['type' => EnumType::class, 'options' => ['class' => HomepageItems::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => HomepageItems::class, 'multiple' => true, 'ordered' => true] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + public array $homepageitems = [HomepageItems::SEARCH, HomepageItems::BANNER, HomepageItems::FIRST_STEPS, HomepageItems::LICENSE, HomepageItems::LAST_ACTIVITY]; + + #[SettingsParameter( + label: new TM("settings.system.customization.showVersionOnHomepage") + )] + public bool $showVersionOnHomepage = true; +} diff --git a/src/Settings/SystemSettings/HistorySettings.php b/src/Settings/SystemSettings/HistorySettings.php new file mode 100644 index 00000000..46003c6d --- /dev/null +++ b/src/Settings/SystemSettings/HistorySettings.php @@ -0,0 +1,87 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use App\Form\History\EnforceEventCommentTypesType; +use App\Services\LogSystem\EventCommentType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\EnumType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.system.history"))] +#[SettingsIcon("fa-binoculars")] +class HistorySettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.system.history.saveChangedFields"), + envVar: "bool:HISTORY_SAVE_CHANGED_FIELDS", envVarMode: EnvVarMode::OVERWRITE)] + public bool $saveChangedFields = true; + + #[SettingsParameter( + label: new TM("settings.system.history.saveOldData"), + envVar: "bool:HISTORY_SAVE_CHANGED_DATA", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $saveOldData = true; + + #[SettingsParameter( + label: new TM("settings.system.history.saveNewData"), + envVar: "bool:HISTORY_SAVE_NEW_DATA", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $saveNewData = true; + + #[SettingsParameter( + label: new TM("settings.system.history.saveRemovedData"), + envVar: "bool:HISTORY_SAVE_REMOVED_DATA", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $saveRemovedData = true; + + /** @var EventCommentType[] */ + #[SettingsParameter( + type: ArrayType::class, + label: new TM("settings.system.history.enforceComments"), + description: new TM("settings.system.history.enforceComments.description"), + options: ['type' => EnumType::class, 'nullable' => false, 'options' => ['class' => EventCommentType::class]], + formType: EnforceEventCommentTypesType::class, + formOptions: ['required' => false, "empty_data" => []], + envVar: "ENFORCE_CHANGE_COMMENTS_FOR", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapEnforceComments'] + )] + public array $enforceComments = []; + + public static function mapEnforceComments(string $value): array + { + if (trim($value) === '') { + return []; + } + + $explode = explode(',', $value); + return array_map(fn(string $type) => EventCommentType::from($type), $explode); + } +} \ No newline at end of file diff --git a/src/Settings/SystemSettings/HomepageItems.php b/src/Settings/SystemSettings/HomepageItems.php new file mode 100644 index 00000000..7366dfa2 --- /dev/null +++ b/src/Settings/SystemSettings/HomepageItems.php @@ -0,0 +1,51 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +use function Symfony\Component\Translation\t; + +enum HomepageItems: string implements TranslatableInterface +{ + case SEARCH = 'search'; + case BANNER = 'banner'; + case LICENSE = 'license'; + case FIRST_STEPS = 'first_steps'; + case LAST_ACTIVITY = 'last_activity'; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + self::SEARCH => 'search.placeholder', + self::BANNER => 'settings.system.customization.banner', + self::LICENSE => 'homepage.license', + self::FIRST_STEPS => 'homepage.first_steps.title', + self::LAST_ACTIVITY => 'homepage.last_activity', + }; + + return $translator->trans($key, locale: $locale); + } +} diff --git a/src/Settings/SystemSettings/LocalizationSettings.php b/src/Settings/SystemSettings/LocalizationSettings.php new file mode 100644 index 00000000..c6780c6c --- /dev/null +++ b/src/Settings/SystemSettings/LocalizationSettings.php @@ -0,0 +1,76 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use App\Form\Settings\LanguageMenuEntriesType; +use App\Form\Type\LocaleSelectType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\StringType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\TimezoneType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.system.localization"))] +#[SettingsIcon("fa-globe")] +class LocalizationSettings +{ + use SettingsTrait; + + #[Assert\Locale()] + #[Assert\NotBlank()] + #[SettingsParameter(label: new TM("settings.system.localization.locale"), formType: LocaleSelectType::class, + envVar: "string:DEFAULT_LANG", envVarMode: EnvVarMode::OVERWRITE)] + public string $locale = 'en'; + + #[Assert\Timezone()] + #[Assert\NotBlank()] + #[SettingsParameter(label: new TM("settings.system.localization.timezone"), formType: TimezoneType::class, + envVar: "string:DEFAULT_TIMEZONE", envVarMode: EnvVarMode::OVERWRITE)] + public string $timezone = 'Europe/Berlin'; + + #[Assert\Currency()] + #[Assert\NotBlank()] + #[SettingsParameter(label: new TM("settings.system.localization.base_currency"), + description: new TM("settings.system.localization.base_currency_description"), + formType: CurrencyType::class, formOptions: ['preferred_choices' => ['EUR', 'USD', 'GBP', "JPY", "CNY"], 'help_html' => true], + envVar: "string:BASE_CURRENCY", envVarMode: EnvVarMode::OVERWRITE + )] + public string $baseCurrency = 'EUR'; + + #[SettingsParameter(type: ArrayType::class, + label: new TM("settings.system.localization.language_menu_entries"), + description: new TM("settings.system.localization.language_menu_entries.description"), + options: ['type' => StringType::class], + formType: LanguageMenuEntriesType::class, + formOptions: ['multiple' => true, 'required' => false, 'ordered' => true], + )] + #[Assert\All([new Assert\Locale()])] + public array $languageMenuEntries = []; +} diff --git a/src/Settings/SystemSettings/PrivacySettings.php b/src/Settings/SystemSettings/PrivacySettings.php new file mode 100644 index 00000000..1ef3c635 --- /dev/null +++ b/src/Settings/SystemSettings/PrivacySettings.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.system.privacy"))] +#[SettingsIcon("fa-location-pin-lock")] +class PrivacySettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.system.privacy.checkForUpdates"), + description: new TM("settings.system.privacy.checkForUpdates.description"), + envVar: 'bool:CHECK_FOR_UPDATES', envVarMode: EnvVarMode::OVERWRITE)] + public bool $checkForUpdates = true; + + /** + * @var bool Use gravatars for user avatars, when user has no own avatar defined + */ + #[SettingsParameter( + label: new TM("settings.system.privacy.useGravatar"), + description: new TM("settings.system.privacy.useGravatar.description"), + envVar: 'bool:USE_GRAVATAR', envVarMode: EnvVarMode::OVERWRITE)] + public bool $useGravatar = false; +} \ No newline at end of file diff --git a/src/Settings/SystemSettings/SystemSettings.php b/src/Settings/SystemSettings/SystemSettings.php new file mode 100644 index 00000000..8cbeb560 --- /dev/null +++ b/src/Settings/SystemSettings/SystemSettings.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\SystemSettings; + +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.system"))] +class SystemSettings +{ + #[EmbeddedSettings()] + public ?LocalizationSettings $localization = null; + + + + #[EmbeddedSettings()] + public ?CustomizationSettings $customization = null; + + #[EmbeddedSettings()] + public ?PrivacySettings $privacy = null; + + #[EmbeddedSettings()] + public ?AttachmentsSettings $attachments = null; + + #[EmbeddedSettings()] + public ?HistorySettings $history = null; +} diff --git a/src/State/PartDBInfoProvider.php b/src/State/PartDBInfoProvider.php index c6760ede..b3496cad 100644 --- a/src/State/PartDBInfoProvider.php +++ b/src/State/PartDBInfoProvider.php @@ -9,6 +9,8 @@ use ApiPlatform\State\ProviderInterface; use App\ApiResource\PartDBInfo; use App\Services\Misc\GitVersionInfo; use App\Services\System\BannerHelper; +use App\Settings\SystemSettings\CustomizationSettings; +use App\Settings\SystemSettings\LocalizationSettings; use Shivas\VersioningBundle\Service\VersionManagerInterface; class PartDBInfoProvider implements ProviderInterface @@ -16,12 +18,10 @@ class PartDBInfoProvider implements ProviderInterface public function __construct(private readonly VersionManagerInterface $versionManager, private readonly GitVersionInfo $gitVersionInfo, - private readonly string $partdb_title, - private readonly string $base_currency, private readonly BannerHelper $bannerHelper, private readonly string $default_uri, - private readonly string $global_timezone, - private readonly string $global_locale + private readonly LocalizationSettings $localizationSettings, + private readonly CustomizationSettings $customizationSettings, ) { @@ -33,12 +33,12 @@ class PartDBInfoProvider implements ProviderInterface version: $this->versionManager->getVersion()->toString(), git_branch: $this->gitVersionInfo->getGitBranchName(), git_commit: $this->gitVersionInfo->getGitCommitHash(), - title: $this->partdb_title, + title: $this->customizationSettings->instanceName, banner: $this->bannerHelper->getBanner(), default_uri: $this->default_uri, - global_timezone: $this->global_timezone, - base_currency: $this->base_currency, - global_locale: $this->global_locale, + global_timezone: $this->localizationSettings->timezone, + base_currency: $this->localizationSettings->baseCurrency, + global_locale: $this->localizationSettings->locale, ); } } diff --git a/src/Twig/EntityExtension.php b/src/Twig/EntityExtension.php index 762ebb09..427a39b5 100644 --- a/src/Twig/EntityExtension.php +++ b/src/Twig/EntityExtension.php @@ -24,6 +24,7 @@ namespace App\Twig; use App\Entity\Attachments\Attachment; use App\Entity\Base\AbstractDBElement; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; @@ -75,6 +76,8 @@ final class EntityExtension extends AbstractExtension /* Gets a human readable label for the type of the given entity */ new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)), + new TwigFunction('type_label', fn(object|string $entity): string => $this->nameGenerator->typeLabel($entity)), + new TwigFunction('type_label_p', fn(object|string $entity): string => $this->nameGenerator->typeLabelPlural($entity)), ]; } @@ -115,6 +118,7 @@ final class EntityExtension extends AbstractExtension Currency::class => 'currency', MeasurementUnit::class => 'measurement_unit', LabelProfile::class => 'label_profile', + PartCustomState::class => 'part_custom_state', ]; foreach ($map as $class => $type) { diff --git a/src/Twig/FormatExtension.php b/src/Twig/FormatExtension.php index 76628ccd..46313aaf 100644 --- a/src/Twig/FormatExtension.php +++ b/src/Twig/FormatExtension.php @@ -82,7 +82,7 @@ final class FormatExtension extends AbstractExtension public function formatBytes(int $bytes, int $precision = 2): string { $size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB']; - $factor = floor((strlen((string) $bytes) - 1) / 3); + $factor = (int) floor((strlen((string) $bytes) - 1) / 3); //We use the real (10 based) SI prefix here return sprintf("%.{$precision}f", $bytes / (1000 ** $factor)) . ' ' . @$size[$factor]; } diff --git a/src/Twig/MiscExtension.php b/src/Twig/MiscExtension.php index 93762d35..8b6ebc68 100644 --- a/src/Twig/MiscExtension.php +++ b/src/Twig/MiscExtension.php @@ -22,7 +22,11 @@ declare(strict_types=1); */ namespace App\Twig; +use App\Settings\SettingsIcon; use Symfony\Component\HttpFoundation\Request; +use App\Services\LogSystem\EventCommentType; +use Jbtronics\SettingsBundle\Proxy\SettingsProxyInterface; +use ReflectionClass; use Twig\TwigFunction; use App\Services\LogSystem\EventCommentNeededHelper; use Twig\Extension\AbstractExtension; @@ -36,14 +40,43 @@ final class MiscExtension extends AbstractExtension public function getFunctions(): array { return [ - new TwigFunction('event_comment_needed', - fn(string $operation_type) => $this->eventCommentNeededHelper->isCommentNeeded($operation_type) - ), + new TwigFunction('event_comment_needed', $this->evenCommentNeeded(...)), + new TwigFunction('settings_icon', $this->settingsIcon(...)), new TwigFunction('uri_without_host', $this->uri_without_host(...)) ]; } + private function evenCommentNeeded(string|EventCommentType $operation_type): bool + { + if (is_string($operation_type)) { + $operation_type = EventCommentType::from($operation_type); + } + + return $this->eventCommentNeededHelper->isCommentNeeded($operation_type); + } + + /** + * Returns the value of the icon attribute of the SettingsIcon attribute of the given class. + * If the class does not have a SettingsIcon attribute, then null is returned. + * @param string|object $objectOrClass + * @return string|null + * @throws \ReflectionException + */ + private function settingsIcon(string|object $objectOrClass): ?string + { + //If the given object is a proxy, then get the real object + if (is_a($objectOrClass, SettingsProxyInterface::class)) { + $objectOrClass = get_parent_class($objectOrClass); + } + + $reflection = new ReflectionClass($objectOrClass); + + $attribute = $reflection->getAttributes(SettingsIcon::class)[0] ?? null; + + return $attribute?->newInstance()->icon; + } + /** * Similar to the getUri function of the request, but does not contain protocol and host. * @param Request $request diff --git a/src/Twig/Sandbox/InheritanceSecurityPolicy.php b/src/Twig/Sandbox/InheritanceSecurityPolicy.php index 93e874e9..06ab3a1f 100644 --- a/src/Twig/Sandbox/InheritanceSecurityPolicy.php +++ b/src/Twig/Sandbox/InheritanceSecurityPolicy.php @@ -34,9 +34,14 @@ use function is_array; */ final class InheritanceSecurityPolicy implements SecurityPolicyInterface { + /** + * @var array + */ private array $allowedMethods; - public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], private array $allowedProperties = [], private array $allowedFunctions = []) + public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], + /** @var array */ + private array $allowedProperties = [], private array $allowedFunctions = []) { $this->setAllowedMethods($allowedMethods); } diff --git a/src/Twig/TwigCoreExtension.php b/src/Twig/TwigCoreExtension.php index 352e09d3..7b2b58f8 100644 --- a/src/Twig/TwigCoreExtension.php +++ b/src/Twig/TwigCoreExtension.php @@ -34,8 +34,11 @@ use Twig\TwigTest; */ final class TwigCoreExtension extends AbstractExtension { - public function __construct(protected ObjectNormalizer $objectNormalizer) + private readonly ObjectNormalizer $objectNormalizer; + + public function __construct() { + $this->objectNormalizer = new ObjectNormalizer(); } public function getFunctions(): array diff --git a/src/Validator/Constraints/UniquePartIpnConstraint.php b/src/Validator/Constraints/UniquePartIpnConstraint.php new file mode 100644 index 00000000..ca32f9ef --- /dev/null +++ b/src/Validator/Constraints/UniquePartIpnConstraint.php @@ -0,0 +1,22 @@ +entityManager = $entityManager; + $this->ipnSuggestSettings = $ipnSuggestSettings; + } + + public function validate($value, Constraint $constraint): void + { + if (null === $value || '' === $value) { + return; + } + + //If the autoAppendSuffix option is enabled, the IPN becomes unique automatically later + if ($this->ipnSuggestSettings->autoAppendSuffix) { + return; + } + + if (!$constraint instanceof UniquePartIpnConstraint) { + return; + } + + /** @var Part $currentPart */ + $currentPart = $this->context->getObject(); + + if (!$currentPart instanceof Part) { + return; + } + + $repository = $this->entityManager->getRepository(Part::class); + $existingParts = $repository->findBy(['ipn' => $value]); + + foreach ($existingParts as $existingPart) { + if ($currentPart->getId() !== $existingPart->getId()) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } + } +} diff --git a/symfony.lock b/symfony.lock index c7471b73..68de2da7 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,16 +1,16 @@ { - "api-platform/core": { - "version": "3.2", + "api-platform/symfony": { + "version": "4.1", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "3.2", - "ref": "696d44adc3c0d4f5d25a2f1c4f3700dd8a5c6db9" + "version": "4.0", + "ref": "e9952e9f393c2d048f10a78f272cd35e807d972b" }, "files": [ - "config/packages/api_platform.yaml", - "config/routes/api_platform.yaml", - "src/ApiResource/.gitignore" + "./config/packages/api_platform.yaml", + "./config/routes/api_platform.yaml", + "./src/ApiResource/.gitignore" ] }, "beberlei/assert": { @@ -29,15 +29,15 @@ "version": "1.11.99.4" }, "dama/doctrine-test-bundle": { - "version": "8.0", + "version": "8.3", "recipe": { "repo": "github.com/symfony/recipes-contrib", "branch": "main", - "version": "7.2", - "ref": "896306d79d4ee143af9eadf9b09fd34a8c391b70" + "version": "8.3", + "ref": "dfc51177476fb39d014ed89944cde53dc3326d23" }, "files": [ - "./config/packages/dama_doctrine_test_bundle.yaml" + "config/packages/dama_doctrine_test_bundle.yaml" ] }, "doctrine/cache": { @@ -53,20 +53,26 @@ "version": "v2.9.2" }, "doctrine/deprecations": { - "version": "v0.5.3" - }, - "doctrine/doctrine-bundle": { - "version": "2.11", + "version": "1.1", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "2.10", - "ref": "c170ded8fc587d6bd670550c43dafcf093762245" + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } + }, + "doctrine/doctrine-bundle": { + "version": "2.15", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb" }, "files": [ - "./config/packages/doctrine.yaml", - "./src/Entity/.gitignore", - "./src/Repository/.gitignore" + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" ] }, "doctrine/doctrine-fixtures-bundle": { @@ -127,18 +133,6 @@ "ekino/phpstan-banned-code": { "version": "v0.3.1" }, - "erusev/parsedown": { - "version": "1.7.4" - }, - "florianv/exchanger": { - "version": "1.4.1" - }, - "florianv/swap": { - "version": "3.5.0" - }, - "florianv/swap-bundle": { - "version": "5.0.x-dev" - }, "gregwar/captcha": { "version": "v1.1.7" }, @@ -154,6 +148,9 @@ "jbtronics/dompdf-font-loader-bundle": { "version": "v1.1.1" }, + "jbtronics/settings-bundle": { + "version": "2.0.1" + }, "jbtronics/translation-editor-bundle": { "version": "v1.0" }, @@ -207,15 +204,15 @@ ] }, "nelmio/security-bundle": { - "version": "2.4", + "version": "3.5", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", + "branch": "main", "version": "2.4", - "ref": "65726efb67ff51d89de38195bc0d230fa811f64d" + "ref": "71045833e4f882ad9de8c95fe47efb99a1eec2f7" }, "files": [ - "./config/packages/nelmio_security.yaml" + "config/packages/nelmio_security.yaml" ] }, "nikic/php-parser": { @@ -248,11 +245,8 @@ "./config/packages/datatables.yaml" ] }, - "phenx/php-font-lib": { - "version": "0.5.1" - }, - "phenx/php-svg-lib": { - "version": "v0.3.3" + "part-db/swap-bundle": { + "version": "v6.0.0" }, "php-http/discovery": { "version": "1.18", @@ -306,17 +300,18 @@ "version": "0.12.4" }, "phpunit/phpunit": { - "version": "9.6", + "version": "11.5", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "9.6", - "ref": "7364a21d87e658eb363c5020c072ecfdc12e2326" + "version": "11.1", + "ref": "1117deb12541f35793eec9fff7494d7aa12283fc" }, "files": [ - "./.env.test", - "./phpunit.xml.dist", - "./tests/bootstrap.php" + ".env.test", + "bin/phpunit", + "phpunit.xml.dist", + "tests/bootstrap.php" ] }, "psr/cache": { @@ -386,10 +381,10 @@ "repo": "github.com/symfony/recipes-contrib", "branch": "main", "version": "1.0", - "ref": "0f18b4decdf5695d692c1d0dfd65516a07a6adf1" + "ref": "5d454ec6cc4c700ed3d963f3803e1d427d9669fb" }, "files": [ - "./public/.htaccess" + "public/.htaccess" ] }, "symfony/asset": { @@ -481,17 +476,27 @@ ] }, "symfony/form": { - "version": "v4.2.3" - }, - "symfony/framework-bundle": { - "version": "6.4", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.4", - "ref": "a91c965766ad3ff2ae15981801643330eb42b6a5" + "version": "7.2", + "ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b" }, "files": [ + "./config/packages/csrf.yaml" + ] + }, + "symfony/framework-bundle": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d" + }, + "files": [ + ".editorconfig", "config/packages/cache.yaml", "config/packages/framework.yaml", "config/preload.php", @@ -518,15 +523,15 @@ "version": "v4.2.3" }, "symfony/mailer": { - "version": "6.4", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", "version": "4.3", - "ref": "df66ee1f226c46f01e85c29c2f7acce0596ba35a" + "ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f" }, "files": [ - "./config/packages/mailer.yaml" + "config/packages/mailer.yaml" ] }, "symfony/maker-bundle": { @@ -545,15 +550,15 @@ "version": "v4.4.2" }, "symfony/monolog-bundle": { - "version": "3.10", + "version": "3.11", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", "version": "3.7", - "ref": "aff23899c4440dd995907613c1dd709b6f59503f" + "ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459" }, "files": [ - "./config/packages/monolog.yaml" + "config/packages/monolog.yaml" ] }, "symfony/options-resolver": { @@ -563,19 +568,14 @@ "version": "v5.3.8" }, "symfony/phpunit-bridge": { - "version": "6.4", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.3", - "ref": "a411a0480041243d97382cac7984f7dce7813c08" + "version": "7.3", + "ref": "dc13fec96bd527bd399c3c01f0aab915c67fd544" }, - "files": [ - "./.env.test", - "./bin/phpunit", - "./phpunit.xml.dist", - "./tests/bootstrap.php" - ] + "files": [] }, "symfony/polyfill-ctype": { "version": "v1.14.0" @@ -592,12 +592,6 @@ "symfony/polyfill-intl-normalizer": { "version": "v1.17.0" }, - "symfony/polyfill-mbstring": { - "version": "v1.10.0" - }, - "symfony/polyfill-php80": { - "version": "v1.17.0" - }, "symfony/process": { "version": "v4.2.3" }, @@ -605,15 +599,24 @@ "version": "v4.2.3" }, "symfony/property-info": { - "version": "v4.2.3" - }, - "symfony/routing": { - "version": "6.2", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.2", - "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6" + "version": "7.3", + "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" + }, + "files": [ + "./config/packages/property_info.yaml" + ] + }, + "symfony/routing": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded" }, "files": [ "config/packages/routing.yaml", @@ -624,12 +627,12 @@ "version": "v5.3.4" }, "symfony/security-bundle": { - "version": "6.4", + "version": "7.4", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.4", - "ref": "2ae08430db28c8eb4476605894296c82a642028f" + "version": "7.4", + "ref": "c42fee7802181cdd50f61b8622715829f5d2335c" }, "files": [ "config/packages/security.yaml", @@ -652,17 +655,18 @@ "version": "v1.1.5" }, "symfony/stimulus-bundle": { - "version": "2.16", + "version": "2.31", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "2.13", - "ref": "6acd9ff4f7fd5626d2962109bd4ebab351d43c43" + "version": "2.24", + "ref": "3357f2fa6627b93658d8e13baa416b2a94a50c5f" }, "files": [ - "./assets/bootstrap.js", - "./assets/controllers.json", - "./assets/controllers/hello_controller.js" + "assets/controllers.json", + "assets/controllers/csrf_protection_controller.js", + "assets/controllers/hello_controller.js", + "assets/stimulus_bootstrap.js" ] }, "symfony/stopwatch": { @@ -672,16 +676,16 @@ "version": "v5.1.0" }, "symfony/translation": { - "version": "6.4", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", "version": "6.3", - "ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b" + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" }, "files": [ - "./config/packages/translation.yaml", - "./translations/.gitignore" + "config/packages/translation.yaml", + "translations/.gitignore" ] }, "symfony/translation-contracts": { @@ -704,42 +708,48 @@ ] }, "symfony/uid": { - "version": "6.2", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.2", - "ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558" + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" }, - "files": [ - "./config/packages/uid.yaml" - ] + "files": [] }, "symfony/ux-translator": { - "version": "2.9", + "version": "2.32", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "2.9", - "ref": "bc396565cc4cab95692dd6df810553dc22e352e1" + "version": "2.32", + "ref": "20e2abac415da4c3a9a6bafa059a6419beb74593" }, "files": [ - "./assets/translator.js", - "./config/packages/ux_translator.yaml", - "./var/translations/configuration.js", - "./var/translations/index.js" + "assets/translator.js", + "config/packages/ux_translator.yaml", + "var/translations/index.js" ] }, "symfony/ux-turbo": { - "version": "v2.16.0" - }, - "symfony/validator": { - "version": "5.4", + "version": "2.28", "recipe": { "repo": "github.com/symfony/recipes", - "branch": "master", - "version": "5.3", - "ref": "c32cfd98f714894c4f128bb99aa2530c1227603c" + "branch": "main", + "version": "2.20", + "ref": "287f7c6eb6e9b65e422d34c00795b360a787380b" + }, + "files": [ + "config/packages/ux_turbo.yaml" + ] + }, + "symfony/validator": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" }, "files": [ "config/packages/validator.yaml" @@ -755,12 +765,12 @@ "version": "v4.2.3" }, "symfony/web-profiler-bundle": { - "version": "6.3", + "version": "7.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", - "version": "6.1", - "ref": "e42b3f0177df239add25373083a564e5ead4e13a" + "version": "7.3", + "ref": "a363460c1b0b4a4d0242f2ce1a843ca0f6ac9026" }, "files": [ "config/packages/web_profiler.yaml", @@ -768,12 +778,12 @@ ] }, "symfony/webpack-encore-bundle": { - "version": "2.1", + "version": "2.3", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", "version": "2.0", - "ref": "082d754b3bd54b3fc669f278f1eea955cfd23cf5" + "ref": "719f6110345acb6495e496601fc1b4977d7102b3" }, "files": [ "assets/app.js", @@ -786,9 +796,6 @@ "symfony/yaml": { "version": "v4.2.3" }, - "symplify/easy-coding-standard": { - "version": "v7.1.3" - }, "tecnickcom/tc-lib-barcode": { "version": "1.15.20" }, diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index cd1f641f..446ccdab 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -1,4 +1,5 @@ {% import "helper.twig" as helper %} +{% import "vars.macro.twig" as vars %} {% import "components/search.macro.html.twig" as search %}
    {{ partdb_title }} + aria-hidden="true"> {{ vars.partdb_title() }} diff --git a/templates/bundles/TwigBundle/Exception/error403.html.twig b/templates/bundles/TwigBundle/Exception/error403.html.twig index f5987179..334670fc 100644 --- a/templates/bundles/TwigBundle/Exception/error403.html.twig +++ b/templates/bundles/TwigBundle/Exception/error403.html.twig @@ -1,6 +1,9 @@ {% extends "bundles/TwigBundle/Exception/error.html.twig" %} {% block status_comment %} - Nice try! But you are not allowed to do this! + Nice try! But you are not allowed to do this!
    + {{ exception.message }}
    If you think you should have access to this ressource, contact the adminstrator. -{% endblock %} \ No newline at end of file + + +{% endblock %} diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index 5ce0f23f..d7873498 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -29,9 +29,7 @@ -
    - {# #} - +
    @@ -41,7 +39,7 @@ @@ -95,4 +97,4 @@
    -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/components/search.macro.html.twig b/templates/components/search.macro.html.twig index e62af2b1..a324ad35 100644 --- a/templates/components/search.macro.html.twig +++ b/templates/components/search.macro.html.twig @@ -11,6 +11,10 @@
    +
    + + +
    diff --git a/templates/components/tree_macros.html.twig b/templates/components/tree_macros.html.twig index 12bef78f..aaa871ea 100644 --- a/templates/components/tree_macros.html.twig +++ b/templates/components/tree_macros.html.twig @@ -1,13 +1,15 @@ {% macro sidebar_dropdown() %} + {% set currentLocale = app.request.locale %} + {# Format is [mode, route, label, show_condition] #} {% set data_sources = [ - ['categories', path('tree_category_root'), 'category.labelp', is_granted('@categories.read') and is_granted('@parts.read')], - ['locations', path('tree_location_root'), 'storelocation.labelp', is_granted('@storelocations.read') and is_granted('@parts.read')], - ['footprints', path('tree_footprint_root'), 'footprint.labelp', is_granted('@footprints.read') and is_granted('@parts.read')], - ['manufacturers', path('tree_manufacturer_root'), 'manufacturer.labelp', is_granted('@manufacturers.read') and is_granted('@parts.read')], - ['suppliers', path('tree_supplier_root'), 'supplier.labelp', is_granted('@suppliers.read') and is_granted('@parts.read')], - ['devices', path('tree_device_root'), 'project.labelp', is_granted('@projects.read')], - ['tools', path('tree_tools'), 'tools.label', true], + ['categories', path('tree_category_root'), '@category@@', is_granted('@categories.read') and is_granted('@parts.read')], + ['locations', path('tree_location_root'), '@storage_location@@', is_granted('@storelocations.read') and is_granted('@parts.read'), ], + ['footprints', path('tree_footprint_root'), '@footprint@@', is_granted('@footprints.read') and is_granted('@parts.read')], + ['manufacturers', path('tree_manufacturer_root'), '@manufacturer@@', is_granted('@manufacturers.read') and is_granted('@parts.read'), 'manufacturer'], + ['suppliers', path('tree_supplier_root'), '@supplier@@', is_granted('@suppliers.read') and is_granted('@parts.read'), 'supplier'], + ['projects', path('tree_device_root'), '@project@@', is_granted('@projects.read'), 'project'], + ['tools', path('tree_tools'), 'tools.label', true, 'tool'], ] %} @@ -18,9 +20,20 @@ {% for source in data_sources %} {% if source[3] %} {# show_condition #} -
  • +
  • + {% if source[2] starts with '@' %} + {% set label = type_label_p(source[2]|replace({'@': ''})) %} + {% else %} + {% set label = source[2]|trans %} + {% endif %} + + +
  • {% endif %} {% endfor %} {% endmacro %} @@ -61,4 +74,4 @@
    -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/form/extended_bootstrap_layout.html.twig b/templates/form/extended_bootstrap_layout.html.twig index 811f57ac..75e44a15 100644 --- a/templates/form/extended_bootstrap_layout.html.twig +++ b/templates/form/extended_bootstrap_layout.html.twig @@ -1,5 +1,9 @@ {% extends 'bootstrap_5_horizontal_layout.html.twig' %} +{%- block toggle_password_widget -%} +
    {{ block('password_widget') }}
    +{%- endblock toggle_password_widget -%} + {# Make form rows smaller #} {% block form_row -%} {%- set row_attr = row_attr|merge({"class": "mb-2"}) -%} @@ -139,4 +143,4 @@ {% else %} {{- parent() -}} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/form/permission_layout.html.twig b/templates/form/permission_layout.html.twig index 166147b4..747208dd 100644 --- a/templates/form/permission_layout.html.twig +++ b/templates/form/permission_layout.html.twig @@ -70,18 +70,20 @@ {% endif %} {% if show_presets %} + {# This hidden field is there to ensure that none of the presets is submitted, if a user presses enter #} +
    @@ -110,4 +112,4 @@ {% endfor %}
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/form/settings_form.html.twig b/templates/form/settings_form.html.twig new file mode 100644 index 00000000..8f76720e --- /dev/null +++ b/templates/form/settings_form.html.twig @@ -0,0 +1,25 @@ +{% extends "form/extended_bootstrap_layout.html.twig" %} + +{% block form_label %} + {# If parameter_envvar exists on form then show it as tooltip #} + {% if parameter_envvar is defined and parameter_envvar is not null %} + {%- set label_attr = label_attr|merge({title: t('settings.tooltip.overrideable_by_env', {'%env%': (parameter_envvar)|trim})}) -%} + {% endif %} + {{- parent() -}} +{% endblock %} + +{% block checkbox_radio_label %} + {# If parameter_envvar exists on form then show it as tooltip #} + {% if parameter_envvar is defined and parameter_envvar is not null %} + {%- set label_attr = label_attr|merge({title: t('settings.tooltip.overrideable_by_env', {'%env%': (parameter_envvar)|trim})}) -%} + {% endif %} + {{- parent() -}} +{% endblock %} + +{% block tristate_label %} + {# If parameter_envvar exists on form then show it as tooltip #} + {% if parameter_envvar is defined and parameter_envvar is not null %} + {%- set label_attr = label_attr|merge({title: t('settings.tooltip.overrideable_by_env', {'%env%': (parameter_envvar)|trim})}) -%} + {% endif %} + {{- parent() -}} +{% endblock %} diff --git a/templates/form/synonyms_collection.html.twig b/templates/form/synonyms_collection.html.twig new file mode 100644 index 00000000..ee69dffc --- /dev/null +++ b/templates/form/synonyms_collection.html.twig @@ -0,0 +1,59 @@ +{% macro renderForm(child) %} +
    + {% form_theme child "form/vertical_bootstrap_layout.html.twig" %} +
    +
    {{ form_row(child.dataSource) }}
    +
    {{ form_row(child.locale) }}
    +
    {{ form_row(child.translation_singular) }}
    +
    {{ form_row(child.translation_plural) }}
    +
    + +
    +
    +
    +{% endmacro %} + +{% block type_synonyms_collection_widget %} + {% set _attrs = attr|default({}) %} + {% set _attrs = _attrs|merge({ + class: (_attrs.class|default('') ~ ' type_synonyms_collection-widget')|trim + }) %} + + {% set has_proto = prototype is defined %} + {% if has_proto %} + {% set __proto %} + {{- _self.renderForm(prototype) -}} + {% endset %} + {% set _proto_html = __proto|e('html_attr') %} + {% set _proto_name = form.vars.prototype_name|default('__name__') %} + {% set _index = form|length %} + {% endif %} + +
    +
    +
    {% trans%}settings.synonyms.type_synonym.type{% endtrans%}
    +
    {% trans%}settings.synonyms.type_synonym.language{% endtrans%}
    +
    {% trans%}settings.synonyms.type_synonym.translation_singular{% endtrans%}
    +
    {% trans%}settings.synonyms.type_synonym.translation_plural{% endtrans%}
    +
    +
    + +
    + {% for child in form %} + {{ _self.renderForm(child) }} + {% endfor %} +
    + +
    +{% endblock %} diff --git a/templates/form/vertical_bootstrap_layout.html.twig b/templates/form/vertical_bootstrap_layout.html.twig new file mode 100644 index 00000000..5f41d82e --- /dev/null +++ b/templates/form/vertical_bootstrap_layout.html.twig @@ -0,0 +1,26 @@ +{% extends 'bootstrap_5_layout.html.twig' %} + + +{%- block choice_widget_collapsed -%} + {# Only add the BS5 form-select class if we dont use bootstrap-selectpicker #} + {# {% if attr["data-controller"] is defined and attr["data-controller"] not in ["elements--selectpicker"] %} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%} + {% else %} + {# If it is an selectpicker add form-control class to fill whole width + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%} + {% endif %} + #} + + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%} + + {# If no data-controller was explictly defined add data-controller=elements--select #} + {% if attr["data-controller"] is not defined %} + {%- set attr = attr|merge({"data-controller": "elements--select"}) -%} + + {% if attr["data-empty-message"] is not defined %} + {%- set attr = attr|merge({"data-empty-message": ("selectpicker.nothing_selected"|trans)}) -%} + {% endif %} + {% endif %} + + {{- block("choice_widget_collapsed", "bootstrap_base_layout.html.twig") -}} +{%- endblock choice_widget_collapsed -%} diff --git a/templates/helper.twig b/templates/helper.twig index bd1d2aa7..66268a96 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -214,11 +214,11 @@ {% endmacro %} {% macro parameters_table(parameters) %} - +
    - + @@ -240,4 +240,4 @@ {% else %} {{ datetime|format_datetime }} {% endif %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/homepage.html.twig b/templates/homepage.html.twig index 68adb59f..6e7aa360 100644 --- a/templates/homepage.html.twig +++ b/templates/homepage.html.twig @@ -2,27 +2,25 @@ {% import "components/new_version.macro.html.twig" as nv %} {% import "components/search.macro.html.twig" as search %} +{% import "vars.macro.twig" as vars %} -{% block content %} - - {% if is_granted('@system.show_updates') %} - {{ nv.new_version_alert(new_version_available, new_version, new_version_url) }} - {% endif %} - +{% block item_search %} {% if is_granted('@parts.read') %} {{ search.search_form("standalone") }} -
    {% endif %} +{% endblock %} - +{% block item_banner %}
    -

    {{ partdb_title }}

    -

    - {% trans %}version.caption{% endtrans %}: {{ shivas_app_version }} - {% if git_branch is not empty or git_commit is not empty %} - ({{ git_branch ?? '' }}/{{ git_commit ?? '' }}) - {% endif %} -

    +

    {{ vars.partdb_title() }}

    + {% if settings_instance('customization').showVersionOnHomepage %} +

    + {% trans %}version.caption{% endtrans %}: {{ shivas_app_version }} + {% if git_branch is not empty or git_commit is not empty %} + ({{ git_branch ?? '' }}/{{ git_commit ?? '' }}) + {% endif %} +

    + {% endif %} {% if banner is not empty %}
    @@ -30,9 +28,11 @@
    {% endif %}
    +{% endblock %} +{% block item_first_steps %} {% if show_first_steps %} -
    +

    {% trans %}homepage.first_steps.title{% endtrans %}

    @@ -50,8 +50,10 @@
    {% endif %} +{% endblock %} -
    +{% block item_license %} +

    {% trans %}homepage.license{% endtrans %}

    @@ -67,9 +69,11 @@ {% trans %}homepage.forum.caption{% endtrans %}: {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server/discussions'}%}homepage.forum.text{% endtrans %}
    +{% endblock %} +{% block item_last_activity %} {% if datatable is not null %} -
    +
    {% trans %}homepage.last_activity{% endtrans %}
    {% import "components/history_log_macros.html.twig" as log %} @@ -77,4 +81,23 @@
    {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block content %} + + {% if is_granted('@system.show_updates') %} + {{ nv.new_version_alert(new_version_available, new_version, new_version_url) }} + {% endif %} + + {% for item in settings_instance('customization').homepageitems %} + {% if block('item_' ~ item.value) is defined %} + {{ block('item_' ~ item.value) }} +
    + {% else %} + + {% endif %} + {% endfor %} + +{% endblock %} diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig new file mode 100644 index 00000000..9bbed906 --- /dev/null +++ b/templates/info_providers/bulk_import/manage.html.twig @@ -0,0 +1,124 @@ +{% extends "main_card.html.twig" %} + +{% block title %} + {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} +{% endblock %} + +{% block card_title %} + {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} +{% endblock %} + +{% block card_content %} + +
    + +
    +

    + {% trans %}info_providers.bulk_import.manage_jobs_description{% endtrans %} +

    +
    + + {% if jobs is not empty %} +
    +
    {% trans %}specifications.property{% endtrans %}{% trans %}specifications.symbol{% endtrans %}{% trans %}specifications.symbol{% endtrans %} {% trans %}specifications.value{% endtrans %}
    + + + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + + + {% endfor %} + +
    {% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_by{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.completed_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
    + {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} + {% if job.isInProgress %} + Active + {% endif %} + {{ job.partCount }}{{ job.resultCount }} +
    +
    +
    +
    +
    + {{ job.progressPercentage }}% +
    + + {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %} + +
    + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isStopped %} + {% trans %}info_providers.bulk_import.status.stopped{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} + {{ job.createdBy.fullName(true) }}{{ job.createdAt|format_datetime('short') }} + {% if job.completedAt %} + {{ job.completedAt|format_datetime('short') }} + {% else %} + - + {% endif %} + +
    + {% if job.isInProgress or job.isCompleted or job.isStopped %} + + {% trans %}info_providers.bulk_import.view_results{% endtrans %} + + {% endif %} + {% if job.canBeStopped %} + + {% endif %} + {% if job.isCompleted or job.isFailed or job.isStopped %} + + {% endif %} +
    +
    +
    + {% else %} + + {% endif %} + + + +{% endblock %} diff --git a/templates/info_providers/bulk_import/step1.html.twig b/templates/info_providers/bulk_import/step1.html.twig new file mode 100644 index 00000000..bb9bb351 --- /dev/null +++ b/templates/info_providers/bulk_import/step1.html.twig @@ -0,0 +1,304 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} +{% import "helper.twig" as helper %} + +{% block title %} + {% trans %}info_providers.bulk_import.step1.title{% endtrans %} +{% endblock %} + +{% block card_title %} + {% trans %}info_providers.bulk_import.step1.title{% endtrans %} + {{ parts|length }} {% trans %}info_providers.bulk_import.parts_selected{% endtrans %} +{% endblock %} + +{% block card_content %} + +
    + + + {% if existing_jobs is not empty %} +
    +
    +
    {% trans %}info_providers.bulk_import.existing_jobs{% endtrans %}
    +
    +
    +
    + + + + + + + + + + + + + + {% for job in existing_jobs %} + + + + + + + + + + {% endfor %} + +
    {% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
    {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}{{ job.partCount }}{{ job.resultCount }} +
    +
    +
    +
    +
    + {{ job.progressPercentage }}% +
    + {{ job.completedPartsCount }}/{{ job.partCount }} +
    + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} + {{ job.createdAt|date('Y-m-d H:i') }} + {% if job.isInProgress or job.isCompleted %} + + {% trans %}info_providers.bulk_import.view_results{% endtrans %} + + {% endif %} +
    +
    +
    +
    + {% endif %} + + + + + + + + +
    +
    +
    {% trans %}info_providers.bulk_import.selected_parts{% endtrans %}
    +
    + +
    + + {{ form_start(form) }} + +
    +
    +
    {% trans %}info_providers.bulk_import.field_mappings{% endtrans %}
    + {% trans %}info_providers.bulk_import.field_mappings_help{% endtrans %} +
    +
    + + + + + + + + + + + {% for mapping in form.field_mappings %} + + + + + + + {% endfor %} + +
    {% trans %}info_providers.bulk_search.search_field{% endtrans %}{% trans %}info_providers.bulk_search.providers{% endtrans %}{% trans %}info_providers.bulk_search.priority{% endtrans %}{% trans %}info_providers.bulk_import.actions.label{% endtrans %}
    {{ form_widget(mapping.field) }}{{ form_errors(mapping.field) }}{{ form_widget(mapping.providers) }}{{ form_errors(mapping.providers) }}{{ form_widget(mapping.priority) }}{{ form_errors(mapping.priority) }} + +
    + +
    +
    + +
    + + +
    + {{ form_widget(form.prefetch_details, {'attr': {'class': 'form-check-input'}}) }} + {{ form_label(form.prefetch_details, null, {'label_attr': {'class': 'form-check-label'}}) }} + {{ form_help(form.prefetch_details) }} +
    + + {{ form_widget(form.submit, {'attr': {'class': 'btn btn-primary', 'data-field-mapping-target': 'submitButton'}}) }} +
    + + {{ form_end(form) }} + + {% if search_results is not null %} +
    +

    {% trans %}info_providers.bulk_import.search_results.title{% endtrans %}

    + + {% for part_result in search_results %} + {% set part = part_result.part %} +
    +
    +
    + {{ part.name }} + {% if part_result.errors is not empty %} + {{ part_result.errors|length }} {% trans %}info_providers.bulk_import.errors{% endtrans %} + {% endif %} + {{ part_result.search_results|length }} {% trans %}info_providers.bulk_import.results_found{% endtrans %} +
    +
    +
    + {% if part_result.errors is not empty %} + {% for error in part_result.errors %} + + {% endfor %} + {% endif %} + + {% if part_result.search_results|length > 0 %} +
    + + + + + + + + + + + + + + {% for result in part_result.search_results %} + {% set dto = result.dto %} + {% set localPart = result.localPart %} + + + + + + + + + + {% endfor %} + +
    {% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
    + + + {% if dto.provider_url is not null %} + {{ dto.name }} + {% else %} + {{ dto.name }} + {% endif %} + {% if dto.mpn is not null %} +
    {{ dto.mpn }} + {% endif %} +
    {{ dto.description }}{{ dto.manufacturer ?? '' }} + {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} +
    {{ dto.provider_id }} +
    + {{ result.source_field ?? 'unknown' }} + {% if result.source_keyword %} +
    {{ result.source_keyword }} + {% endif %} +
    +
    + {% set updateHref = path('info_providers_update_part', + {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %} + + {% trans %}info_providers.bulk_import.update_part{% endtrans %} + + + {% if localPart is not null %} + + {% trans %}info_providers.bulk_import.view_existing{% endtrans %} + + {% endif %} +
    +
    +
    + {% else %} + + {% endif %} +
    +
    + {% endfor %} + {% endif %} + +
    + +{% endblock %} + diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig new file mode 100644 index 00000000..559ca20a --- /dev/null +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -0,0 +1,240 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} +{% import "helper.twig" as helper %} + +{% block title %} + {% trans %}info_providers.bulk_import.step2.title{% endtrans %} +{% endblock %} + +{% block card_title %} + {% trans %}info_providers.bulk_import.step2.title{% endtrans %} + {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} +{% endblock %} + +{% block card_content %} + +
    +
    +
    +
    {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
    + + {{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} โ€ข + {{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} โ€ข + {% trans %}info_providers.bulk_import.created_at{% endtrans %}: {{ job.createdAt|date('Y-m-d H:i') }} + +
    +
    + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} +
    +
    + + +
    +
    +
    +
    Progress
    + {{ job.completedPartsCount }} / {{ job.partCount }} completed +
    +
    +
    +
    +
    +
    + + {{ job.completedPartsCount }} {% trans %}info_providers.bulk_import.completed{% endtrans %} โ€ข + {{ job.skippedPartsCount }} {% trans %}info_providers.bulk_import.skipped{% endtrans %} + + {{ job.progressPercentage }}% +
    +
    +
    + + + + + +
    +
    +
    +
    +
    {% trans %}info_providers.bulk_import.research.title{% endtrans %}
    + {% trans %}info_providers.bulk_import.research.description{% endtrans %} +
    +
    + +
    +
    +
    +
    + + {% for part_result in search_results %} + {# @var part_result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO #} + + {% set part = part_result.part %} + {% set isCompleted = job.isPartCompleted(part.id) %} + {% set isSkipped = job.isPartSkipped(part.id) %} +
    +
    +
    +
    + + {{ part.name }} + + {% if isCompleted %} + + {% trans %}info_providers.bulk_import.completed{% endtrans %} + + {% elseif isSkipped %} + + {% trans %}info_providers.bulk_import.skipped{% endtrans %} + + {% endif %} + {% if part_result.errors is not empty %} + {% trans with {'%count%': part_result.errors|length} %}info_providers.bulk_import.errors{% endtrans %} + {% endif %} + {% trans with {'%count%': part_result.searchResults|length} %}info_providers.bulk_import.results_found{% endtrans %} +
    +
    +
    + + {% if not isCompleted and not isSkipped %} + + + {% elseif isCompleted %} + + {% elseif isSkipped %} + + {% endif %} +
    +
    +
    + {% if part_result.errors is not empty %} + {% for error in part_result.errors %} + + {% endfor %} + {% endif %} + + {% if part_result.searchResults|length > 0 %} +
    + + + + + + + + + + + + + + {% for result in part_result.searchResults %} + {# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #} + {% set dto = result.searchResult %} + {% set localPart = result.localPart %} + + + + + + + + + + {% endfor %} + +
    {% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
    + + + {% if dto.provider_url is not null %} + {{ dto.name }} + {% else %} + {{ dto.name }} + {% endif %} + {% if dto.mpn is not null %} +
    {{ dto.mpn }} + {% endif %} +
    {{ dto.description }}{{ dto.manufacturer ?? '' }} + {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} +
    {{ dto.provider_id }} +
    + {{ result.sourceField ?? 'unknown' }} + {% if result.sourceKeyword %} +
    {{ result.sourceKeyword }} + {% endif %} +
    +
    + {% set updateHref = path('info_providers_update_part', + {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %} + + {% trans %}info_providers.bulk_import.update_part{% endtrans %} + +
    +
    +
    + {% else %} + + {% endif %} +
    +
    + {% endfor %} + +
    +{% endblock %} + diff --git a/templates/info_providers/providers.macro.html.twig b/templates/info_providers/providers.macro.html.twig index 7304806a..bec8f24b 100644 --- a/templates/info_providers/providers.macro.html.twig +++ b/templates/info_providers/providers.macro.html.twig @@ -13,7 +13,6 @@ {% else %} {{ provider.providerInfo.name | trans }} {% endif %} -
    {% if provider.providerInfo.description is defined and provider.providerInfo.description is not null %} @@ -23,7 +22,12 @@
    - {% for capability in provider.capabilities %} + {% if provider.providerInfo.settings_class is defined %} + + {% endif %} + {% for capability in provider.capabilities|sort((a, b) => a.orderIndex <=> b.orderIndex) %} {# @var capability \App\Services\InfoProviderSystem\Providers\ProviderCapabilities #} @@ -52,4 +56,4 @@ {% endfor %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/info_providers/settings/provider_settings.html.twig b/templates/info_providers/settings/provider_settings.html.twig new file mode 100644 index 00000000..1876c2eb --- /dev/null +++ b/templates/info_providers/settings/provider_settings.html.twig @@ -0,0 +1,31 @@ +{% extends "main_card.html.twig" %} +{% macro genId(widget) %}{{ widget.vars.full_name }}{% endmacro %} + +{% form_theme form "form/settings_form.html.twig" %} + +{% block title %}{% trans %}info_providers.settings.title{% endtrans %}: {{ info_provider_info.name }}{% endblock %} + +{% block card_title %} {% trans %}info_providers.settings.title{% endtrans %}: {{ info_provider_info.name }}{% endblock %} + +{% block card_content %} +
    +

    + {% if info_provider_info.url %} + {{ info_provider_info.name }} + {% else %} + {{ info_provider_info.name }} + {% endif %} +

    + {% if info_provider_info.description %} +

    {{ info_provider_info.description }}

    + {% endif %} +
    + + {{ form_start(form) }} +
    +
    + {{ form_help(form) }} +
    +
    + {{ form_end(form) }} +{% endblock %} diff --git a/templates/label_system/dialog.html.twig b/templates/label_system/dialog.html.twig index 50db99e7..11877a4c 100644 --- a/templates/label_system/dialog.html.twig +++ b/templates/label_system/dialog.html.twig @@ -10,6 +10,9 @@ {% block card_content %} {{ form_start(form) }} + {# Default submit to use when pressing enter. #} + +