diff --git a/README.md b/README.md index b857711f..3c738025 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/?branch=master) ![PHPUnit Tests](https://github.com/Part-DB/Part-DB-symfony/workflows/PHPUnit%20Tests/badge.svg) ![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) @@ -62,7 +61,8 @@ for the first time. * Automatic thumbnail generation for pictures * Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and prices for parts -* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction +* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction. +A browser plugin allows to quickly submit parts from any website to your Part-DB instance, and even allows to circumvent anti-bot measures on shop websites. * API to access Part-DB from other applications/scripts * [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your KiCad and see available parts from Part-DB directly inside KiCad. diff --git a/VERSION b/VERSION index 6ceb272e..d8b69897 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.11.1 +2.12.0 diff --git a/assets/controllers/common/dirty_form_controller.js b/assets/controllers/common/dirty_form_controller.js new file mode 100644 index 00000000..aad2e6b0 --- /dev/null +++ b/assets/controllers/common/dirty_form_controller.js @@ -0,0 +1,274 @@ +/* + * 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 {visit} from "@hotwired/turbo"; +import * as bootbox from "bootbox"; +import "../../css/components/bootbox_extensions.css"; +import "../../css/components/dirty_form.css"; + +/** + * Attach to a
element (or a wrapper containing a ) to prevent accidental navigation + * away when the form has unsaved changes. + * + * Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger + * a check of whether any element's current value differs from the DOM default recorded in the HTML + * (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both + * native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input` + * when they sync their underlying textarea). + * + * Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty: + * the submitted data was never saved, so navigating away would lose it. This removes the need for + * any snapshot mechanism — the `.is-invalid` classes in the re-rendered HTML are the signal. + * + * Intercepts three navigation paths: + * 1. Any link click (capture phase) + * 2. window beforeunload + * 3. turbo:before-visit + * + * Values: + * - confirmTitle (String) – dialog title + * - confirmMessage (String) – dialog body text + */ +export default class extends Controller { + static values = { + confirmTitle: {type: String, default: 'Unsaved Changes'}, + confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'}, + }; + + connect() { + this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form'); + this._isDirty = false; + this._submitting = false; + this._navigating = false; + + this._changeHandler = this._handleChange.bind(this); + this._linkClickHandler = this._handleLinkClick.bind(this); + this._beforeUnloadHandler = this._handleBeforeUnload.bind(this); + this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this); + this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this); + + if (this._form) { + this._form.addEventListener('change', this._changeHandler); + // CKEditor (and other rich-text widgets) dispatch `input` rather than `change` + // when their underlying textarea value is updated. + this._form.addEventListener('input', this._changeHandler); + } + document.addEventListener('click', this._linkClickHandler, true); + window.addEventListener('beforeunload', this._beforeUnloadHandler); + document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler); + document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler); + + const modal = this.element.closest('.modal'); + if (modal) { + this._modal = modal; + this._modalHideHandler = this._handleModalHide.bind(this); + modal.addEventListener('hide.bs.modal', this._modalHideHandler); + } + } + + disconnect() { + if (this._form) { + this._form.removeEventListener('change', this._changeHandler); + this._form.removeEventListener('input', this._changeHandler); + } + document.removeEventListener('click', this._linkClickHandler, true); + window.removeEventListener('beforeunload', this._beforeUnloadHandler); + document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler); + document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler); + + if (this._modal && this._modalHideHandler) { + this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler); + } + } + + /** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */ + submit() { + this._submitting = true; + } + + /** + * data-action="reset->common--dirty-form#resetDirtyState" — marks the form as clean after + * a programmatic reset. Native change events are not fired by form.reset(), so we set the + * flag directly. Turbo also calls form.reset() internally before the post-submit redirect; + * the _submitting guard prevents that from incorrectly clearing the flag. + */ + resetDirtyState() { + if (this._submitting) return; + + // Wait for a frame to allow the form's DOM state to update after the reset() call, then refresh markers and update the dirty flag. + requestAnimationFrame(() => { + this._isDirty = false; + this._clearDirtyMarkers(); + }); + } + + _handleChange(event) { + const target = event?.target; + if (target?.name) { + this._updateDirtyMarker(target); + } else { + this._refreshDirtyMarkers(); + } + this._isDirty = this._form?.querySelector('[data-dirty]') !== null; + } + + /** + * Walk every named form element and update its `data-dirty` attribute. + * Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are + * skipped — they are not submitted and are not the source of truth for form data. + */ + _refreshDirtyMarkers() { + if (!this._form) return; + for (const el of this._form.elements) { + if (!el.name) continue; + this._updateDirtyMarker(el); + } + } + + /** + * Set or clear `data-dirty` on a single named form element. + * Hidden inputs are not visually rendered, so special handling applies: + * - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox — + * mark that instead. + * - Other hidden inputs (e.g. CSRF tokens): ignored. + * TomSelect hides the so the dirty-check + * controller can detect changes, and restores that value when the form is reset. + */ +export default function form_reset_handler() { + const self = this; + const input = this.input; + + // Multiple selects not yet supported + if (input.multiple) { + return; + } + + // Always capture the initial value, even empty string. + // Empty string is falsy, so the old `|| null` guard would silently skip it, + // leaving data-default-value unset and breaking the dirty check for blank defaults. + input.dataset.defaultValue = input.value; + + if (input.form) { + input.form.addEventListener('reset', () => { + input.value = input.dataset.defaultValue ?? ''; + self.sync(); + }); + } +} diff --git a/composer.json b/composer.json index 9ff78651..3fcaa5e3 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,11 @@ "amphp/http-client": "^5.1", "api-platform/doctrine-orm": "^4.1", "api-platform/json-api": "^4.0.0", - "api-platform/mcp": "4.3.x-dev", + "api-platform/mcp": "^v4.3.6", "api-platform/symfony": "^4.0.0", "api-platform/metadata": "4.3.x-dev", "beberlei/doctrineextensions": "^1.2", - "brick/math": "^0.14.8", + "brick/math": "^0.17.0", "brick/schema": "^0.2.0", "composer/ca-bundle": "^1.5", "composer/package-versions-deprecated": "^1.11.99.5", @@ -42,7 +42,7 @@ "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", "maennchen/zipstream-php": "2.1", - "mcp/sdk": "v0.5.0 as 0.4.0", + "mcp/sdk": "v0.5.0", "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", @@ -60,9 +60,9 @@ "scheb/2fa-trusted-device": "^v7.11.0", "shivas/versioning-bundle": "^4.0", "spatie/db-dumper": "^3.3.1", - "symfony/ai-bundle": "^0.8.0", - "symfony/ai-lm-studio-platform": "^0.8.0", - "symfony/ai-open-router-platform": "^0.8.0", + "symfony/ai-bundle": "^0.9.0", + "symfony/ai-lm-studio-platform": "^0.9.0", + "symfony/ai-open-router-platform": "^0.9.0", "symfony/apache-pack": "^1.0", "symfony/asset": "7.4.*", "symfony/console": "7.4.*", @@ -76,7 +76,7 @@ "symfony/http-client": "7.4.*", "symfony/http-kernel": "7.4.*", "symfony/mailer": "7.4.*", - "symfony/mcp-bundle": "^0.8.0", + "symfony/mcp-bundle": "^v0.9.0.0", "symfony/monolog-bundle": "^4.0", "symfony/process": "7.4.*", "symfony/property-access": "7.4.*", diff --git a/composer.lock b/composer.lock index 98a9924e..c409386c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "79f9618efcdf9891b62843fb5c3fb8de", + "content-hash": "9171608b0a2f6caa65f4eca3b66ae351", "packages": [ { "name": "amphp/amp", @@ -456,16 +456,16 @@ }, { "name": "amphp/http-client", - "version": "v5.3.4", + "version": "v5.3.6", "source": { "type": "git", "url": "https://github.com/amphp/http-client.git", - "reference": "75ad21574fd632594a2dd914496647816d5106bc" + "reference": "ca155026acafa74a612d776a97202d53077fee86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", - "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86", + "reference": "ca155026acafa74a612d776a97202d53077fee86", "shasum": "" }, "require": { @@ -493,9 +493,8 @@ "amphp/phpunit-util": "^3", "ext-json": "*", "kelunik/link-header-rfc5988": "^1", - "laminas/laminas-diactoros": "^2.3", "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" + "psalm/phar": "6.16.1" }, "suggest": { "amphp/file": "Required for file request bodies and HTTP archive logging", @@ -542,7 +541,7 @@ ], "support": { "issues": "https://github.com/amphp/http-client/issues", - "source": "https://github.com/amphp/http-client/tree/v5.3.4" + "source": "https://github.com/amphp/http-client/tree/v5.3.6" }, "funding": [ { @@ -550,7 +549,7 @@ "type": "github" } ], - "time": "2025-08-16T20:41:23+00:00" + "time": "2026-05-15T23:29:38+00:00" }, { "name": "amphp/parser", @@ -977,22 +976,22 @@ }, { "name": "api-platform/doctrine-common", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-common.git", - "reference": "2072247e3c8126d815f20324e7aaa97c2b5ee889" + "reference": "089b196c2f8e4d14333aaa3c6db33356e8fd8be0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/2072247e3c8126d815f20324e7aaa97c2b5ee889", - "reference": "2072247e3c8126d815f20324e7aaa97c2b5ee889", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/089b196c2f8e4d14333aaa3c6db33356e8fd8be0", + "reference": "089b196c2f8e4d14333aaa3c6db33356e8fd8be0", "shasum": "" }, "require": { "api-platform/metadata": "^4.2.6", "api-platform/state": "^4.2.4", - "doctrine/collections": "^2.1", + "doctrine/collections": "^2.1 || ^3.0", "doctrine/common": "^3.2.2", "doctrine/persistence": "^3.2 || ^4.0", "php": ">=8.2" @@ -1061,22 +1060,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.4" + "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-04T13:25:58+00:00" }, { "name": "api-platform/doctrine-orm", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-orm.git", - "reference": "3dc88ee48ffcdb6eee45ec1d3e9f25ea2aad4eaa" + "reference": "095a4c56cdd9986208100dedd5d28be50a4830ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/3dc88ee48ffcdb6eee45ec1d3e9f25ea2aad4eaa", - "reference": "3dc88ee48ffcdb6eee45ec1d3e9f25ea2aad4eaa", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/095a4c56cdd9986208100dedd5d28be50a4830ba", + "reference": "095a4c56cdd9986208100dedd5d28be50a4830ba", "shasum": "" }, "require": { @@ -1150,13 +1149,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.4" + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-07T11:45:31+00:00" }, { "name": "api-platform/documentation", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/documentation.git", @@ -1213,13 +1212,13 @@ ], "description": "API Platform documentation controller.", "support": { - "source": "https://github.com/api-platform/documentation/tree/v4.3.4" + "source": "https://github.com/api-platform/documentation/tree/v4.3.6" }, "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/http-cache", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/http-cache.git", @@ -1293,22 +1292,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/http-cache/tree/v4.3.4" + "source": "https://github.com/api-platform/http-cache/tree/v4.3.6" }, "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/hydra", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/hydra.git", - "reference": "9b0a677b21ee4f2ec255386a84bdcf1d12ea7bc4" + "reference": "317a696e396b80ba87de2560679c362923ef0a14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/hydra/zipball/9b0a677b21ee4f2ec255386a84bdcf1d12ea7bc4", - "reference": "9b0a677b21ee4f2ec255386a84bdcf1d12ea7bc4", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/317a696e396b80ba87de2560679c362923ef0a14", + "reference": "317a696e396b80ba87de2560679c362923ef0a14", "shasum": "" }, "require": { @@ -1380,22 +1379,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/hydra/tree/v4.3.4" + "source": "https://github.com/api-platform/hydra/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-11T11:50:19+00:00" }, { "name": "api-platform/json-api", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/json-api.git", - "reference": "30e399ea2266403d04fd93df83c6983cf0a30e5d" + "reference": "3a562e7f1bb1bc802e58eff674a20b78fe107275" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-api/zipball/30e399ea2266403d04fd93df83c6983cf0a30e5d", - "reference": "30e399ea2266403d04fd93df83c6983cf0a30e5d", + "url": "https://api.github.com/repos/api-platform/json-api/zipball/3a562e7f1bb1bc802e58eff674a20b78fe107275", + "reference": "3a562e7f1bb1bc802e58eff674a20b78fe107275", "shasum": "" }, "require": { @@ -1462,13 +1461,13 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/json-api/tree/v4.3.4" + "source": "https://github.com/api-platform/json-api/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-22T11:06:32+00:00" }, { "name": "api-platform/json-schema", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/json-schema.git", @@ -1543,13 +1542,13 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/json-schema/tree/v4.3.4" + "source": "https://github.com/api-platform/json-schema/tree/v4.3.6" }, "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/jsonld", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/jsonld.git", @@ -1623,22 +1622,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/jsonld/tree/v4.3.4" + "source": "https://github.com/api-platform/jsonld/tree/v4.3.6" }, "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/mcp", - "version": "4.3.x-dev", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/mcp.git", - "reference": "407c1039e8e022f7baed7d2cfbf63f5f09957f6b" + "reference": "35177126b8beb69169e7a33a325e00dbbdbbd7bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/mcp/zipball/407c1039e8e022f7baed7d2cfbf63f5f09957f6b", - "reference": "407c1039e8e022f7baed7d2cfbf63f5f09957f6b", + "url": "https://api.github.com/repos/api-platform/mcp/zipball/35177126b8beb69169e7a33a325e00dbbdbbd7bf", + "reference": "35177126b8beb69169e7a33a325e00dbbdbbd7bf", "shasum": "" }, "require": { @@ -1700,9 +1699,9 @@ ], "support": { "issues": "https://github.com/api-platform/mcp/issues", - "source": "https://github.com/api-platform/mcp/tree/4.3" + "source": "https://github.com/api-platform/mcp/tree/v4.3.6" }, - "time": "2026-05-06T12:07:59+00:00" + "time": "2026-05-11T06:02:06+00:00" }, { "name": "api-platform/metadata", @@ -1710,12 +1709,12 @@ "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", - "reference": "52b367f046c5d202629e9441aece39b0e6b37838" + "reference": "e9e8a7b85d2d513edff3b108072f8ab23a9d6344" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/metadata/zipball/52b367f046c5d202629e9441aece39b0e6b37838", - "reference": "52b367f046c5d202629e9441aece39b0e6b37838", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/e9e8a7b85d2d513edff3b108072f8ab23a9d6344", + "reference": "e9e8a7b85d2d513edff3b108072f8ab23a9d6344", "shasum": "" }, "require": { @@ -1800,11 +1799,11 @@ "support": { "source": "https://github.com/api-platform/metadata/tree/4.3" }, - "time": "2026-05-06T12:07:59+00:00" + "time": "2026-05-22T12:00:17+00:00" }, { "name": "api-platform/openapi", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/openapi.git", @@ -1889,22 +1888,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/openapi/tree/v4.3.4" + "source": "https://github.com/api-platform/openapi/tree/v4.3.6" }, "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/serializer", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/serializer.git", - "reference": "bd7c26cc8e6858abc9661d677c15eaf4c61e08e3" + "reference": "2c4f996bb6e5fef49106df0c48d0c1954e10998b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/serializer/zipball/bd7c26cc8e6858abc9661d677c15eaf4c61e08e3", - "reference": "bd7c26cc8e6858abc9661d677c15eaf4c61e08e3", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/2c4f996bb6e5fef49106df0c48d0c1954e10998b", + "reference": "2c4f996bb6e5fef49106df0c48d0c1954e10998b", "shasum": "" }, "require": { @@ -1913,7 +1912,7 @@ "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/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" }, "require-dev": { @@ -1983,22 +1982,22 @@ "serializer" ], "support": { - "source": "https://github.com/api-platform/serializer/tree/v4.3.4" + "source": "https://github.com/api-platform/serializer/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-12T10:07:44+00:00" }, { "name": "api-platform/state", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/state.git", - "reference": "dda8789e95b1627a6427edb48f9024b306fdf5ff" + "reference": "6e3f6d75e605ba7171a7590c82da5126979a936b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/state/zipball/dda8789e95b1627a6427edb48f9024b306fdf5ff", - "reference": "dda8789e95b1627a6427edb48f9024b306fdf5ff", + "url": "https://api.github.com/repos/api-platform/state/zipball/6e3f6d75e605ba7171a7590c82da5126979a936b", + "reference": "6e3f6d75e605ba7171a7590c82da5126979a936b", "shasum": "" }, "require": { @@ -2006,7 +2005,7 @@ "php": ">=8.2", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/translation-contracts": "^3.0" }, @@ -2080,22 +2079,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/state/tree/v4.3.4" + "source": "https://github.com/api-platform/state/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-22T12:02:28+00:00" }, { "name": "api-platform/symfony", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/symfony.git", - "reference": "532063884e3f91a8a831322a572220cc55501a2f" + "reference": "13308ad99dd1479e70fe79c20519d8135df8e7b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/symfony/zipball/532063884e3f91a8a831322a572220cc55501a2f", - "reference": "532063884e3f91a8a831322a572220cc55501a2f", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/13308ad99dd1479e70fe79c20519d8135df8e7b9", + "reference": "13308ad99dd1479e70fe79c20519d8135df8e7b9", "shasum": "" }, "require": { @@ -2112,6 +2111,7 @@ "php": ">=8.2", "symfony/asset": "^6.4 || ^7.0 || ^8.0", "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^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", @@ -2208,28 +2208,28 @@ "symfony" ], "support": { - "source": "https://github.com/api-platform/symfony/tree/v4.3.4" + "source": "https://github.com/api-platform/symfony/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-18T09:34:32+00:00" }, { "name": "api-platform/validator", - "version": "v4.3.4", + "version": "v4.3.6", "source": { "type": "git", "url": "https://github.com/api-platform/validator.git", - "reference": "22693bc3d3538af700cf274b99c834c37b1d1a68" + "reference": "6df6804799f8831469d2602d0845a0316e81fbab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/validator/zipball/22693bc3d3538af700cf274b99c834c37b1d1a68", - "reference": "22693bc3d3538af700cf274b99c834c37b1d1a68", + "url": "https://api.github.com/repos/api-platform/validator/zipball/6df6804799f8831469d2602d0845a0316e81fbab", + "reference": "6df6804799f8831469d2602d0845a0316e81fbab", "shasum": "" }, "require": { "api-platform/metadata": "^4.3", "php": ">=8.2", - "symfony/http-kernel": "^6.4 || ^7.1 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^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", @@ -2284,9 +2284,9 @@ "validator" ], "support": { - "source": "https://github.com/api-platform/validator/tree/v4.3.4" + "source": "https://github.com/api-platform/validator/tree/v4.3.6" }, - "time": "2026-04-30T12:21:24+00:00" + "time": "2026-05-07T11:45:31+00:00" }, { "name": "beberlei/assert", @@ -2419,23 +2419,22 @@ }, { "name": "brick/math", - "version": "0.14.8", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, @@ -2467,7 +2466,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.8" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -2475,7 +2474,7 @@ "type": "github" } ], - "time": "2026-02-10T14:33:43+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "brick/schema", @@ -2590,16 +2589,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.11", + "version": "1.5.12", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6" + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/68ff39175e8e94a4bb1d259407ce51a6a60f09e6", - "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", "shasum": "" }, "require": { @@ -2646,7 +2645,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.11" + "source": "https://github.com/composer/ca-bundle/tree/1.5.12" }, "funding": [ { @@ -2658,7 +2657,7 @@ "type": "github" } ], - "time": "2026-03-30T09:16:10+00:00" + "time": "2026-05-19T11:26:22+00:00" }, { "name": "composer/package-versions-deprecated", @@ -4063,16 +4062,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.4", + "version": "3.6.7", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd" + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd", - "reference": "156f3b5a984e7eaa72d440bb6de1d3b6f8d2d6fd", + "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", "shasum": "" }, "require": { @@ -4145,9 +4144,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.4" + "source": "https://github.com/doctrine/orm/tree/3.6.7" }, - "time": "2026-05-07T07:04:34+00:00" + "time": "2026-05-25T16:45:47+00:00" }, { "name": "doctrine/persistence", @@ -4714,16 +4713,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.10.4", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "aec528da477062d3af11f51e6b33402be233b21f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aec528da477062d3af11f51e6b33402be233b21f", + "reference": "aec528da477062d3af11f51e6b33402be233b21f", "shasum": "" }, "require": { @@ -4741,8 +4740,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -4820,7 +4820,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.10.4" }, "funding": [ { @@ -4836,20 +4836,20 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-05-22T19:00:53+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", "shasum": "" }, "require": { @@ -4857,7 +4857,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -4903,7 +4903,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.4.1" }, "funding": [ { @@ -4919,20 +4919,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-05-20T22:57:30+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.10.2", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "a1bbdc172f32a25fe999965b65b6e71fd87da9ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a1bbdc172f32a25fe999965b65b6e71fd87da9ed", + "reference": "a1bbdc172f32a25fe999965b65b6e71fd87da9ed", "shasum": "" }, "require": { @@ -4947,9 +4947,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -5020,7 +5020,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.10.2" }, "funding": [ { @@ -5036,7 +5036,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-05-25T22:58:15+00:00" }, { "name": "hshn/base64-encoded-file", @@ -7903,16 +7903,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -7988,9 +7988,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikolaposa/version", @@ -8253,21 +8253,21 @@ }, { "name": "onelogin/php-saml", - "version": "4.3.1", + "version": "4.3.2", "source": { "type": "git", "url": "https://github.com/SAML-Toolkits/php-saml.git", - "reference": "b009f160e4ac11f49366a45e0d45706b48429353" + "reference": "26b3a47349415e5b7aa300ba4ab7fc316c65f19e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/b009f160e4ac11f49366a45e0d45706b48429353", - "reference": "b009f160e4ac11f49366a45e0d45706b48429353", + "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/26b3a47349415e5b7aa300ba4ab7fc316c65f19e", + "reference": "26b3a47349415e5b7aa300ba4ab7fc316c65f19e", "shasum": "" }, "require": { "php": ">=7.3", - "robrichards/xmlseclibs": ">=3.1.4" + "robrichards/xmlseclibs": "^3.1.5" }, "require-dev": { "pdepend/pdepend": "^2.8.0", @@ -8313,7 +8313,7 @@ "type": "github" } ], - "time": "2025-12-09T10:50:49+00:00" + "time": "2026-05-07T22:38:04+00:00" }, { "name": "opis/json-schema", @@ -10135,16 +10135,16 @@ }, { "name": "revolt/event-loop", - "version": "v1.0.8", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + "reference": "44061cf513e53c6200372fc935ac42271566295d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", "shasum": "" }, "require": { @@ -10154,7 +10154,7 @@ "ext-json": "*", "jetbrains/phpstorm-stubs": "^2019.3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" + "psalm/phar": "6.16.*" }, "type": "library", "extra": { @@ -10201,9 +10201,9 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" }, - "time": "2025-08-27T21:33:23+00:00" + "time": "2026-05-16T17:55:38+00:00" }, { "name": "rhukster/dom-sanitizer", @@ -11193,21 +11193,21 @@ }, { "name": "symfony/ai-bundle", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/ai-bundle.git", - "reference": "847365e0f885f8814421e9c94f03ce19e0b54bbc" + "reference": "77fd1b513174770acf49abd68effa995fa518f7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ai-bundle/zipball/847365e0f885f8814421e9c94f03ce19e0b54bbc", - "reference": "847365e0f885f8814421e9c94f03ce19e0b54bbc", + "url": "https://api.github.com/repos/symfony/ai-bundle/zipball/77fd1b513174770acf49abd68effa995fa518f7c", + "reference": "77fd1b513174770acf49abd68effa995fa518f7c", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/ai-platform": "^0.8", + "symfony/ai-platform": "^0.9", "symfony/clock": "^7.3|^8.0", "symfony/config": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", @@ -11222,72 +11222,74 @@ "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.53", - "symfony/ai-agent": "^0.8", - "symfony/ai-ai-ml-api-platform": "^0.8", - "symfony/ai-albert-platform": "^0.8", - "symfony/ai-amazee-ai-platform": "^0.8", - "symfony/ai-anthropic-platform": "^0.8", - "symfony/ai-azure-platform": "^0.8", - "symfony/ai-azure-search-store": "^0.8", - "symfony/ai-bedrock-platform": "^0.8", - "symfony/ai-cache-message-store": "^0.8", - "symfony/ai-cache-platform": "^0.8", - "symfony/ai-cache-store": "^0.8", - "symfony/ai-cartesia-platform": "^0.8", - "symfony/ai-cerebras-platform": "^0.8", - "symfony/ai-chat": "^0.8", - "symfony/ai-chroma-db-store": "^0.8", - "symfony/ai-click-house-store": "^0.8", - "symfony/ai-cloudflare-message-store": "^0.8", - "symfony/ai-cloudflare-store": "^0.8", - "symfony/ai-decart-platform": "^0.8", - "symfony/ai-deep-seek-platform": "^0.8", - "symfony/ai-docker-model-runner-platform": "^0.8", - "symfony/ai-doctrine-message-store": "^0.8", - "symfony/ai-elasticsearch-store": "^0.8", - "symfony/ai-eleven-labs-platform": "^0.8", - "symfony/ai-failover-platform": "^0.8", - "symfony/ai-gemini-platform": "^0.8", - "symfony/ai-generic-platform": "^0.8", - "symfony/ai-hugging-face-platform": "^0.8", - "symfony/ai-lm-studio-platform": "^0.8", - "symfony/ai-manticore-search-store": "^0.8", - "symfony/ai-maria-db-store": "^0.8", - "symfony/ai-meilisearch-message-store": "^0.8", - "symfony/ai-meilisearch-store": "^0.8", - "symfony/ai-meta-platform": "^0.8", - "symfony/ai-milvus-store": "^0.8", - "symfony/ai-mistral-platform": "^0.8", - "symfony/ai-mongo-db-message-store": "^0.8", - "symfony/ai-mongo-db-store": "^0.8", - "symfony/ai-neo4j-store": "^0.8", - "symfony/ai-ollama-platform": "^0.8", - "symfony/ai-open-ai-platform": "^0.8", - "symfony/ai-open-responses-platform": "^0.8", - "symfony/ai-open-router-platform": "^0.8", - "symfony/ai-open-search-store": "^0.8", - "symfony/ai-perplexity-platform": "^0.8", - "symfony/ai-pinecone-store": "^0.8", - "symfony/ai-pogocache-message-store": "^0.8", - "symfony/ai-postgres-store": "^0.8", - "symfony/ai-qdrant-store": "^0.8", - "symfony/ai-redis-message-store": "^0.8", - "symfony/ai-redis-store": "^0.8", - "symfony/ai-replicate-platform": "^0.8", - "symfony/ai-s3vectors-store": "^0.8", - "symfony/ai-scaleway-platform": "^0.8", - "symfony/ai-session-message-store": "^0.8", - "symfony/ai-sqlite-store": "^0.8", - "symfony/ai-store": "^0.8", - "symfony/ai-supabase-store": "^0.8", - "symfony/ai-surreal-db-message-store": "^0.8", - "symfony/ai-surreal-db-store": "^0.8", - "symfony/ai-transformers-php-platform": "^0.8", - "symfony/ai-typesense-store": "^0.8", - "symfony/ai-vektor-store": "^0.8", - "symfony/ai-vertex-ai-platform": "^0.8", - "symfony/ai-voyage-platform": "^0.8", - "symfony/ai-weaviate-store": "^0.8", + "symfony/ai-agent": "^0.9", + "symfony/ai-ai-ml-api-platform": "^0.9", + "symfony/ai-albert-platform": "^0.9", + "symfony/ai-amazee-ai-platform": "^0.9", + "symfony/ai-anthropic-platform": "^0.9", + "symfony/ai-azure-platform": "^0.9", + "symfony/ai-azure-search-store": "^0.9", + "symfony/ai-bedrock-platform": "^0.9", + "symfony/ai-cache-message-store": "^0.9", + "symfony/ai-cache-platform": "^0.9", + "symfony/ai-cache-store": "^0.9", + "symfony/ai-cartesia-platform": "^0.9", + "symfony/ai-cerebras-platform": "^0.9", + "symfony/ai-chat": "^0.9", + "symfony/ai-chroma-db-store": "^0.9", + "symfony/ai-click-house-store": "^0.9", + "symfony/ai-cloudflare-message-store": "^0.9", + "symfony/ai-cloudflare-store": "^0.9", + "symfony/ai-cohere-platform": "^0.9", + "symfony/ai-decart-platform": "^0.9", + "symfony/ai-deep-seek-platform": "^0.9", + "symfony/ai-docker-model-runner-platform": "^0.9", + "symfony/ai-doctrine-message-store": "^0.9", + "symfony/ai-elasticsearch-store": "^0.9", + "symfony/ai-eleven-labs-platform": "^0.9", + "symfony/ai-failover-platform": "^0.9", + "symfony/ai-gemini-platform": "^0.9", + "symfony/ai-generic-platform": "^0.9", + "symfony/ai-hugging-face-platform": "^0.9", + "symfony/ai-lm-studio-platform": "^0.9", + "symfony/ai-manticore-search-store": "^0.9", + "symfony/ai-maria-db-store": "^0.9", + "symfony/ai-meilisearch-message-store": "^0.9", + "symfony/ai-meilisearch-store": "^0.9", + "symfony/ai-meta-platform": "^0.9", + "symfony/ai-milvus-store": "^0.9", + "symfony/ai-mistral-platform": "^0.9", + "symfony/ai-mongo-db-message-store": "^0.9", + "symfony/ai-mongo-db-store": "^0.9", + "symfony/ai-neo4j-store": "^0.9", + "symfony/ai-ollama-platform": "^0.9", + "symfony/ai-open-ai-platform": "^0.9", + "symfony/ai-open-responses-platform": "^0.9", + "symfony/ai-open-router-platform": "^0.9", + "symfony/ai-open-search-store": "^0.9", + "symfony/ai-ovh-platform": "^0.9", + "symfony/ai-perplexity-platform": "^0.9", + "symfony/ai-pinecone-store": "^0.9", + "symfony/ai-pogocache-message-store": "^0.9", + "symfony/ai-postgres-store": "^0.9", + "symfony/ai-qdrant-store": "^0.9", + "symfony/ai-redis-message-store": "^0.9", + "symfony/ai-redis-store": "^0.9", + "symfony/ai-replicate-platform": "^0.9", + "symfony/ai-s3vectors-store": "^0.9", + "symfony/ai-scaleway-platform": "^0.9", + "symfony/ai-session-message-store": "^0.9", + "symfony/ai-sqlite-store": "^0.9", + "symfony/ai-store": "^0.9", + "symfony/ai-supabase-store": "^0.9", + "symfony/ai-surreal-db-message-store": "^0.9", + "symfony/ai-surreal-db-store": "^0.9", + "symfony/ai-transformers-php-platform": "^0.9", + "symfony/ai-typesense-store": "^0.9", + "symfony/ai-vektor-store": "^0.9", + "symfony/ai-vertex-ai-platform": "^0.9", + "symfony/ai-voyage-platform": "^0.9", + "symfony/ai-weaviate-store": "^0.9", "symfony/expression-language": "^7.3|^8.0", "symfony/security-core": "^7.3|^8.0", "symfony/translation": "^7.3|^8.0", @@ -11325,7 +11327,7 @@ ], "description": "Integration bundle for Symfony AI components", "support": { - "source": "https://github.com/symfony/ai-bundle/tree/v0.8.0" + "source": "https://github.com/symfony/ai-bundle/tree/v0.9.0" }, "funding": [ { @@ -11345,25 +11347,25 @@ "type": "tidelift" } ], - "time": "2026-04-20T21:23:24+00:00" + "time": "2026-05-16T08:40:45+00:00" }, { "name": "symfony/ai-generic-platform", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/ai-generic-platform.git", - "reference": "2e358c0e88c676fad0b61b3df715f9822d29a7e3" + "reference": "8887d12b8ea97d079c5c97de4aebb19f42c58dc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ai-generic-platform/zipball/2e358c0e88c676fad0b61b3df715f9822d29a7e3", - "reference": "2e358c0e88c676fad0b61b3df715f9822d29a7e3", + "url": "https://api.github.com/repos/symfony/ai-generic-platform/zipball/8887d12b8ea97d079c5c97de4aebb19f42c58dc5", + "reference": "8887d12b8ea97d079c5c97de4aebb19f42c58dc5", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/ai-platform": "^0.8", + "symfony/ai-platform": "^0.9", "symfony/http-client": "^7.3|^8.0" }, "require-dev": { @@ -11410,7 +11412,7 @@ "platform" ], "support": { - "source": "https://github.com/symfony/ai-generic-platform/tree/v0.8.0" + "source": "https://github.com/symfony/ai-generic-platform/tree/v0.9.0" }, "funding": [ { @@ -11430,26 +11432,26 @@ "type": "tidelift" } ], - "time": "2026-04-20T21:23:24+00:00" + "time": "2026-05-16T01:01:33+00:00" }, { "name": "symfony/ai-lm-studio-platform", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/ai-lm-studio-platform.git", - "reference": "ad1c046dd9e7d6e474bc86554443e2d9400a7826" + "reference": "9e53e56c8c3a04dddb955088b40904e747ec3981" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ai-lm-studio-platform/zipball/ad1c046dd9e7d6e474bc86554443e2d9400a7826", - "reference": "ad1c046dd9e7d6e474bc86554443e2d9400a7826", + "url": "https://api.github.com/repos/symfony/ai-lm-studio-platform/zipball/9e53e56c8c3a04dddb955088b40904e747ec3981", + "reference": "9e53e56c8c3a04dddb955088b40904e747ec3981", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/ai-generic-platform": "^0.8", - "symfony/ai-platform": "^0.8", + "symfony/ai-generic-platform": "^0.9", + "symfony/ai-platform": "^0.9", "symfony/http-client": "^7.3|^8.0" }, "require-dev": { @@ -11497,7 +11499,7 @@ "platform" ], "support": { - "source": "https://github.com/symfony/ai-lm-studio-platform/tree/v0.8.0" + "source": "https://github.com/symfony/ai-lm-studio-platform/tree/v0.9.0" }, "funding": [ { @@ -11517,33 +11519,34 @@ "type": "tidelift" } ], - "time": "2026-04-20T21:23:24+00:00" + "time": "2026-05-16T01:01:33+00:00" }, { "name": "symfony/ai-open-router-platform", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/ai-open-router-platform.git", - "reference": "eb5ed3176b78bc489bf325c5d6bc4efc255804be" + "reference": "7e2b560c86f618cd5d33f9f0c581d83bebc9802f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ai-open-router-platform/zipball/eb5ed3176b78bc489bf325c5d6bc4efc255804be", - "reference": "eb5ed3176b78bc489bf325c5d6bc4efc255804be", + "url": "https://api.github.com/repos/symfony/ai-open-router-platform/zipball/7e2b560c86f618cd5d33f9f0c581d83bebc9802f", + "reference": "7e2b560c86f618cd5d33f9f0c581d83bebc9802f", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/ai-generic-platform": "^0.8", - "symfony/ai-platform": "^0.8", + "symfony/ai-generic-platform": "^0.9", + "symfony/ai-platform": "^0.9", "symfony/http-client": "^7.3|^8.0" }, "require-dev": { "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^11.5.53" + "phpunit/phpunit": "^11.5.53", + "symfony/console": "^7.4|^8.0" }, "type": "symfony-ai-platform", "extra": { @@ -11583,7 +11586,7 @@ "platform" ], "support": { - "source": "https://github.com/symfony/ai-open-router-platform/tree/v0.8.0" + "source": "https://github.com/symfony/ai-open-router-platform/tree/v0.9.0" }, "funding": [ { @@ -11603,20 +11606,20 @@ "type": "tidelift" } ], - "time": "2026-04-20T21:23:24+00:00" + "time": "2026-05-16T01:01:33+00:00" }, { "name": "symfony/ai-platform", - "version": "v0.8.1", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/ai-platform.git", - "reference": "86ed9396f53cad02b5d1ca8092956ea74f65823f" + "reference": "fb55ebdf20bbe30af6752a0ce6a25abc56b2b625" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ai-platform/zipball/86ed9396f53cad02b5d1ca8092956ea74f65823f", - "reference": "86ed9396f53cad02b5d1ca8092956ea74f65823f", + "url": "https://api.github.com/repos/symfony/ai-platform/zipball/fb55ebdf20bbe30af6752a0ce6a25abc56b2b625", + "reference": "fb55ebdf20bbe30af6752a0ce6a25abc56b2b625", "shasum": "" }, "require": { @@ -11715,7 +11718,7 @@ "voyage" ], "support": { - "source": "https://github.com/symfony/ai-platform/tree/v0.8.1" + "source": "https://github.com/symfony/ai-platform/tree/v0.9.0" }, "funding": [ { @@ -11735,7 +11738,7 @@ "type": "tidelift" } ], - "time": "2026-04-20T21:28:38+00:00" + "time": "2026-05-15T19:15:50+00:00" }, { "name": "symfony/apache-pack", @@ -11838,16 +11841,16 @@ }, { "name": "symfony/cache", - "version": "v7.4.10", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8c5fbb4b5bc7a878f7ce66f1b7e29653c404984b" + "reference": "902d621e0b6ef0ebeaa133770b5c339a19328589" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8c5fbb4b5bc7a878f7ce66f1b7e29653c404984b", - "reference": "8c5fbb4b5bc7a878f7ce66f1b7e29653c404984b", + "url": "https://api.github.com/repos/symfony/cache/zipball/902d621e0b6ef0ebeaa133770b5c339a19328589", + "reference": "902d621e0b6ef0ebeaa133770b5c339a19328589", "shasum": "" }, "require": { @@ -11918,7 +11921,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.10" + "source": "https://github.com/symfony/cache/tree/v7.4.12" }, "funding": [ { @@ -11938,7 +11941,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:23:16+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/cache-contracts", @@ -12179,16 +12182,16 @@ }, { "name": "symfony/console", - "version": "v7.4.9", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58" + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d7d2b64a45a89d607865927b176fa51c33ddbb58", - "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58", + "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", "shasum": "" }, "require": { @@ -12253,7 +12256,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.9" + "source": "https://github.com/symfony/console/tree/v7.4.11" }, "funding": [ { @@ -12273,7 +12276,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/css-selector", @@ -12614,16 +12617,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" + "reference": "b59b59122690976550fd142c23fab62c84738db6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", - "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", + "reference": "b59b59122690976550fd142c23fab62c84738db6", "shasum": "" }, "require": { @@ -12662,7 +12665,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.8" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" }, "funding": [ { @@ -12682,20 +12685,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/dotenv", - "version": "v7.4.9", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "ba757a8564a0ccac1a26a859b83295645020ea68" + "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/ba757a8564a0ccac1a26a859b83295645020ea68", - "reference": "ba757a8564a0ccac1a26a859b83295645020ea68", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/82e9b1355c68ef7b96397dbd34cc75a92eebae7c", + "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c", "shasum": "" }, "require": { @@ -12740,7 +12743,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.9" + "source": "https://github.com/symfony/dotenv/tree/v7.4.11" }, "funding": [ { @@ -12760,7 +12763,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T13:21:53+00:00" + "time": "2026-05-11T13:02:51+00:00" }, { "name": "symfony/error-handler", @@ -13079,16 +13082,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.9", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/dcd8f96bcdc0f128ec406c765cc066c6035d1be3", - "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { @@ -13125,7 +13128,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.9" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -13145,7 +13148,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", @@ -13393,16 +13396,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v7.4.10", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "4b9cb207d72b2e4793f28a3c62ea0865098bea20" + "reference": "637f5cac1ac2698a012b41610215bf366004295f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/4b9cb207d72b2e4793f28a3c62ea0865098bea20", - "reference": "4b9cb207d72b2e4793f28a3c62ea0865098bea20", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/637f5cac1ac2698a012b41610215bf366004295f", + "reference": "637f5cac1ac2698a012b41610215bf366004295f", "shasum": "" }, "require": { @@ -13527,7 +13530,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.4.10" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.11" }, "funding": [ { @@ -13547,7 +13550,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T11:48:54+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/http-client", @@ -13816,16 +13819,16 @@ }, { "name": "symfony/http-kernel", - "version": "v7.4.10", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "23486f59234c6fd6e8f1bec97124f3829d686627" + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/23486f59234c6fd6e8f1bec97124f3829d686627", - "reference": "23486f59234c6fd6e8f1bec97124f3829d686627", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", "shasum": "" }, "require": { @@ -13911,7 +13914,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.10" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.12" }, "funding": [ { @@ -13931,7 +13934,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T12:07:34+00:00" + "time": "2026-05-20T09:27:11+00:00" }, { "name": "symfony/intl", @@ -14025,16 +14028,16 @@ }, { "name": "symfony/mailer", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", - "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", "shasum": "" }, "require": { @@ -14085,7 +14088,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.8" + "source": "https://github.com/symfony/mailer/tree/v7.4.12" }, "funding": [ { @@ -14105,28 +14108,29 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/mcp-bundle", - "version": "v0.8.0", + "version": "v0.9.0", "source": { "type": "git", "url": "https://github.com/symfony/mcp-bundle.git", - "reference": "b8db100e64fcb2d651e3bfd6c6ab70ad3599833d" + "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/b8db100e64fcb2d651e3bfd6c6ab70ad3599833d", - "reference": "b8db100e64fcb2d651e3bfd6c6ab70ad3599833d", + "url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/654f639e94f4d7694771e6628380a9ff04c9d9c1", + "reference": "654f639e94f4d7694771e6628380a9ff04c9d9c1", "shasum": "" }, "require": { - "mcp/sdk": "^0.4", + "mcp/sdk": "^0.5", "php-http/discovery": "^1.20", "symfony/config": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", "symfony/dependency-injection": "^7.3|^8.0", + "symfony/finder": "^7.3|^8.0", "symfony/framework-bundle": "^7.3|^8.0", "symfony/http-foundation": "^7.3|^8.0", "symfony/http-kernel": "^7.3|^8.0", @@ -14169,7 +14173,7 @@ ], "description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)", "support": { - "source": "https://github.com/symfony/mcp-bundle/tree/v0.8.0" + "source": "https://github.com/symfony/mcp-bundle/tree/v0.9.0" }, "funding": [ { @@ -14189,20 +14193,20 @@ "type": "tidelift" } ], - "time": "2026-04-12T00:28:34+00:00" + "time": "2026-05-15T23:41:17+00:00" }, { "name": "symfony/mime", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "2d550c4758ba4c47519a6667c36553d535705b0c" + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/2d550c4758ba4c47519a6667c36553d535705b0c", - "reference": "2d550c4758ba4c47519a6667c36553d535705b0c", + "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", "shasum": "" }, "require": { @@ -14258,7 +14262,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.9" + "source": "https://github.com/symfony/mime/tree/v7.4.12" }, "funding": [ { @@ -14278,20 +14282,20 @@ "type": "tidelift" } ], - "time": "2026-04-29T13:21:53+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "20366bceee51838144a14805204bb792cb3d09f2" + "reference": "20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/20366bceee51838144a14805204bb792cb3d09f2", - "reference": "20366bceee51838144a14805204bb792cb3d09f2", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8", + "reference": "20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8", "shasum": "" }, "require": { @@ -14341,7 +14345,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.9" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.12" }, "funding": [ { @@ -14361,7 +14365,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T13:21:53+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/monolog-bundle", @@ -14747,16 +14751,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -14805,7 +14809,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -14825,20 +14829,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "3510b63d07376b04e57e27e82607d468bb134f78" + "reference": "445c90e341fccda10311019cf82ff73bb7343945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78", - "reference": "3510b63d07376b04e57e27e82607d468bb134f78", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", "shasum": "" }, "require": { @@ -14893,7 +14897,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" }, "funding": [ { @@ -14913,20 +14917,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-05-25T11:52:53+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -14980,7 +14984,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -15000,20 +15004,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -15065,7 +15069,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -15085,20 +15089,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -15145,7 +15149,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -15165,20 +15169,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -15225,7 +15229,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -15245,20 +15249,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -15305,7 +15309,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -15325,7 +15329,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-uuid", @@ -15412,16 +15416,16 @@ }, { "name": "symfony/process", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", + "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", "shasum": "" }, "require": { @@ -15453,7 +15457,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.8" + "source": "https://github.com/symfony/process/tree/v7.4.11" }, "funding": [ { @@ -15473,7 +15477,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-11T16:55:21+00:00" }, { "name": "symfony/property-access", @@ -15810,16 +15814,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "287771d8bc86eacb30678dd10eda6c64a859951f" + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/287771d8bc86eacb30678dd10eda6c64a859951f", - "reference": "287771d8bc86eacb30678dd10eda6c64a859951f", + "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", "shasum": "" }, "require": { @@ -15871,7 +15875,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.9" + "source": "https://github.com/symfony/routing/tree/v7.4.12" }, "funding": [ { @@ -15891,20 +15895,20 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e" + "reference": "0b032fa77359745db793df5aff626779180c5f3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/6d792a64fec1eae2f011cfe9ab5978a9eab3071e", - "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e", + "url": "https://api.github.com/repos/symfony/runtime/zipball/0b032fa77359745db793df5aff626779180c5f3b", + "reference": "0b032fa77359745db793df5aff626779180c5f3b", "shasum": "" }, "require": { @@ -15917,6 +15921,7 @@ "require-dev": { "composer/composer": "^2.6", "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^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" @@ -15954,7 +15959,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.8" + "source": "https://github.com/symfony/runtime/tree/v7.4.12" }, "funding": [ { @@ -15974,20 +15979,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/security-bundle", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "6f73fdfd9ad23bf24b6f6c8d35be3ea6853d91af" + "reference": "6f6f859b437fb95028addfa21b417d25daca86d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/6f73fdfd9ad23bf24b6f6c8d35be3ea6853d91af", - "reference": "6f73fdfd9ad23bf24b6f6c8d35be3ea6853d91af", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/6f6f859b437fb95028addfa21b417d25daca86d5", + "reference": "6f6f859b437fb95028addfa21b417d25daca86d5", "shasum": "" }, "require": { @@ -16066,7 +16071,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v7.4.8" + "source": "https://github.com/symfony/security-bundle/tree/v7.4.12" }, "funding": [ { @@ -16086,20 +16091,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:54:39+00:00" + "time": "2026-05-15T07:14:02+00:00" }, { "name": "symfony/security-core", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "23e0cd6615661e33e53faf714bf6a130c2f75c25" + "reference": "efff84605474ec682c7d9c6278088811e6f3caaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/23e0cd6615661e33e53faf714bf6a130c2f75c25", - "reference": "23e0cd6615661e33e53faf714bf6a130c2f75c25", + "url": "https://api.github.com/repos/symfony/security-core/zipball/efff84605474ec682c7d9c6278088811e6f3caaa", + "reference": "efff84605474ec682c7d9c6278088811e6f3caaa", "shasum": "" }, "require": { @@ -16157,7 +16162,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.4.8" + "source": "https://github.com/symfony/security-core/tree/v7.4.12" }, "funding": [ { @@ -16177,7 +16182,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:00:19+00:00" + "time": "2026-05-15T06:48:59+00:00" }, { "name": "symfony/security-csrf", @@ -16255,16 +16260,16 @@ }, { "name": "symfony/security-http", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "a34991b13899de1f953df245395aa2196f9bc113" + "reference": "1fc7ca636cbd2cad29b42cc13c9fd0c681c6efee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/a34991b13899de1f953df245395aa2196f9bc113", - "reference": "a34991b13899de1f953df245395aa2196f9bc113", + "url": "https://api.github.com/repos/symfony/security-http/zipball/1fc7ca636cbd2cad29b42cc13c9fd0c681c6efee", + "reference": "1fc7ca636cbd2cad29b42cc13c9fd0c681c6efee", "shasum": "" }, "require": { @@ -16323,7 +16328,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.4.9" + "source": "https://github.com/symfony/security-http/tree/v7.4.12" }, "funding": [ { @@ -16343,7 +16348,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/serializer", @@ -16677,16 +16682,16 @@ }, { "name": "symfony/string", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "114ac57257d75df748eda23dd003878080b8e688" + "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", - "reference": "114ac57257d75df748eda23dd003878080b8e688", + "url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", + "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", "shasum": "" }, "require": { @@ -16744,7 +16749,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.8" + "source": "https://github.com/symfony/string/tree/v7.4.11" }, "funding": [ { @@ -16764,7 +16769,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/translation", @@ -16950,16 +16955,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.4.8", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c" + "reference": "81663873d946531129c76c65e80b681ce99c0e89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/ac43e7e59298ed1ce98c8d228b651d46e907d02c", - "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/81663873d946531129c76c65e80b681ce99c0e89", + "reference": "81663873d946531129c76c65e80b681ce99c0e89", "shasum": "" }, "require": { @@ -16975,7 +16980,7 @@ "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", "symfony/http-foundation": "<6.4", "symfony/http-kernel": "<6.4", - "symfony/mime": "<6.4.36|>7,<7.4.8|>8.0,<8.0.8", + "symfony/mime": "<6.4.37|>7,<7.4.9|>8.0,<8.0.9", "symfony/serializer": "<6.4", "symfony/translation": "<6.4", "symfony/workflow": "<6.4" @@ -16996,7 +17001,7 @@ "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.36|^7.4.8|^8.0.8", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", "symfony/polyfill-intl-icu": "~1.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/routing": "^6.4|^7.0|^8.0", @@ -17041,7 +17046,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.4.8" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.12" }, "funding": [ { @@ -17061,7 +17066,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:17:09+00:00" + "time": "2026-04-29T17:13:54+00:00" }, { "name": "symfony/twig-bundle", @@ -17935,16 +17940,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.10", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7" + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c660d6538545a3e8e65a5621ee3d7a6d352892c7", - "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", "shasum": "" }, "require": { @@ -17987,7 +17992,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.10" + "source": "https://github.com/symfony/yaml/tree/v7.4.12" }, "funding": [ { @@ -18007,7 +18012,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:01:55+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symplify/easy-coding-standard", @@ -18061,16 +18066,16 @@ }, { "name": "tecnickcom/tc-lib-barcode", - "version": "2.4.39", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/tecnickcom/tc-lib-barcode.git", - "reference": "11886fb5a44ec0f6e77302439e9ebf55034383fa" + "reference": "4e53047a4ba4ed592ae677b3729ce9bfeae1cfbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/11886fb5a44ec0f6e77302439e9ebf55034383fa", - "reference": "11886fb5a44ec0f6e77302439e9ebf55034383fa", + "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/4e53047a4ba4ed592ae677b3729ce9bfeae1cfbb", + "reference": "4e53047a4ba4ed592ae677b3729ce9bfeae1cfbb", "shasum": "" }, "require": { @@ -18078,15 +18083,13 @@ "ext-date": "*", "ext-gd": "*", "ext-pcre": "*", - "php": ">=8.1", - "tecnickcom/tc-lib-color": "^2.5" + "php": ">=8.2", + "tecnickcom/tc-lib-color": "^2.7" }, "require-dev": { "pdepend/pdepend": "^2.16", "phpcompatibility/php-compatibility": "^10.0.0@dev", - "phpmd/phpmd": "^2.15", - "phpunit/phpunit": "^13.1 || ^12.5 || ^11.5 || ^10.5", - "squizlabs/php_codesniffer": "^4.0" + "phpunit/phpunit": "^13.1 || ^12.5 || ^11.5" }, "type": "library", "autoload": { @@ -18158,32 +18161,30 @@ "type": "github" } ], - "time": "2026-05-01T19:04:12+00:00" + "time": "2026-05-22T07:09:18+00:00" }, { "name": "tecnickcom/tc-lib-color", - "version": "2.5.3", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/tecnickcom/tc-lib-color.git", - "reference": "136d522f1640723e490b79171e910e647403d971" + "reference": "6947cc9fffe23a21642279b8ab73a43f3311c5f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/136d522f1640723e490b79171e910e647403d971", - "reference": "136d522f1640723e490b79171e910e647403d971", + "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/6947cc9fffe23a21642279b8ab73a43f3311c5f9", + "reference": "6947cc9fffe23a21642279b8ab73a43f3311c5f9", "shasum": "" }, "require": { "ext-pcre": "*", - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { "pdepend/pdepend": "^2.16", "phpcompatibility/php-compatibility": "^10.0.0@dev", - "phpmd/phpmd": "^2.15", - "phpunit/phpunit": "^13.1 || ^12.5 || ^11.5 || ^10.5", - "squizlabs/php_codesniffer": "^4.0" + "phpunit/phpunit": "^13.1 || ^12.5 || ^11.5" }, "type": "library", "autoload": { @@ -18228,7 +18229,7 @@ "type": "github" } ], - "time": "2026-05-01T19:02:25+00:00" + "time": "2026-05-22T06:55:57+00:00" }, { "name": "thecodingmachine/safe", @@ -18478,16 +18479,16 @@ }, { "name": "twig/cssinliner-extra", - "version": "v3.24.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/cssinliner-extra.git", - "reference": "c25fa18b09a418e4d1454ec291f9406f630675ba" + "reference": "1b0dc906bbad7226c967bd325e99cccb1a850c4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/cssinliner-extra/zipball/c25fa18b09a418e4d1454ec291f9406f630675ba", - "reference": "c25fa18b09a418e4d1454ec291f9406f630675ba", + "url": "https://api.github.com/repos/twigphp/cssinliner-extra/zipball/1b0dc906bbad7226c967bd325e99cccb1a850c4b", + "reference": "1b0dc906bbad7226c967bd325e99cccb1a850c4b", "shasum": "" }, "require": { @@ -18531,7 +18532,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.26.0" }, "funding": [ { @@ -18543,7 +18544,7 @@ "type": "tidelift" } ], - "time": "2025-12-02T14:45:16+00:00" + "time": "2026-05-15T13:14:14+00:00" }, { "name": "twig/extra-bundle", @@ -18689,16 +18690,16 @@ }, { "name": "twig/inky-extra", - "version": "v3.24.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/inky-extra.git", - "reference": "6bdca65a38167f7bd0ad7ea04819098d465a5cc4" + "reference": "0a8b24f0d0247bf6dc5e2af0c0ab09c0c4e5343e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/inky-extra/zipball/6bdca65a38167f7bd0ad7ea04819098d465a5cc4", - "reference": "6bdca65a38167f7bd0ad7ea04819098d465a5cc4", + "url": "https://api.github.com/repos/twigphp/inky-extra/zipball/0a8b24f0d0247bf6dc5e2af0c0ab09c0c4e5343e", + "reference": "0a8b24f0d0247bf6dc5e2af0c0ab09c0c4e5343e", "shasum": "" }, "require": { @@ -18743,7 +18744,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/inky-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/inky-extra/tree/v3.26.0" }, "funding": [ { @@ -18755,20 +18756,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T14:45:16+00:00" + "time": "2026-05-15T13:14:14+00:00" }, { "name": "twig/intl-extra", - "version": "v3.24.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/intl-extra.git", - "reference": "32f15a38d45a8d0ec11bc8a3d97d3ac2a261499f" + "reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/32f15a38d45a8d0ec11bc8a3d97d3ac2a261499f", - "reference": "32f15a38d45a8d0ec11bc8a3d97d3ac2a261499f", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", + "reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "shasum": "" }, "require": { @@ -18807,7 +18808,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/intl-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/intl-extra/tree/v3.26.0" }, "funding": [ { @@ -18819,20 +18820,20 @@ "type": "tidelift" } ], - "time": "2026-01-17T13:57:47+00:00" + "time": "2026-05-19T20:44:48+00:00" }, { "name": "twig/markdown-extra", - "version": "v3.24.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/markdown-extra.git", - "reference": "67a11120356e034a5bbc70c5b9b9a4d0f31ca06e" + "reference": "e3f3fd0836eb6c39457da22c8a76abaac62692b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/67a11120356e034a5bbc70c5b9b9a4d0f31ca06e", - "reference": "67a11120356e034a5bbc70c5b9b9a4d0f31ca06e", + "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/e3f3fd0836eb6c39457da22c8a76abaac62692b9", + "reference": "e3f3fd0836eb6c39457da22c8a76abaac62692b9", "shasum": "" }, "require": { @@ -18879,7 +18880,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/markdown-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/markdown-extra/tree/v3.26.0" }, "funding": [ { @@ -18891,7 +18892,7 @@ "type": "tidelift" } ], - "time": "2026-02-07T08:07:38+00:00" + "time": "2026-05-15T13:14:02+00:00" }, { "name": "twig/string-extra", @@ -18962,16 +18963,16 @@ }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc", + "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc", "shasum": "" }, "require": { @@ -19026,7 +19027,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.26.0" }, "funding": [ { @@ -19038,7 +19039,7 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-05-20T07:31:59+00:00" }, { "name": "ua-parser/uap-php", @@ -19176,16 +19177,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.3.2", + "version": "5.3.4", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d" + "reference": "dbb2d7a03db5893da2ef1f2898063ab8f7792838" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", - "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/dbb2d7a03db5893da2ef1f2898063ab8f7792838", + "reference": "dbb2d7a03db5893da2ef1f2898063ab8f7792838", "shasum": "" }, "require": { @@ -19246,7 +19247,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.2" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.4" }, "funding": [ { @@ -19258,20 +19259,20 @@ "type": "patreon" } ], - "time": "2026-05-01T12:14:37+00:00" + "time": "2026-05-18T11:59:46+00:00" }, { "name": "web-auth/webauthn-symfony-bundle", - "version": "5.3.2", + "version": "5.3.4", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-symfony-bundle.git", - "reference": "1d20af98b50810e8776c52b671201b6bb73ea981" + "reference": "7bf9d0e5e1f6d6bcad97c6bd93dc11a61d6fbd83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/1d20af98b50810e8776c52b671201b6bb73ea981", - "reference": "1d20af98b50810e8776c52b671201b6bb73ea981", + "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/7bf9d0e5e1f6d6bcad97c6bd93dc11a61d6fbd83", + "reference": "7bf9d0e5e1f6d6bcad97c6bd93dc11a61d6fbd83", "shasum": "" }, "require": { @@ -19329,7 +19330,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.2" + "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.4" }, "funding": [ { @@ -19341,20 +19342,20 @@ "type": "patreon" } ], - "time": "2026-05-04T08:08:16+00:00" + "time": "2026-05-24T09:55:30+00:00" }, { "name": "webmozart/assert", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { @@ -19370,7 +19371,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -19401,9 +19406,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.3.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2026-04-11T10:33:05+00:00" + "time": "2026-05-20T13:07:01+00:00" }, { "name": "willdurand/negotiation", @@ -20040,11 +20045,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.1.56", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537", + "reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537", "shasum": "" }, "require": { @@ -20089,7 +20094,7 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-05-26T17:04:57+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -20221,16 +20226,16 @@ }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.17", + "version": "2.0.18", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4" + "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/fdd0cb5f08d1980c612d6f259d825ea644ed03f4", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", + "reference": "a12176b639dec54e8bfd0a5ebf5fc36ffe003b5d", "shasum": "" }, "require": { @@ -20289,9 +20294,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.17" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.18" }, - "time": "2026-05-10T08:14:07+00:00" + "time": "2026-05-18T14:51:49+00:00" }, { "name": "phpunit/php-code-coverage", @@ -20752,16 +20757,16 @@ }, { "name": "rector/rector", - "version": "2.4.2", + "version": "2.4.4", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091", + "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091", "shasum": "" }, "require": { @@ -20800,7 +20805,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.2" + "source": "https://github.com/rectorphp/rector/tree/2.4.4" }, "funding": [ { @@ -20808,7 +20813,7 @@ "type": "github" } ], - "time": "2026-04-16T13:07:34+00:00" + "time": "2026-05-20T19:30:21+00:00" }, { "name": "roave/security-advisories", @@ -20816,12 +20821,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c" + "reference": "11be66e3adc8bf2c9805209a598ad4b42ee3c0d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c", - "reference": "5a7fa9fd3ce6eefbc9b982d6c78d0aa15d328d6c", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/11be66e3adc8bf2c9805209a598ad4b42ee3c0d6", + "reference": "11be66e3adc8bf2c9805209a598ad4b42ee3c0d6", "shasum": "" }, "conflict": { @@ -20936,13 +20941,13 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", - "ci4-cms-erp/ci4ms": "<=0.31.7", + "ci4-cms-erp/ci4ms": "<=0.31.8", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", "ckeditor/ckeditor": "<4.25", "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.14", - "code16/sharp": "<9.20", + "cockpit-hq/cockpit": "<=2.14", + "code16/sharp": "<9.22", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.6.2", @@ -20952,7 +20957,7 @@ "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": "<2.2.27|>=2.3,<2.9.6", + "composer/composer": "<2.2.28|>=2.3,<2.9.8", "concrete5/concrete5": "<9.4.8", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", @@ -20962,7 +20967,7 @@ "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.9", + "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", "couleurcitron/tarteaucitron-wp": "<0.3", @@ -21010,7 +21015,7 @@ "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": "<=22.0.4", + "dolibarr/dolibarr": "<=23.0.2", "dompdf/dompdf": "<2.0.4", "doublethreedigital/guest-entries": "<3.1.2", "dreamfactory/df-core": "<1.0.4", @@ -21067,6 +21072,7 @@ "erusev/parsedown": "<1.7.2", "ether/logs": "<3.0.4", "evolutioncms/evolution": "<=3.2.3", + "evoweb/sf-register": "<13.2.4|>=14,<14.0.2", "exceedone/exment": "<4.4.3|>=5,<5.0.3", "exceedone/laravel-admin": "<2.2.3|==3", "ezsystems/demobundle": ">=5.4,<5.4.6.1-dev", @@ -21131,6 +21137,7 @@ "friendsofsymfony1/symfony1": ">=1.1,<1.5.19", "friendsoftypo3/mediace": ">=7.6.2,<7.6.5", "friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6", + "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1", "froala/wysiwyg-editor": "<=4.3", "frosh/adminer-platform": "<2.2.1", "froxlor/froxlor": "<2.3.6", @@ -21139,10 +21146,10 @@ "funadmin/funadmin": "<=7.1.0.0-RC6", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", - "georgringer/news": "<1.3.3", + "georgringer/news": "<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", - "getgrav/grav": "<2.0.0.0-beta4", + "getgrav/grav": "<=2.0.0.0-RC1", "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", "getkirby/cms": "<4.9|>=5,<5.4", @@ -21247,13 +21254,14 @@ "kimai/kimai": "<=2.55", "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "klaviyo/magento2-extension": ">=1,<3", - "knplabs/knp-snappy": "<=1.4.2", + "knplabs/knp-snappy": "<=1.7", "kohana/core": "<3.3.3", "koillection/koillection": "<1.6.12", "krayin/laravel-crm": "<=2.2", "kreait/firebase-php": ">=3.2,<3.8.1", "kumbiaphp/kumbiapp": "<=1.1.1", "la-haute-societe/tcpdf": "<6.2.22", + "laktak/hjson": "<2.3", "laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2", "laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1", "laminas/laminas-http": "<2.14.2", @@ -21302,7 +21310,7 @@ "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", "manogi/nova-tiptap": "<=3.2.6", - "mantisbt/mantisbt": "<2.28.1", + "mantisbt/mantisbt": "<2.28.2", "marcwillmann/turn": "<0.3.3", "markhuot/craftql": "<=1.3.7", "marshmallow/nova-tiptap": "<5.7", @@ -21338,6 +21346,7 @@ "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", "mix/mix": ">=2,<=2.2.17", + "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1", "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", @@ -21441,7 +21450,7 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<=4.1.1", + "phpmyfaq/phpmyfaq": "<4.1.3", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", @@ -21489,7 +21498,7 @@ "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", "propel/propel1": ">=1,<=1.7.1", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", - "pterodactyl/panel": "<1.12.1", + "pterodactyl/panel": "<1.12.3", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", @@ -21532,9 +21541,11 @@ "scheb/two-factor-bundle": "<3.26|>=4,<4.11", "sensiolabs/connect": "<4.2.3", "serluck/phpwhois": "<=4.2.6", - "setasign/fpdi": "<2.6.4", + "setasign/fpdi": "<2.6.7", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", + "shopper/cart": "<2.8", + "shopper/framework": "<2.8", "shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", "shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev", "shopware/production": "<=6.3.5.2", @@ -21566,6 +21577,7 @@ "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19", "simplesamlphp/saml2-legacy": "<=4.16.15", "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", "simplesamlphp/simplesamlphp-module-openid": "<1", "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", @@ -21598,14 +21610,14 @@ "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.73.21|>=6,<6.15", + "statamic/cms": "<5.73.22|>=6,<6.18.1", "stormpath/sdk": "<9.9.99", - "studio-42/elfinder": "<2.1.67", + "studio-42/elfinder": "<=2.1.67", "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": "<2.6.22|>=3,<3.0.5", + "sulu/sulu": "<=2.6.22|>=3,<=3.0.5", "sumocoders/framework-user-bundle": "<1.4", "superbig/craft-audit": "<3.0.2", "svewap/a21glossary": "<=0.4.10", @@ -21623,42 +21635,52 @@ "symbiote/silverstripe-seed": "<6.0.3", "symbiote/silverstripe-versionedfiles": "<=2.0.3", "symfont/process": ">=0", - "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/cache": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/dom-crawler": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", "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/html-sanitizer": ">=6.1,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/http-client": ">=4.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8", "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/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6|>=7.4,<7.4.12|>=8,<8.0.12", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/json-path": ">=7.3,<7.4.12|>=8,<8.0.12", + "symfony/lox24-notifier": ">=7.1,<7.4.12|>=8,<8.0.12", + "symfony/mailer": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mailjet-mailer": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/mailtrap-mailer": ">=7.2,<7.4.12|>=8,<8.0.12", "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", - "symfony/mime": ">=4.3,<4.3.8", + "symfony/mime": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/monolog-bridge": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/polyfill": ">=1,<1.10", + "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1", + "symfony/polyfill-intl-idn": ">=1.17.1,<1.38.1", "symfony/polyfill-php55": ">=1,<1.10", "symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", - "symfony/routing": ">=2,<2.0.19", - "symfony/runtime": ">=5.3,<5.4.46|>=6,<6.4.14|>=7,<7.1.7", + "symfony/routing": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", + "symfony/runtime": ">=5.3,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8", "symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.4.10|>=7,<7.0.10|>=7.1,<7.1.3", "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9", "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", "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/security-http": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", - "symfony/symfony": "<5.4.51|>=6,<6.4.33|>=7,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5", + "symfony/symfony": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/translation": ">=2,<2.0.17", - "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8", + "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", + "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "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", + "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4|>=7.2.9,<7.4.12|>=8,<8.0.12", "symfony/webhook": ">=6.3,<6.3.8", - "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7|>=2.2.0.0-beta1,<2.2.0.0-beta2", + "symfony/yaml": ">=2,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symphonycms/symphony-2": "<2.6.4", "t3/dce": "<0.11.5|>=2.2,<2.6.2", "t3g/svg-sanitizer": "<1.0.3", @@ -21672,7 +21694,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<=4.1.1", + "thorsten/phpmyfaq": "<4.1.3", "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", @@ -21680,16 +21702,20 @@ "titon/framework": "<9.9.99", "tltneon/lgsl": "<7", "tobiasbg/tablepress": "<=2.0.0.0-RC1", + "tomasnorre/crawler": "<11.0.13|>=12,<12.0.11", "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.8.8", - "tpwd/ke_search": "<4.0.3|>=4.1,<4.6.6|>=5,<5.0.2", + "tpwd/ke_search": "<5.6.2|>=6,<6.6.1|>=7,<7.0.1", "tribalsystems/zenario": "<=9.7.61188", "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", "twbs/bootstrap": "<3.4.1|>=4,<4.3.1", - "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", + "twig/cssinliner-extra": "<3.26", + "twig/intl-extra": "<3.26", + "twig/markdown-extra": "<3.26", + "twig/twig": "<3.26", "typicms/core": "<16.1.7", "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.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1|==14.2", @@ -21731,7 +21757,7 @@ "uvdesk/core-framework": "<=1.1.1", "vanilla/safecurl": "<0.9.2", "verbb/comments": "<1.5.5", - "verbb/formie": "<=2.1.43", + "verbb/formie": "<2.2.20|>=3.0.0.0-beta1,<3.1.24", "verbb/image-resizer": "<2.0.9", "verbb/knock-knock": "<1.2.8", "verot/class.upload.php": "<=2.1.6", @@ -21779,12 +21805,12 @@ "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", "yansongda/pay": "<=3.7.19", - "yeswiki/yeswiki": "<=4.6", + "yeswiki/yeswiki": "<4.6.4", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", "yiisoft/yii": "<1.1.31", - "yiisoft/yii2": "<2.0.52", + "yiisoft/yii2": "<2.0.55", "yiisoft/yii2-authclient": "<2.2.15", "yiisoft/yii2-bootstrap": "<2.0.4", "yiisoft/yii2-dev": "<=2.0.45", @@ -21874,7 +21900,7 @@ "type": "tidelift" } ], - "time": "2026-05-08T23:22:52+00:00" + "time": "2026-05-26T19:41:38+00:00" }, { "name": "sebastian/cli-parser", @@ -21988,6 +22014,7 @@ "type": "github" } ], + "abandoned": true, "time": "2025-03-19T07:56:08+00:00" }, { @@ -22044,6 +22071,7 @@ "type": "github" } ], + "abandoned": true, "time": "2024-07-03T04:45:54+00:00" }, { @@ -23248,16 +23276,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "36dd8b8c05da059925c5804641aad9159e5b73e8" + "reference": "558fe81a383302318d9b92f7661deb731153c86e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/36dd8b8c05da059925c5804641aad9159e5b73e8", - "reference": "36dd8b8c05da059925c5804641aad9159e5b73e8", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/558fe81a383302318d9b92f7661deb731153c86e", + "reference": "558fe81a383302318d9b92f7661deb731153c86e", "shasum": "" }, "require": { @@ -23314,7 +23342,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.9" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.12" }, "funding": [ { @@ -23334,7 +23362,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "theseer/tokenizer", @@ -23387,17 +23415,9 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [ - { - "package": "mcp/sdk", - "version": "0.5.0.0", - "alias": "0.4.0", - "alias_normalized": "0.4.0.0" - } - ], + "aliases": [], "minimum-stability": "stable", "stability-flags": { - "api-platform/mcp": 20, "api-platform/metadata": 20, "roave/security-advisories": 20 }, diff --git a/config/reference.php b/config/reference.php index 8d95c97f..91694bd9 100644 --- a/config/reference.php +++ b/config/reference.php @@ -2823,6 +2823,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * region?: scalar|Param|null, // The region for OpenAI API (EU, US, or null for default) // Default: null * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" * }, + * openresponses?: array, * openrouter?: array{ * api_key?: string|Param, * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" @@ -2957,6 +2964,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * endpoint?: string|Param, * api_key?: string|Param, * index_name?: string|Param, + * http_client?: string|Param, // Default: "http_client" * embedder?: string|Param, // Default: "default" * vector_field?: string|Param, // Default: "_vectors" * dimensions?: int|Param, // Default: 1536 @@ -3019,6 +3027,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * table_name?: string|Param, * vector_field?: string|Param, // Default: "embedding" * distance?: "cosine"|"inner_product"|"l1"|"l2"|Param, // Distance metric to use for vector similarity search // Default: "l2" + * lang?: string|Param, // Default: "english" * dbal_connection?: string|Param, * setup_options?: array{ * vector_type?: string|Param, // Default: "vector" diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 223771c0..4b5e2b22 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -75,6 +75,15 @@ the parts you want to update. In the bulk actions dropdown select "Bulk info pro 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. +## Browser plugin +There is a browser plugin available for [Chrome](https://chromewebstore.google.com/detail/part-db-page-submitter/bckkfkpidiiibmjdhjakleoagjmepioi) and [Firefox](https://addons.mozilla.org/de/firefox/addon/part-db-page-submitter/) +that allows to submit a website from your browser with one click to Part-DB, which then utilizes the Generic Web URL or the AI Web Provider to extract the part information from the page and pre-fill the part creation form. +The advantage is that it also works for pages behind logins, CAPTCHAs, or bot-blocking sites, as the plugin sends the already loaded page HTML to Part-DB. +The plugin is open source and available on [GitHub](https://github.com/Part-DB/browser-plugin). + +To use it install it in your browser, enable one or more of the web page providers in Part-DB and allow the plugin support +in Part-DB settings. After that you can submit any product page to Part-DB with one click and the part creation form will be pre-filled with the information from the page. + ## Data providers The system tries to be as flexible as possible, so many different information sources can be used. diff --git a/package.json b/package.json index 99636d37..f846f1d1 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "exports-loader": "^5.0.0", "json-formatter-js": "^2.3.4", "jszip": "^3.2.0", - "katex": "^0.16.0", - "marked": "^17.0.1", + "katex": "^0.17.0", + "marked": "^18.0.0", "marked-gfm-heading-id": "^4.1.1", "marked-mangle": "^1.0.1", "pdfmake": "^0.3.7", diff --git a/public/kicad/footprints.txt b/public/kicad/footprints.txt index c893ad4b..779a7751 100644 --- a/public/kicad/footprints.txt +++ b/public/kicad/footprints.txt @@ -1,4 +1,4 @@ -# Generated on Mon May 4 05:40:05 UTC 2026 +# Generated on Mon May 25 06:41:46 UTC 2026 # This file contains all footprints available in the offical KiCAD library Audio_Module:Reverb_BTDR-1H Audio_Module:Reverb_BTDR-1V @@ -11883,6 +11883,8 @@ Package_DFN_QFN:Texas_VQFN-HR-20_3x2.5mm_P0.5mm_RQQ0011A Package_DFN_QFN:Texas_VQFN-RHL-20 Package_DFN_QFN:Texas_VQFN-RHL-20_ThermalVias Package_DFN_QFN:Texas_VQFN-RNR0011A-11 +Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm +Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm_ThermalVias Package_DFN_QFN:Texas_WQFN-MR-100_3x3-DapStencil Package_DFN_QFN:Texas_WQFN-MR-100_ThermalVias_3x3-DapStencil Package_DFN_QFN:Texas_X2QFN-12_1.6x1.6mm_P0.4mm @@ -12956,6 +12958,7 @@ Package_SON:MPS_VSON-6_1x1.5mm_P0.5mm Package_SON:MicroCrystal_C7_SON-8_1.5x3.2mm_P0.9mm Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm_ThermalVias +Package_SON:NXP_LSON-16-1EP_3.5x4.5mm_P0.5mm_EP2x3.8mm Package_SON:NXP_XSON-16 Package_SON:Nexperia_HUSON-12_USON-12-1EP_1.35x2.5mm_P0.4mm_EP0.4x2mm Package_SON:Nexperia_HUSON-16_USON-16-1EP_1.35x3.3mm_P0.4mm_EP0.4x2.8mm diff --git a/public/kicad/symbols.txt b/public/kicad/symbols.txt index f41aa152..46a15ee1 100644 --- a/public/kicad/symbols.txt +++ b/public/kicad/symbols.txt @@ -1,4 +1,4 @@ -# Generated on Mon May 4 05:40:43 UTC 2026 +# Generated on Mon May 25 06:42:27 UTC 2026 # This file contains all symbols available in the offical KiCAD library 4xxx:14528 4xxx:14529 @@ -7545,6 +7545,7 @@ Driver_FET:ZXGD3003E6 Driver_FET:ZXGD3004E6 Driver_FET:ZXGD3006E6 Driver_FET:ZXGD3009E6 +Driver_LED:AL5819W6 Driver_LED:AL8860MP Driver_LED:AL8860WT Driver_LED:AP3019AKTR @@ -7692,6 +7693,7 @@ Driver_Motor:DRV8311P Driver_Motor:DRV8311S Driver_Motor:DRV8412 Driver_Motor:DRV8432 +Driver_Motor:DRV8434PWP Driver_Motor:DRV8461SPWP Driver_Motor:DRV8662 Driver_Motor:DRV8800PWP @@ -19322,6 +19324,9 @@ Regulator_Switching:MAX15062C Regulator_Switching:MAX1522 Regulator_Switching:MAX1523 Regulator_Switching:MAX1524 +Regulator_Switching:MAX15462A +Regulator_Switching:MAX15462B +Regulator_Switching:MAX15462C Regulator_Switching:MAX17501AxTB Regulator_Switching:MAX17501BxTB Regulator_Switching:MAX17501ExTB @@ -21301,6 +21306,7 @@ Timer_RTC:MCP79512-xMS Timer_RTC:MCP79520-xMS Timer_RTC:MCP79521-xMS Timer_RTC:MCP79522-xMS +Timer_RTC:PCA2131 Timer_RTC:PCF85063ATL Timer_RTC:PCF8523T Timer_RTC:PCF8523TK diff --git a/src/Controller/BrowserPluginController.php b/src/Controller/BrowserPluginController.php new file mode 100644 index 00000000..1bb95787 --- /dev/null +++ b/src/Controller/BrowserPluginController.php @@ -0,0 +1,139 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Entity\UserSystem\User; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\SubmittedPageStorage; +use App\Services\InfoProviderSystem\DTOs\BrowserSubmittedPage; +use App\Settings\InfoProviderSystem\BrowserPluginSettings; +use App\Settings\SystemSettings\CustomizationSettings; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * Provides the endpoint used by browser extensions to submit the current page's HTML to Part-DB, + * so that info providers can use it instead of fetching the URL themselves. + */ +#[Route('/tools/info_providers')] +class BrowserPluginController extends AbstractController +{ + public function __construct( + private readonly SubmittedPageStorage $browserHtmlStorage, + private readonly ProviderRegistry $providerRegistry, + private readonly CustomizationSettings $customizationSettings, + private readonly BrowserPluginSettings $browserPluginSettings, + ) { + } + + private const URL_PROVIDER_KEYS = ['generic_web', 'ai_web']; + + /** + * Returns instance info for the browser extension: logged-in username, instance name, and active URL providers. + * + * Response: { "username": "admin", "instance_name": "Part-DB", "url_providers": [{"id": "generic_web", "label": "Generic Web URL"}] } + */ + #[Route('/browser_info', name: 'browser_plugin_info', methods: ['GET'])] + public function getInfo(): JsonResponse + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + $this->throwIfDisabled(); + + $activeProviders = $this->providerRegistry->getActiveProviders(); + + $urlProviders = []; + foreach (self::URL_PROVIDER_KEYS as $key) { + if (isset($activeProviders[$key])) { + $urlProviders[] = [ + 'id' => $key, + 'label' => $activeProviders[$key]->getProviderInfo()['name'], + ]; + } + } + + $user = $this->getUser(); + if ($user instanceof User) { + $username = $user->getFullName(true); + } else { + $username = $user ? $user->getUserIdentifier() : "unknown"; + } + + return new JsonResponse([ + 'username' => $username, + 'instance_name' => $this->customizationSettings->instanceName, + 'url_providers' => $urlProviders, + ]); + } + + /** + * Accepts a JSON POST body with the HTML of the current page from a browser extension. + * Stores the HTML in the session via BrowserHtmlSessionStorage and returns a redirect URL + * pointing to the standard part-creation flow with use_browser_html=1. + * + * Expected JSON body: { "html": "", "url": "https://example.com/product", "provider": "generic_web" } + * The "provider" field is optional and defaults to "generic_web". Use "ai_web" for the AI extractor. + * Response: { "redirect_url": "https://partdb.example.com/en/part/from_info_provider/generic_web/https%3A%2F%2F.../create?use_browser_html=1&no_cache=1" } + */ + #[Route('/browser_html', name: 'browser_plugin_submit_html', methods: ['POST'])] + public function submitHtml(Request $request, + #[MapRequestPayload] + BrowserSubmittedPage $page + ): JsonResponse + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + $this->throwIfDisabled(); + + $payload = $request->getPayload(); + + $provider = $payload->get('provider', null); + + // The maprequestpayload already validates the URL and HTML content: + $token = $this->browserHtmlStorage->store($page); + + if ($provider !== null) { + $redirectUrl = $this->generateUrl('info_providers_create_part', [ + 'providerKey' => $provider, + 'providerId' => $page->url, + 'submitted_page_token' => $token, + ], UrlGeneratorInterface::ABSOLUTE_URL); + } + + return new JsonResponse([ + 'redirect_url' => $redirectUrl ?? null, + ]); + } + + public function throwIfDisabled(): void + { + if (!$this->browserPluginSettings->enabled) { + throw HttpException::fromStatusCode(451, "Browser plugin feature is disabled by the administrator, ask him to enable it in system settings."); + } + } +} diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index 817a6651..28c281d0 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -28,6 +28,7 @@ use App\Entity\Parts\Part; use App\Exceptions\OAuthReconnectRequiredException; use App\Form\InfoProviderSystem\FromURLFormType; use App\Form\InfoProviderSystem\PartSearchType; +use App\Services\InfoProviderSystem\SubmittedPageStorage; use App\Services\InfoProviderSystem\ExistingPartFinder; use App\Services\InfoProviderSystem\CreateFromUrlHelper; use App\Services\InfoProviderSystem\PartInfoRetriever; @@ -62,7 +63,8 @@ class InfoProviderController extends AbstractController private readonly PartInfoRetriever $infoRetriever, private readonly ExistingPartFinder $existingPartFinder, private readonly SettingsManagerInterface $settingsManager, - private readonly SettingsFormFactoryInterface $settingsFormFactory + private readonly SettingsFormFactoryInterface $settingsFormFactory, + private readonly SubmittedPageStorage $browserHtmlStorage, ) { @@ -221,7 +223,7 @@ class InfoProviderController extends AbstractController } #[Route('/from_url', name: 'info_providers_from_url')] - public function fromURL(Request $request, GenericWebProvider $provider, CreateFromUrlHelper $fromUrlHelper): Response + public function fromURL(Request $request, CreateFromUrlHelper $fromUrlHelper): Response { $this->denyAccessUnlessGranted('@info_providers.create_parts'); @@ -242,6 +244,12 @@ class InfoProviderController extends AbstractController $no_cache = $form->get('no_cache')->getData(); $skip_delegation = $form->get('skip_delegation')->getData(); + $submittedPageToken = $request->request->get('submitted_page_token', null); + if ($submittedPageToken !== null && $submittedPageToken !== '') { + $url = $this->browserHtmlStorage->retrieve($submittedPageToken)->url; + } + + try { //It's okay if we use the cached results here, as its just for convenience $searchResult = $this->infoRetriever->searchByKeyword( @@ -249,6 +257,7 @@ class InfoProviderController extends AbstractController providers: [$method], options: [ InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation, + InfoProviderInterface::OPTION_SUBMITTED_PAGE_TOKEN => $submittedPageToken, ] ); @@ -262,6 +271,7 @@ class InfoProviderController extends AbstractController 'providerId' => $searchResult->provider_id, 'no_cache' => $no_cache ? 1 : null, 'skip_delegation' => $skip_delegation ? 1 : null, + 'submitted_page_token' => $submittedPageToken ?: null, ]); } } catch (ExceptionInterface $e) { @@ -272,6 +282,7 @@ class InfoProviderController extends AbstractController return $this->render('info_providers/from_url/from_url.html.twig', [ 'form' => $form, 'partDetail' => $partDetail, + 'recentBrowserPages' => $this->browserHtmlStorage->getRecentPages(), ]); } diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index 735a48f8..c4c0e526 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -328,10 +328,12 @@ final class PartController extends AbstractController //Force info providers to not use cache, when retrieving part details for creating a new part, because otherwise we might end up with outdated information $no_cache = $request->query->getBoolean('no_cache', false); $skip_delegation = $request->query->getBoolean('skip_delegation', false); + $submitted_page_token = $request->query->getString('submitted_page_token'); $dto = $infoRetriever->getDetails($providerKey, $providerId, [ InfoProviderInterface::OPTION_NO_CACHE => $no_cache, InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation, + InfoProviderInterface::OPTION_SUBMITTED_PAGE_TOKEN => $submitted_page_token, ]); $new_part = $infoRetriever->dtoToPart($dto); diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 5fed1571..c4a52bc7 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Controller; +use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use App\Settings\AppSettings; use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface; @@ -50,7 +51,9 @@ class SettingsController extends AbstractController $settings = $this->settingsManager->createTemporaryCopy(AppSettings::class); //Create a form builder for the settings object - $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings); + $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings, formOptions: [ + 'warn_on_unsaved_changes' => true, + ]); //Add a submit button to the form $builder->add('submit', SubmitType::class, ['label' => 'save']); diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php index 2d5c4ebc..b5beeca0 100644 --- a/src/DataTables/ProjectBomEntriesDataTable.php +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -37,6 +37,7 @@ use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; use App\Services\Formatters\MoneyFormatter; use App\Services\ProjectSystem\ProjectBuildHelper; +use Brick\Math\BigDecimal; use Brick\Math\RoundingMode; use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\Query; @@ -93,14 +94,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('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)', @@ -161,7 +162,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('part.table.manufacturingStatus'), 'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(), 'orderField' => 'part.manufacturing_status', - 'class' => ManufacturingStatus::class, + 'class' => ManufacturingStatus::class, 'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string { if ($status === null) { return ''; @@ -212,7 +213,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface 'visible' => false, 'render' => function ($value, ProjectBOMEntry $context) { $price = $this->projectBuildHelper->getEntryUnitPrice($context); - return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true); + return $this->moneyFormatter->format($price->toScale(2, RoundingMode::Up)->toFloat(), null, 2, true); }, ]) ->add('ext_price', TextColumn::class, [ @@ -221,7 +222,8 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface 'render' => function ($value, ProjectBOMEntry $context) { $price = $this->projectBuildHelper->getEntryUnitPrice($context); return $this->moneyFormatter->format( - $price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(), + $price->multipliedBy(BigDecimal::fromFloatShortest($context->getQuantity())) + ->toScale(2, RoundingMode::Up)->toFloat(), null, 2, true diff --git a/src/Doctrine/Types/BigDecimalType.php b/src/Doctrine/Types/BigDecimalType.php index a9f796dd..2712cce8 100644 --- a/src/Doctrine/Types/BigDecimalType.php +++ b/src/Doctrine/Types/BigDecimalType.php @@ -44,7 +44,7 @@ class BigDecimalType extends Type - return BigDecimal::of($value); + return BigDecimal::of(is_float($value) ? BigDecimal::fromFloatShortest($value) : $value); } /** diff --git a/src/Entity/PriceInformations/Currency.php b/src/Entity/PriceInformations/Currency.php index ce20caf8..4a811aa0 100644 --- a/src/Entity/PriceInformations/Currency.php +++ b/src/Entity/PriceInformations/Currency.php @@ -204,7 +204,7 @@ class Currency extends AbstractStructuralDBElement return null; } - return BigDecimal::one()->dividedBy($tmp, $tmp->getScale(), RoundingMode::HALF_UP); + return BigDecimal::one()->dividedBy($tmp, $tmp->getScale(), RoundingMode::HalfUp); } /** diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php index 553b07a3..58e02c64 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -195,10 +195,10 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface #[SerializedName('price_per_unit')] public function getPricePerUnit(float|string|BigDecimal $multiplier = 1.0): BigDecimal { - $tmp = BigDecimal::of($multiplier); + $tmp = is_float($multiplier) ? BigDecimal::fromFloatShortest($multiplier) : BigDecimal::of($multiplier); $tmp = $tmp->multipliedBy($this->price); - return $tmp->dividedBy($this->price_related_quantity, static::PRICE_PRECISION, RoundingMode::HALF_UP); + return $tmp->dividedBy(BigDecimal::fromFloatShortest($this->price_related_quantity), static::PRICE_PRECISION, RoundingMode::HalfUp); } /** @@ -317,7 +317,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface */ public function setPrice(BigDecimal $new_price): self { - $tmp = $new_price->toScale(self::PRICE_PRECISION, RoundingMode::HALF_UP); + $tmp = $new_price->toScale(self::PRICE_PRECISION, RoundingMode::HalfUp); //Only change the object, if the value changes, so that doctrine does not detect it as changed. if ((string) $tmp !== (string) $this->price) { $this->price = $tmp; diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index bf005882..54cb0406 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -57,6 +57,10 @@ class BaseEntityAdminForm extends AbstractType $resolver->setRequired('attachment_class'); $resolver->setRequired('parameter_class'); $resolver->setAllowedTypes('parameter_class', ['string', 'null']); + + $resolver->setDefaults([ + 'warn_on_unsaved_changes' => true, + ]); } public function buildForm(FormBuilderInterface $builder, array $options): void diff --git a/src/Form/Extension/UnsavedChangesExtension.php b/src/Form/Extension/UnsavedChangesExtension.php new file mode 100644 index 00000000..1187eb19 --- /dev/null +++ b/src/Form/Extension/UnsavedChangesExtension.php @@ -0,0 +1,84 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Extension; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Adds a `warn_on_unsaved_changes` option to any root form. When set to true, the Stimulus + * `common--dirty-form` controller attributes are merged into the form element's HTML + * attributes, enabling unsaved-change detection without any template boilerplate. + * + * Usage in a form type: + * + * public function configureOptions(OptionsResolver $resolver): void + * { + * $resolver->setDefaults(['warn_on_unsaved_changes' => true]); + * } + * + * Or per-instance from a controller: + * + * $form = $this->createForm(MyFormType::class, $data, ['warn_on_unsaved_changes' => true]); + */ +class UnsavedChangesExtension extends AbstractTypeExtension +{ + public function __construct(private readonly TranslatorInterface $translator) + { + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('warn_on_unsaved_changes', false); + $resolver->setAllowedTypes('warn_on_unsaved_changes', 'bool'); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + if (!$options['warn_on_unsaved_changes'] || $view->parent !== null) { + return; + } + + $extraAttr = [ + 'data-controller' => 'common--dirty-form', + 'data-common--dirty-form-confirm-title-value' => $this->translator->trans('form.dirty_form.unsaved_changes.title'), + 'data-common--dirty-form-confirm-message-value' => $this->translator->trans('form.dirty_form.unsaved_changes.message'), + ]; + + // Merge data-action so existing actions on the form element are preserved. + $existingAction = $view->vars['attr']['data-action'] ?? ''; + $dirtyActions = 'submit->common--dirty-form#submit reset->common--dirty-form#resetDirtyState'; + $extraAttr['data-action'] = $existingAction !== '' ? $existingAction . ' ' . $dirtyActions : $dirtyActions; + + $view->vars['attr'] = array_merge($view->vars['attr'], $extraAttr); + } +} diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 6b929486..a31f2469 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -353,6 +353,7 @@ class PartBaseType extends AbstractType $resolver->setDefaults([ 'data_class' => Part::class, 'info_provider_dto' => null, + 'warn_on_unsaved_changes' => true, ]); $resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']); diff --git a/src/Form/Type/BigDecimalMoneyType.php b/src/Form/Type/BigDecimalMoneyType.php index 189416ff..1950391c 100644 --- a/src/Form/Type/BigDecimalMoneyType.php +++ b/src/Form/Type/BigDecimalMoneyType.php @@ -59,6 +59,16 @@ class BigDecimalMoneyType extends AbstractType implements DataTransformerInterfa return null; } - return BigDecimal::of($value); + if ($value instanceof BigDecimal) { + return $value; + } + if (is_float($value)) { + return BigDecimal::fromFloatShortest($value); + } + if (is_string($value)) { + return BigDecimal::of($value); + } + + throw new \InvalidArgumentException(sprintf('Expected a string, float or BigDecimal, got %s', get_debug_type($value))); } } diff --git a/src/Form/Type/BigDecimalNumberType.php b/src/Form/Type/BigDecimalNumberType.php index c225f0a4..04e8e655 100644 --- a/src/Form/Type/BigDecimalNumberType.php +++ b/src/Form/Type/BigDecimalNumberType.php @@ -59,6 +59,17 @@ class BigDecimalNumberType extends AbstractType implements DataTransformerInterf return null; } - return BigDecimal::of($value); + if ($value instanceof BigDecimal) { + return $value; + } + if (is_float($value)) { + return BigDecimal::fromFloatShortest($value); + } + if (is_string($value)) { + return BigDecimal::of($value); + } + + throw new \InvalidArgumentException(sprintf('Expected a string, float or BigDecimal, got %s', get_debug_type($value))); } + } diff --git a/src/Form/UserAdminForm.php b/src/Form/UserAdminForm.php index 457a6e0b..6331dfb7 100644 --- a/src/Form/UserAdminForm.php +++ b/src/Form/UserAdminForm.php @@ -59,6 +59,10 @@ class UserAdminForm extends AbstractType $resolver->setDefault('parameter_class', false); $resolver->setDefault('validation_groups', ['Default', 'permissions:edit']); + + $resolver->setDefaults([ + 'warn_on_unsaved_changes' => true, + ]); } public function buildForm(FormBuilderInterface $builder, array $options): void diff --git a/src/Serializer/APIPlatform/SkippableItemNormalizer.php b/src/Serializer/APIPlatform/SkippableItemNormalizer.php index 5568c4cb..61873615 100644 --- a/src/Serializer/APIPlatform/SkippableItemNormalizer.php +++ b/src/Serializer/APIPlatform/SkippableItemNormalizer.php @@ -23,9 +23,13 @@ declare(strict_types=1); namespace App\Serializer\APIPlatform; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Serializer\ItemNormalizer; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerAwareInterface; @@ -35,6 +39,10 @@ use Symfony\Component\Serializer\SerializerInterface; * This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the * DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API * Platform subsystem should not be used. + * + * It also works around a bug in API Platform's AbstractItemNormalizer where IRI strings for abstract resource classes + * with a discriminator map fail deserialization when objectToPopulate is null (the discriminator is checked before + * the IRI string check). See: https://github.com/Part-DB/Part-DB-server/issues/1370 */ #[AsDecorator("api_platform.serializer.normalizer.item")] class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface @@ -42,13 +50,44 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER'; - public function __construct(private readonly ItemNormalizer $inner) - { - + public function __construct( + private readonly ItemNormalizer $inner, + private readonly IriConverterInterface $iriConverter, + ) { } public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { + // API Platform's AbstractItemNormalizer has a bug: when objectToPopulate is null and data is an IRI + // string, it tries to resolve the discriminator class from [$iri_string] before reaching the IRI + // check (line 271). For abstract resource classes with a discriminator map (e.g. Attachment), this + // fails because the array has no _type key. Fix by resolving IRI strings directly. + // See: https://github.com/Part-DB/Part-DB-server/issues/1370 + if (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id']))) { + if (is_array($data)) { + $iri = $data['@id']; + } else { + $iri = $data; + } + + try { + return $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); + } catch (ItemNotFoundException $e) { + if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) { + return null; + } + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); + } + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + } catch (InvalidArgumentException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $iri), $e->getCode(), $e); + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Invalid IRI "%s".', $data), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + } + } + return $this->inner->denormalize($data, $type, $format, $context); } @@ -87,4 +126,4 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf 'object' => false ]; } -} \ No newline at end of file +} diff --git a/src/Services/Attachments/AttachmentManager.php b/src/Services/Attachments/AttachmentManager.php index 1075141b..c661b0f4 100644 --- a/src/Services/Attachments/AttachmentManager.php +++ b/src/Services/Attachments/AttachmentManager.php @@ -156,8 +156,8 @@ class AttachmentManager //Taken from: https://www.php.net/manual/de/function.filesize.php#106569 and slightly modified $sz = 'BKMGTP'; - $factor = (int) floor((strlen((string) $bytes) - 1) / 3); + $factor = min((int) floor((strlen((string) $bytes) - 1) / 3), strlen($sz) - 1); //Use real (10 based) SI prefixes - return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).@$sz[$factor]; + return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).$sz[$factor]; } } diff --git a/src/Services/Formatters/SIFormatter.php b/src/Services/Formatters/SIFormatter.php index b83501fa..1f25dbe6 100644 --- a/src/Services/Formatters/SIFormatter.php +++ b/src/Services/Formatters/SIFormatter.php @@ -59,10 +59,10 @@ class SIFormatter $prefixes_neg = ['', 'm', 'μ', 'n', 'p', 'f', 'a', 'z', 'y']; if ($magnitude >= 0) { - $nearest = (int) floor(abs($magnitude) / 3); + $nearest = min((int) floor(abs($magnitude) / 3), count($prefixes_pos) - 1); $symbol = $prefixes_pos[$nearest]; } else { - $nearest = (int) round(abs($magnitude) / 3); + $nearest = min((int) round(abs($magnitude) / 3), count($prefixes_neg) - 1); $symbol = $prefixes_neg[$nearest]; } diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php index 64127341..08b1c301 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php @@ -89,7 +89,7 @@ trait PKImportHelperTrait //Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type) //See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291) if (!empty ($attachment_row['mimetype'])) { - $attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1]; + $attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1] ?? ''; } else { //If the mime type is empty, we use the original extension $attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION); diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php index cab5a49b..e63ec7f1 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php @@ -286,7 +286,7 @@ class PKPartImporter //Partkeepr stores the price per item, we need to convert it to the price per packaging unit $price_per_item = BigDecimal::of($partdistributor['price']); $packaging_unit = (float) ($partdistributor['packagingUnit'] ?? 1); - $pricedetail->setPrice($price_per_item->multipliedBy($packaging_unit)); + $pricedetail->setPrice($price_per_item->multipliedBy(BigDecimal::fromFloatShortest($packaging_unit))); $pricedetail->setPriceRelatedQuantity($packaging_unit); //We have to set the minimum discount quantity to the packaging unit (PartKeepr does not know this concept) //But in Part-DB the minimum discount qty have to be unique across a orderdetail diff --git a/src/Services/InfoProviderSystem/DTOs/BrowserSubmittedPage.php b/src/Services/InfoProviderSystem/DTOs/BrowserSubmittedPage.php new file mode 100644 index 00000000..0f4fbf5f --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/BrowserSubmittedPage.php @@ -0,0 +1,50 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem\DTOs; + +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Represents a webpage submitted by the browser extension, held temporarily in the application cache. + */ +final readonly class BrowserSubmittedPage +{ + /** + * @var string A unique token for this page, derived from the URL and HTML content. Used to identify the page in the cache without storing the full HTML in the session. + */ + public string $token; + + public function __construct( + #[Assert\Url()] + #[Assert\NotBlank] + public string $url, + #[Assert\NotBlank] + #[Assert\Length(max: 5 * 1024 * 1024)] // Limit to 5 MB to prevent abuse + public string $html, + #[Assert\NotBlank] + public string $title, + public \DateTimeImmutable $submittedAt = new \DateTimeImmutable(), + ) { + $this->token = hash('xxh3', $url . '|' . $html); + } +} diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 6c10f10e..f5ff144d 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -175,15 +175,15 @@ final class PartInfoRetriever */ public function dtoToPart(PartDetailDTO $search_result): Part { - return $this->createPart($search_result->provider_key, $search_result->provider_id); + return $this->dto_to_entity_converter->convertPart($search_result); } /** * Use the given details to create a part entity */ - public function createPart(string $provider_key, string $part_id): Part + public function createPart(string $provider_key, string $part_id, array $options): Part { - $details = $this->getDetails($provider_key, $part_id); + $details = $this->getDetails($provider_key, $part_id, $options); return $this->dto_to_entity_converter->convertPart($details); } diff --git a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php index 79f07be8..6539e69b 100644 --- a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php @@ -27,12 +27,11 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Exceptions\ProviderIDNotSupportedException; use App\Helpers\RandomizeUseragentHttpClient; use App\Services\AI\AIPlatformRegistry; +use App\Services\InfoProviderSystem\SubmittedPageStorage; use App\Services\InfoProviderSystem\CreateFromUrlHelper; use App\Services\InfoProviderSystem\DTOJsonSchemaConverter; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Settings\InfoProviderSystem\AIExtractorSettings; -use Brick\Schema\SchemaReader; -use Imagine\Image\Format; use Jkphl\Micrometa; use League\HTMLToMarkdown\HtmlConverter; use Psr\Cache\CacheItemPoolInterface; @@ -62,6 +61,7 @@ final class AIWebProvider implements InfoProviderInterface private readonly DTOJsonSchemaConverter $jsonSchemaConverter, private readonly CacheItemPoolInterface $partInfoCache, private readonly CreateFromUrlHelper $createFromUrlHelper, + private readonly SubmittedPageStorage $browserHtmlStorage, ) { //Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us $this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions( @@ -142,9 +142,17 @@ final class AIWebProvider implements InfoProviderInterface return $cacheItem->get(); } - // Fetch HTML content - $response = $this->httpClient->request('GET', $url); - $html = $response->getContent(); + // Use pre-fetched browser HTML if the option is set and a stored page is available for this URL + $html = null; + if (($token = ($options[self::OPTION_SUBMITTED_PAGE_TOKEN] ?? '')) !== '') { + $html = $this->browserHtmlStorage->retrieve($token)?->html; + } + + //Otherwise fetch it ourselves. + if ($html === null) { + $response = $this->httpClient->request('GET', $url); + $html = $response->getContent(); + } //Convert html to markdown, to provide a cleaner input to the LLM. $markdown = $this->htmlToMarkdown($html, $url); @@ -176,9 +184,20 @@ final class AIWebProvider implements InfoProviderInterface */ private function extractStructuredData(string $html, string $url): string { - //Only parse microdata, json-ld and rdfa, as they are the most common formats for structured data on product pages. Links and microformat only create clutter for the LLM - $micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA | Micrometa\Ports\Format::RDFA_LITE); - $items = $micrometa($url, $html); + try { + //Only parse microdata, json-ld and rdfa, as they are the most common formats for structured data on product pages. Links and microformat only create clutter for the LLM + $micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA | Micrometa\Ports\Format::RDFA_LITE); + $items = $micrometa($url, $html); + } catch (\RuntimeException $exception) { + //If parsing fails, try again without rdfa, as it seems to cause problems on pages like ebay + try { + $micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA); + $items = $micrometa($url, $html); + } catch (\RuntimeException $exception) { + //If it still fails, return empty structured data + return '{}'; + } + } return json_encode($items->toObject(), JSON_THROW_ON_ERROR); } diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 06a9d4c1..45777f9e 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Exceptions\ProviderIDNotSupportedException; use App\Helpers\RandomizeUseragentHttpClient; +use App\Services\InfoProviderSystem\SubmittedPageStorage; use App\Services\InfoProviderSystem\CreateFromUrlHelper; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; @@ -57,6 +58,7 @@ class GenericWebProvider implements InfoProviderInterface public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings, private readonly CreateFromUrlHelper $createFromUrlHelper, + private readonly SubmittedPageStorage $browserHtmlStorage, ) { //Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us @@ -294,9 +296,17 @@ class GenericWebProvider implements InfoProviderInterface } } - //Try to get the webpage content - $response = $this->httpClient->request('GET', $url); - $content = $response->getContent(); + // Use pre-fetched browser HTML if the option is set and a stored page is available for this URL + $content = null; + if (($token = ($options[self::OPTION_SUBMITTED_PAGE_TOKEN] ?? '')) !== '') { + $content = $this->browserHtmlStorage->retrieve($token)?->html; + } + + //Otherwise, fetch the page content ourselves + if ($content === null) { + $response = $this->httpClient->request('GET', $url); + $content = $response->getContent(); + } $dom = new Crawler($content); diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index a6e073a5..d3895795 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -30,6 +30,7 @@ interface InfoProviderInterface { public const OPTION_NO_CACHE = 'no_cache'; // if set to true, the provider should not use any cache and retrieve fresh data from the source public const OPTION_SKIP_DELEGATION = 'skip_delegation'; // if set to true, the provider should not delegate the request to other providers, even if it supports delegation. + public const OPTION_SUBMITTED_PAGE_TOKEN = 'submitted_page_token'; // if set to a non-empty string, the provider should use the browser-submitted page with the given token (and retrieve it from BrowserHtmlSessionStorage) /** * Get information about this provider diff --git a/src/Services/InfoProviderSystem/SubmittedPageStorage.php b/src/Services/InfoProviderSystem/SubmittedPageStorage.php new file mode 100644 index 00000000..5e623f57 --- /dev/null +++ b/src/Services/InfoProviderSystem/SubmittedPageStorage.php @@ -0,0 +1,131 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\InfoProviderSystem; + +use App\Services\InfoProviderSystem\DTOs\BrowserSubmittedPage; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Stores browser-submitted pages for the browser extension feature. + * + * Each page is stored as a {@see BrowserSubmittedPage} DTO in the application cache with a short TTL. + * The session holds only a compact list of recently submitted URLs so that pages can be listed + * without bloating the session with HTML content. + */ +class SubmittedPageStorage +{ + private const CACHE_KEY_PREFIX = 'browser_plugin_html_'; + private const CACHE_TTL = 1800; // 30 minutes + private const SESSION_KEY = 'browser_plugin_recent_urls'; + private const MAX_RECENT = 10; + + public function __construct( + private readonly RequestStack $requestStack, + private readonly CacheItemPoolInterface $cache, + ) { + } + + /** + * Stores a submitted page in the cache and records its URL in the session's recent list. + * @return string The token under which the page was stored, derived from the URL and HTML. This token is used to retrieve the page later. It is the same value as $page->token. + */ + public function store(BrowserSubmittedPage $page): string + { + $item = $this->cache->getItem($this->cacheKey($page)); + $item->set($page); + $item->expiresAfter(self::CACHE_TTL); + $this->cache->save($item); + + $session = $this->requestStack->getSession(); + $tokens = array_values(array_filter( + $session->get(self::SESSION_KEY, []), + static fn(string $u): bool => $u !== $page->token, + )); + array_unshift($tokens, $page->token); + $session->set(self::SESSION_KEY, array_slice($tokens, 0, self::MAX_RECENT)); + + return $page->token; + } + + /** + * Retrieves the stored page via its token (which is derived from the URL and HTML). Returns null if not found or expired. + */ + public function retrieve(string $token): ?BrowserSubmittedPage + { + $item = $this->cache->getItem($this->cacheKey($token)); + if (!$item->isHit()) { + return null; + } + return $item->get(); + } + + /** + * Returns the list of recently submitted pages, newest first. + * Pages whose cache entry has expired are silently omitted. + * The list depends on the session and thus is per-browser and per-user. + * + * @return BrowserSubmittedPage[] + */ + public function getRecentPages(): array + { + $tokens = $this->requestStack->getSession()->get(self::SESSION_KEY, []); + $pages = []; + foreach ($tokens as $token) { + $page = $this->retrieve($token); + if ($page !== null) { + $pages[] = $page; + } + } + return $pages; + } + + /** + * Removes a page from both the cache and the recent list. + * @param BrowserSubmittedPage|string $page The page or its token to remove. + */ + public function remove(BrowserSubmittedPage|string $page): void + { + $this->cache->deleteItem($this->cacheKey($page)); + + $token = is_string($page) ? $page : $page->token; + + $session = $this->requestStack->getSession(); + //Remove the token from the recent list in the session: + $tokens = array_values(array_filter( + $session->get(self::SESSION_KEY, []), + static fn(string $u): bool => $u !== $token + )); + $session->set(self::SESSION_KEY, $tokens); + } + + private function cacheKey(BrowserSubmittedPage|string $token): string + { + if (!is_string($token)) { + $token = $token->token; + } + + return self::CACHE_KEY_PREFIX . $token; + } +} diff --git a/src/Services/LogSystem/TimeTravel.php b/src/Services/LogSystem/TimeTravel.php index 68d962bb..79d9f8e2 100644 --- a/src/Services/LogSystem/TimeTravel.php +++ b/src/Services/LogSystem/TimeTravel.php @@ -222,7 +222,7 @@ class TimeTravel if (isset($metadata->fieldMappings[$field])) { //We need to convert the string to a BigDecimal first if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)->type)) { - $data = BigDecimal::of($data); + $data = is_float($data) ? BigDecimal::fromFloatShortest($data) : BigDecimal::of($data); } if (!$data instanceof \DateTimeInterface diff --git a/src/Services/Parts/PricedetailHelper.php b/src/Services/Parts/PricedetailHelper.php index b2e1340f..e40fc44d 100644 --- a/src/Services/Parts/PricedetailHelper.php +++ b/src/Services/Parts/PricedetailHelper.php @@ -170,7 +170,7 @@ class PricedetailHelper return null; } - return $avg->dividedBy($count, Pricedetail::PRICE_PRECISION, RoundingMode::HALF_UP); + return $avg->dividedBy($count, Pricedetail::PRICE_PRECISION, RoundingMode::HalfUp); } /** @@ -213,6 +213,6 @@ class PricedetailHelper $val_target = $val_base->multipliedBy($targetCurrency->getInverseExchangeRate()); } - return $val_target->toScale(Pricedetail::PRICE_PRECISION, RoundingMode::HALF_UP); + return $val_target->toScale(Pricedetail::PRICE_PRECISION, RoundingMode::HalfUp); } } diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php index ee5b8c68..e011d980 100644 --- a/src/Services/ProjectSystem/ProjectBuildHelper.php +++ b/src/Services/ProjectSystem/ProjectBuildHelper.php @@ -190,7 +190,7 @@ final readonly class ProjectBuildHelper continue; } $has_price = true; - $total = $total->plus($unit_price->multipliedBy($entry->getQuantity())->multipliedBy($number_of_builds)); + $total = $total->plus($unit_price->multipliedBy(BigDecimal::fromFloatShortest($entry->getQuantity()))->multipliedBy($number_of_builds)); } return $has_price ? $total : null; @@ -206,7 +206,7 @@ final readonly class ProjectBuildHelper if ($total === null) { return null; } - return $total->dividedBy($number_of_builds, 10, RoundingMode::HALF_UP); + return $total->dividedBy($number_of_builds, 10, RoundingMode::HalfUp); } /** @@ -215,7 +215,7 @@ final readonly class ProjectBuildHelper public function roundedTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal { return $this->calculateTotalBuildPrice($project, $number_of_builds, $currency) - ?->toScale(2, RoundingMode::UP); + ?->toScale(2, RoundingMode::Up); } /** @@ -224,7 +224,7 @@ final readonly class ProjectBuildHelper public function roundedUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal { return $this->calculateUnitBuildPrice($project, $number_of_builds, $currency) - ?->toScale(2, RoundingMode::UP); + ?->toScale(2, RoundingMode::Up); } /** diff --git a/src/Services/System/GitVersionInfoProvider.php b/src/Services/System/GitVersionInfoProvider.php index 01925ff8..927326e5 100644 --- a/src/Services/System/GitVersionInfoProvider.php +++ b/src/Services/System/GitVersionInfoProvider.php @@ -62,6 +62,9 @@ final readonly class GitVersionInfoProvider { if (is_file($this->getGitDirectory() . '/HEAD')) { $git = file($this->getGitDirectory() . '/HEAD'); + if ($git === false) { + return null; + } $head = explode('/', $git[0], 3); if (!isset($head[2])) { diff --git a/src/Services/Tools/ExchangeRateUpdater.php b/src/Services/Tools/ExchangeRateUpdater.php index 6eb7ec13..ccc3f19f 100644 --- a/src/Services/Tools/ExchangeRateUpdater.php +++ b/src/Services/Tools/ExchangeRateUpdater.php @@ -44,15 +44,15 @@ class ExchangeRateUpdater 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()); + $effective_rate = BigDecimal::fromFloatShortest($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()); - $effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP); + $rate_bd = BigDecimal::fromFloatShortest($rate->getValue()); + $effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HalfUp); } $currency->setExchangeRate($effective_rate); diff --git a/src/Settings/InfoProviderSystem/BrowserPluginSettings.php b/src/Settings/InfoProviderSystem/BrowserPluginSettings.php new file mode 100644 index 00000000..1ad5c50b --- /dev/null +++ b/src/Settings/InfoProviderSystem/BrowserPluginSettings.php @@ -0,0 +1,40 @@ +. + */ + +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(name: "browser_plugin", label: new TM("settings.ips.browser_plugin"), description: new TM("settings.ips.browser_plugin.description"))] +#[SettingsIcon("fa-cloud-arrow-up")] +class BrowserPluginSettings +{ + #[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), description: new TM("settings.ips.browser_plugin.enabled.help"), + envVar: "bool:BROWSER_PLUGIN_ENABLED", envVarMode: EnvVarMode::OVERWRITE + )] + public bool $enabled = false; +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index 3e2a27ef..96de19cb 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -37,6 +37,9 @@ class InfoProviderSettings #[EmbeddedSettings] public ?InfoProviderGeneralSettings $general = null; + #[EmbeddedSettings] + public ?BrowserPluginSettings $browserPlugin = null; + #[EmbeddedSettings] public ?GenericWebProviderSettings $genericWebProvider = null; diff --git a/templates/info_providers/from_url/from_url.html.twig b/templates/info_providers/from_url/from_url.html.twig index 49d4b116..3146c5a5 100644 --- a/templates/info_providers/from_url/from_url.html.twig +++ b/templates/info_providers/from_url/from_url.html.twig @@ -33,5 +33,31 @@ {{ form_row(form.submit) }} + + {% if recentBrowserPages is not empty %} +
+ +
+ +
+

{% trans %}browser_plugin.recent_pages.help{% endtrans %}

+
+ {% for page in recentBrowserPages %} + + {% endfor %} +
+
+
+ {% endif %} + {{ form_end(form) }} {% endblock %} diff --git a/tests/API/Endpoints/PartEndpointTest.php b/tests/API/Endpoints/PartEndpointTest.php index 8d66d362..c8c087c5 100644 --- a/tests/API/Endpoints/PartEndpointTest.php +++ b/tests/API/Endpoints/PartEndpointTest.php @@ -69,4 +69,52 @@ final class PartEndpointTest extends CrudEndpointTestCase { $this->_testDeleteItem(1); } -} \ No newline at end of file + + public function testMasterPictureAttachmentPatchWithIRI(): void + { + $client = static::createAuthenticatedClient(); + + // Create a new attachment with a picture URL for Part 1 + $response = $client->request('POST', '/api/attachments', ['json' => [ + 'name' => 'Test Picture', + 'url' => 'http://example.com/test.jpg', + '_type' => 'Part', + 'element' => '/api/parts/1', + 'attachment_type' => '/api/attachment_types/1', + ]]); + self::assertResponseIsSuccessful(); + $attachmentIri = $response->toArray()['@id']; + + // Now PATCH Part 1 to set master_picture_attachment + $client->request('PATCH', '/api/parts/1', [ + 'json' => ['master_picture_attachment' => $attachmentIri], + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + ]); + self::assertResponseIsSuccessful(); + self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]); + } + + public function testMasterPictureAttachmentPatchWithArray(): void + { + $client = static::createAuthenticatedClient(); + + // Create a new attachment with a picture URL for Part 1 + $response = $client->request('POST', '/api/attachments', ['json' => [ + 'name' => 'Test Picture', + 'url' => 'http://example.com/test.jpg', + '_type' => 'Part', + 'element' => '/api/parts/1', + 'attachment_type' => '/api/attachment_types/1', + ]]); + self::assertResponseIsSuccessful(); + $attachmentIri = $response->toArray()['@id']; + + // Now PATCH Part 1 to set master_picture_attachment + $client->request('PATCH', '/api/parts/1', [ + 'json' => ['master_picture_attachment' => ['@id' => $attachmentIri, '_type' => 'Part']], + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + ]); + self::assertResponseIsSuccessful(); + self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]); + } +} diff --git a/tests/Controller/AuthorizationTest.php b/tests/Controller/AuthorizationTest.php new file mode 100644 index 00000000..4e211301 --- /dev/null +++ b/tests/Controller/AuthorizationTest.php @@ -0,0 +1,222 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Response; + +/** + * Verifies the HTTP access-control boundaries: + * + * The app has an "anonymous" fixture user with readonly permissions, so truly + * public read routes return 200 even without a session. Write-protected routes + * return 401 for unauthenticated requests (not a 302 redirect). + * + * Users: admin (all-allow), user (editor preset), noread (no group/no perms) + */ +#[Group('DB')] +#[Group('slow')] +final class AuthorizationTest extends WebTestCase +{ + // ----------------------------------------------------------------------- + // Data providers + // ----------------------------------------------------------------------- + + /** + * Routes readable by the anonymous user — unauthenticated requests get 200. + */ + public static function publicReadRoutesProvider(): \Generator + { + yield 'homepage' => ['/en/']; + yield 'part view' => ['/en/part/1']; + yield 'statistics' => ['/en/statistics']; + yield 'select category' => ['/en/select_api/category']; + yield 'typeahead tags' => ['/en/typeahead/tags/search/test']; + } + + /** + * Write-protected routes — unauthenticated gets 401 (not 302). + */ + public static function writeProtectedRoutesProvider(): \Generator + { + yield 'part edit' => ['/en/part/1/edit']; + yield 'part new' => ['/en/part/new']; + yield 'user edit' => ['/en/user/1/edit']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + } + + /** + * Routes the `noread` user (no group = no permissions) must be denied. + */ + public static function noreadDeniedRoutesProvider(): \Generator + { + yield 'part view' => ['/en/part/1']; + yield 'part edit' => ['/en/part/1/edit']; + yield 'part new' => ['/en/part/new']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + yield 'select category' => ['/en/select_api/category']; + yield 'typeahead tags' => ['/en/typeahead/tags/search/test']; + } + + /** + * Routes the `user` (editor preset) must have access to. + */ + public static function editorAllowedRoutesProvider(): \Generator + { + yield 'homepage' => ['/en/']; + yield 'part view' => ['/en/part/1']; + yield 'part edit' => ['/en/part/1/edit']; + yield 'part new' => ['/en/part/new']; + yield 'select cat' => ['/en/select_api/category']; + yield 'typeahead' => ['/en/typeahead/tags/search/test']; + } + + /** + * Admin-only routes the `user` (editor) must be denied. + */ + public static function editorDeniedRoutesProvider(): \Generator + { + yield 'user edit' => ['/en/user/1/edit']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + } + + /** + * Routes the `admin` user must be able to reach. + */ + public static function adminAllowedRoutesProvider(): \Generator + { + yield 'user edit' => ['/en/user/1/edit']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + yield 'part view' => ['/en/part/1']; + yield 'part edit' => ['/en/part/1/edit']; + yield 'statistics' => ['/en/statistics']; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function loginAs(string $username): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => $username]); + if ($user === null) { + $this->markTestSkipped("Fixture user '$username' not found."); + } + $client->loginUser($user); + $client->followRedirects(false); + return $client; + } + + private function assertDenied(KernelBrowser $client, string $url): void + { + $client->request('GET', $url); + $code = $client->getResponse()->getStatusCode(); + $this->assertTrue( + $code === Response::HTTP_FORBIDDEN || $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(), + "Expected 401/403/redirect on $url, got $code" + ); + } + + // ----------------------------------------------------------------------- + // Unauthenticated: public reads + // ----------------------------------------------------------------------- + + #[DataProvider('publicReadRoutesProvider')] + public function testUnauthenticatedCanReadPublicRoutes(string $url): void + { + $client = static::createClient(); + $client->request('GET', $url); + // Anonymous user (readonly group) can access read-only content + $this->assertResponseIsSuccessful(); + } + + // ----------------------------------------------------------------------- + // Unauthenticated: write routes → 401 + // ----------------------------------------------------------------------- + + #[DataProvider('writeProtectedRoutesProvider')] + public function testUnauthenticatedIsUnauthorizedOnWriteRoutes(string $url): void + { + $client = static::createClient(); + $client->followRedirects(false); + $client->request('GET', $url); + + $code = $client->getResponse()->getStatusCode(); + $this->assertTrue( + $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(), + "Expected 401 or redirect on $url for unauthenticated request, got $code" + ); + } + + // ----------------------------------------------------------------------- + // noread user: denied everywhere + // ----------------------------------------------------------------------- + + #[DataProvider('noreadDeniedRoutesProvider')] + public function testNoreadUserIsDenied(string $url): void + { + $this->assertDenied($this->loginAs('noread'), $url); + } + + // ----------------------------------------------------------------------- + // Editor user + // ----------------------------------------------------------------------- + + #[DataProvider('editorAllowedRoutesProvider')] + public function testEditorCanAccess(string $url): void + { + $client = $this->loginAs('user'); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } + + #[DataProvider('editorDeniedRoutesProvider')] + public function testEditorIsDeniedOnAdminRoutes(string $url): void + { + $this->assertDenied($this->loginAs('user'), $url); + } + + // ----------------------------------------------------------------------- + // Admin user: can access everything + // ----------------------------------------------------------------------- + + #[DataProvider('adminAllowedRoutesProvider')] + public function testAdminCanAccessAllRoutes(string $url): void + { + $client = $this->loginAs('admin'); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/BrowserPluginControllerTest.php b/tests/Controller/BrowserPluginControllerTest.php new file mode 100644 index 00000000..8af82ce9 --- /dev/null +++ b/tests/Controller/BrowserPluginControllerTest.php @@ -0,0 +1,247 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use App\Settings\InfoProviderSystem\BrowserPluginSettings; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Response; + +#[Group("slow")] +#[Group("DB")] +final class BrowserPluginControllerTest extends WebTestCase +{ + // --- GET /browser_info --- + + public function testGetInfoReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('GET', '/en/tools/info_providers/browser_info'); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + } + + public function testGetInfoReturnsForbiddenForUnprivilegedUser(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'noread'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('GET', '/en/tools/info_providers/browser_info'); + + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + } + + public function testGetInfoReturns451WhenPluginDisabled(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + // BrowserPluginSettings::$enabled defaults to false + + $client->request('GET', '/en/tools/info_providers/browser_info'); + + self::assertResponseStatusCodeSame(451); + } + + public function testGetInfoReturnsJsonWithExpectedKeys(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('GET', '/en/tools/info_providers/browser_info'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + self::assertResponseHeaderSame('Content-Type', 'application/json'); + + $data = json_decode((string) $client->getResponse()->getContent(), true); + $this->assertArrayHasKey('username', $data); + $this->assertArrayHasKey('instance_name', $data); + $this->assertArrayHasKey('url_providers', $data); + $this->assertIsString($data['username']); + $this->assertIsString($data['instance_name']); + $this->assertIsArray($data['url_providers']); + $this->assertNotEmpty($data['username']); + $this->assertNotEmpty($data['instance_name']); + } + + public function testGetInfoUrlProvidersHaveIdAndLabel(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('GET', '/en/tools/info_providers/browser_info'); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + $data = json_decode((string) $client->getResponse()->getContent(), true); + + foreach ($data['url_providers'] as $provider) { + $this->assertArrayHasKey('id', $provider); + $this->assertArrayHasKey('label', $provider); + $this->assertIsString($provider['id']); + $this->assertIsString($provider['label']); + $this->assertNotEmpty($provider['id']); + $this->assertNotEmpty($provider['label']); + } + } + + // --- POST /browser_html --- + + public function testSubmitHtmlReturns401WhenNotAuthenticated(): void + { + $client = static::createClient(); + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['url' => 'https://example.com', 'html' => '', 'title' => 'Test'])); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + } + + public function testSubmitHtmlReturns451WhenPluginDisabled(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + // BrowserPluginSettings::$enabled defaults to false + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['url' => 'https://example.com', 'html' => '', 'title' => 'Test'])); + + self::assertResponseStatusCodeSame(451); + } + + public function testSubmitHtmlWithValidDataAndProvider(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'url' => 'https://example.com/product/123', + 'html' => 'Product page', + 'title' => 'Some Product', + 'provider' => 'generic_web', + ])); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + $data = json_decode((string) $client->getResponse()->getContent(), true); + $this->assertArrayHasKey('redirect_url', $data); + $this->assertNotNull($data['redirect_url']); + $this->assertStringContainsString('generic_web', (string) $data['redirect_url']); + } + + public function testSubmitHtmlWithoutProviderReturnsNullRedirectUrl(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'url' => 'https://example.com/product/123', + 'html' => 'Product page', + 'title' => 'Some Product', + ])); + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + $data = json_decode((string) $client->getResponse()->getContent(), true); + $this->assertArrayHasKey('redirect_url', $data); + $this->assertNull($data['redirect_url']); + } + + public function testSubmitHtmlWithInvalidJsonReturns400(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], 'this is not valid json {'); + + self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + } + + public function testSubmitHtmlWithMissingUrlReturns422(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['html' => '', 'title' => 'Test'])); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testSubmitHtmlWithMissingHtmlReturns422(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['url' => 'https://example.com', 'title' => 'Test'])); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function testSubmitHtmlWithInvalidUrlReturns422(): void + { + $client = static::createClient(); + $client->disableReboot(); + $this->loginAsUser($client, 'admin'); + static::getContainer()->get(BrowserPluginSettings::class)->enabled = true; + + $client->request('POST', '/en/tools/info_providers/browser_html', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['url' => 'not-a-url', 'html' => '', 'title' => 'Test'])); + + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + private function loginAsUser(mixed $client, string $username): void + { + $entityManager = static::getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => $username]); + if (!$user) { + $this->markTestSkipped("User '{$username}' not found in fixtures"); + } + $client->loginUser($user); + } +} diff --git a/tests/Controller/SelectApiControllerTest.php b/tests/Controller/SelectApiControllerTest.php new file mode 100644 index 00000000..b07053b9 --- /dev/null +++ b/tests/Controller/SelectApiControllerTest.php @@ -0,0 +1,152 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * Tests the SelectAPIController endpoints used by select2 widgets. + * These JSON endpoints back every structural-entity dropdown in the UI. + */ +#[Group('DB')] +#[Group('slow')] +final class SelectApiControllerTest extends WebTestCase +{ + public static function endpointProvider(): \Generator + { + yield 'category' => ['/en/select_api/category']; + yield 'footprint' => ['/en/select_api/footprint']; + yield 'manufacturer' => ['/en/select_api/manufacturer']; + yield 'measurement_unit' => ['/en/select_api/measurement_unit']; + yield 'project' => ['/en/select_api/project']; + yield 'storage_location' => ['/en/select_api/storage_location']; + yield 'label_profiles' => ['/en/select_api/label_profiles']; + } + + private function adminClient(): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['name' => 'admin']); + if ($admin === null) { + $this->markTestSkipped('Fixture user admin not found.'); + } + $client->loginUser($admin); + return $client; + } + + // ----------------------------------------------------------------------- + // Response format + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testEndpointReturns200WithJsonContentType(string $url): void + { + $client = $this->adminClient(); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/json'); + } + + #[DataProvider('endpointProvider')] + public function testEndpointReturnsValidJsonArray(string $url): void + { + $client = $this->adminClient(); + $client->request('GET', $url); + + $body = $client->getResponse()->getContent(); + $decoded = json_decode($body, true); + + $this->assertIsArray($decoded, "Response from $url is not a valid JSON array"); + } + + #[DataProvider('endpointProvider')] + public function testEachEntryHasTextAndValueKeys(string $url): void + { + $client = $this->adminClient(); + $client->request('GET', $url); + + $decoded = json_decode($client->getResponse()->getContent(), true); + // Some endpoints include an empty "select none" entry at index 0; all entries must have text + value + foreach ($decoded as $entry) { + $this->assertArrayHasKey('text', $entry, "Entry in $url missing 'text' key"); + $this->assertArrayHasKey('value', $entry, "Entry in $url missing 'value' key"); + } + } + + // ----------------------------------------------------------------------- + // Access control + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testUnauthenticatedCanReadSelectApi(string $url): void + { + // The anonymous user (readonly group) has read access to structural entities, + // so these endpoints return 200 even without a session. + $client = static::createClient(); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } + + #[DataProvider('endpointProvider')] + public function testNoreadUserIsDenied(string $url): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $noread = $em->getRepository(User::class)->findOneBy(['name' => 'noread']); + if ($noread === null) { + $this->markTestSkipped('Fixture user noread not found.'); + } + $client->loginUser($noread); + $client->followRedirects(false); + $client->request('GET', $url); + + $response = $client->getResponse(); + $this->assertTrue( + $response->getStatusCode() === 403 || $response->isRedirect(), + "Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode() + ); + } + + #[DataProvider('endpointProvider')] + public function testEditorUserCanAccess(string $url): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => 'user']); + if ($user === null) { + $this->markTestSkipped('Fixture user user not found.'); + } + $client->loginUser($user); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/TypeaheadControllerTest.php b/tests/Controller/TypeaheadControllerTest.php new file mode 100644 index 00000000..ce2747fa --- /dev/null +++ b/tests/Controller/TypeaheadControllerTest.php @@ -0,0 +1,162 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * Tests the TypeaheadController JSON endpoints that back autocomplete widgets in the UI. + */ +#[Group('DB')] +#[Group('slow')] +final class TypeaheadControllerTest extends WebTestCase +{ + public static function endpointProvider(): \Generator + { + yield 'tags search' => ['/en/typeahead/tags/search/test']; + yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage']; + yield 'parameters category search' => ['/en/typeahead/parameters/category/search/NPN']; + yield 'builtin resources' => ['/en/typeahead/builtInResources/search?query=DIP']; + yield 'parts search' => ['/en/typeahead/parts/search/res']; + } + + public static function partsReadEndpointProvider(): \Generator + { + // These require @parts.read — noread user must be denied + yield 'tags search' => ['/en/typeahead/tags/search/test']; + yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage']; + yield 'parts search' => ['/en/typeahead/parts/search/res']; + } + + private function loginClient(string $username): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => $username]); + if ($user === null) { + $this->markTestSkipped("Fixture user '$username' not found."); + } + $client->loginUser($user); + return $client; + } + + // ----------------------------------------------------------------------- + // Response format + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testEndpointReturnsSuccessfulJsonForAdmin(string $url): void + { + $client = $this->loginClient('admin'); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertJson($client->getResponse()->getContent()); + } + + #[DataProvider('endpointProvider')] + public function testEndpointReturnsJsonArray(string $url): void + { + $client = $this->loginClient('admin'); + $client->request('GET', $url); + + $decoded = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($decoded, "Response from $url should be a JSON array"); + } + + // ----------------------------------------------------------------------- + // Tags search: result structure + // ----------------------------------------------------------------------- + + public function testTagsSearchReturnsStrings(): void + { + $client = $this->loginClient('admin'); + $client->request('GET', '/en/typeahead/tags/search/a'); + + $tags = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($tags); + foreach ($tags as $tag) { + $this->assertIsString($tag, 'Each tag entry should be a plain string'); + } + } + + // ----------------------------------------------------------------------- + // Parts search: result structure + // ----------------------------------------------------------------------- + + public function testPartsSearchReturnsArrayWithExpectedKeys(): void + { + $client = $this->loginClient('admin'); + $client->request('GET', '/en/typeahead/parts/search/test'); + + $parts = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($parts); + // Each result must have at least id and name + foreach ($parts as $part) { + $this->assertArrayHasKey('id', $part); + $this->assertArrayHasKey('name', $part); + } + } + + // ----------------------------------------------------------------------- + // Access control + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testUnauthenticatedCanAccessTypeahead(string $url): void + { + // Anonymous user (readonly group) has @parts.read, so these endpoints return 200. + $client = static::createClient(); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } + + #[DataProvider('partsReadEndpointProvider')] + public function testNoreadUserIsDenied(string $url): void + { + $client = $this->loginClient('noread'); + $client->followRedirects(false); + $client->request('GET', $url); + + $response = $client->getResponse(); + $this->assertTrue( + $response->getStatusCode() === 403 || $response->isRedirect(), + "Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode() + ); + } + + #[DataProvider('endpointProvider')] + public function testEditorUserCanAccess(string $url): void + { + $client = $this->loginClient('user'); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/EventSubscriber/MaintenanceModeSubscriberTest.php b/tests/EventSubscriber/MaintenanceModeSubscriberTest.php new file mode 100644 index 00000000..0d975ee0 --- /dev/null +++ b/tests/EventSubscriber/MaintenanceModeSubscriberTest.php @@ -0,0 +1,103 @@ +. + */ + +namespace App\Tests\EventSubscriber; + +use App\EventSubscriber\MaintenanceModeSubscriber; +use App\Services\System\UpdateExecutor; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; + +final class MaintenanceModeSubscriberTest extends TestCase +{ + private function makeSubscriber(bool $maintenanceActive): MaintenanceModeSubscriber + { + $executor = $this->createMock(UpdateExecutor::class); + $executor->method('isMaintenanceMode')->willReturn($maintenanceActive); + $executor->method('getMaintenanceInfo')->willReturn( + $maintenanceActive ? ['reason' => 'Test update', 'enabled_at' => date('Y-m-d H:i:s')] : null + ); + return new MaintenanceModeSubscriber($executor); + } + + private function makeEvent(string $url = 'http://example.com/'): RequestEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create($url); + return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + } + + public function testNoMaintenanceModeDoesNotSetResponse(): void + { + $subscriber = $this->makeSubscriber(false); + $event = $this->makeEvent(); + + $subscriber->onKernelRequest($event); + + // When not in maintenance mode, no response is ever set regardless of SAPI + $this->assertFalse($event->hasResponse()); + } + + public function testCliRequestIsNeverBlocked(): void + { + // Tests run from CLI (PHP_SAPI === 'cli'), so maintenance mode never blocks CLI requests. + // This verifies the intentional behaviour: maintenance mode only affects web requests. + $subscriber = $this->makeSubscriber(true); + $event = $this->makeEvent(); + + $subscriber->onKernelRequest($event); + + // CLI requests pass through even with maintenance active + $this->assertFalse($event->hasResponse()); + } + + public function testSubRequestIsIgnored(): void + { + $subscriber = $this->makeSubscriber(true); + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('http://example.com/'); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST); + + $subscriber->onKernelRequest($event); + + $this->assertFalse($event->hasResponse()); + } + + public function testSubscriberListensToKernelRequest(): void + { + $events = MaintenanceModeSubscriber::getSubscribedEvents(); + $this->assertArrayHasKey(KernelEvents::REQUEST, $events); + } + + public function testSubscriberListensWithHighPriority(): void + { + $events = MaintenanceModeSubscriber::getSubscribedEvents(); + $config = $events[KernelEvents::REQUEST]; + // Config is ['methodName', priority] + $priority = is_array($config) ? (int) ($config[1] ?? 0) : 0; + $this->assertGreaterThan(0, $priority, 'Maintenance subscriber should run with high priority'); + } +} diff --git a/tests/EventSubscriber/RedirectToHttpsSubscriberTest.php b/tests/EventSubscriber/RedirectToHttpsSubscriberTest.php new file mode 100644 index 00000000..ec782b66 --- /dev/null +++ b/tests/EventSubscriber/RedirectToHttpsSubscriberTest.php @@ -0,0 +1,101 @@ +. + */ + +namespace App\Tests\EventSubscriber; + +use App\EventSubscriber\RedirectToHttpsSubscriber; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Http\HttpUtils; + +final class RedirectToHttpsSubscriberTest extends TestCase +{ + private function makeEvent(string $url, bool $isMainRequest = true): RequestEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create($url); + return new RequestEvent($kernel, $request, $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST); + } + + public function testHttpRequestIsRedirectedToHttpsWhenEnabled(): void + { + $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils()); + $event = $this->makeEvent('http://example.com/some/path'); + + $subscriber->onKernelRequest($event); + + $this->assertTrue($event->hasResponse()); + $response = $event->getResponse(); + $this->assertStringStartsWith('https://', $response->getTargetUrl()); + } + + public function testHttpsRequestIsNotRedirectedWhenEnabled(): void + { + $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils()); + $event = $this->makeEvent('https://example.com/some/path'); + + $subscriber->onKernelRequest($event); + + $this->assertFalse($event->hasResponse()); + } + + public function testHttpRequestIsNotRedirectedWhenDisabled(): void + { + $subscriber = new RedirectToHttpsSubscriber(false, new HttpUtils()); + $event = $this->makeEvent('http://example.com/some/path'); + + $subscriber->onKernelRequest($event); + + $this->assertFalse($event->hasResponse()); + } + + public function testSubRequestIsNotRedirected(): void + { + $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils()); + $event = $this->makeEvent('http://example.com/', false); + + $subscriber->onKernelRequest($event); + + $this->assertFalse($event->hasResponse()); + } + + public function testRedirectUrlPreservesPath(): void + { + $subscriber = new RedirectToHttpsSubscriber(true, new HttpUtils()); + $event = $this->makeEvent('http://example.com/admin/parts?q=test'); + + $subscriber->onKernelRequest($event); + + $this->assertTrue($event->hasResponse()); + $this->assertStringContainsString('/admin/parts', $event->getResponse()->getTargetUrl()); + $this->assertStringContainsString('q=test', $event->getResponse()->getTargetUrl()); + } + + public function testSubscriberListensToKernelRequestEvent(): void + { + $events = RedirectToHttpsSubscriber::getSubscribedEvents(); + $this->assertArrayHasKey('kernel.request', $events); + } +} diff --git a/tests/Services/Cache/ElementCacheTagGeneratorTest.php b/tests/Services/Cache/ElementCacheTagGeneratorTest.php new file mode 100644 index 00000000..f747441f --- /dev/null +++ b/tests/Services/Cache/ElementCacheTagGeneratorTest.php @@ -0,0 +1,67 @@ +. + */ + +namespace App\Tests\Services\Cache; + +use App\Entity\Parts\Part; +use App\Services\Cache\ElementCacheTagGenerator; +use PHPUnit\Framework\TestCase; + +final class ElementCacheTagGeneratorTest extends TestCase +{ + private ElementCacheTagGenerator $service; + + protected function setUp(): void + { + $this->service = new ElementCacheTagGenerator(); + } + + public function testClassNameIsConvertedToTag(): void + { + $tag = $this->service->getElementTypeCacheTag(Part::class); + // Backslashes must be replaced by underscores + $this->assertStringNotContainsString('\\', $tag); + $this->assertSame(str_replace('\\', '_', Part::class), $tag); + } + + public function testObjectInputGivesSameResultAsClassName(): void + { + $part = new Part(); + $tagFromObject = $this->service->getElementTypeCacheTag($part); + $tagFromClass = $this->service->getElementTypeCacheTag(Part::class); + $this->assertSame($tagFromClass, $tagFromObject); + } + + public function testResultIsCached(): void + { + $tag1 = $this->service->getElementTypeCacheTag(Part::class); + $tag2 = $this->service->getElementTypeCacheTag(Part::class); + $this->assertSame($tag1, $tag2); + } + + public function testNonExistentClassThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->service->getElementTypeCacheTag('App\\NonExistent\\Foo'); + } +} diff --git a/tests/Services/Cache/UserCacheKeyGeneratorTest.php b/tests/Services/Cache/UserCacheKeyGeneratorTest.php new file mode 100644 index 00000000..23583db4 --- /dev/null +++ b/tests/Services/Cache/UserCacheKeyGeneratorTest.php @@ -0,0 +1,110 @@ +. + */ + +namespace App\Tests\Services\Cache; + +use App\Entity\UserSystem\User; +use App\Services\Cache\UserCacheKeyGenerator; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +final class UserCacheKeyGeneratorTest extends TestCase +{ + private function makeGenerator(?User $loggedInUser, ?Request $request = null): UserCacheKeyGenerator + { + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn($loggedInUser); + + $requestStack = $this->createMock(RequestStack::class); + $requestStack->method('getCurrentRequest')->willReturn($request); + + return new UserCacheKeyGenerator($security, $requestStack); + } + + private function makeUserWithId(int $id): User + { + $user = new User(); + $ref = new \ReflectionProperty(User::class, 'id'); + $ref->setValue($user, $id); + return $user; + } + + public function testAnonymousUserKeyContainsAnonymousId(): void + { + $service = $this->makeGenerator(null); + $key = $service->generateKey(); + $this->assertStringContainsString((string) User::ID_ANONYMOUS, $key); + } + + public function testExplicitAnonymousUserGivesSameKeyAsNull(): void + { + $anonUser = $this->makeUserWithId(User::ID_ANONYMOUS); + $anonUser->setName('anonymous'); + + $service = $this->makeGenerator(null); + $keyFromNull = $service->generateKey(null); + $keyFromAnon = $service->generateKey($anonUser); + $this->assertSame($keyFromNull, $keyFromAnon); + } + + public function testKeyForRealUserContainsUserId(): void + { + $user = $this->makeUserWithId(42); + $service = $this->makeGenerator(null); + + $key = $service->generateKey($user); + $this->assertStringContainsString('42', $key); + $this->assertStringNotContainsString((string) User::ID_ANONYMOUS, $key); + } + + public function testLocaleFromRequestIsIncludedInKey(): void + { + $request = Request::create('/'); + $request->setLocale('de'); + + $service = $this->makeGenerator(null, $request); + $key = $service->generateKey(); + $this->assertStringContainsString('de', $key); + } + + public function testDifferentUsersProduceDifferentKeys(): void + { + $service = $this->makeGenerator(null); + + $user1 = $this->makeUserWithId(10); + $user2 = $this->makeUserWithId(20); + + $this->assertNotSame($service->generateKey($user1), $service->generateKey($user2)); + } + + public function testCurrentlyLoggedInUserIsUsedWhenNoExplicitUser(): void + { + $loggedIn = $this->makeUserWithId(99); + $service = $this->makeGenerator($loggedIn); + + $key = $service->generateKey(); + $this->assertStringContainsString('99', $key); + } +} diff --git a/tests/Services/EntityURLGeneratorTest.php b/tests/Services/EntityURLGeneratorTest.php new file mode 100644 index 00000000..f21511e0 --- /dev/null +++ b/tests/Services/EntityURLGeneratorTest.php @@ -0,0 +1,113 @@ +. + */ + +namespace App\Tests\Services; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\Part; +use App\Entity\Parts\StorageLocation; +use App\Entity\Parts\Supplier; +use App\Entity\UserSystem\User; +use App\Exceptions\EntityNotSupportedException; +use App\Services\EntityURLGenerator; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +final class EntityURLGeneratorTest extends WebTestCase +{ + private static EntityURLGenerator $service; + + public static function setUpBeforeClass(): void + { + self::bootKernel(); + self::$service = self::getContainer()->get(EntityURLGenerator::class); + } + + private function entityWithId(string $class, int $id): AbstractDBElement + { + $entity = new $class(); + $ref = new \ReflectionProperty(AbstractDBElement::class, 'id'); + $ref->setValue($entity, $id); + return $entity; + } + + public function testInfoUrlForPartContainsPartPath(): void + { + $part = $this->entityWithId(Part::class, 1); + $url = self::$service->infoURL($part); + $this->assertStringContainsString('part', $url); + $this->assertStringContainsString('1', $url); + } + + public function testEditUrlForCategoryContainsCategoryPath(): void + { + $category = $this->entityWithId(Category::class, 5); + $url = self::$service->editURL($category); + $this->assertStringContainsString('category', $url); + $this->assertStringContainsString('5', $url); + } + + public function testListPartsUrlForSupplierContainsSupplierPath(): void + { + $supplier = $this->entityWithId(Supplier::class, 7); + $url = self::$service->listPartsURL($supplier); + $this->assertStringContainsString('supplier', $url); + } + + public function testGetUrlWithInfoTypeCallsInfoUrl(): void + { + $part = $this->entityWithId(Part::class, 3); + $url = self::$service->getURL($part, 'info'); + $this->assertStringContainsString('part', $url); + } + + public function testGetUrlWithEditTypeCallsEditUrl(): void + { + $manufacturer = $this->entityWithId(Manufacturer::class, 2); + $url = self::$service->getURL($manufacturer, 'edit'); + $this->assertStringContainsString('manufacturer', $url); + } + + public function testGetUrlWithUnknownTypeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $part = $this->entityWithId(Part::class, 1); + self::$service->getURL($part, 'unsupported_type'); + } + + public function testInfoUrlForUserContainsUserPath(): void + { + $user = $this->entityWithId(User::class, 10); + $url = self::$service->editURL($user); + $this->assertStringContainsString('user', $url); + } + + public function testListPartsUrlForStorelocationContainsStorelocationPath(): void + { + $loc = $this->entityWithId(StorageLocation::class, 4); + $url = self::$service->listPartsURL($loc); + $this->assertStringContainsString('store', $url); + } +} diff --git a/tests/Services/Formatters/MarkdownParserTest.php b/tests/Services/Formatters/MarkdownParserTest.php new file mode 100644 index 00000000..0b27972f --- /dev/null +++ b/tests/Services/Formatters/MarkdownParserTest.php @@ -0,0 +1,86 @@ +. + */ + +namespace App\Tests\Services\Formatters; + +use App\Services\Formatters\MarkdownParser; +use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class MarkdownParserTest extends TestCase +{ + private MarkdownParser $service; + + protected function setUp(): void + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturn('Loading...'); + $this->service = new MarkdownParser($translator); + } + + public function testOutputContainsDataMarkdownAttribute(): void + { + $result = $this->service->markForRendering('**hello**'); + $this->assertStringContainsString('data-markdown=', $result); + $this->assertStringContainsString('data-controller="common--markdown"', $result); + } + + public function testMarkdownContentIsHtmlescapedInAttribute(): void + { + $result = $this->service->markForRendering(''); + // The raw < should not appear unescaped inside the attribute + $this->assertStringNotContainsString('